nisse-dev now runs only the conversational loop. The gated state machine it was built alongside is gone: - Removed control.py, planner.py, executor.py, approvals.py, gates.py (command_form moves to loop/reader.py), publish.py and poller.py, the old assembly in agent.py (its shared helpers move to loop/build.py), the store's state-machine view (get_state, transition), their tests, the old end-to-end and live planning/milestone/gate tests, and specs/orchestrator.qnt with its scenarios. - Roles are driver, helper, expert and classifier; driver is required. loop:, roles.planner, roles.executor and tuning.executor are refused with what to use instead; tuning.driver takes the turn cap and failure limit. - The benchmark and the capability matrix measure the driver: the loop's own system prompt and tools, the task's plan as the agreed restatement. Task plans end with finish_milestone, so earlier runs show as (old); stored runs' executor tuning reads as the driver's. - A delegation on the loop records what its helper's alias resolved to (L9), as the old path's did. - The dump shows a work item's revision and what it waits for. - Behaviour the old tests covered and the loop shares stays covered: driver runs that fail on a model call, stop mid-turn, reset the failure count; go-aheads ignoring case and punctuation; a request too big to fit is never sent; the assembly tests, now on build_loop_agent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
143 lines
5.2 KiB
Python
143 lines
5.2 KiB
Python
"""Per-role transcript views (architecture L6; Development Plan M1.2)."""
|
|
|
|
import io
|
|
from pathlib import Path
|
|
|
|
from nisse_dev.canonical import (
|
|
AssistantText,
|
|
DelegationCompleted,
|
|
DelegationStarted,
|
|
Fact,
|
|
PlanSubmitted,
|
|
StoredEvent,
|
|
ToolCall,
|
|
ToolResult,
|
|
UserMessage,
|
|
)
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.render import OpenAIChatRenderer
|
|
from nisse_dev.render_anthropic import AnthropicMessagesRenderer
|
|
from nisse_dev.state import StateStore
|
|
|
|
|
|
def new_store(tmp_path: Path) -> StateStore:
|
|
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
|
|
|
|
|
def texts(events: list[StoredEvent]) -> list[str]:
|
|
return [getattr(e.event, "text", "") for e in events]
|
|
|
|
|
|
# --- the projection, per role ----------------------------------------------------
|
|
|
|
|
|
def delegated_milestone(store: StateStore) -> None:
|
|
"""A milestone in which the driver failed and handed off to a helper."""
|
|
store.transcript("a", "driver").append(PlanSubmitted(plan="1. fix it", model="y"))
|
|
driver = store.transcript("a", "driver")
|
|
driver.append(
|
|
ToolCall(call_id="e1", tool="run_command", arguments={"command": "make"})
|
|
)
|
|
driver.append(ToolResult(call_id="e1", result="error: missing ;", success=False))
|
|
helper = driver.as_actor("helper")
|
|
helper.append(
|
|
DelegationStarted(brief="make fails", role="helper", model="y", caps={})
|
|
)
|
|
helper.append(AssistantText(text="looking at main.c", model="y"))
|
|
helper.append(
|
|
ToolCall(call_id="h1", tool="read_file", arguments={"path": "main.c"})
|
|
)
|
|
helper.append(ToolResult(call_id="h1", result="int main()", success=True))
|
|
helper.append(
|
|
DelegationCompleted(
|
|
handback="fixed the missing semicolon",
|
|
artifacts=["main.c"],
|
|
tokens=0,
|
|
duration_s=1.0,
|
|
exit_status="completed",
|
|
)
|
|
)
|
|
|
|
|
|
def test_the_driver_sees_the_helpers_report_not_its_turns(tmp_path: Path) -> None:
|
|
store = new_store(tmp_path)
|
|
delegated_milestone(store)
|
|
view = store.transcript("a", "driver").view()
|
|
assert [type(e.event).__name__ for e in view] == [
|
|
"ToolCall",
|
|
"ToolResult",
|
|
"UserMessage",
|
|
]
|
|
assert view[-1].event.text == (
|
|
"[helper report, completed] fixed the missing semicolon"
|
|
)
|
|
|
|
|
|
def test_the_helper_sees_the_plan_and_the_drivers_work_as_narration(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store = new_store(tmp_path)
|
|
delegated_milestone(store)
|
|
view = store.transcript("a", "helper").view()
|
|
said = texts(view)
|
|
assert said[0] == "[approved plan] 1. fix it"
|
|
assert said[1] == "[driver] run_command(make) → failed: error: missing ;"
|
|
# Its own events are unchanged, tool calls and all.
|
|
assert any(isinstance(e.event, ToolCall) and e.event.call_id == "h1" for e in view)
|
|
assert not any(
|
|
isinstance(e.event, ToolCall) and e.event.call_id == "e1" for e in view
|
|
)
|
|
|
|
|
|
def test_a_view_keeps_log_ids_so_cursors_still_work(tmp_path: Path) -> None:
|
|
store = new_store(tmp_path)
|
|
delegated_milestone(store)
|
|
raw = store.transcript("a", "driver").read()
|
|
view = store.transcript("a", "driver").view(after=raw[2].id)
|
|
assert [e.id for e in view] == [raw[-1].id] # the helper's report
|
|
|
|
|
|
# --- rendering ---------------------------------------------------------------------
|
|
|
|
|
|
def test_both_renderers_show_a_fact_as_a_labelled_user_message() -> None:
|
|
events = [StoredEvent(id=1, ts="", event=Fact(text="PR #3 opened"))]
|
|
openai = OpenAIChatRenderer().render(events, tools=None, model="m")
|
|
assert openai["messages"] == [
|
|
{"role": "user", "content": "[orchestrator] PR #3 opened"}
|
|
]
|
|
anthropic = AnthropicMessagesRenderer().render(events, tools=None, model="m")
|
|
assert anthropic["messages"] == [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "[orchestrator] PR #3 opened"}],
|
|
}
|
|
]
|
|
|
|
|
|
def test_operator_messages_carry_their_channel(tmp_path: Path) -> None:
|
|
store = new_store(tmp_path)
|
|
operator = store.transcript("a", "operator")
|
|
for source in ("transport:@don", "gitea:issue:9:comment:4", "gitea:comment:5"):
|
|
operator.append(UserMessage(text="hi", source=source))
|
|
assert texts(store.transcript("a", "driver").view()) == [
|
|
"[operator, Matrix] hi",
|
|
"[operator, comment on plan issue #9] hi",
|
|
"[operator, Gitea comment] hi",
|
|
]
|
|
|
|
|
|
def test_something_written_mid_call_moves_after_the_result(tmp_path: Path) -> None:
|
|
# Live, M2.1: start_milestone's fact landed between the call and its
|
|
# result, and the model API rejected every later request in the run.
|
|
store = new_store(tmp_path)
|
|
driver = store.transcript("a", "driver")
|
|
driver.append(ToolCall(call_id="c1", tool="start_milestone", arguments={}))
|
|
store.transcript("a", "orchestrator").append(Fact(text="milestone 1 started"))
|
|
driver.append(ToolResult(call_id="c1", result="started", success=True))
|
|
kinds = [type(e.event).__name__ for e in driver.view()]
|
|
assert kinds == ["ToolCall", "ToolResult", "UserMessage"]
|
|
body = AnthropicMessagesRenderer().render(driver.view(), tools=None, model="m")
|
|
result_first = body["messages"][1]["content"][0]
|
|
assert result_first["type"] == "tool_result"
|