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
@@ -19,7 +19,7 @@ from nisse_dev.canonical import PlanSubmitted, StoredEvent, ToolCall, ToolResult
|
||||
from nisse_dev.delegation import Delegation
|
||||
from nisse_dev.expert import ASK_EXPERT_TOOL, BudgetExhausted, ExpertDesk
|
||||
from nisse_dev.llm import LLMClient, LLMError, RoleMap
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.state import Transcript
|
||||
from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, ToolOutcome, Workspace
|
||||
|
||||
EXECUTOR_SYSTEM = """You are nisse-dev's executor, doing mechanical work.
|
||||
@@ -76,7 +76,6 @@ class LLMExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
client: LLMClient,
|
||||
store: StateStore,
|
||||
roles: RoleMap,
|
||||
workspace: Workspace,
|
||||
*,
|
||||
@@ -88,7 +87,6 @@ class LLMExecutor:
|
||||
cancel: Cancel | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._store = store
|
||||
self._roles = roles
|
||||
self._workspace = workspace
|
||||
self._max_turns = max_turns
|
||||
@@ -113,7 +111,7 @@ class LLMExecutor:
|
||||
self._notify(f"turn {turn + 1}: {summary}")
|
||||
|
||||
def _escalate(
|
||||
self, milestone: int, failures: int, last_failure: str
|
||||
self, transcript: Transcript, milestone: int, failures: int, last_failure: str
|
||||
) -> ExecutorOutcome | None:
|
||||
"""Hand the sub-task to a stronger model, per L9.
|
||||
|
||||
@@ -134,7 +132,9 @@ class LLMExecutor:
|
||||
"above. Finish this sub-task, staying inside the approved plan."
|
||||
)
|
||||
result = self._delegate.run(
|
||||
brief=brief, branch=f"delegation/milestone-{milestone}-{failures}"
|
||||
transcript.as_actor("helper"),
|
||||
brief=brief,
|
||||
branch=f"delegation/milestone-{milestone}-{failures}",
|
||||
)
|
||||
if result.usable:
|
||||
return None
|
||||
@@ -153,13 +153,16 @@ class LLMExecutor:
|
||||
return [*EXECUTOR_TOOLS, FINISH_TOOL]
|
||||
return [*EXECUTOR_TOOLS, ASK_EXPERT_TOOL, FINISH_TOOL]
|
||||
|
||||
def _consult(self, arguments: dict[str, Any]) -> ToolOutcome:
|
||||
def _consult(
|
||||
self, transcript: Transcript, arguments: dict[str, Any]
|
||||
) -> ToolOutcome:
|
||||
assert self._expert is not None
|
||||
question = str(arguments.get("question", "")).strip()
|
||||
if not question:
|
||||
return ToolOutcome("ask_expert needs a question", success=False)
|
||||
try:
|
||||
return ToolOutcome(self._expert.ask(question).render(), success=True)
|
||||
answer = self._expert.ask(transcript.as_actor("expert"), question)
|
||||
return ToolOutcome(answer.render(), success=True)
|
||||
except BudgetExhausted as exc:
|
||||
# Not an error the model should route around: it is the signal
|
||||
# that it is circling and should get on with the work or fail.
|
||||
@@ -167,7 +170,7 @@ class LLMExecutor:
|
||||
except LLMError as exc:
|
||||
return ToolOutcome(f"the expert did not answer: {exc}", success=False)
|
||||
|
||||
def _scope(self) -> tuple[str | None, int]:
|
||||
def _scope(self, transcript: Transcript) -> tuple[str | None, int]:
|
||||
"""The approved plan, and where this milestone's events begin.
|
||||
|
||||
The most recent plan in the log is the approved milestone plan: no
|
||||
@@ -177,7 +180,7 @@ class LLMExecutor:
|
||||
context window cannot afford and the plan already distils.
|
||||
"""
|
||||
latest: StoredEvent | None = None
|
||||
for stored in self._store.read_events():
|
||||
for stored in transcript.read():
|
||||
if isinstance(stored.event, PlanSubmitted):
|
||||
latest = stored
|
||||
if latest is None:
|
||||
@@ -185,9 +188,11 @@ class LLMExecutor:
|
||||
assert isinstance(latest.event, PlanSubmitted)
|
||||
return latest.event.plan, latest.id
|
||||
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
def run(self, transcript: Transcript, milestone: int) -> ExecutorOutcome:
|
||||
"""Run a milestone of the work item whose transcript this is, writing
|
||||
as the executor (L6, L7)."""
|
||||
model = self._roles.resolve("executor")
|
||||
plan, after = self._scope()
|
||||
plan, after = self._scope(transcript)
|
||||
system = (
|
||||
f"{EXECUTOR_SYSTEM}\n\n{self._workspace.describe()}"
|
||||
f"\n\nYou are executing milestone {milestone}."
|
||||
@@ -209,7 +214,7 @@ class LLMExecutor:
|
||||
return ExecutorOutcome("stopped", f"before turn {turn + 1}")
|
||||
try:
|
||||
produced = self._client.complete(
|
||||
self._store.read_events(after=after),
|
||||
transcript.read(after=after),
|
||||
tools=tools,
|
||||
model=model,
|
||||
system=system,
|
||||
@@ -219,7 +224,7 @@ class LLMExecutor:
|
||||
|
||||
calls: list[ToolCall] = []
|
||||
for event in produced:
|
||||
self._store.append_event(event)
|
||||
transcript.append(event)
|
||||
if isinstance(event, ToolCall):
|
||||
calls.append(event)
|
||||
|
||||
@@ -235,7 +240,7 @@ class LLMExecutor:
|
||||
self._report(turn, calls)
|
||||
for index, call in enumerate(calls):
|
||||
if self._cancel.requested:
|
||||
skip_calls(self._store, calls[index:])
|
||||
skip_calls(transcript, calls[index:])
|
||||
return ExecutorOutcome("stopped", f"during turn {turn + 1}")
|
||||
if call.tool == "finish":
|
||||
if not did_work and not finish_refused:
|
||||
@@ -248,7 +253,7 @@ class LLMExecutor:
|
||||
"making any change",
|
||||
)
|
||||
else:
|
||||
self._store.append_event(
|
||||
transcript.append(
|
||||
ToolResult(
|
||||
call_id=call.call_id,
|
||||
result="milestone finished",
|
||||
@@ -256,7 +261,7 @@ class LLMExecutor:
|
||||
)
|
||||
)
|
||||
skip_calls(
|
||||
self._store,
|
||||
transcript,
|
||||
calls[index + 1 :],
|
||||
"not run: finish had already ended the milestone",
|
||||
)
|
||||
@@ -274,12 +279,12 @@ class LLMExecutor:
|
||||
success=False,
|
||||
)
|
||||
elif call.tool == "ask_expert":
|
||||
outcome = self._consult(call.arguments)
|
||||
outcome = self._consult(transcript, call.arguments)
|
||||
else:
|
||||
if call.tool in MUTATING_TOOLS:
|
||||
did_work = True
|
||||
outcome = self._workspace.dispatch(call.tool, call.arguments)
|
||||
self._store.append_event(
|
||||
transcript.append(
|
||||
ToolResult(
|
||||
call_id=call.call_id,
|
||||
result=outcome.result,
|
||||
@@ -298,6 +303,7 @@ class LLMExecutor:
|
||||
# Escalating to the human is the fallback when there
|
||||
# is no delegate, not the intended path.
|
||||
escalation = self._escalate(
|
||||
transcript,
|
||||
milestone,
|
||||
consecutive_failures,
|
||||
outcome.result,
|
||||
|
||||
Reference in new issue
Block a user