Files
nisse/nisse-dev/src/nisse_dev/views.py
T
dongarandClaude Opus 5.5 659915d309
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m33s
CI / e2e (pull_request) Successful in 10m38s
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
2026-10-08 00:02:50 +00:00

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