M7.1: extract nisse-core
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m42s
CI / e2e (pull_request) Successful in 4m0s

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
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-13 18:37:01 +00:00
1 parent 4bbcb09c8e
commit 154a0c3fa8
119 files changed
+1086 -912

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
"""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