Put work items in the state store, and bind the log to them (M1.1)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
Development Plan M1.1, as discussed. - Schema v4: a work_items table (id, scope, title, status, outcome, position, data) and an agent row whose active_item points at one (NULL: idle). Canonical events and intents carry their work item, and events their actor. Older stores are refused with a clear message: there was no production data, so no migration (the mechanism stays). - The log is reached through a Transcript bound to one work item and one actor: store.transcript(item, actor) reads only that item and writes with both. The planner, executor, delegation and expert desk get one per call from the orchestrator instead of holding the store, so a component can neither read another task's events (the planner read every earlier task's, #78) nor write without attribution. store.all_events() is kept for readers that span items (evaluation capture, dumps). Intents are begun through the transcript or with work_item=, so each belongs to its item. - The orchestrator opens a work item on 'use', closes it with its outcome (completed or abandoned), and carries what the operator said while idle into the new item as copies with their origin, so a task sent before 'use' is still planned from (D1). control.py keeps its state machine through get_state()/transition(), now over the active work item, until M2.6 retires it. - nisse_dev.dump shows work items and each event's item and actor. Tests: test_work_items.py (scoping, attribution, intent ownership, restart, outcomes, carried messages, a second task never seeing the first); the suites updated for the per-call transcript. 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
3cf4cefacd
commit
83804fe02a
40 files changed
+1026
-363
No files matched your search
@@ -30,6 +30,7 @@ class Planner:
|
||||
|
||||
def plan(
|
||||
self,
|
||||
transcript: object,
|
||||
gate: str,
|
||||
project: str,
|
||||
feedback: str | None = None,
|
||||
@@ -64,7 +65,7 @@ class Board:
|
||||
|
||||
|
||||
class Executor:
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
return ExecutorOutcome("completed", "done")
|
||||
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ def test_stale_feedback_is_recorded_but_rejects_nothing(tmp_path: Path) -> None:
|
||||
assert gate(store)["rev"] == 2
|
||||
assert "not taken as feedback" in transport.outbound[-1].text
|
||||
said = [
|
||||
e.event.text for e in store.read_events() if isinstance(e.event, UserMessage)
|
||||
e.event.text for e in store.all_events() if isinstance(e.event, UserMessage)
|
||||
]
|
||||
assert "also, keep it in one file" in said # the planner still hears it
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ def build(
|
||||
orch = Orchestrator(
|
||||
store,
|
||||
transport,
|
||||
LLMPlanningAgent(ScriptedClient(), store, ROLES),
|
||||
LLMPlanningAgent(ScriptedClient(), ROLES),
|
||||
operators={"don"},
|
||||
roles=ROLES,
|
||||
gitea_operators={"dongar"},
|
||||
|
||||
@@ -16,7 +16,7 @@ from nisse_dev.canonical import (
|
||||
from_payload,
|
||||
mint_call_id,
|
||||
)
|
||||
from nisse_dev.state import CURRENT_SCHEMA_VERSION, StateStore
|
||||
from nisse_dev.state import StateSchemaError, StateStore
|
||||
|
||||
ALL_EVENTS = [
|
||||
UserMessage(text="use demo", source="matrix:$abc"),
|
||||
@@ -45,18 +45,20 @@ ALL_EVENTS = [
|
||||
def test_all_event_types_round_trip(tmp_path: Path) -> None:
|
||||
with StateStore(tmp_path / "state.db") as store:
|
||||
for event in ALL_EVENTS:
|
||||
store.append_event(event)
|
||||
store.transcript("t", "executor").append(event)
|
||||
with StateStore(tmp_path / "state.db") as store:
|
||||
stored = store.read_events()
|
||||
stored = store.transcript("t", "executor").read()
|
||||
assert [s.event for s in stored] == ALL_EVENTS
|
||||
assert [s.id for s in stored] == sorted(s.id for s in stored)
|
||||
assert {(s.work_item, s.actor) for s in stored} == {("t", "executor")}
|
||||
|
||||
|
||||
def test_read_events_after_id(tmp_path: Path) -> None:
|
||||
with StateStore(tmp_path / "state.db") as store:
|
||||
first = store.append_event(UserMessage(text="one", source="m:1"))
|
||||
store.append_event(UserMessage(text="two", source="m:2"))
|
||||
tail = store.read_events(after=first)
|
||||
log = store.transcript("t", "operator")
|
||||
first = log.append(UserMessage(text="one", source="m:1"))
|
||||
log.append(UserMessage(text="two", source="m:2"))
|
||||
tail = log.read(after=first)
|
||||
assert [s.event.text for s in tail] == ["two"]
|
||||
|
||||
|
||||
@@ -102,26 +104,17 @@ V1_SCHEMA = [
|
||||
]
|
||||
|
||||
|
||||
def test_default_migration_upgrades_a_real_v1_store(tmp_path: Path) -> None:
|
||||
def test_a_store_from_before_work_items_is_refused_clearly(tmp_path: Path) -> None:
|
||||
# No production data predates work items, so there is no migration from
|
||||
# these versions (Development Plan M1.1): the store must say so rather
|
||||
# than open half-understood.
|
||||
db = tmp_path / "state.db"
|
||||
conn = sqlite3.connect(db)
|
||||
for statement in V1_SCHEMA:
|
||||
conn.execute(statement)
|
||||
conn.execute("INSERT INTO meta (key, value) VALUES ('schema_version', '1')")
|
||||
conn.execute(
|
||||
"INSERT INTO agent_state (id, state, project, data, updated_at) "
|
||||
"VALUES (1, 'IDLE', NULL, '{}', 't')"
|
||||
)
|
||||
conn.execute("INSERT INTO meta (key, value) VALUES ('schema_version', '3')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with StateStore(db) as store:
|
||||
assert store.schema_version == CURRENT_SCHEMA_VERSION
|
||||
state = store.get_state()
|
||||
assert state is not None
|
||||
assert state.state == "IDLE" # v1 data intact
|
||||
store.append_event(UserMessage(text="hello", source="m:1"))
|
||||
assert len(store.read_events()) == 1
|
||||
# Two migrations were traversed, so both later tables must exist.
|
||||
store.mark_outcome(1, "good", "arrived via v1 -> v3")
|
||||
assert store.outcomes()[1] == ("good", "arrived via v1 -> v3")
|
||||
with pytest.raises(StateSchemaError, match="predates work items"):
|
||||
StateStore(db)
|
||||
@@ -248,7 +248,7 @@ def test_the_executor_sees_only_its_milestone(tmp_path: Path) -> None:
|
||||
GateOpened(gate="milestone-1", reason="plan submitted"),
|
||||
UserMessage(text="start milestone 1", source="t"),
|
||||
):
|
||||
store.append_event(event)
|
||||
store.transcript("t", "executor").append(event)
|
||||
|
||||
class Workspace:
|
||||
def describe(self) -> str:
|
||||
@@ -263,7 +263,7 @@ def test_the_executor_sees_only_its_milestone(tmp_path: Path) -> None:
|
||||
return [AssistantText(text="done", model=model)]
|
||||
|
||||
roles = RoleMap(executor="m", planner="m", helper="m", expert="m")
|
||||
LLMExecutor(Client(), store, roles, Workspace()).run(1)
|
||||
LLMExecutor(Client(), roles, Workspace()).run(store.transcript("t", "executor"), 1)
|
||||
|
||||
texts = [getattr(e, "text", None) for e in seen["events"]]
|
||||
assert "start milestone 1" in texts
|
||||
@@ -278,7 +278,9 @@ def test_a_request_that_cannot_fit_stops_the_milestone(tmp_path: Path) -> None:
|
||||
# Never sent: the executor stops and asks the operator, like any failed
|
||||
# call, rather than handing a server a prompt it would silently cut.
|
||||
store = StateStore(tmp_path / "state.db", oplog=OpLog("t", io.StringIO()))
|
||||
store.append_event(UserMessage(text="start milestone 1", source="t"))
|
||||
store.transcript("t", "operator").append(
|
||||
UserMessage(text="start milestone 1", source="t")
|
||||
)
|
||||
|
||||
class Workspace:
|
||||
def describe(self) -> str:
|
||||
@@ -287,7 +289,9 @@ def test_a_request_that_cannot_fit_stops_the_milestone(tmp_path: Path) -> None:
|
||||
inner = RecordingClient()
|
||||
client = BudgetedClient(inner, lambda m: (4096, 1024))
|
||||
roles = RoleMap(executor="m", planner="m", helper="m", expert="m")
|
||||
outcome = LLMExecutor(client, store, roles, Workspace()).run(1)
|
||||
outcome = LLMExecutor(client, roles, Workspace()).run(
|
||||
store.transcript("t", "executor"), 1
|
||||
)
|
||||
|
||||
assert outcome.status == "escalate"
|
||||
assert "fit" in outcome.note
|
||||
|
||||
@@ -142,7 +142,7 @@ def test_a_message_is_matched_as_stripped_but_recorded_as_sent(
|
||||
h.transport.receive("don", "approve", original="Nisse Dev: approve")
|
||||
assert h.state == AWAIT_MILESTONE_SIGNOFF
|
||||
recorded = [
|
||||
e.event.text for e in h.store.read_events() if isinstance(e.event, UserMessage)
|
||||
e.event.text for e in h.store.all_events() if isinstance(e.event, UserMessage)
|
||||
]
|
||||
assert "Nisse Dev: approve" in recorded
|
||||
assert "approve" not in recorded
|
||||
@@ -160,6 +160,7 @@ def test_status_reports_state_and_gate(h: Harness) -> None:
|
||||
class QuestionThenPlan:
|
||||
def plan(
|
||||
self,
|
||||
transcript: object,
|
||||
gate: str,
|
||||
project: str,
|
||||
feedback: str | None = None,
|
||||
@@ -192,6 +193,7 @@ class FailsOnce:
|
||||
|
||||
def plan(
|
||||
self,
|
||||
transcript: object,
|
||||
gate: str,
|
||||
project: str,
|
||||
feedback: str | None = None,
|
||||
|
||||
@@ -19,12 +19,13 @@ from nisse_dev.delegation import (
|
||||
DELEGATION_INTENT,
|
||||
Caps,
|
||||
Delegation,
|
||||
DelegationResult,
|
||||
delegation_reconciler,
|
||||
)
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.llm import BudgetExhausted, LLMError, UpstreamUnavailable
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.state import StateStore, Transcript
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
|
||||
@@ -86,8 +87,34 @@ def ws(repo: GitRepo) -> Workspace:
|
||||
return Workspace(repo.root, timeout_s=10)
|
||||
|
||||
|
||||
ITEM = "t" # the work item these delegations run in
|
||||
|
||||
|
||||
def log(store: StateStore) -> Transcript:
|
||||
return store.transcript(ITEM, "helper")
|
||||
|
||||
|
||||
class Bound:
|
||||
"""A delegation run in the test's work item, as the executor runs it."""
|
||||
|
||||
def __init__(self, delegation: Delegation, store: StateStore) -> None:
|
||||
self.delegation = delegation
|
||||
self.store = store
|
||||
|
||||
def run(self, **kw: Any) -> DelegationResult:
|
||||
return self.delegation.run(log(self.store), **kw)
|
||||
|
||||
def resume(self, intent: Any) -> DelegationResult:
|
||||
return self.delegation.resume(intent)
|
||||
|
||||
def recover(self, notify: Any) -> DelegationResult | None:
|
||||
return self.delegation.recover(notify)
|
||||
|
||||
|
||||
def build(client: Any, store: StateStore, ws: Workspace, repo: GitRepo, **kw: Any):
|
||||
return Delegation(client, store, ws, repo, model="claude-haiku-4-5", **kw)
|
||||
return Bound(
|
||||
Delegation(client, store, ws, repo, model="claude-haiku-4-5", **kw), store
|
||||
)
|
||||
|
||||
|
||||
def test_successful_delegation_returns_a_structured_handback(
|
||||
@@ -169,7 +196,7 @@ def test_stop_discards_the_branch_and_settles_the_intent(
|
||||
# The unrun call still has a result, so the transcript renders.
|
||||
skipped = [
|
||||
e.event
|
||||
for e in store.read_events()
|
||||
for e in log(store).read()
|
||||
if isinstance(e.event, ToolResult) and e.event.result == STOPPED_RESULT
|
||||
]
|
||||
assert len(skipped) == 1
|
||||
@@ -225,7 +252,7 @@ def test_events_are_checkpointed_as_they_arrive(
|
||||
)
|
||||
build(client, store, ws, repo).run(brief="add a", branch="d")
|
||||
|
||||
kinds = [type(e.event).__name__ for e in store.read_events()]
|
||||
kinds = [type(e.event).__name__ for e in log(store).read()]
|
||||
assert kinds == [
|
||||
"DelegationStarted",
|
||||
"ToolCall",
|
||||
@@ -245,7 +272,7 @@ def test_composite_events_record_the_brief_caps_and_outcome(
|
||||
caps = Caps(turns=7, tokens=1234, wall_clock_s=42.0)
|
||||
build(client, store, ws, repo, caps=caps).run(brief="the brief", branch="d")
|
||||
|
||||
events = [e.event for e in store.read_events()]
|
||||
events = [e.event for e in log(store).read()]
|
||||
started = next(e for e in events if isinstance(e, DelegationStarted))
|
||||
completed = next(e for e in events if isinstance(e, DelegationCompleted))
|
||||
assert started.brief == "the brief"
|
||||
@@ -356,6 +383,7 @@ def pending_delegation_intent(store: StateStore, branch: str = "d") -> Any:
|
||||
DELEGATION_INTENT,
|
||||
{"branch": branch, "base": "main", "brief": "finish it"},
|
||||
cause="test",
|
||||
work_item=ITEM, # owned by the work item it ran in
|
||||
)
|
||||
return store.pending_intents()[-1]
|
||||
|
||||
@@ -385,9 +413,9 @@ def test_clean_cut_after_a_tool_result_is_resumable(
|
||||
repo.checkout_new_branch("d")
|
||||
(repo.root / "partial.py").write_text("x")
|
||||
repo.commit_all("partial work")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolCall(call_id="c", tool="write_file", arguments={}))
|
||||
store.append_event(ToolResult(call_id="c", result="wrote", success=True))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolCall(call_id="c", tool="write_file", arguments={}))
|
||||
log(store).append(ToolResult(call_id="c", result="wrote", success=True))
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is True
|
||||
@@ -398,8 +426,8 @@ def test_dangling_tool_call_gates(
|
||||
) -> None:
|
||||
# The effect of the call is unknown and not verifiable by inspection.
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is None
|
||||
@@ -408,8 +436,8 @@ def test_dangling_tool_call_gates(
|
||||
def test_missing_branch_means_nothing_started(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolResult(call_id="c", result="ok", success=True))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolResult(call_id="c", result="ok", success=True))
|
||||
intent = pending_delegation_intent(store, branch="never-created")
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is False
|
||||
@@ -419,8 +447,8 @@ def test_a_finished_delegation_settles_rather_than_resumes(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(
|
||||
DelegationCompleted(
|
||||
handback="done",
|
||||
artifacts=[],
|
||||
@@ -440,8 +468,8 @@ def test_resume_continues_the_same_delegation(
|
||||
# Interrupt after one turn, then resume: no second delegation_started,
|
||||
# because L7 defines a session as the delegation, not the process.
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
client = ScriptedClient(
|
||||
@@ -450,7 +478,7 @@ def test_resume_continues_the_same_delegation(
|
||||
result = build(client, store, ws, repo).resume(intent)
|
||||
|
||||
assert result.usable
|
||||
starts = [e for e in store.read_events() if isinstance(e.event, DelegationStarted)]
|
||||
starts = [e for e in log(store).read() if isinstance(e.event, DelegationStarted)]
|
||||
assert len(starts) == 1
|
||||
assert store.pending_intents() == []
|
||||
assert (repo.root / "b.py").exists()
|
||||
@@ -474,8 +502,8 @@ def test_recover_resumes_a_clean_cut_with_a_notice(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
pending_delegation_intent(store)
|
||||
notices: list[str] = []
|
||||
|
||||
@@ -493,8 +521,8 @@ def test_recover_settles_a_finished_delegation_without_resuming(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(
|
||||
DelegationCompleted(
|
||||
handback="done",
|
||||
artifacts=[],
|
||||
@@ -514,7 +542,7 @@ def test_recover_settles_a_finished_delegation_without_resuming(
|
||||
def test_recover_discards_an_intent_whose_branch_never_existed(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
store.append_event(ToolResult(call_id="c", result="ok", success=True))
|
||||
log(store).append(ToolResult(call_id="c", result="ok", success=True))
|
||||
pending_delegation_intent(store, branch="never-made")
|
||||
|
||||
assert build(ScriptedClient([]), store, ws, repo).recover(lambda _m: None) is None
|
||||
@@ -525,8 +553,8 @@ def test_recover_leaves_an_unclean_cut_for_the_boot_gate(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
pending_delegation_intent(store)
|
||||
|
||||
assert build(ScriptedClient([]), store, ws, repo).recover(lambda _m: None) is None
|
||||
@@ -581,7 +609,7 @@ def test_tokens_spent_are_reported_not_left_at_zero(
|
||||
assert result.tokens == 2000 # two calls, 1000 each
|
||||
|
||||
completed = next(
|
||||
e.event for e in store.read_events() if isinstance(e.event, DelegationCompleted)
|
||||
e.event for e in log(store).read() if isinstance(e.event, DelegationCompleted)
|
||||
)
|
||||
# L9 evaluates cost against outcome, which needs a real number.
|
||||
assert completed.tokens == 2000
|
||||
|
||||
@@ -9,34 +9,43 @@ from nisse_dev.state import StateStore
|
||||
|
||||
def store_with_a_run(path: Path) -> None:
|
||||
store = StateStore(path, oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
store.transcript(None, "operator").append(
|
||||
UserMessage(text="hello before use", source="matrix")
|
||||
)
|
||||
store.transition(
|
||||
"AWAIT_ARCH_SIGNOFF",
|
||||
cause="test",
|
||||
project="demo",
|
||||
data={"task_id": "demo-1", "gate": {"name": "arch", "rev": 1}},
|
||||
)
|
||||
store.append_event(UserMessage(text="Add a truncate helper", source="matrix"))
|
||||
store.append_event(PlanSubmitted(plan="x" * 500, model="opus"))
|
||||
store.append_event(
|
||||
log = store.transcript("demo-1", "operator")
|
||||
log.append(UserMessage(text="Add a truncate helper", source="matrix"))
|
||||
log.as_actor("planner").append(PlanSubmitted(plan="x" * 500, model="opus"))
|
||||
executor = log.as_actor("executor")
|
||||
executor.append(
|
||||
ToolCall(call_id="c1", tool="run_command", arguments={"command": "ls"})
|
||||
)
|
||||
store.append_event(ToolResult(call_id="c1", result="README.md", success=False))
|
||||
store.begin_intent("open_pr", {"branch": "b"}, cause="test")
|
||||
executor.append(ToolResult(call_id="c1", result="README.md", success=False))
|
||||
log.begin_intent("open_pr", {"branch": "b"}, cause="test")
|
||||
store.close()
|
||||
|
||||
|
||||
def test_the_dump_shows_state_intents_and_each_event(tmp_path: Path) -> None:
|
||||
def test_the_dump_shows_items_intents_and_each_event_with_its_actor(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
db = tmp_path / "state.db"
|
||||
store_with_a_run(db)
|
||||
text = render(db)
|
||||
assert "active work item: demo-1" in text
|
||||
assert (
|
||||
"state: AWAIT_ARCH_SIGNOFF project: demo task: demo-1 gate: arch rev-1"
|
||||
in text
|
||||
"demo-1 open position: AWAIT_ARCH_SIGNOFF scope: demo gate: arch rev-1"
|
||||
in (text)
|
||||
)
|
||||
assert "pending intent #1 open_pr" in text
|
||||
assert "USER [matrix]: Add a truncate helper" in text
|
||||
assert "PLAN (opus, 500 chars): " in text and "[340 more]" in text
|
||||
assert 'CALL run_command {"command": "ls"}' in text
|
||||
assert "pending intent #1 open_pr [demo-1]" in text
|
||||
assert "[agent/operator] USER [matrix]: hello before use" in text
|
||||
assert "[demo-1/operator] USER [matrix]: Add a truncate helper" in text
|
||||
assert "[demo-1/planner] PLAN (opus, 500 chars): " in text and "[340 more]" in text
|
||||
assert '[demo-1/executor] CALL run_command {"command": "ls"}' in text
|
||||
assert "result (FAILED): README.md" in text
|
||||
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@ def record_pair(
|
||||
role: str = "helper",
|
||||
status: str = "handback",
|
||||
) -> int:
|
||||
store.append_event(
|
||||
store.transcript("t", "helper").append(
|
||||
DelegationStarted(brief=brief, role=role, model=model, caps={"turns": 5})
|
||||
)
|
||||
store.append_event(
|
||||
store.transcript("t", "helper").append(
|
||||
DelegationCompleted(
|
||||
handback="what changed: greet.py",
|
||||
artifacts=["greet.py"],
|
||||
@@ -39,7 +39,7 @@ def record_pair(
|
||||
exit_status=status,
|
||||
)
|
||||
)
|
||||
return store.read_events()[-1].id
|
||||
return store.all_events()[-1].id
|
||||
|
||||
|
||||
def test_pairs_a_start_with_its_completion(store: StateStore) -> None:
|
||||
@@ -55,16 +55,18 @@ def test_pairs_a_start_with_its_completion(store: StateStore) -> None:
|
||||
def test_unpaired_start_is_skipped(store: StateStore) -> None:
|
||||
# A crash mid-delegation leaves a start with no completion; a half-formed
|
||||
# record would pollute the set L9 exists to make trustworthy.
|
||||
store.append_event(
|
||||
store.transcript("t", "helper").append(
|
||||
DelegationStarted(brief="died", role="helper", model="m", caps={})
|
||||
)
|
||||
assert collect(store) == []
|
||||
|
||||
|
||||
def test_unrelated_events_between_the_pair_are_ignored(store: StateStore) -> None:
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(AssistantText(text="working", model="m"))
|
||||
store.append_event(
|
||||
store.transcript("t", "helper").append(
|
||||
DelegationStarted(brief="b", role="helper", model="m", caps={})
|
||||
)
|
||||
store.transcript("t", "helper").append(AssistantText(text="working", model="m"))
|
||||
store.transcript("t", "helper").append(
|
||||
DelegationCompleted(
|
||||
handback="done",
|
||||
artifacts=[],
|
||||
@@ -103,7 +105,7 @@ def test_consults_and_delegations_share_the_set(store: StateStore) -> None:
|
||||
|
||||
def test_collect_can_start_after_a_marker(store: StateStore) -> None:
|
||||
record_pair(store, brief="old")
|
||||
marker = store.read_events()[-1].id
|
||||
marker = store.all_events()[-1].id
|
||||
record_pair(store, brief="new")
|
||||
assert [r.brief for r in collect(store, after=marker)] == ["new"]
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from nisse_dev.canonical import (
|
||||
from nisse_dev.executor import LLMExecutor
|
||||
from nisse_dev.llm import LLMError, RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.state import StateStore, Transcript
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
ROLES = RoleMap(
|
||||
@@ -66,8 +66,26 @@ def ws(tmp_path: Path) -> Workspace:
|
||||
return Workspace(tmp_path / "work", timeout_s=10)
|
||||
|
||||
|
||||
def build(client: Any, store: StateStore, ws: Workspace, **kw: Any) -> LLMExecutor:
|
||||
return LLMExecutor(client, store, ROLES, ws, **kw)
|
||||
ITEM = "t" # the work item these tests run in
|
||||
|
||||
|
||||
def log(store: StateStore) -> Transcript:
|
||||
return store.transcript(ITEM, "executor")
|
||||
|
||||
|
||||
class Bound:
|
||||
"""An executor run in the test's work item, as the orchestrator runs it."""
|
||||
|
||||
def __init__(self, executor: LLMExecutor, store: StateStore) -> None:
|
||||
self.executor = executor
|
||||
self.store = store
|
||||
|
||||
def run(self, milestone: int) -> Any:
|
||||
return self.executor.run(log(self.store), milestone)
|
||||
|
||||
|
||||
def build(client: Any, store: StateStore, ws: Workspace, **kw: Any) -> Bound:
|
||||
return Bound(LLMExecutor(client, ROLES, ws, **kw), store)
|
||||
|
||||
|
||||
def test_tool_calls_are_executed_and_results_logged(
|
||||
@@ -84,7 +102,7 @@ def test_tool_calls_are_executed_and_results_logged(
|
||||
assert outcome.status == "completed"
|
||||
assert outcome.note == "wrote the file"
|
||||
assert (ws.root / "app.py").read_text() == "print(1)\n"
|
||||
kinds = [type(e.event).__name__ for e in store.read_events()]
|
||||
kinds = [type(e.event).__name__ for e in log(store).read()]
|
||||
assert kinds == ["ToolCall", "ToolResult", "ToolCall", "ToolResult"]
|
||||
|
||||
|
||||
@@ -138,7 +156,7 @@ def test_finishing_without_any_work_is_refused_once(
|
||||
assert (ws.root / "a.py").exists() # the refusal produced actual work
|
||||
[refusal] = [
|
||||
e.event
|
||||
for e in store.read_events()
|
||||
for e in log(store).read()
|
||||
if isinstance(e.event, ToolResult) and e.event.call_id == "f1"
|
||||
]
|
||||
assert not refusal.success
|
||||
@@ -165,7 +183,7 @@ def test_calls_after_finish_are_not_run(store: StateStore, ws: Workspace) -> Non
|
||||
assert not (ws.root / "b.py").exists()
|
||||
[late] = [
|
||||
e.event
|
||||
for e in store.read_events()
|
||||
for e in log(store).read()
|
||||
if isinstance(e.event, ToolResult) and e.event.call_id == "late"
|
||||
]
|
||||
assert "finish had already ended the milestone" in late.result
|
||||
@@ -183,7 +201,7 @@ def test_an_invented_tool_counts_toward_escalation(
|
||||
outcome = build(client, store, ws, failure_limit=2).run(milestone=1)
|
||||
assert outcome.status == "escalate"
|
||||
assert len(client.calls) == 2
|
||||
results = [e.event for e in store.read_events() if isinstance(e.event, ToolResult)]
|
||||
results = [e.event for e in log(store).read() if isinstance(e.event, ToolResult)]
|
||||
assert "no such tool: chat" in results[0].result
|
||||
assert "finish" in results[0].result # it is told what does exist
|
||||
|
||||
@@ -280,7 +298,7 @@ def test_multiple_calls_in_one_turn_all_run(store: StateStore, ws: Workspace) ->
|
||||
build(client, store, ws).run(milestone=1)
|
||||
assert (ws.root / "one.txt").exists() and (ws.root / "two.txt").exists()
|
||||
results = [
|
||||
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
|
||||
e.event for e in log(store).read() if type(e.event).__name__ == "ToolResult"
|
||||
]
|
||||
assert {r.call_id for r in results} == {"a", "b", "fin"}
|
||||
|
||||
@@ -293,7 +311,7 @@ class StubDesk:
|
||||
self._answer = answer
|
||||
self._error = error
|
||||
|
||||
def ask(self, question: str, **kw: Any) -> Any:
|
||||
def ask(self, transcript: Any, question: str, **kw: Any) -> Any:
|
||||
self.asked.append(question)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
@@ -336,7 +354,7 @@ def test_ask_expert_answer_comes_back_as_a_tool_result(
|
||||
|
||||
assert desk.asked == ["sqlite or postgres?"]
|
||||
results = [
|
||||
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
|
||||
e.event for e in log(store).read() if type(e.event).__name__ == "ToolResult"
|
||||
]
|
||||
assert "prefer sqlite here" in results[0].result
|
||||
|
||||
@@ -372,7 +390,7 @@ def test_exhausted_budget_is_reported_to_the_model_not_raised(
|
||||
|
||||
assert outcome.status == "completed" # it got on with the work
|
||||
results = [
|
||||
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
|
||||
e.event for e in log(store).read() if type(e.event).__name__ == "ToolResult"
|
||||
]
|
||||
assert not results[0].success
|
||||
assert "budget" in results[0].result
|
||||
@@ -20,7 +20,7 @@ from nisse_dev.expert import (
|
||||
)
|
||||
from nisse_dev.llm import LLMError, RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.state import StateStore, Transcript
|
||||
|
||||
ROLES = RoleMap(
|
||||
executor="llama3.2",
|
||||
@@ -57,8 +57,25 @@ def store(tmp_path: Path) -> StateStore:
|
||||
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
|
||||
|
||||
def desk(client: Any, store: StateStore, **kw: Any) -> ExpertDesk:
|
||||
return ExpertDesk(client, store, ROLES, **kw)
|
||||
ITEM = "t" # the work item the consults happen in
|
||||
|
||||
|
||||
def log(store: StateStore, item: str = ITEM) -> Transcript:
|
||||
return store.transcript(item, "expert")
|
||||
|
||||
|
||||
class Bound:
|
||||
"""The desk as the executor uses it: within one work item."""
|
||||
|
||||
def __init__(self, desk: ExpertDesk, store: StateStore, item: str) -> None:
|
||||
self.desk, self.store, self.item = desk, store, item
|
||||
|
||||
def ask(self, question: str, **kw: Any) -> Any:
|
||||
return self.desk.ask(log(self.store, self.item), question, **kw)
|
||||
|
||||
|
||||
def desk(client: Any, store: StateStore, item: str = ITEM, **kw: Any) -> Bound:
|
||||
return Bound(ExpertDesk(client, ROLES, **kw), store, item)
|
||||
|
||||
|
||||
# --- parsing (the contract is prompt-level, so parsing is the guard) ------
|
||||
@@ -104,7 +121,7 @@ def test_ask_returns_a_validated_answer(store: StateStore) -> None:
|
||||
def test_consult_is_stateless_and_uses_the_consultant_role(
|
||||
store: StateStore,
|
||||
) -> None:
|
||||
store.append_event(AssistantText(text="secret repo context", model="m"))
|
||||
log(store).append(AssistantText(text="secret repo context", model="m"))
|
||||
client = ScriptedClient([GOOD])
|
||||
desk(client, store).ask("Is this API deprecated?")
|
||||
|
||||
@@ -134,7 +151,7 @@ def test_consults_are_recorded_as_composite_events(store: StateStore) -> None:
|
||||
# L9 wants a consult/delegation eval set; reusing the pair gets consult
|
||||
# evaluation for free.
|
||||
desk(ScriptedClient([GOOD]), store).ask("q")
|
||||
events = [e.event for e in store.read_events()]
|
||||
events = [e.event for e in log(store).read()]
|
||||
started = next(e for e in events if isinstance(e, DelegationStarted))
|
||||
completed = next(e for e in events if isinstance(e, DelegationCompleted))
|
||||
assert started.role == "expert"
|
||||
@@ -146,7 +163,7 @@ def test_a_failed_consult_is_recorded_too(store: StateStore) -> None:
|
||||
with pytest.raises(LLMError):
|
||||
desk(ScriptedClient(["waffle", "waffle"]), store).ask("q")
|
||||
completed = [
|
||||
e.event for e in store.read_events() if isinstance(e.event, DelegationCompleted)
|
||||
e.event for e in log(store).read() if isinstance(e.event, DelegationCompleted)
|
||||
]
|
||||
assert completed[0].exit_status == "failed"
|
||||
|
||||
@@ -177,10 +194,18 @@ def test_budget_survives_a_restart(tmp_path: Path) -> None:
|
||||
def test_budget_counts_only_after_the_task_marker(store: StateStore) -> None:
|
||||
d = desk(ScriptedClient([GOOD] * 5), store, budget=1)
|
||||
d.ask("earlier task")
|
||||
marker = store.read_events()[-1].id
|
||||
marker = log(store).read()[-1].id
|
||||
# A new task starts its allowance afresh from its own marker.
|
||||
assert d.ask("new task", since_event_id=marker).confidence == "high"
|
||||
|
||||
|
||||
def test_the_budget_is_per_work_item(store: StateStore) -> None:
|
||||
# Consults in one task never use up another's allowance (L9).
|
||||
desk(ScriptedClient([GOOD]), store, item="a", budget=1).ask("one")
|
||||
assert desk(ScriptedClient([GOOD]), store, item="b", budget=1).ask("two")
|
||||
with pytest.raises(BudgetExhausted):
|
||||
desk(ScriptedClient([GOOD]), store, item="a", budget=1).ask("three")
|
||||
|
||||
|
||||
def test_default_budget_is_small() -> None:
|
||||
assert DEFAULT_BUDGET <= 5 # L9: do not let this become a second subsystem
|
||||
@@ -62,6 +62,7 @@ class Planner:
|
||||
|
||||
def plan(
|
||||
self,
|
||||
transcript: object,
|
||||
gate: str,
|
||||
project: str,
|
||||
feedback: str | None = None,
|
||||
@@ -74,7 +75,7 @@ class Executor:
|
||||
def __init__(self, faults: Faults) -> None:
|
||||
self._faults = faults
|
||||
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
return self._faults.check("executor") or ExecutorOutcome("completed", "done")
|
||||
|
||||
|
||||
@@ -189,7 +190,7 @@ def test_a_signoff_state_without_its_gate_offers_a_way_out(tmp_path: Path) -> No
|
||||
|
||||
|
||||
class RaisingReviewExecutor:
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
raise RuntimeError("bug while addressing review")
|
||||
|
||||
|
||||
|
||||
@@ -45,9 +45,9 @@ def test_transition_logs_from_to_and_cause(
|
||||
) -> None:
|
||||
store.transition("PLANNING_ARCH", cause="operator command: use demo", project="d")
|
||||
store.transition("AWAIT_ARCH_SIGNOFF", cause="plan submitted", project="d")
|
||||
first, second = events(stream)
|
||||
assert first["event"] == "state.transition"
|
||||
assert (first["from"], first["to"]) == (None, "PLANNING_ARCH")
|
||||
# Opening the work item logs its own events first (work_item.open, .switch).
|
||||
first, second = [e for e in events(stream) if e["event"] == "state.transition"]
|
||||
assert (first["from"], first["to"]) == ("IDLE", "PLANNING_ARCH")
|
||||
assert (second["from"], second["to"]) == ("PLANNING_ARCH", "AWAIT_ARCH_SIGNOFF")
|
||||
assert second["cause"] == "plan submitted"
|
||||
|
||||
@@ -66,9 +66,9 @@ def test_task_id_threads_through_events(store: StateStore, stream: io.StringIO)
|
||||
store.transition("IDLE", cause="boot")
|
||||
store.oplog.task_id = "task-42"
|
||||
store.transition("PLANNING_ARCH", cause="operator command")
|
||||
before, during = events(stream)
|
||||
before, *during = events(stream)
|
||||
assert before["task_id"] is None
|
||||
assert during["task_id"] == "task-42"
|
||||
assert {e["task_id"] for e in during} == {"task-42"}
|
||||
|
||||
|
||||
def test_migration_emits_event(tmp_path: Path, stream: io.StringIO) -> None:
|
||||
@@ -120,7 +120,10 @@ def test_literal_log_lines_for_human_readability(
|
||||
monkeypatch.setattr("nisse_dev.oplog.datetime", _FrozenDatetime)
|
||||
|
||||
store.transition(
|
||||
"PLANNING_ARCH", cause="operator command: use demo", project="demo"
|
||||
"PLANNING_ARCH",
|
||||
cause="operator command: use demo",
|
||||
project="demo",
|
||||
data={"task_id": "demo-m2"},
|
||||
)
|
||||
store.oplog.task_id = "demo-m2"
|
||||
intent_id = store.begin_intent(
|
||||
@@ -129,11 +132,23 @@ def test_literal_log_lines_for_human_readability(
|
||||
store.commit_intent(intent_id, cause="push verified by Gitea query")
|
||||
|
||||
assert stream.getvalue().splitlines() == [
|
||||
(
|
||||
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
||||
' "event": "work_item.open", "task_id": null,'
|
||||
' "cause": "operator command: use demo",'
|
||||
' "work_item": "demo-m2", "scope": "demo"}'
|
||||
),
|
||||
(
|
||||
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
||||
' "event": "work_item.switch", "task_id": null,'
|
||||
' "cause": "operator command: use demo",'
|
||||
' "from": null, "to": "demo-m2"}'
|
||||
),
|
||||
(
|
||||
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
||||
' "event": "state.transition", "task_id": null,'
|
||||
' "cause": "operator command: use demo",'
|
||||
' "from": null, "to": "PLANNING_ARCH", "project": "demo"}'
|
||||
' "from": "IDLE", "to": "PLANNING_ARCH", "project": "demo"}'
|
||||
),
|
||||
(
|
||||
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
||||
|
||||
@@ -23,7 +23,7 @@ from nisse_dev.control import (
|
||||
from nisse_dev.llm import BudgetExhausted, RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.planner import PLANNER_TOOLS, LLMPlanningAgent
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.state import StateStore, Transcript
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
|
||||
ROLES = RoleMap(
|
||||
@@ -64,6 +64,13 @@ def plan_call(content: str, gate: str = "arch") -> ToolCall:
|
||||
)
|
||||
|
||||
|
||||
ITEM = "t" # the work item the unit tests plan in
|
||||
|
||||
|
||||
def log(store: StateStore) -> Transcript:
|
||||
return store.transcript(ITEM, "planner")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> StateStore:
|
||||
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
@@ -71,8 +78,8 @@ def store(tmp_path: Path) -> StateStore:
|
||||
|
||||
def test_submit_plan_tool_call_becomes_submit_plan(store: StateStore) -> None:
|
||||
client = ScriptedClient([[plan_call("step 1: create src/app.py")]])
|
||||
agent = LLMPlanningAgent(client, store, ROLES)
|
||||
action = agent.plan("arch", "demo")
|
||||
agent = LLMPlanningAgent(client, ROLES)
|
||||
action = agent.plan(log(store), "arch", "demo")
|
||||
assert action == SubmitPlan("step 1: create src/app.py")
|
||||
|
||||
|
||||
@@ -88,13 +95,13 @@ def test_ask_human_tool_call_becomes_ask_human(store: StateStore) -> None:
|
||||
]
|
||||
]
|
||||
)
|
||||
action = LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
|
||||
action = LLMPlanningAgent(client, ROLES).plan(log(store), "arch", "demo")
|
||||
assert action == AskHuman("postgres or sqlite?")
|
||||
|
||||
|
||||
def test_planner_role_and_tools_are_used(store: StateStore) -> None:
|
||||
client = ScriptedClient([[plan_call("plan")]])
|
||||
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
|
||||
LLMPlanningAgent(client, ROLES).plan(log(store), "arch", "demo")
|
||||
call = client.calls[0]
|
||||
assert call["model"] == "anthropic/claude-opus-5" # never the executor
|
||||
assert [t["function"]["name"] for t in call["tools"]] == [
|
||||
@@ -113,8 +120,8 @@ def test_model_output_is_appended_to_the_log(store: StateStore) -> None:
|
||||
client = ScriptedClient(
|
||||
[[AssistantText(text="thinking out loud", model="m"), plan_call("the plan")]]
|
||||
)
|
||||
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
|
||||
kinds = [type(e.event).__name__ for e in store.read_events()]
|
||||
LLMPlanningAgent(client, ROLES).plan(log(store), "arch", "demo")
|
||||
kinds = [type(e.event).__name__ for e in log(store).read()]
|
||||
assert kinds == ["AssistantText", "ToolCall", "ToolResult"]
|
||||
|
||||
|
||||
@@ -122,8 +129,8 @@ def test_gate_tool_call_is_answered_in_the_log(store: StateStore) -> None:
|
||||
# submit_plan ends the turn and has no natural result, but a tool call
|
||||
# left dangling makes the next render invalid for the provider.
|
||||
client = ScriptedClient([[plan_call("the plan")]])
|
||||
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
|
||||
events = [e.event for e in store.read_events()]
|
||||
LLMPlanningAgent(client, ROLES).plan(log(store), "arch", "demo")
|
||||
events = [e.event for e in log(store).read()]
|
||||
assert [type(e).__name__ for e in events] == ["ToolCall", "ToolResult"]
|
||||
assert events[1].call_id == events[0].call_id
|
||||
assert "posted for approval" in events[1].result
|
||||
@@ -137,7 +144,7 @@ def test_prose_only_turn_retries_once_then_raises(store: StateStore) -> None:
|
||||
]
|
||||
)
|
||||
with pytest.raises(PlanningError, match="no submit_plan or ask_human"):
|
||||
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
|
||||
LLMPlanningAgent(client, ROLES).plan(log(store), "arch", "demo")
|
||||
assert len(client.calls) == 2
|
||||
assert "did not call a tool" in client.calls[1]["system"]
|
||||
|
||||
@@ -146,7 +153,7 @@ def test_retry_succeeds_after_prose(store: StateStore) -> None:
|
||||
client = ScriptedClient(
|
||||
[[AssistantText(text="prose", model="m")], [plan_call("the real plan")]]
|
||||
)
|
||||
action = LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
|
||||
action = LLMPlanningAgent(client, ROLES).plan(log(store), "arch", "demo")
|
||||
assert action == SubmitPlan("the real plan")
|
||||
|
||||
|
||||
@@ -154,13 +161,15 @@ def test_malformed_tool_arguments_are_rejected(store: StateStore) -> None:
|
||||
blank = ToolCall(call_id="c", tool="submit_plan", arguments={"content": " "})
|
||||
client = ScriptedClient([[blank], [blank]])
|
||||
with pytest.raises(PlanningError):
|
||||
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
|
||||
LLMPlanningAgent(client, ROLES).plan(log(store), "arch", "demo")
|
||||
|
||||
|
||||
def test_planner_sees_prior_transcript(store: StateStore) -> None:
|
||||
store.append_event(UserMessage(text="use demo", source="transport:@don"))
|
||||
store.transcript(ITEM, "operator").append(
|
||||
UserMessage(text="use demo", source="transport:@don")
|
||||
)
|
||||
client = ScriptedClient([[plan_call("plan")]])
|
||||
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
|
||||
LLMPlanningAgent(client, ROLES).plan(log(store), "arch", "demo")
|
||||
seen = [e.event for e in client.calls[0]["events"]]
|
||||
assert UserMessage(text="use demo", source="transport:@don") in seen
|
||||
|
||||
@@ -171,7 +180,7 @@ def test_planner_sees_prior_transcript(store: StateStore) -> None:
|
||||
def build(tmp_path: Path, client: ScriptedClient) -> tuple[Orchestrator, StateStore]:
|
||||
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
transport = InMemoryTransport()
|
||||
agent = LLMPlanningAgent(client, store, ROLES)
|
||||
agent = LLMPlanningAgent(client, ROLES)
|
||||
orch = Orchestrator(store, transport, agent, operators={"don"}, roles=ROLES)
|
||||
orch.start()
|
||||
return orch, store, transport
|
||||
@@ -186,7 +195,10 @@ def test_use_project_produces_llm_plan_and_transcript(tmp_path: Path) -> None:
|
||||
assert request.kind == "approval_request"
|
||||
assert "create app.py" in request.text
|
||||
|
||||
events = [e.event for e in store.read_events()]
|
||||
# The work item's own events: `use demo` was said while idle, and is
|
||||
# carried into the item it opened (D1).
|
||||
item = store.active_item()
|
||||
events = [e.event for e in store.transcript(item.id, "operator").read()]
|
||||
kinds = [type(e).__name__ for e in events]
|
||||
assert kinds == [
|
||||
"UserMessage",
|
||||
@@ -237,7 +249,7 @@ def test_operator_can_recover_from_a_planning_failure(tmp_path: Path) -> None:
|
||||
|
||||
def test_operator_can_recover_after_a_stopped_milestone(tmp_path: Path) -> None:
|
||||
class StoppedExecutor:
|
||||
def run(self, milestone: int) -> Any:
|
||||
def run(self, transcript: object, milestone: int) -> Any:
|
||||
from nisse_dev.executor import ExecutorOutcome
|
||||
|
||||
return ExecutorOutcome("escalate", "tests failed twice")
|
||||
@@ -248,7 +260,7 @@ def test_operator_can_recover_after_a_stopped_milestone(tmp_path: Path) -> None:
|
||||
orch = Orchestrator(
|
||||
store,
|
||||
transport,
|
||||
LLMPlanningAgent(client, store, ROLES),
|
||||
LLMPlanningAgent(client, ROLES),
|
||||
operators={"don"},
|
||||
roles=ROLES,
|
||||
executor=StoppedExecutor(),
|
||||
@@ -295,5 +307,5 @@ def test_budget_exhaustion_is_not_retried(store: StateStore) -> None:
|
||||
|
||||
client = BrokeClient()
|
||||
with pytest.raises(BudgetExhausted):
|
||||
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
|
||||
LLMPlanningAgent(client, ROLES).plan(log(store), "arch", "demo")
|
||||
assert client.calls == 1 # not two
|
||||
@@ -57,7 +57,7 @@ class ScriptedClient:
|
||||
|
||||
|
||||
class DoneExecutor:
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
return ExecutorOutcome("completed", f"milestone {milestone} done")
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ def build(
|
||||
orch = Orchestrator(
|
||||
store,
|
||||
transport,
|
||||
LLMPlanningAgent(client, store, ROLES),
|
||||
LLMPlanningAgent(client, ROLES),
|
||||
operators={"don"},
|
||||
roles=ROLES,
|
||||
executor=DoneExecutor(),
|
||||
@@ -183,7 +183,7 @@ def test_publisher_reconciler_is_registered_at_boot(tmp_path: Path) -> None:
|
||||
orch = Orchestrator(
|
||||
store,
|
||||
transport,
|
||||
LLMPlanningAgent(ScriptedClient([]), store, ROLES),
|
||||
LLMPlanningAgent(ScriptedClient([]), ROLES),
|
||||
operators={"don"},
|
||||
roles=ROLES,
|
||||
publisher=FakePublisher(),
|
||||
|
||||
@@ -73,7 +73,7 @@ class Executor:
|
||||
self.runs = 0
|
||||
self.outcome = ExecutorOutcome("completed", "done")
|
||||
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
self.runs += 1
|
||||
return self.outcome
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class Executor:
|
||||
def __init__(self) -> None:
|
||||
self.outcome = ExecutorOutcome("completed", "done")
|
||||
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
return self.outcome
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ def test_crash_during_a_review_loses_the_comment(tmp_path: Path) -> None:
|
||||
class Crash(BaseException):
|
||||
pass
|
||||
|
||||
def dies(milestone: int) -> ExecutorOutcome:
|
||||
def dies(transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
raise Crash() # the process dies mid-review
|
||||
|
||||
agent.executor.run = dies # type: ignore[method-assign]
|
||||
@@ -348,7 +348,7 @@ class Crash(BaseException):
|
||||
|
||||
|
||||
def crash_mid_review(agent: Agent, gitea: FakeGitea) -> None:
|
||||
def dies(milestone: int) -> ExecutorOutcome:
|
||||
def dies(transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
raise Crash()
|
||||
|
||||
agent.executor.run = dies # type: ignore[method-assign]
|
||||
@@ -377,7 +377,7 @@ def test_an_executor_error_answers_the_comment_so_it_is_not_redelivered(
|
||||
agent.say("use demo", "approve", "approve", "start milestone 1")
|
||||
gitea.add(20, "don", "please add a test")
|
||||
|
||||
def fails(milestone: int) -> ExecutorOutcome:
|
||||
def fails(transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
raise RuntimeError("bug in the executor")
|
||||
|
||||
agent.executor.run = fails # type: ignore[method-assign]
|
||||
|
||||
@@ -42,10 +42,10 @@ class RecordingExecutor:
|
||||
self._outcomes = outcomes or []
|
||||
self.store: StateStore | None = None
|
||||
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: Any, milestone: int) -> ExecutorOutcome:
|
||||
self.runs.append(milestone)
|
||||
if self.store is not None:
|
||||
self.seen_events.append(self.store.read_events())
|
||||
# What it is given: the work item's transcript, nothing wider.
|
||||
self.seen_events.append(transcript.read())
|
||||
status = self._outcomes.pop(0) if self._outcomes else "completed"
|
||||
return ExecutorOutcome(status, f"executor said {status}")
|
||||
|
||||
@@ -88,7 +88,7 @@ def build(
|
||||
orch = Orchestrator(
|
||||
store,
|
||||
transport,
|
||||
LLMPlanningAgent(ScriptedClient(), store, ROLES),
|
||||
LLMPlanningAgent(ScriptedClient(), ROLES),
|
||||
operators={"don"},
|
||||
roles=ROLES,
|
||||
executor=executor,
|
||||
|
||||
@@ -23,7 +23,7 @@ from nisse_dev.executor import ExecutorOutcome, LLMExecutor
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.planner import LLMPlanningAgent
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.state import StateStore, Transcript
|
||||
from nisse_dev.tools import ToolOutcome
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
from nisse_dev.worker import SerialWorker
|
||||
@@ -36,13 +36,17 @@ ROLES = RoleMap(
|
||||
)
|
||||
|
||||
|
||||
def log(store: StateStore, actor: str = "executor") -> Transcript:
|
||||
return store.transcript("t", actor)
|
||||
|
||||
|
||||
def new_store(path: Path) -> StateStore:
|
||||
return StateStore(path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
|
||||
|
||||
def user_messages(store: StateStore) -> list[str]:
|
||||
return [
|
||||
e.event.text for e in store.read_events() if isinstance(e.event, UserMessage)
|
||||
e.event.text for e in store.all_events() if isinstance(e.event, UserMessage)
|
||||
]
|
||||
|
||||
|
||||
@@ -58,7 +62,7 @@ class OperatorDuringWork:
|
||||
self.says = says
|
||||
self.transport: InMemoryTransport | None = None
|
||||
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
assert self.transport is not None
|
||||
self.transport.receive("don", self.says)
|
||||
if self.cancel.requested:
|
||||
@@ -158,6 +162,7 @@ class StopsBeforePlanning:
|
||||
|
||||
def plan(
|
||||
self,
|
||||
transcript: object,
|
||||
gate: str,
|
||||
project: str,
|
||||
feedback: str | None = None,
|
||||
@@ -200,7 +205,7 @@ class BlocksUntilStopped:
|
||||
self.cancel = cancel
|
||||
self.running = threading.Event()
|
||||
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
|
||||
self.running.set()
|
||||
for _ in range(500):
|
||||
if self.cancel.requested:
|
||||
@@ -287,10 +292,8 @@ def test_executor_stops_before_a_turn_without_calling_the_model(
|
||||
cancel = Cancel()
|
||||
cancel.set()
|
||||
client = ScriptedClient([])
|
||||
executor = LLMExecutor(
|
||||
client, new_store(tmp_path), ROLES, StopsAfterFirstCall(cancel), cancel=cancel
|
||||
)
|
||||
outcome = executor.run(1)
|
||||
executor = LLMExecutor(client, ROLES, StopsAfterFirstCall(cancel), cancel=cancel)
|
||||
outcome = executor.run(log(new_store(tmp_path)), 1)
|
||||
assert outcome == ExecutorOutcome("stopped", "before turn 1")
|
||||
assert client.calls == 0
|
||||
|
||||
@@ -305,14 +308,14 @@ def test_executor_stops_between_tool_calls_and_records_the_skipped_ones(
|
||||
ToolCall(call_id="a", tool="run_command", arguments={"command": "make"}),
|
||||
ToolCall(call_id="b", tool="write_file", arguments={"path": "x"}),
|
||||
]
|
||||
executor = LLMExecutor(ScriptedClient([calls]), store, ROLES, ws, cancel=cancel)
|
||||
outcome = executor.run(1)
|
||||
executor = LLMExecutor(ScriptedClient([calls]), ROLES, ws, cancel=cancel)
|
||||
outcome = executor.run(log(store), 1)
|
||||
|
||||
assert outcome == ExecutorOutcome("stopped", "during turn 1")
|
||||
assert ws.ran == ["run_command"]
|
||||
results = {
|
||||
e.event.call_id: e.event.result
|
||||
for e in store.read_events()
|
||||
for e in log(store).read()
|
||||
if isinstance(e.event, ToolResult)
|
||||
}
|
||||
# Every call has a result, so the transcript still renders.
|
||||
@@ -320,7 +323,7 @@ def test_executor_stops_between_tool_calls_and_records_the_skipped_ones(
|
||||
|
||||
|
||||
class StoppedDelegate:
|
||||
def run(self, *, brief: str, branch: str) -> DelegationResult:
|
||||
def run(self, transcript: object, *, brief: str, branch: str) -> DelegationResult:
|
||||
return DelegationResult("stopped", "stopped by the operator during turn 2")
|
||||
|
||||
|
||||
@@ -339,12 +342,11 @@ def test_a_stopped_delegation_stops_the_milestone(tmp_path: Path) -> None:
|
||||
|
||||
executor = LLMExecutor(
|
||||
ScriptedClient(failing),
|
||||
new_store(tmp_path),
|
||||
ROLES,
|
||||
Failing(),
|
||||
delegate=StoppedDelegate(),
|
||||
)
|
||||
outcome = executor.run(1)
|
||||
outcome = executor.run(log(new_store(tmp_path)), 1)
|
||||
assert outcome.status == "stopped"
|
||||
assert "during a delegation" in outcome.note
|
||||
|
||||
@@ -353,9 +355,9 @@ def test_planner_does_not_start_a_turn_once_stopped(tmp_path: Path) -> None:
|
||||
cancel = Cancel()
|
||||
cancel.set()
|
||||
client = ScriptedClient([])
|
||||
planner = LLMPlanningAgent(client, new_store(tmp_path), ROLES, cancel=cancel)
|
||||
planner = LLMPlanningAgent(client, ROLES, cancel=cancel)
|
||||
try:
|
||||
planner.plan("arch", "demo")
|
||||
planner.plan(log(new_store(tmp_path), "planner"), "arch", "demo")
|
||||
except PlanningStopped:
|
||||
pass
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Work items in the state store (architecture L1, L7; Development Plan M1.1)."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.canonical import AssistantText, UserMessage
|
||||
from nisse_dev.control import (
|
||||
AWAIT_ARCH_SIGNOFF,
|
||||
PR_OPEN,
|
||||
AskHuman,
|
||||
Orchestrator,
|
||||
SubmitPlan,
|
||||
)
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import IDLE, StateStore
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
|
||||
|
||||
def new_store(path: Path) -> StateStore:
|
||||
return StateStore(path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
|
||||
|
||||
# --- the store ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_transcript_reads_only_its_own_work_item(tmp_path: Path) -> None:
|
||||
store = new_store(tmp_path)
|
||||
store.transcript("a", "operator").append(UserMessage(text="for a", source="m"))
|
||||
store.transcript("b", "operator").append(UserMessage(text="for b", source="m"))
|
||||
store.transcript(None, "operator").append(UserMessage(text="idle", source="m"))
|
||||
assert [e.event.text for e in store.transcript("a", "planner").read()] == ["for a"]
|
||||
assert [e.event.text for e in store.transcript(None, "planner").read()] == ["idle"]
|
||||
|
||||
|
||||
def test_every_event_carries_its_work_item_and_actor(tmp_path: Path) -> None:
|
||||
store = new_store(tmp_path)
|
||||
log = store.transcript("a", "executor")
|
||||
log.append(AssistantText(text="working", model="m"))
|
||||
log.as_actor("helper").append(AssistantText(text="helping", model="f"))
|
||||
assert [(e.work_item, e.actor) for e in store.all_events()] == [
|
||||
("a", "executor"),
|
||||
("a", "helper"),
|
||||
]
|
||||
|
||||
|
||||
def test_an_unknown_actor_is_refused(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match="unknown actor"):
|
||||
new_store(tmp_path).transcript("a", "someone")
|
||||
|
||||
|
||||
def test_intents_are_owned_by_their_work_item(tmp_path: Path) -> None:
|
||||
store = new_store(tmp_path)
|
||||
store.transcript("a", "helper").begin_intent("delegation", {}, cause="t")
|
||||
[intent] = store.pending_intents()
|
||||
assert intent.work_item == "a"
|
||||
|
||||
|
||||
def test_work_items_and_the_active_one_survive_a_restart(tmp_path: Path) -> None:
|
||||
store = new_store(tmp_path)
|
||||
store.open_work_item("a", title="demo", scope="demo", position=IDLE, cause="t")
|
||||
store.set_active("a", cause="t")
|
||||
store.update_work_item("a", position="PLANNING_ARCH", data={"x": 1}, cause="t")
|
||||
store.close()
|
||||
store = new_store(tmp_path)
|
||||
item = store.active_item()
|
||||
assert item is not None
|
||||
assert (item.id, item.position, item.data, item.status) == (
|
||||
"a",
|
||||
"PLANNING_ARCH",
|
||||
{"x": 1},
|
||||
"open",
|
||||
)
|
||||
|
||||
|
||||
def test_closing_records_the_outcome_and_going_idle_clears_active(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = new_store(tmp_path)
|
||||
store.open_work_item("a", title="demo", position=IDLE, cause="t")
|
||||
store.set_active("a", cause="t")
|
||||
store.close_work_item("a", outcome="completed", cause="t")
|
||||
store.set_active(None, cause="t")
|
||||
assert store.active_item() is None
|
||||
item = store.work_item("a")
|
||||
assert item is not None and (item.status, item.outcome) == ("closed", "completed")
|
||||
with pytest.raises(KeyError):
|
||||
store.close_work_item("a", outcome="abandoned", cause="t") # already closed
|
||||
|
||||
|
||||
def test_messages_sent_while_idle_are_carried_with_their_origin(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = new_store(tmp_path)
|
||||
store.set_active(None, cause="boot")
|
||||
idle = store.transcript(None, "operator")
|
||||
first = idle.append(UserMessage(text="add a truncate helper", source="m:1"))
|
||||
store.open_work_item("a", title="demo", position=IDLE, cause="t")
|
||||
assert store.carry_idle_messages("a") == 1
|
||||
[carried] = store.transcript("a", "planner").read()
|
||||
assert carried.event.text == "add a truncate helper"
|
||||
assert f"#{first}" in carried.event.source # where it came from
|
||||
assert len(store.all_events()) == 2 # a copy; the original is untouched
|
||||
|
||||
|
||||
# --- the orchestrator ----------------------------------------------------------
|
||||
|
||||
|
||||
class RecordingPlanner:
|
||||
"""Records what each planning turn could read."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.seen: list[list[str]] = []
|
||||
|
||||
def plan(
|
||||
self,
|
||||
transcript: Any,
|
||||
gate: str,
|
||||
project: str,
|
||||
feedback: str | None = None,
|
||||
answer: str | None = None,
|
||||
) -> SubmitPlan | AskHuman:
|
||||
self.seen.append(
|
||||
[
|
||||
e.event.text
|
||||
for e in transcript.read()
|
||||
if isinstance(e.event, UserMessage)
|
||||
]
|
||||
)
|
||||
return SubmitPlan(f"{gate} plan for {project}")
|
||||
|
||||
|
||||
def build(tmp_path: Path) -> tuple[StateStore, InMemoryTransport, RecordingPlanner]:
|
||||
store = new_store(tmp_path)
|
||||
transport = InMemoryTransport()
|
||||
planner = RecordingPlanner()
|
||||
Orchestrator(store, transport, planner, operators={"don"}).start()
|
||||
return store, transport, planner
|
||||
|
||||
|
||||
def test_a_second_task_never_sees_the_first(tmp_path: Path) -> None:
|
||||
# The second live run's planner read every earlier task's events (#78).
|
||||
store, transport, planner = build(tmp_path)
|
||||
for text in ("build the first thing", "use first", "approve", "approve"):
|
||||
transport.receive("don", text)
|
||||
transport.receive("don", "start milestone 1")
|
||||
assert store.get_state().state == PR_OPEN
|
||||
transport.receive("don", "project complete")
|
||||
|
||||
transport.receive("don", "build the second thing")
|
||||
transport.receive("don", "use second")
|
||||
assert store.get_state().state == AWAIT_ARCH_SIGNOFF
|
||||
second_task_view = planner.seen[-1]
|
||||
assert "build the second thing" in second_task_view
|
||||
assert not any("first" in text for text in second_task_view)
|
||||
|
||||
|
||||
def test_each_task_is_its_own_work_item_with_its_outcome(tmp_path: Path) -> None:
|
||||
store, transport, _ = build(tmp_path)
|
||||
for text in ("use first", "approve", "approve", "start milestone 1"):
|
||||
transport.receive("don", text)
|
||||
transport.receive("don", "project complete")
|
||||
transport.receive("don", "use second")
|
||||
transport.receive("don", "abandon")
|
||||
outcomes = [(item.title, item.outcome) for item in store.work_items()]
|
||||
assert outcomes == [("first", "completed"), ("second", "abandoned")]
|
||||
assert store.active_item() is None
|
||||
|
||||
|
||||
def test_the_task_message_before_use_reaches_the_planner(tmp_path: Path) -> None:
|
||||
# Run 2's flow: the task, then `use`. It must still be planned from.
|
||||
_, transport, planner = build(tmp_path)
|
||||
transport.receive("don", "add a truncate helper")
|
||||
transport.receive("don", "use demo")
|
||||
assert "add a truncate helper" in planner.seen[0]
|
||||
Reference in new issue
Block a user