Fixes from the first live session of the new loop
Found live on 2026-10-07 (bring-up on nisse-testing-operator/nisse-bringup,
Opus as driver):
- The driver's prose nudge went into the system prompt, so the retry ended
on the model's own reply. Opus 5.5 refuses that ("does not support
assistant message prefill. The conversation must end with a user
message"). The nudge is now a harness fact in the conversation. The
delegation harness's prose path did the same and is fixed likewise.
- A helper's first request ended with its own delegation_started event,
rendered as an assistant message: every delegation would have failed on
Opus. The helper now sees it as the harness handing over its task.
- model_contract checks that every request ends on the user's side, so
scripted tests catch this from now on (each fix is mutation-checked).
- `status` (and `details`) in an item's thread were taken as replies: they
woke the driver and cancelled a bug's countdown. They now answer at once
and change nothing.
- The driver had no way to answer a remark without changing the item, so it
answered in prose. Every run woken by a message (or a review comment) now
offers `reply`, which answers where asked and returns to the very wait
that was interrupted.
Also: scripts/bringup-repo.sh makes the bring-up's test repository
(make_repo/delete_repo, split out of fresh_repo), and made repositories get
the agent's labels (bug, discuss, design).
Tests: test_loop_live_fixes.py (one per finding); the walk sends `status`
into threads (status_changes_nothing) and its scripted driver sometimes
answers remarks with reply.
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:
1 parent
572bd9f8eb
commit
659915d309
9 files changed
+341
-42
No files matched your search
@@ -33,6 +33,7 @@ from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
PlanSubmitted,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
@@ -414,6 +415,15 @@ class Delegation:
|
||||
# and its tree state is unknown. Treat it as a failure rather
|
||||
# than inventing a handback on its behalf.
|
||||
if any(isinstance(e, AssistantText) for e in produced):
|
||||
# Said something but did nothing: told so, as the
|
||||
# harness, so the next request ends on the user's side
|
||||
# (models refuse assistant prefill).
|
||||
transcript.as_actor("orchestrator").append(
|
||||
Fact(
|
||||
text="Your last reply was prose. Use your tools to "
|
||||
"do the work, then call hand_back."
|
||||
)
|
||||
)
|
||||
continue
|
||||
return DelegationResult(
|
||||
"failed",
|
||||
|
||||
@@ -116,6 +116,10 @@ asking is better than guessing.
|
||||
Before each group of tool calls, say in a sentence what you're doing and why:
|
||||
the operator sees it as your progress.
|
||||
|
||||
When the operator says something that needs an answer but no change (a
|
||||
remark, a status question), answer with reply: the item goes back to what it
|
||||
was waiting for. Every reply of yours is a tool call, never bare prose.
|
||||
|
||||
The project's documents are in its repository: read ARCHITECTURE.md and any
|
||||
build plans under docs/plans/ before you restate.
|
||||
|
||||
@@ -587,6 +591,8 @@ class LoopAgent:
|
||||
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
||||
elif new and item.position == WAITING:
|
||||
position = READY
|
||||
# A comment can be answered with a reply, back to this wait.
|
||||
loop = replace(loop, back_to=loop.wait, back_note=loop.note)
|
||||
if position != item.position or loop != loop_of(item):
|
||||
if position == READY:
|
||||
loop = replace(loop, wait="", note="")
|
||||
@@ -824,6 +830,19 @@ class LoopAgent:
|
||||
elif not self._store.active_item():
|
||||
self._transport.post("Nothing is running.", thread=msg.thread)
|
||||
return
|
||||
if command in ("status", "details"):
|
||||
# About the item, not a reply to it: nothing changes, a
|
||||
# countdown runs on (L5). Recorded at the agent's level.
|
||||
self._store.transcript(None, "operator").append(
|
||||
UserMessage(text=text, source=f"transport:{msg.sender}")
|
||||
)
|
||||
said = (
|
||||
self._details(item)
|
||||
if command == "details"
|
||||
else self._status_line(item, [i.id for i in self._queue()])
|
||||
)
|
||||
self._transport.post(said, thread=loop_of(item).thread)
|
||||
return
|
||||
self._store.transcript(item.id, "operator").append(
|
||||
UserMessage(text=text, source=f"transport:{msg.sender}")
|
||||
)
|
||||
@@ -844,8 +863,6 @@ class LoopAgent:
|
||||
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
|
||||
elif command == "done" and loop.new_project:
|
||||
self._provisioned(item, loop)
|
||||
elif command == "details":
|
||||
self._transport.post(self._details(item), thread=loop.thread)
|
||||
elif (
|
||||
item.position == WAITING
|
||||
and item.scope != GENERAL
|
||||
@@ -903,12 +920,13 @@ class LoopAgent:
|
||||
)
|
||||
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.
|
||||
# An answer, changes to a restatement, or a remark: the driver
|
||||
# reads it, and may reply and go back to this wait (L5).
|
||||
save(
|
||||
self._store,
|
||||
item.id,
|
||||
READY,
|
||||
replace(loop, wait="", note=""),
|
||||
replace(loop, wait="", note="", back_to=loop.wait, back_note=loop.note),
|
||||
cause="the operator answered",
|
||||
)
|
||||
self._schedule()
|
||||
@@ -1272,28 +1290,28 @@ class LoopAgent:
|
||||
f"{self._repos()}."
|
||||
)
|
||||
queue = [i.id for i in self._queue()]
|
||||
lines = []
|
||||
for item in items:
|
||||
loop = loop_of(item)
|
||||
what = item.position
|
||||
if item.position == WAITING and loop.wait == CHAT:
|
||||
what = "talking; your turn"
|
||||
elif 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:
|
||||
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
|
||||
name = (
|
||||
f'conversation "{item.title}"'
|
||||
if item.scope == GENERAL
|
||||
else f"{self._label(item)} {item.title}"
|
||||
)
|
||||
lines.append(f"{name}: {what}")
|
||||
return "\n".join(lines)
|
||||
return "\n".join(self._status_line(item, queue) for item in items)
|
||||
|
||||
def _status_line(self, item: WorkItem, queue: list[str]) -> str:
|
||||
loop = loop_of(item)
|
||||
what = item.position
|
||||
if item.position == WAITING and loop.wait == CHAT:
|
||||
what = "talking; your turn"
|
||||
elif 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:
|
||||
what = f"ready, {queue.index(item.id) + 1} of {len(queue)} in line"
|
||||
name = (
|
||||
f'conversation "{item.title}"'
|
||||
if item.scope == GENERAL
|
||||
else f"{self._label(item)} {item.title}"
|
||||
)
|
||||
return f"{name}: {what}"
|
||||
|
||||
# -- running (L1, L2) ------------------------------------------------------
|
||||
|
||||
@@ -1663,12 +1681,15 @@ class LoopAgent:
|
||||
CLOSE_ITEM,
|
||||
*([WAIT_FOR_REVIEW] if loop.pr else []),
|
||||
*([DELEGATE, ASK_EXPERT] if self._helpers else []),
|
||||
*([REPLY] if loop.back_to else []),
|
||||
*EXECUTOR_TOOLS,
|
||||
]
|
||||
),
|
||||
]
|
||||
if loop.question:
|
||||
tools = [REPLY, *_LOOK]
|
||||
elif loop.back_to and not loop.started:
|
||||
tools = [*tools, REPLY] # before the go-ahead, a remark answered
|
||||
roles = self._roles_of(item)
|
||||
workspace = project.workspace
|
||||
system = SYSTEM.format(
|
||||
@@ -1771,6 +1792,13 @@ class LoopAgent:
|
||||
replace(loop, rev=rev, wait=GO_AHEAD, note="", grace_until=grace),
|
||||
None,
|
||||
)
|
||||
elif outcome.kind == "wait" and outcome.tool == "reply" and loop.back_to:
|
||||
# A project item's reply: answered where asked (the thread or the
|
||||
# PR), then back to the very wait the message interrupted (L5).
|
||||
self._answer_where_asked(item, loop, str(args.get("text", "")).strip())
|
||||
self._wait(
|
||||
item, replace(loop, wait=loop.back_to, note=loop.back_note), None
|
||||
)
|
||||
elif outcome.kind == "wait" and outcome.tool == "reply":
|
||||
text = str(args.get("text", "")).strip()
|
||||
self._transport.post(text, thread=loop.thread)
|
||||
@@ -2068,6 +2096,8 @@ class LoopAgent:
|
||||
def _wait(self, item: WorkItem, loop: Loop, say: str | None) -> None:
|
||||
if say:
|
||||
self._transport.post(say, thread=loop.thread)
|
||||
# Waiting again: whatever woke the item is settled.
|
||||
loop = replace(loop, question=False, back_to="", back_note="")
|
||||
save(self._store, item.id, WAITING, loop, cause=f"waiting for {loop.wait}")
|
||||
|
||||
def _close(self, item: WorkItem, loop: Loop, outcome: str, note: str) -> None:
|
||||
|
||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from nisse_dev.cancel import Cancel, skip_calls
|
||||
from nisse_dev.canonical import AssistantText, ToolCall, ToolResult
|
||||
from nisse_dev.canonical import AssistantText, Fact, ToolCall, ToolResult
|
||||
from nisse_dev.llm import LLMClient, LLMError
|
||||
from nisse_dev.loop.tools import WAITING, names
|
||||
from nisse_dev.state import Transcript
|
||||
@@ -100,7 +100,10 @@ class Driver:
|
||||
if nudged:
|
||||
return RunOutcome("failed", note="kept replying in prose")
|
||||
nudged = True
|
||||
system = f"{system}\n\n{_PROSE_NUDGE}"
|
||||
# In the conversation, as the harness: a request must end on
|
||||
# the user's side (models refuse assistant prefill), and the
|
||||
# nudge is then in the log as what it was.
|
||||
transcript.as_actor("orchestrator").append(Fact(text=_PROSE_NUDGE))
|
||||
continue
|
||||
for index, call in enumerate(calls):
|
||||
if self._cancel.requested:
|
||||
|
||||
@@ -22,6 +22,7 @@ from dataclasses import replace
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
GateOpened,
|
||||
PlanSubmitted,
|
||||
@@ -105,6 +106,13 @@ def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
|
||||
if viewer == "helper":
|
||||
out.append(_said(stored, "approved plan", event.plan))
|
||||
continue
|
||||
if isinstance(event, DelegationStarted):
|
||||
# The harness handing a task over: to the helper given it, a
|
||||
# message from the harness, never its own turn (a request must
|
||||
# end on the user's side; models refuse assistant prefill).
|
||||
if actor == viewer:
|
||||
out.append(_said(stored, "orchestrator", f"your task: {event.brief}"))
|
||||
continue
|
||||
if actor == viewer:
|
||||
out.append(stored)
|
||||
elif actor == "operator" and isinstance(event, UserMessage):
|
||||
|
||||
Reference in new issue
Block a user