M2.8: switch over to the new loop
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
This commit is contained in:
1 parent
09232dcbdd
commit
6ae2375194
89 files changed
+951
-8592
No files matched your search
+13
-170
@@ -2,11 +2,9 @@
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
CanonicalEvent,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
Fact,
|
||||
@@ -16,19 +14,10 @@ from nisse_dev.canonical import (
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_dev.control import MILESTONE_PLANNING, PR_OPEN, Orchestrator
|
||||
from nisse_dev.executor import ExecutorOutcome
|
||||
from nisse_dev.gitea import Comment, PullRequest
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.planner import LLMPlanningAgent
|
||||
from nisse_dev.publish import PublishResult
|
||||
from nisse_dev.render import OpenAIChatRenderer
|
||||
from nisse_dev.render_anthropic import AnthropicMessagesRenderer
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
|
||||
ROLES = RoleMap(executor="x", planner="y", helper="y", expert="y")
|
||||
|
||||
|
||||
def new_store(tmp_path: Path) -> StateStore:
|
||||
@@ -39,155 +28,18 @@ def texts(events: list[StoredEvent]) -> list[str]:
|
||||
return [getattr(e.event, "text", "") for e in events]
|
||||
|
||||
|
||||
# --- the orchestrator: run 2's planner, after a milestone ----------------------
|
||||
|
||||
|
||||
class PlanningClient:
|
||||
"""Always submits a plan, recording the events of each request."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.requests: list[list[StoredEvent]] = []
|
||||
|
||||
def complete(self, events, tools, model, system=None) -> list[CanonicalEvent]:
|
||||
self.requests.append(events)
|
||||
n = len(self.requests)
|
||||
return [
|
||||
ToolCall(
|
||||
call_id=f"plan-{n}",
|
||||
tool="submit_plan",
|
||||
arguments={"content": f"plan {n}"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class WorkingExecutor:
|
||||
"""Leaves the trail a real executor run does: text, calls, results."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.views: list[list[StoredEvent]] = []
|
||||
|
||||
def run(self, transcript: Any, milestone: int) -> ExecutorOutcome:
|
||||
self.views.append(transcript.view())
|
||||
transcript.append(AssistantText(text="I'll write the helper", model="x"))
|
||||
call = ToolCall(
|
||||
call_id=f"run-{len(self.views)}",
|
||||
tool="run_command",
|
||||
arguments={"command": "pytest"},
|
||||
)
|
||||
transcript.append(call)
|
||||
transcript.append(
|
||||
ToolResult(call_id=call.call_id, result="1 passed", success=True)
|
||||
)
|
||||
return ExecutorOutcome("completed", "added truncate() with a test")
|
||||
|
||||
|
||||
class Publisher:
|
||||
def reconcilers(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def publish(self, **kwargs: Any) -> PublishResult:
|
||||
return PublishResult(
|
||||
PullRequest(number=165, state="open", head="h", url="https://g/pr/165"),
|
||||
"opened PR #165",
|
||||
)
|
||||
|
||||
def push_followup(self, **kwargs: Any) -> str | None:
|
||||
return None
|
||||
|
||||
def comment(self, index: int, body: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def run_one_milestone(
|
||||
tmp_path: Path,
|
||||
) -> tuple[Orchestrator, StateStore, InMemoryTransport, PlanningClient, Any]:
|
||||
store = new_store(tmp_path)
|
||||
transport = InMemoryTransport()
|
||||
client = PlanningClient()
|
||||
executor = WorkingExecutor()
|
||||
orch = Orchestrator(
|
||||
store,
|
||||
transport,
|
||||
LLMPlanningAgent(client, ROLES),
|
||||
operators={"don"},
|
||||
roles=ROLES,
|
||||
executor=executor,
|
||||
publisher=Publisher(),
|
||||
gitea_operators={"don"},
|
||||
)
|
||||
orch.start()
|
||||
for text in ("add a truncate helper", "use demo", "approve", "approve"):
|
||||
transport.receive("don", text)
|
||||
transport.receive("don", "start milestone 1")
|
||||
assert store.get_state().state == PR_OPEN
|
||||
return orch, store, transport, client, executor
|
||||
|
||||
|
||||
def test_the_planner_after_a_milestone_sees_facts_not_executor_turns(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Run 2: shown the executor's calls as its own turns, the planner took
|
||||
# milestone 1's work for its own and planned around it (#78).
|
||||
_, store, transport, client, _ = run_one_milestone(tmp_path)
|
||||
transport.receive("don", "next milestone")
|
||||
assert store.get_state().state != MILESTONE_PLANNING # it planned
|
||||
|
||||
events = client.requests[-1]
|
||||
body = OpenAIChatRenderer().render(events, tools=None, model="y")
|
||||
assistant = [m for m in body["messages"] if m["role"] == "assistant"]
|
||||
called = [c["function"]["name"] for m in assistant for c in m["tool_calls"]]
|
||||
assert set(called) == {"submit_plan"} # its own turns, and only those
|
||||
seen = "\n".join(texts(events))
|
||||
assert "I'll write the helper" not in seen
|
||||
assert "[operator, Matrix, sent while idle] add a truncate helper" in seen
|
||||
assert "[orchestrator] arch plan rev-1 approved by don" in seen
|
||||
assert "milestone 1 finished: added truncate() with a test" in seen
|
||||
assert "PR #165" in seen and "https://g/pr/165" in seen
|
||||
assert "planning milestone 2" in seen
|
||||
|
||||
|
||||
def test_the_executor_sees_the_milestone_start_and_the_comment_source(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
orch, _, _, _, executor = run_one_milestone(tmp_path)
|
||||
assert "[orchestrator] milestone 1 started" in texts(executor.views[0])
|
||||
|
||||
orch.handle_review_comment(
|
||||
Comment(id=7, author="don", body="add a docstring", created_at="t")
|
||||
)
|
||||
review_view = texts(executor.views[-1])
|
||||
assert "[operator, comment on PR #165] add a docstring" in review_view
|
||||
# Its own earlier turn is still its own, not relabelled.
|
||||
own = [e for e in executor.views[-1] if isinstance(e.event, AssistantText)]
|
||||
assert [e.event.text for e in own] == ["I'll write the helper"]
|
||||
|
||||
|
||||
def test_the_outcome_is_recorded_as_a_fact_when_the_task_closes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_, store, transport, _, _ = run_one_milestone(tmp_path)
|
||||
item = store.active_item()
|
||||
transport.receive("don", "project complete")
|
||||
facts = [
|
||||
e.event.text
|
||||
for e in store.transcript(item.id, "orchestrator").read()
|
||||
if isinstance(e.event, Fact)
|
||||
]
|
||||
assert facts[-1] == "work item completed: don said project complete"
|
||||
|
||||
|
||||
# --- the projection, per role ----------------------------------------------------
|
||||
|
||||
|
||||
def delegated_milestone(store: StateStore) -> None:
|
||||
"""A milestone in which the executor failed and handed off to a helper."""
|
||||
store.transcript("a", "planner").append(PlanSubmitted(plan="1. fix it", model="y"))
|
||||
executor = store.transcript("a", "executor")
|
||||
executor.append(
|
||||
"""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"})
|
||||
)
|
||||
executor.append(ToolResult(call_id="e1", result="error: missing ;", success=False))
|
||||
helper = executor.as_actor("helper")
|
||||
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={})
|
||||
)
|
||||
@@ -207,10 +59,10 @@ def delegated_milestone(store: StateStore) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_the_executor_sees_the_helpers_report_not_its_turns(tmp_path: Path) -> None:
|
||||
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", "executor").view()
|
||||
view = store.transcript("a", "driver").view()
|
||||
assert [type(e.event).__name__ for e in view] == [
|
||||
"ToolCall",
|
||||
"ToolResult",
|
||||
@@ -221,7 +73,7 @@ def test_the_executor_sees_the_helpers_report_not_its_turns(tmp_path: Path) -> N
|
||||
)
|
||||
|
||||
|
||||
def test_the_helper_sees_the_plan_and_the_executors_work_as_narration(
|
||||
def test_the_helper_sees_the_plan_and_the_drivers_work_as_narration(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = new_store(tmp_path)
|
||||
@@ -229,7 +81,7 @@ def test_the_helper_sees_the_plan_and_the_executors_work_as_narration(
|
||||
view = store.transcript("a", "helper").view()
|
||||
said = texts(view)
|
||||
assert said[0] == "[approved plan] 1. fix it"
|
||||
assert said[1] == "[executor] run_command(make) → failed: error: missing ;"
|
||||
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(
|
||||
@@ -237,20 +89,11 @@ def test_the_helper_sees_the_plan_and_the_executors_work_as_narration(
|
||||
)
|
||||
|
||||
|
||||
def test_the_planner_sees_no_other_roles_work(tmp_path: Path) -> None:
|
||||
store = new_store(tmp_path)
|
||||
delegated_milestone(store)
|
||||
store.transcript("a", "orchestrator").append(Fact(text="milestone 1 finished"))
|
||||
assert texts(store.transcript("a", "planner").view()) == [
|
||||
"[orchestrator] milestone 1 finished"
|
||||
]
|
||||
|
||||
|
||||
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", "executor").read()
|
||||
view = store.transcript("a", "executor").view(after=raw[2].id)
|
||||
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
|
||||
|
||||
|
||||
@@ -277,7 +120,7 @@ def test_operator_messages_carry_their_channel(tmp_path: Path) -> None:
|
||||
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", "planner").view()) == [
|
||||
assert texts(store.transcript("a", "driver").view()) == [
|
||||
"[operator, Matrix] hi",
|
||||
"[operator, comment on plan issue #9] hi",
|
||||
"[operator, Gitea comment] hi",
|
||||
|
||||
Reference in new issue
Block a user