Threads, commands and labels on the new loop (M2.3)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 3m45s

- Commands that name their item ("go #14", "abandon #14", "stop #14")
  apply to it wherever they're said; go, approve and abandon alone at top
  level are asked about (L5). stop is never taken as an answer.
- A plain-language reply to a restatement is read by GoAheadReader, one
  model call that agrees only on a clear yes; questions never reach it. The
  agent says "taking that as a go-ahead on rev-N" first, bound to the
  revision on screen at arrival (L3, L5, D1).
- Labels steer (D1): `bug` goes ahead after tuning.loop.bug_grace_s
  (default 600 s) unless the operator replies; `discuss` isn't offered
  restate until the operator has spoken; `bug`, `discuss` and `design` add
  to the driver's instructions. A restatement asks no questions.

Tests: label and command unit tests, the property walk with labelled
issues, plain yeses, named gos and time passing (grace_only_unanswered,
discuss_talks_first), and e2e tests for a bug proceeding after its grace
period and a plain-language go-ahead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-04 16:13:22 +00:00
1 parent a504526da2
commit 4d9c1d7529
13 files changed
+793 -35

No files matched your search

+8
View File
@@ -104,6 +104,13 @@ class ContextTuning:
safety_margin: float = 0.1
@dataclass(frozen=True)
class LoopTuning:
# A restatement on a `bug` issue proceeds after this long without a
# reply (D1).
bug_grace_s: float = 600.0
@dataclass(frozen=True)
class Tuning:
executor: ExecutorTuning = field(default_factory=ExecutorTuning)
@@ -111,6 +118,7 @@ class Tuning:
delegation: DelegationTuning = field(default_factory=DelegationTuning)
tools: ToolsTuning = field(default_factory=ToolsTuning)
context: ContextTuning = field(default_factory=ContextTuning)
loop: LoopTuning = field(default_factory=LoopTuning)
_TUNING_SECTIONS: dict[str, type] = {
+182 -19
View File
@@ -17,6 +17,7 @@ run (L1).
import re
import threading
import time
import uuid
from collections.abc import Callable, Iterable
from dataclasses import replace
@@ -42,6 +43,7 @@ from nisse_dev.loop.items import (
save,
)
from nisse_dev.loop.milestones import OPEN_PR, Milestones
from nisse_dev.loop.reader import GoAheadReader, is_question
from nisse_dev.loop.tools import (
ASK_HUMAN,
CLOSE_ITEM,
@@ -59,6 +61,11 @@ from nisse_dev.worker import Worker
INTAKE_INTERVAL_S = 30.0
_GO = frozenset({"go", "approve"})
_NEXT = re.compile(r"(?:do|work on) #?(\d+) (?:next|first|now)")
# A command that names its item applies to it, wherever it is said (L5).
_NAMED = re.compile(r"(go|approve|abandon|stop) #(\d+)")
# At top level these need an item; without one they are ambiguous (L5).
_NEEDS_ITEM = frozenset({"go", "approve", "abandon"})
BUG_GRACE_S = 600.0
# Before the go-ahead the driver may look around (read, list, run a command
# to reproduce a bug) but not change anything (D1); after it, everything.
@@ -71,7 +78,8 @@ The issue and the conversation about it are below.
Before any work, find out what you need, then use restate to put your
understanding to the operator: what you take the request to mean, how it fits
the project, and your first step. Wait for their go-ahead. If the request is
unclear, ask_human instead of guessing.
unclear, ask_human instead of guessing: a restatement asks no questions, so
the operator can agree to it as it stands.
After the go-ahead, work in a milestone: call start_milestone before
changing anything (never create or switch branches yourself), make the
@@ -84,8 +92,25 @@ the PR is closed without merging, ask the operator what next. When a review
needs no change from you, or you are only waiting for the operator to merge
(you can't merge it yourself), call wait_for_review.
{labels}Never label the issue yourself; you may suggest a label to the operator.
{workspace}"""
# What an issue's labels add to the driver's instructions (D1).
LABELS = {
"bug": "This issue is labelled bug. Before restating, reproduce it with "
"your tools (run the code or the tests). If you can't reproduce it, "
"ask_human instead of restating: never fix a bug you couldn't see. Once "
"you restate, the fix goes ahead after a grace period unless the operator "
"replies; make it a failing test first, then the fix.",
"discuss": "This issue is labelled discuss: talk it through with the "
"operator (ask_human) before restating, and restate only once you agree "
"on what is wanted.",
"design": "This issue is labelled design: agree the design with the "
"operator before any code. Your restatement puts the design to them; "
"code waits for their go-ahead on it.",
}
class LoopAgent:
def __init__(
@@ -106,6 +131,9 @@ class LoopAgent:
oplog: OpLog,
roles: dict[str, str] | None = None,
milestones: Milestones | None = None,
reader: GoAheadReader | None = None,
bug_grace_s: float = BUG_GRACE_S,
clock: Callable[[], float] = time.time,
intake_interval_s: float = INTAKE_INTERVAL_S,
) -> None:
self._store = store
@@ -123,10 +151,16 @@ class LoopAgent:
self._oplog = oplog
self._roles = roles or {}
self._milestones = milestones
self._reader = reader
self._grace = bug_grace_s
self._clock = clock
self._interval = intake_interval_s
# thread root -> revision on screen there, read on the transport's
# thread to stamp replies on arrival (L3).
# thread to stamp replies on arrival (L3); issue -> thread root, for
# a command that names its item.
self._shown: dict[str, int] = {}
self._threads: dict[int, str] = {}
self._active_thread = ""
self._running = False
self._stop = threading.Event()
self._intake_thread: threading.Thread | None = None
@@ -173,6 +207,7 @@ class LoopAgent:
self._reconcile_publishing()
for item in self._store.work_items("open"):
loop = loop_of(item)
self._threads[loop.issue] = loop.thread
if item.position == RUNNING:
note = "I restarted while working on this. Reply to continue."
self._wait(item, replace(loop, wait=ANSWER, note=note), note)
@@ -249,6 +284,7 @@ class LoopAgent:
self._open(issue, tuple(blocked_by))
self.watch()
self.check_blocked()
self.check_grace()
self._schedule()
# -- PR review and merge (D4, D5) -------------------------------------------
@@ -354,6 +390,24 @@ class LoopAgent:
cause=said,
)
def check_grace(self) -> None:
"""D1: a `bug` restatement left unanswered for the grace period is
taken as agreed, bound to the revision that was up all along."""
now = self._clock()
for item in self._store.work_items("open"):
loop = loop_of(item)
if (
item.position == WAITING
and loop.wait == GO_AHEAD
and loop.grace_until
and now >= loop.grace_until
):
self._transport.post(
f"No reply on rev-{loop.rev}, so I'm going ahead.",
thread=loop.thread,
)
self._go(item, loop, "the bug grace period")
def _from_an_operator(self, issue: Issue) -> bool:
"""L11: only an operator's issue, or one an operator assigned, is
an instruction."""
@@ -375,7 +429,13 @@ class LoopAgent:
root = self._transport.post(
f"Queued {self._repo}#{issue.number}: {issue.title}{where}"
)
loop = Loop(thread=root, issue=issue.number, blocked_by=blocked_by)
loop = Loop(
thread=root,
issue=issue.number,
blocked_by=blocked_by,
labels=tuple(issue.labels),
)
self._threads[issue.number] = root
self._store.open_work_item(
item_id,
title=issue.title,
@@ -408,10 +468,21 @@ class LoopAgent:
"""On the transport's thread: stamp, then hand to the worker."""
if msg.sender not in self._operators:
return
if command_form(msg.text) == "stop" and self._running:
command = command_form(msg.text)
thread = msg.thread
named = _NAMED.fullmatch(command)
if named is not None:
command = named.group(1)
thread = self._threads.get(int(named.group(2)), thread)
if (
command == "stop"
and self._running
# Unnamed at top level, it stops the active item (L5).
and (not thread or thread == self._active_thread)
):
# Straight to the flag: the run is what it has to reach (L2).
self._cancel.set()
about = self._shown.get(msg.thread) if msg.thread else None
about = self._shown.get(thread) if thread else None
self._worker.submit(
self._settled("your message", lambda: self.handle(msg, about))
)
@@ -430,13 +501,38 @@ class LoopAgent:
)
self._schedule()
return
named = _NAMED.fullmatch(command)
if named is not None:
# L5: a command that names its item applies to it.
command, issue = named.group(1), int(named.group(2))
item = next(
(
i
for i in self._store.work_items("open")
if loop_of(i).issue == issue
),
None,
)
if item is None:
self._store.transcript(None, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
)
self._transport.post(
f"#{issue} isn't one of my open work items.", thread=msg.thread
)
return
if item is None:
self._store.transcript(None, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
)
if command == "status":
self._transport.post(self.status(), thread=msg.thread)
elif command != "stop":
elif command in _NEEDS_ITEM:
self._transport.post(self._which(command), thread=msg.thread)
elif command == "stop":
if not self._store.active_item():
self._transport.post("Nothing is running.", thread=msg.thread)
else:
self._transport.post(
f"I take work from issues assigned to me in {self._repo}: "
"assign one and I'll pick it up. Reply in its thread to "
@@ -447,12 +543,27 @@ class LoopAgent:
self._store.transcript(item.id, "operator").append(
UserMessage(text=text, source=f"transport:{msg.sender}")
)
loop = replace(loop_of(item), asked_on_pr=0, asked_where="", asked_text="")
# Any reply stops a grace period's countdown: the operator is here.
loop = replace(
loop_of(item),
asked_on_pr=0,
asked_where="",
asked_text="",
grace_until=0.0,
talked=True,
)
if loop != loop_of(item):
save(self._store, item.id, item.position, loop, cause="operator spoke")
item = self._store.work_item(item.id) or item
if command == "abandon":
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
elif command == "stop":
# Stopping a run took effect on arrival; a stop is never an answer.
if not loop.note.startswith("stopped ("):
self._transport.post(
f"#{loop.issue} isn't running; nothing to stop.",
thread=loop.thread,
)
elif item.position == BLOCKED:
self._transport.post(
f"#{loop.issue} is blocked by {', '.join(loop.blocked_by)}; "
@@ -468,14 +579,18 @@ class LoopAgent:
thread=loop.thread,
)
return
self._fact(item, f"go-ahead on rev-{loop.rev} from {msg.sender}")
save(
self._store,
item.id,
READY,
replace(loop, started=True, wait=""),
cause=f"go-ahead on rev-{loop.rev}",
self._go(item, loop, msg.sender)
elif (
item.position == WAITING
and loop.wait == GO_AHEAD
and about == loop.rev
and self._plain_go(item, text)
):
# L5, D1: a plain-language yes, said so before acting.
self._transport.post(
f"Taking that as a go-ahead on rev-{loop.rev}.", thread=loop.thread
)
self._go(item, loop, f"{msg.sender} (in plain language)")
elif item.position == WAITING:
# An answer, or changes to a restatement: the driver reads it.
save(
@@ -487,6 +602,39 @@ class LoopAgent:
)
self._schedule()
def _go(self, item: WorkItem, loop: Loop, by: str) -> None:
self._fact(item, f"go-ahead on rev-{loop.rev} from {by}")
save(
self._store,
item.id,
READY,
replace(loop, started=True, wait="", grace_until=0.0),
cause=f"go-ahead on rev-{loop.rev}",
)
def _plain_go(self, item: WorkItem, text: str) -> bool:
"""Whether a reply that isn't a command agrees to the restatement up.
Questions never do (L5)."""
if self._reader is None or is_question(text):
return False
proposals = [
e.event.plan
for e in self._store.transcript(item.id, "driver").read()
if isinstance(e.event, PlanSubmitted)
]
return bool(proposals) and self._reader.agrees(proposals[-1], text)
def _which(self, command: str) -> str:
"""L5: an unnamed command at top level is ambiguous: ask."""
items = self._store.work_items("open")
if not items:
return f"{command} what? I have no open work items."
listed = ", ".join(f"#{loop_of(i).issue} ({i.title})" for i in items)
return (
f"Which item? Say it in the item's thread, or name it "
f'("{command} #{loop_of(items[0]).issue}"). Open: {listed}.'
)
def _put_first(self, issue: int) -> str:
"""The operator's "do #N next" (L1): it runs before every other
ready item, the latest such order first. It doesn't unblock."""
@@ -525,6 +673,9 @@ class LoopAgent:
what = item.position
if item.position == WAITING:
what = f"waiting for {loop.wait} ({loop.note or f'rev-{loop.rev}'})"
if loop.grace_until:
left = max(0, int(loop.grace_until - self._clock()) // 60)
what += f", going ahead in {left} min unless you reply"
elif item.position == BLOCKED:
what = f"blocked by {', '.join(loop.blocked_by)}"
elif item.position == READY:
@@ -574,8 +725,10 @@ class LoopAgent:
self._transport.post(said, thread=loop.thread)
loop = replace(loop, prepared=True)
save(self._store, item.id, RUNNING, loop, cause="driver run")
# D1: a `discuss` item talks before it restates.
hold = "discuss" in loop.labels and not loop.talked and not loop.started
tools = [
RESTATE,
*([] if hold else [RESTATE]),
ASK_HUMAN,
*(
_LOOK
@@ -593,8 +746,10 @@ class LoopAgent:
repo=self._repo,
issue=loop.issue,
title=item.title,
labels="".join(f"{LABELS[x]}\n\n" for x in loop.labels if x in LABELS),
workspace=self._workspace.describe() if self._workspace else "",
)
self._active_thread = loop.thread
self._running = True
try:
outcome = self._driver.run(
@@ -622,11 +777,19 @@ class LoopAgent:
)
self._fact(item, f"restatement rev-{rev} put up for a go-ahead")
self._shown[loop.thread] = rev
self._transport.post(
f"rev-{rev}: {text}\n\nSay go to start, or reply with changes.",
thread=loop.thread,
grace = self._clock() + self._grace if "bug" in loop.labels else 0.0
after = "Say go to start, or reply with changes."
if grace:
after = (
f"It's a bug, so I'll go ahead in {round(self._grace / 60)} min "
"unless you reply; say go to start now, or reply with changes."
)
self._transport.post(f"rev-{rev}: {text}\n\n{after}", thread=loop.thread)
self._wait(
item,
replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace),
None,
)
self._wait(item, replace(loop, rev=rev, wait=GO_AHEAD, note=""), None)
elif outcome.kind == "wait" and outcome.tool == "ask_human":
question = str(args.get("question", "")).strip()
self._answer_where_asked(loop, question)
+5
View File
@@ -19,6 +19,7 @@ from nisse_dev.gitea import GiteaClient
from nisse_dev.loop.agent import LoopAgent
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.milestones import Milestones
from nisse_dev.loop.reader import GoAheadReader
from nisse_dev.matrix import MatrixTransport
from nisse_dev.oplog import OpLog
from nisse_dev.state import StateStore
@@ -109,6 +110,10 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
cancel=cancel,
oplog=oplog,
roles=config.roles.pinned(),
# The driver's model reads plain-language go-aheads until the
# classifier role arrives (M2.6).
reader=GoAheadReader(normalized, config.roles.resolve("planner")),
bug_grace_s=config.tuning.loop.bug_grace_s,
milestones=Milestones(
store,
git,
+4
View File
@@ -40,6 +40,9 @@ class Loop:
fixes: int = 0 # review fixes pushed, for follow-up branch names
blocked_by: tuple[str, ...] = () # open dependencies, "owner/repo#N" (L1)
first: int = 0 # the operator's "do #N next": higher runs sooner
labels: tuple[str, ...] = () # the issue's labels at intake: they steer (D1)
grace_until: float = 0.0 # a `bug` restatement proceeds then, unanswered
talked: bool = False # the operator has spoken in the item's thread
# Where the operator last spoke (D5: answer where asked): "" for the
# Matrix thread, or the PR it came from, with the inline location and a
# little of what they said, for quoting.
@@ -55,6 +58,7 @@ def loop_of(item: WorkItem) -> Loop:
data = dict(item.data.get("loop", {}))
data["notes"] = tuple(data.get("notes", ()))
data["blocked_by"] = tuple(data.get("blocked_by", ()))
data["labels"] = tuple(data.get("labels", ()))
return Loop(**data)
+81
View File
@@ -0,0 +1,81 @@
"""Reading a plain-language reply as a go-ahead (L5, D1).
"Yep, that's it" in reply to a restatement may be taken as `go`, because a
wrong reading costs only time. One short model call decides, and it decides
conservatively: only a clear yes counts. A question is never a go-ahead and
never reaches the model (L5: questions never change state).
The driver's model reads for now; the classifier role takes over in M2.6.
"""
from typing import Any
from nisse_dev.canonical import StoredEvent, ToolCall, UserMessage
from nisse_dev.llm import LLMClient, LLMError
VERDICT = {
"type": "function",
"function": {
"name": "verdict",
"description": "Your reading of the operator's reply.",
"parameters": {
"type": "object",
"properties": {
"answer": {
"type": "string",
"enum": ["yes", "no", "unclear"],
"description": "yes: a clear go-ahead on the proposal as it "
"stands. no: it asks for changes, disagrees or says to wait. "
"unclear: anything else.",
}
},
"required": ["answer"],
},
},
}
SYSTEM = """You read one reply from an operator to a software agent. The agent
put a proposal up for agreement; decide whether the reply clearly agrees to the
proposal as it stands, so the agent may start. A reply that asks for any
change, asks a question, adds a condition, or is about something else is not a
go-ahead. When in doubt, it is not. Answer with the verdict tool."""
def is_question(text: str) -> bool:
return text.rstrip().endswith("?")
class GoAheadReader:
def __init__(self, client: LLMClient, model: str) -> None:
self._client = client
self._model = model
def agrees(self, proposal: str, reply: str) -> bool:
if is_question(reply):
return False
prompt = f"The proposal:\n\n{proposal}\n\nThe operator's reply:\n\n{reply}"
try:
produced = self._client.complete(
[
StoredEvent(
id=0,
ts="",
event=UserMessage(text=prompt, source="go-ahead reader"),
actor="orchestrator",
)
],
tools=[VERDICT],
model=self._model,
system=SYSTEM,
)
except LLMError:
return False # unread: the driver reads it as an answer instead
return any(_yes(event) for event in produced)
def _yes(event: Any) -> bool:
return (
isinstance(event, ToolCall)
and event.tool == "verdict"
and event.arguments.get("answer") == "yes"
)