Files
nisse/nisse-dev/src/nisse_dev/render.py
T
dongarandClaude Opus 5.5 3fba958958
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m27s
Give each role its own view of the transcript (M1.2)
A model no longer reads another role's turns as its own (#78). The log
stays one record per work item; what a role is shown is projected from
it by Transcript.view() (views.py), which the planner, executor and
helper now build their context from:

- own events unchanged;
- operator messages labelled with their channel, e.g.
  [operator, Matrix] or [operator, comment on PR #165];
- orchestrator facts (a new `fact` event) as [orchestrator] statements;
- the executor sees a helper's handback as [helper report], not its turns;
- a helper sees the approved plan and the executor's milestone work as
  one-line narration;
- the planner sees no other role's work at all.

The orchestrator now writes facts at the points the models must know
about: work item opened, plan approved or rejected, milestone started,
finished or stopped, published (with PR number and URL) or not, review
comment answered, next milestone, task completed or abandoned.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-02 00:39:38 +00:00

159 lines
6.1 KiB
Python

"""L7 renderer: provider request bodies are built from the canonical log at
call time, never stored provider-shaped.
First adapter: the OpenAI-compatible chat format LiteLLM's normalized route
speaks — the executor role's path (L9). The Anthropic passthrough adapter
arrives with delegations (L9). Token budgeting and prompt-cache breakpoints
are render-time concerns and are never stored (L7); neither exists yet in
this skeleton.
"""
import json
from typing import Any
from nisse_dev.canonical import (
AssistantText,
Compaction,
DelegationCompleted,
DelegationStarted,
Fact,
GateOpened,
PlanSubmitted,
StoredEvent,
ToolCall,
ToolResult,
UserMessage,
)
def effective_events(stored: list[StoredEvent]) -> list[StoredEvent]:
"""Apply compactions: events whose ids fall in a compaction's replaced
range are dropped; the compaction event itself stays as their summary."""
replaced: list[tuple[int, int]] = [
(e.event.replaces[0], e.event.replaces[1])
for e in stored
if isinstance(e.event, Compaction)
]
def is_replaced(event_id: int) -> bool:
return any(first <= event_id <= last for first, last in replaced)
return [
e for e in stored if isinstance(e.event, Compaction) or not is_replaced(e.id)
]
class OpenAIChatRenderer:
"""Mappings for orchestrator-side events (gates, plans, delegations,
compaction summaries) are provisional; they get tuned when the executor
loop (step 5) and delegations (step 7) consume them for real."""
def render(
self,
events: list[StoredEvent],
tools: list[dict[str, Any]] | None,
model: str,
system: str | None = None,
) -> dict[str, Any]:
messages: list[dict[str, Any]] = []
if system is not None:
messages.append({"role": "system", "content": system})
kept = effective_events(events)
answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)}
for stored in kept:
_append_merged(messages, self._message_for(stored.event))
# Providers reject a tool call with no result following it. One
# can legitimately be missing: a crash between the call and its
# result (L8), or a turn-ending gate tool. Render a placeholder so
# the transcript stays replayable rather than poisoned.
if isinstance(stored.event, ToolCall) and (
stored.event.call_id not in answered
):
messages.append(
{
"role": "tool",
"tool_call_id": stored.event.call_id,
"content": "[no result recorded]",
}
)
body: dict[str, Any] = {"model": model, "messages": messages}
if tools:
body["tools"] = tools
return body
def _message_for(self, event: object) -> dict[str, Any]:
match event:
case UserMessage(text=text):
return {"role": "user", "content": text}
case AssistantText(text=text):
return {"role": "assistant", "content": text}
case ToolCall(call_id=call_id, tool=tool, arguments=arguments):
return {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": tool,
"arguments": json.dumps(arguments),
},
}
],
}
case ToolResult(call_id=call_id, result=result):
return {
"role": "tool",
"tool_call_id": call_id,
"content": result,
}
case PlanSubmitted(plan=plan):
return {"role": "assistant", "content": plan}
case GateOpened(gate=gate, reason=reason):
return {"role": "user", "content": f"[gate {gate} opened: {reason}]"}
case DelegationStarted(brief=brief, model=model):
return {
"role": "assistant",
"content": f"[delegation started ({model}): {brief}]",
}
case DelegationCompleted(handback=handback):
return {
"role": "assistant",
"content": f"[delegation handback]\n{handback}",
}
case Fact(text=text):
return {"role": "user", "content": f"[orchestrator] {text}"}
case Compaction(summary=summary):
return {
"role": "user",
"content": f"[summary of earlier conversation]\n{summary}",
}
case _:
raise ValueError(f"unrenderable event: {event!r}")
def _append_merged(messages: list[dict[str, Any]], message: dict[str, Any]) -> None:
"""Append, folding into the previous message when both are the same user
or assistant role.
One model turn is often several events (some text, then one or more tool
calls), and the OpenAI format carries a turn as one assistant message
with a tool_calls list. Strict chat templates enforce that: Devstral's
(Mistral) rejected a turn rendered as a text message then a tool-call
message with a 500, failing runs whenever the model spoke before acting."""
previous = messages[-1] if messages else None
role = message["role"]
if (
previous is None
or previous["role"] != role
or role not in ("user", "assistant")
):
messages.append(message)
return
texts = [t for t in (previous.get("content"), message.get("content")) if t]
previous["content"] = "\n\n".join(texts) if texts else None
calls = previous.get("tool_calls", []) + message.get("tool_calls", [])
if calls:
previous["tool_calls"] = calls