M7.1: extract nisse-core
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:
1 parent
4bbcb09c8e
commit
154a0c3fa8
119 files changed
+1086
-912
No files matched your search
@@ -0,0 +1,151 @@
|
||||
import json
|
||||
from itertools import pairwise
|
||||
|
||||
from nisse_core.canonical import (
|
||||
AssistantText,
|
||||
Compaction,
|
||||
GateOpened,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_core.render import OpenAIChatRenderer, effective_events
|
||||
|
||||
|
||||
def stored(events: list) -> list[StoredEvent]:
|
||||
return [StoredEvent(id=i + 1, ts="t", event=e) for i, e in enumerate(events)]
|
||||
|
||||
|
||||
def test_renders_mixed_transcript_in_order() -> None:
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="run the tests", source="m:1"),
|
||||
ToolCall(call_id="call-9", tool="run_tests", arguments={"path": "x"}),
|
||||
ToolResult(call_id="call-9", result="1 passed", success=True),
|
||||
AssistantText(text="all green", model="local/qwen"),
|
||||
]
|
||||
)
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="local/qwen")
|
||||
assert body["model"] == "local/qwen"
|
||||
roles = [m["role"] for m in body["messages"]]
|
||||
assert roles == ["user", "assistant", "tool", "assistant"]
|
||||
call = body["messages"][1]["tool_calls"][0]
|
||||
assert call["id"] == "call-9"
|
||||
assert call["function"]["name"] == "run_tests"
|
||||
assert json.loads(call["function"]["arguments"]) == {"path": "x"}
|
||||
assert body["messages"][2]["tool_call_id"] == "call-9"
|
||||
assert "tools" not in body
|
||||
|
||||
|
||||
def test_system_prompt_and_tools_pass_through() -> None:
|
||||
tools = [{"type": "function", "function": {"name": "run_tests"}}]
|
||||
body = OpenAIChatRenderer().render(
|
||||
stored([UserMessage(text="hi", source="m:1")]),
|
||||
tools=tools,
|
||||
model="local/qwen",
|
||||
system="you are nisse-dev",
|
||||
)
|
||||
assert body["messages"][0] == {"role": "system", "content": "you are nisse-dev"}
|
||||
assert body["tools"] == tools
|
||||
|
||||
|
||||
def test_compaction_replaces_its_range_only() -> None:
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="old-1", source="m:1"),
|
||||
AssistantText(text="old-2", model="q"),
|
||||
UserMessage(text="kept", source="m:3"),
|
||||
]
|
||||
)
|
||||
events.append(
|
||||
StoredEvent(
|
||||
id=4,
|
||||
ts="t",
|
||||
event=Compaction(summary="the old stuff", model="q", replaces=[1, 2]),
|
||||
)
|
||||
)
|
||||
remaining = effective_events(events)
|
||||
assert [e.id for e in remaining] == [3, 4]
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
contents = [m["content"] for m in body["messages"]]
|
||||
# Both render as user messages, so they share one: roles must alternate.
|
||||
assert contents == ["kept\n\n[summary of earlier conversation]\nthe old stuff"]
|
||||
|
||||
|
||||
def test_tool_call_without_a_result_gets_a_placeholder() -> None:
|
||||
# Providers reject a dangling tool_use; this happens after a crash (L8)
|
||||
# or with turn-ending gate tools like submit_plan.
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="go", source="m:1"),
|
||||
ToolCall(call_id="call-x", tool="submit_plan", arguments={}),
|
||||
]
|
||||
)
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
assert [m["role"] for m in body["messages"]] == ["user", "assistant", "tool"]
|
||||
assert body["messages"][2]["tool_call_id"] == "call-x"
|
||||
assert body["messages"][2]["content"] == "[no result recorded]"
|
||||
|
||||
|
||||
def test_answered_tool_call_gets_no_placeholder() -> None:
|
||||
events = stored(
|
||||
[
|
||||
ToolCall(call_id="call-x", tool="run_command", arguments={}),
|
||||
ToolResult(call_id="call-x", result="ok", success=True),
|
||||
]
|
||||
)
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
assert [m["role"] for m in body["messages"]] == ["assistant", "tool"]
|
||||
assert body["messages"][1]["content"] == "ok"
|
||||
|
||||
|
||||
def test_gate_event_renders_as_context() -> None:
|
||||
events = stored([GateOpened(gate="arch", reason="plan submitted (rev-1)")])
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
assert body["messages"][0]["role"] == "user"
|
||||
assert "arch" in body["messages"][0]["content"]
|
||||
|
||||
|
||||
def _roles(body: dict) -> list[str]:
|
||||
return [m["role"] for m in body["messages"]]
|
||||
|
||||
|
||||
def test_one_turn_is_one_assistant_message() -> None:
|
||||
# Text and two parallel tool calls from one model turn: the OpenAI format
|
||||
# carries them as a single assistant message with a tool_calls list.
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="go", source="m:1"),
|
||||
AssistantText(text="Looking around first.", model="q"),
|
||||
ToolCall(call_id="a", tool="list_dir", arguments={"path": "."}),
|
||||
ToolCall(call_id="b", tool="read_file", arguments={"path": "README.md"}),
|
||||
ToolResult(call_id="a", result="README.md", success=True),
|
||||
ToolResult(call_id="b", result="hello", success=True),
|
||||
]
|
||||
)
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="q")
|
||||
assert _roles(body) == ["user", "assistant", "tool", "tool"]
|
||||
turn = body["messages"][1]
|
||||
assert turn["content"] == "Looking around first."
|
||||
assert [c["id"] for c in turn["tool_calls"]] == ["a", "b"]
|
||||
|
||||
|
||||
def test_roles_never_repeat_for_strict_templates() -> None:
|
||||
"""Mistral's chat template raises on two user or two assistant messages
|
||||
in a row; every mix of events must render without one."""
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="one", source="m:1"),
|
||||
UserMessage(text="two", source="m:2"),
|
||||
AssistantText(text="thinking", model="q"),
|
||||
ToolCall(call_id="a", tool="list_dir", arguments={}),
|
||||
ToolResult(call_id="a", result="x", success=True),
|
||||
AssistantText(text="more", model="q"),
|
||||
AssistantText(text="and more", model="q"),
|
||||
ToolCall(call_id="b", tool="finish", arguments={}),
|
||||
]
|
||||
)
|
||||
roles = _roles(OpenAIChatRenderer().render(events, tools=None, model="q"))
|
||||
for first, second in pairwise(roles):
|
||||
assert not (first == second and first in ("user", "assistant")), roles
|
||||
Reference in new issue
Block a user