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
167 lines
6.5 KiB
Python
167 lines
6.5 KiB
Python
"""Per-role views of a work item's transcript (architecture L6; Development
|
|
Plan M1.2).
|
|
|
|
The log records every role's events in one work item. A model must not read
|
|
another role's turns as its own: in the second live run the planner, shown
|
|
the executor's tool calls as assistant turns, took that work for its own
|
|
(#78). So what a role is shown is projected here, before rendering:
|
|
|
|
- its own events, unchanged;
|
|
- what the operator said, as a user message labelled with where it came from;
|
|
- the orchestrator's facts and gates, as labelled statements;
|
|
- another model role's work only where it needs it, and only as a labelled
|
|
report or narration, never as a turn.
|
|
|
|
The projection keeps each event's id, so cursors (``after``) and compaction
|
|
ranges still line up with the log. Everything not the viewer's own becomes a
|
|
``UserMessage``: from where the viewer sits, it is all input.
|
|
"""
|
|
|
|
from dataclasses import replace
|
|
|
|
from nisse_dev.canonical import (
|
|
AssistantText,
|
|
DelegationCompleted,
|
|
DelegationStarted,
|
|
Fact,
|
|
GateOpened,
|
|
PlanSubmitted,
|
|
StoredEvent,
|
|
ToolCall,
|
|
ToolResult,
|
|
UserMessage,
|
|
)
|
|
|
|
# How much of another role's tool result a narration line keeps.
|
|
NARRATION_RESULT_CHARS = 300
|
|
|
|
# The roles whose work the viewer is shown, and how: "report" keeps only the
|
|
# handback, "narrate" keeps a line per step. Anything not listed is not shown.
|
|
_SHOWN: dict[str, dict[str, str]] = {
|
|
"planner": {},
|
|
"driver": {"helper": "report"},
|
|
"executor": {"helper": "report"},
|
|
"helper": {"executor": "narrate", "driver": "narrate"},
|
|
}
|
|
|
|
|
|
def operator_label(source: str) -> str:
|
|
"""Where an operator message came from, for its label."""
|
|
origin, _, idle = source.partition(" (sent while idle")
|
|
if origin.startswith("transport:"):
|
|
channel = "Matrix"
|
|
elif origin.startswith("gitea:pr:"):
|
|
number = origin.split(":")[2]
|
|
channel = f"comment on PR #{number}"
|
|
elif origin.startswith("gitea:issue-body:"):
|
|
channel = f"issue #{origin.split(':')[2]}"
|
|
elif origin.startswith("gitea:issue:"):
|
|
number = origin.split(":")[2]
|
|
channel = f"comment on plan issue #{number}"
|
|
elif origin.startswith("gitea:"):
|
|
channel = "Gitea comment"
|
|
else:
|
|
channel = origin or "unknown"
|
|
if idle:
|
|
channel += ", sent while idle"
|
|
return f"operator, {channel}"
|
|
|
|
|
|
def _said(stored: StoredEvent, label: str, text: str) -> StoredEvent:
|
|
return replace(
|
|
stored,
|
|
event=UserMessage(text=f"[{label}] {text}", source=f"view:{stored.actor}"),
|
|
)
|
|
|
|
|
|
def _narration(call: ToolCall, result: ToolResult | None) -> str:
|
|
target = call.arguments.get("path") or call.arguments.get("command")
|
|
step = f"{call.tool}({str(target)[:80]})" if target else call.tool
|
|
if result is None:
|
|
return f"{step} → no result recorded"
|
|
outcome = "ok" if result.success else "failed"
|
|
text = result.result.strip()
|
|
if len(text) > NARRATION_RESULT_CHARS:
|
|
text = text[:NARRATION_RESULT_CHARS] + " …"
|
|
return f"{step} → {outcome}: {text}" if text else f"{step} → {outcome}"
|
|
|
|
|
|
def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
|
|
"""The events as ``viewer`` should be shown them, in log order."""
|
|
shown = _SHOWN.get(viewer)
|
|
if shown is None:
|
|
# A role with no view defined (the expert is stateless, the
|
|
# classifier sees only an action) reads the log as it is, with
|
|
# results kept after their calls.
|
|
return _results_follow_calls(list(events))
|
|
results = {
|
|
e.event.call_id: e.event for e in events if isinstance(e.event, ToolResult)
|
|
}
|
|
out: list[StoredEvent] = []
|
|
for stored in events:
|
|
event, actor = stored.event, stored.actor
|
|
if isinstance(event, PlanSubmitted):
|
|
# A record for the executor's scope and the dump, not a turn:
|
|
# the planner's own submit_plan call already carries the plan.
|
|
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):
|
|
out.append(_said(stored, operator_label(event.source), event.text))
|
|
elif isinstance(event, Fact):
|
|
out.append(_said(stored, "orchestrator", event.text))
|
|
elif isinstance(event, GateOpened):
|
|
out.append(
|
|
_said(stored, "orchestrator", f"gate {event.gate}: {event.reason}")
|
|
)
|
|
elif shown.get(actor) == "report" and isinstance(event, DelegationCompleted):
|
|
out.append(
|
|
_said(
|
|
stored,
|
|
f"{actor} report, {event.exit_status}",
|
|
event.handback,
|
|
)
|
|
)
|
|
elif shown.get(actor) == "narrate":
|
|
if isinstance(event, ToolCall):
|
|
line = _narration(event, results.get(event.call_id))
|
|
out.append(_said(stored, actor, line))
|
|
elif isinstance(event, AssistantText) and event.text.strip():
|
|
out.append(_said(stored, actor, event.text.strip()))
|
|
return _results_follow_calls(out)
|
|
|
|
|
|
def _results_follow_calls(events: list[StoredEvent]) -> list[StoredEvent]:
|
|
"""Keep each tool result right after its call, as both API formats
|
|
require: anything recorded while a call was in flight (a fact the
|
|
harness wrote while handling it, say) moves to just after the results.
|
|
A call with no result yet keeps its place; the renderer answers it."""
|
|
out: list[StoredEvent] = []
|
|
pending: set[str] = set()
|
|
held: list[StoredEvent] = []
|
|
for stored in events:
|
|
event = stored.event
|
|
if isinstance(event, ToolCall):
|
|
pending.add(event.call_id)
|
|
out.append(stored)
|
|
elif isinstance(event, ToolResult) and event.call_id in pending:
|
|
pending.discard(event.call_id)
|
|
out.append(stored)
|
|
if not pending:
|
|
out.extend(held)
|
|
held = []
|
|
elif pending:
|
|
held.append(stored)
|
|
else:
|
|
out.append(stored)
|
|
return out + held
|