Add control loop, gating primitives, and transport interface (A4/B1)
Step-2 PR A: the approval loop end to end with a stub agent, no LLM and no network. - transport.py: Transport interface per A1 (send_message, send_approval_request, ask_question, on_message) with an InMemoryTransport; no platform SDK outside an implementation - gates.py: PlanRevision with monotonic rev-N tags; Gate.evaluate enforces revision binding (stale tags refused with a notice), operator allowlisting, and approve-word-only semantics; any other reply is rejection feedback. Channel seam ready for Gitea/Vikunja approval sources - control.py: B1 state machine subset with structural gating — the agent's only actions are SubmitPlan (ends its turn) and AskHuman; no proceed tool exists. Gate state, revision counters, task_id, and pending questions checkpoint through StateStore, and boot resumes an open gate or gates to AWAIT_INPUT, never blind-resumes - gate.open/approve/reject A9 events complete the log taxonomy - CLAUDE.md: record the plan-first working convention - Tests: 15 unit (gates + orchestrator flows), 2 integration (mid-gate restart with revisions intact; unresolved intents gate the whole boot) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
78122c407e
commit
a251104a44
7 files changed
+711
No files matched your search
@@ -0,0 +1,309 @@
|
||||
"""Control loop and state machine (spec A4, B1 subset).
|
||||
|
||||
Gating is structural, not advisory: the agent's only actions are ``SubmitPlan``
|
||||
(posts the plan for approval and ends the agent's turn) and ``AskHuman``
|
||||
(pause, ask, resume on answer). There is deliberately no proceed/start action —
|
||||
advancement past a gate happens only here, when an operator approval or command
|
||||
arrives over transport. Every position change is checkpointed through the state
|
||||
store before the loop continues, so a restart lands mid-gate, not mid-air.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from nisse_dev.gates import Approved, Gate, Ignored, PlanRevision, Rejected
|
||||
from nisse_dev.reconcile import Reconciler, reconcile_on_boot
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.transport import InboundMessage, Transport
|
||||
|
||||
IDLE = "IDLE"
|
||||
PLANNING_ARCH = "PLANNING_ARCH"
|
||||
AWAIT_ARCH_SIGNOFF = "AWAIT_ARCH_SIGNOFF"
|
||||
MILESTONE_PLANNING = "MILESTONE_PLANNING"
|
||||
AWAIT_MILESTONE_SIGNOFF = "AWAIT_MILESTONE_SIGNOFF"
|
||||
EXECUTING = "EXECUTING"
|
||||
AWAIT_INPUT = "AWAIT_INPUT"
|
||||
|
||||
TRANSITIONS = {
|
||||
(IDLE, PLANNING_ARCH),
|
||||
(PLANNING_ARCH, AWAIT_ARCH_SIGNOFF),
|
||||
(AWAIT_ARCH_SIGNOFF, PLANNING_ARCH), # rejected: replan with feedback
|
||||
(AWAIT_ARCH_SIGNOFF, MILESTONE_PLANNING),
|
||||
(MILESTONE_PLANNING, AWAIT_MILESTONE_SIGNOFF),
|
||||
(AWAIT_MILESTONE_SIGNOFF, MILESTONE_PLANNING),
|
||||
(AWAIT_MILESTONE_SIGNOFF, EXECUTING),
|
||||
}
|
||||
|
||||
|
||||
class TransitionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubmitPlan:
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AskHuman:
|
||||
question: str
|
||||
|
||||
|
||||
class PlanningAgent(Protocol):
|
||||
def plan(
|
||||
self,
|
||||
gate: str,
|
||||
project: str,
|
||||
feedback: str | None = None,
|
||||
answer: str | None = None,
|
||||
) -> SubmitPlan | AskHuman: ...
|
||||
|
||||
|
||||
class StubAgent:
|
||||
"""Echoes canned plans so the approval loop is exercisable with no LLM."""
|
||||
|
||||
def plan(
|
||||
self,
|
||||
gate: str,
|
||||
project: str,
|
||||
feedback: str | None = None,
|
||||
answer: str | None = None,
|
||||
) -> SubmitPlan | AskHuman:
|
||||
notes = ""
|
||||
if feedback:
|
||||
notes += f" (revised after feedback: {feedback})"
|
||||
if answer:
|
||||
notes += f" (operator answered: {answer})"
|
||||
return SubmitPlan(f"[stub] {gate} plan for {project}{notes}")
|
||||
|
||||
|
||||
def _fresh_data() -> dict[str, Any]:
|
||||
return {
|
||||
"revs": {},
|
||||
"gate": None,
|
||||
"question": None,
|
||||
"milestone": 1,
|
||||
"task_id": None,
|
||||
}
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
def __init__(
|
||||
self,
|
||||
store: StateStore,
|
||||
transport: Transport,
|
||||
agent: PlanningAgent,
|
||||
operators: Iterable[str],
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._transport = transport
|
||||
self._agent = agent
|
||||
self._operators = set(operators)
|
||||
self._data = _fresh_data()
|
||||
self._project: str | None = None
|
||||
transport.on_message(self._handle)
|
||||
|
||||
def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None:
|
||||
report = reconcile_on_boot(self._store, reconcilers or {})
|
||||
state = self._store.get_state()
|
||||
if state is None:
|
||||
self._transition(IDLE, cause="boot: fresh state")
|
||||
self._transport.send_message("nisse-dev ready (idle)")
|
||||
return
|
||||
self._data = {**_fresh_data(), **state.data}
|
||||
self._project = state.project
|
||||
self._store.oplog.task_id = self._data.get("task_id")
|
||||
gate = self._data.get("gate")
|
||||
if report.action == "await_input":
|
||||
self._transition(AWAIT_INPUT, cause="boot: unresolved intents")
|
||||
self._transport.ask_question(
|
||||
"restart found unresolved work: "
|
||||
+ "; ".join(report.notes)
|
||||
+ " — how should I proceed?"
|
||||
)
|
||||
elif state.state == IDLE or (
|
||||
state.state in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF) and gate
|
||||
):
|
||||
# A gate is a safe place to have been interrupted: nothing was in
|
||||
# flight, the plan and revision are durable. Announce and resume.
|
||||
if gate:
|
||||
self._transport.send_message(
|
||||
f"restarted while awaiting sign-off on {gate['name']} "
|
||||
f"rev-{gate['rev']}; the gate is still open"
|
||||
)
|
||||
else:
|
||||
# Interrupted mid-turn (planning, executing, unanswered question):
|
||||
# never blind-resume (A3.6).
|
||||
self._transition(AWAIT_INPUT, cause=f"boot: restarted in {state.state}")
|
||||
self._transport.ask_question(
|
||||
f"restarted mid-{state.state}; continue or re-plan?"
|
||||
)
|
||||
|
||||
def _handle(self, msg: InboundMessage) -> None:
|
||||
if msg.sender not in self._operators:
|
||||
return
|
||||
state = self._store.get_state()
|
||||
current = state.state if state else IDLE
|
||||
text = msg.text.strip()
|
||||
if text == "status":
|
||||
self._send_status(current)
|
||||
elif current == IDLE:
|
||||
if text.startswith("use "):
|
||||
self._begin_project(text[4:].strip())
|
||||
else:
|
||||
self._transport.send_message("no active project; say 'use <project>'")
|
||||
elif current in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF):
|
||||
self._handle_gate_reply(msg, text)
|
||||
elif current == AWAIT_INPUT:
|
||||
self._handle_answer(text)
|
||||
else:
|
||||
self._transport.send_message(f"busy ({current}); 'status' for details")
|
||||
|
||||
def _send_status(self, current: str) -> None:
|
||||
gate = self._data.get("gate")
|
||||
gate_note = (
|
||||
f", awaiting sign-off on {gate['name']} rev-{gate['rev']}" if gate else ""
|
||||
)
|
||||
self._transport.send_message(
|
||||
f"state={current} project={self._project}{gate_note}"
|
||||
)
|
||||
|
||||
def _begin_project(self, project: str) -> None:
|
||||
task_id = f"{project}-{uuid.uuid4().hex[:8]}"
|
||||
self._data = {**_fresh_data(), "task_id": task_id}
|
||||
self._project = project
|
||||
self._store.oplog.task_id = task_id
|
||||
self._transition(PLANNING_ARCH, cause=f"operator command: use {project}")
|
||||
self._run_planning_turn("arch")
|
||||
|
||||
def _run_planning_turn(
|
||||
self, gate_name: str, feedback: str | None = None, answer: str | None = None
|
||||
) -> None:
|
||||
assert self._project is not None
|
||||
action = self._agent.plan(
|
||||
gate_name, self._project, feedback=feedback, answer=answer
|
||||
)
|
||||
if isinstance(action, AskHuman):
|
||||
self._data["question"] = {"text": action.question, "gate": gate_name}
|
||||
self._transition(
|
||||
AWAIT_INPUT, cause=f"agent question during {gate_name} planning"
|
||||
)
|
||||
self._transport.ask_question(action.question)
|
||||
return
|
||||
rev = self._data["revs"].get(gate_name, 0) + 1
|
||||
self._data["revs"][gate_name] = rev
|
||||
self._data["gate"] = {
|
||||
"name": gate_name,
|
||||
"rev": rev,
|
||||
"plan": action.content,
|
||||
"approved": False,
|
||||
}
|
||||
await_state = (
|
||||
AWAIT_ARCH_SIGNOFF if gate_name == "arch" else AWAIT_MILESTONE_SIGNOFF
|
||||
)
|
||||
self._transition(await_state, cause=f"plan submitted ({gate_name} rev-{rev})")
|
||||
self._transport.send_approval_request(
|
||||
f"{action.content}\n\nreply 'approve' to approve rev-{rev}; "
|
||||
"any other reply is rejection feedback"
|
||||
)
|
||||
self._store.oplog.emit(
|
||||
"gate.open",
|
||||
cause=f"plan submitted (rev-{rev})",
|
||||
gate=gate_name,
|
||||
rev=f"rev-{rev}",
|
||||
channel="transport",
|
||||
)
|
||||
|
||||
def _handle_gate_reply(self, msg: InboundMessage, text: str) -> None:
|
||||
gate_data = self._data["gate"]
|
||||
name, rev = gate_data["name"], gate_data["rev"]
|
||||
milestone = self._data["milestone"]
|
||||
if gate_data["approved"]:
|
||||
if text == f"start milestone {milestone}":
|
||||
self._transition(
|
||||
EXECUTING, cause=f"operator command: start milestone {milestone}"
|
||||
)
|
||||
self._transport.send_message(f"[stub] executing milestone {milestone}")
|
||||
else:
|
||||
self._transport.send_message(
|
||||
f"plan approved; say 'start milestone {milestone}' to begin"
|
||||
)
|
||||
return
|
||||
revision = PlanRevision(gate=name, rev=rev, content=gate_data["plan"])
|
||||
result = Gate(revision, self._operators).evaluate(
|
||||
msg.sender, text, channel="transport"
|
||||
)
|
||||
if isinstance(result, Ignored):
|
||||
if result.notice:
|
||||
self._transport.send_message(result.notice)
|
||||
return
|
||||
if isinstance(result, Rejected):
|
||||
self._store.oplog.emit(
|
||||
"gate.reject",
|
||||
cause=f"rejected by {result.approver}",
|
||||
gate=name,
|
||||
rev=f"rev-{rev}",
|
||||
channel=result.channel,
|
||||
)
|
||||
self._data["gate"] = None
|
||||
back = PLANNING_ARCH if name == "arch" else MILESTONE_PLANNING
|
||||
self._transition(back, cause=f"plan rejected ({name} rev-{rev})")
|
||||
self._run_planning_turn(name, feedback=result.feedback)
|
||||
return
|
||||
assert isinstance(result, Approved)
|
||||
self._store.oplog.emit(
|
||||
"gate.approve",
|
||||
cause=f"approved by {result.approver}",
|
||||
gate=name,
|
||||
rev=f"rev-{rev}",
|
||||
channel=result.channel,
|
||||
)
|
||||
if name == "arch":
|
||||
self._data["gate"] = None
|
||||
self._transition(
|
||||
MILESTONE_PLANNING, cause=f"arch plan approved (rev-{rev})"
|
||||
)
|
||||
self._transport.send_message(
|
||||
f"architecture approved; planning milestone {milestone}"
|
||||
)
|
||||
self._run_planning_turn(f"milestone-{milestone}")
|
||||
else:
|
||||
gate_data["approved"] = True
|
||||
self._transition(
|
||||
AWAIT_MILESTONE_SIGNOFF,
|
||||
cause=f"milestone plan approved (rev-{rev})",
|
||||
)
|
||||
self._transport.send_message(
|
||||
f"milestone plan approved; say 'start milestone {milestone}' to begin"
|
||||
)
|
||||
|
||||
def _handle_answer(self, text: str) -> None:
|
||||
question = self._data.get("question")
|
||||
if question is None:
|
||||
self._transport.send_message(
|
||||
"awaiting operator resolution; no pending question to answer"
|
||||
)
|
||||
return
|
||||
self._data["question"] = None
|
||||
gate_name = question["gate"]
|
||||
back = PLANNING_ARCH if gate_name == "arch" else MILESTONE_PLANNING
|
||||
self._transition(back, cause="operator answered question")
|
||||
self._run_planning_turn(gate_name, answer=text)
|
||||
|
||||
def _transition(self, new_state: str, *, cause: str) -> None:
|
||||
state = self._store.get_state()
|
||||
current = state.state if state else None
|
||||
allowed = (
|
||||
current is None
|
||||
or current == new_state
|
||||
or new_state == AWAIT_INPUT
|
||||
or current == AWAIT_INPUT
|
||||
or (current, new_state) in TRANSITIONS
|
||||
)
|
||||
if not allowed:
|
||||
raise TransitionError(f"illegal transition {current} -> {new_state}")
|
||||
self._store.transition(
|
||||
new_state, cause=cause, project=self._project, data=self._data
|
||||
)
|
||||
Reference in new issue
Block a user