What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
159 lines
6.1 KiB
Python
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_core.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
|