Files
nisse/nisse-dev/src/nisse_dev/render.py
T
dongarandClaude Opus 5 0fd0b2e4a5
CI / checks (push) Failing after 13s
CI / checks (pull_request) Failing after 13s
Add executor tool loop (A12/A15) — step 5b
- tools.py: four flat tools (read_file, write_file, list_dir,
  run_command) per A15's reduced manifest. Deliberately unsandboxed:
  the VM is the trust boundary (B3) and tool use is auto-approved
  inside an approved unit of work (A4), so path confinement would add
  failure modes without adding safety. What is enforced is
  operational — timeout with process-group kill so a backgrounded
  server cannot outlive its command, output truncation so a build log
  cannot eat the context window, closed stdin so prompts fail fast,
  and a scrubbed environment so project code never sees the agent's
  credentials
- executor.py: renders the log, calls the executor role with the
  reduced manifest, dispatches tool calls, and checkpoints every
  result (A13). Escalation is observable, never self-assessed (A12):
  consecutive command failures and a hard turn cap. Failed reads do
  not count — looking around is not failing
- Until delegation exists (step 7), escalation gates to the human
  with the failures attached
- control.py: start milestone now runs the executor; a stopped
  milestone gates to AWAIT_INPUT

Two bugs found by running it live against real models:

- A turn-ending gate tool (submit_plan) left a tool_call with no
  tool_result, which providers reject outright, so every planning
  turn after the first failed with a 400. The planner now records the
  gate as the call's outcome, which is also the truthful transcript
- The renderer now emits a placeholder for any tool_call lacking a
  result, so a crash between call and result (A3) cannot poison the
  transcript permanently

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:35:39 +00:00

131 lines
4.9 KiB
Python

"""A10 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 (A5). The Anthropic passthrough adapter
arrives with delegations (A13). Token budgeting and prompt-cache breakpoints
are render-time concerns and are never stored (A10); neither exists yet in
this skeleton.
"""
import json
from typing import Any
from nisse_dev.canonical import (
AssistantText,
Compaction,
DelegationCompleted,
DelegationStarted,
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:
messages.append(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 (A3), 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 Compaction(summary=summary):
return {
"role": "user",
"content": f"[summary of earlier conversation]\n{summary}",
}
case _:
raise ValueError(f"unrenderable event: {event!r}")