"""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}")