- 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>
131 lines
4.9 KiB
Python
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}")
|