Add LLM-backed planning agent (A4/A11/A13) — step 5a
- planner.py: LLMPlanningAgent offers exactly two tools, submit_plan and ask_human, so gating stays structural at the tool layer; no proceed tool exists. System prompt requires explicit, low-inference plans because a weaker model executes them (A13) - Model output is appended to the canonical log as it arrives, before being interpreted, so a crash mid-planning leaves a truthful transcript - A bad turn (prose instead of a tool call, malformed arguments, or a failed call) retries once, then raises PlanningError; the orchestrator gates to AWAIT_INPUT rather than inventing a plan - Orchestrator: appends every operator message, plan_submitted, and gate_opened to the transcript, so re-planning sees rejection feedback as conversation rather than out-of-band parameters; pins the A11 role mapping at task start - llm.py: non-JSON or non-object tool arguments now raise LLMError instead of escaping as JSONDecodeError — observed from llama3.2, which emits truncated argument strings - Live planning test is opt-in on NISSE_LIVE_PLANNER_MODEL: planning escalates unconditionally (A12) and the local model does not hold the tool-call contract, so it awaits a frontier route Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac9ae27bdc
commit
cf85648719
7 files changed
+522
-5
No files matched your search
@@ -13,7 +13,9 @@ from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from nisse_dev.canonical import GateOpened, PlanSubmitted, UserMessage
|
||||
from nisse_dev.gates import Approved, Gate, Ignored, PlanRevision, Rejected
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.reconcile import Reconciler, reconcile_on_boot
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.transport import InboundMessage, Transport
|
||||
@@ -41,6 +43,12 @@ class TransitionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class PlanningError(RuntimeError):
|
||||
"""A planning turn produced nothing usable. Part of the PlanningAgent
|
||||
contract: the orchestrator gates to AWAIT_INPUT rather than inventing a
|
||||
plan or retrying forever."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubmitPlan:
|
||||
content: str
|
||||
@@ -86,6 +94,7 @@ def _fresh_data() -> dict[str, Any]:
|
||||
"question": None,
|
||||
"milestone": 1,
|
||||
"task_id": None,
|
||||
"roles": None, # A11 mapping, pinned at task start
|
||||
}
|
||||
|
||||
|
||||
@@ -96,10 +105,12 @@ class Orchestrator:
|
||||
transport: Transport,
|
||||
agent: PlanningAgent,
|
||||
operators: Iterable[str],
|
||||
roles: RoleMap | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._transport = transport
|
||||
self._agent = agent
|
||||
self._roles = roles
|
||||
self._operators = set(operators)
|
||||
self._data = _fresh_data()
|
||||
self._project: str | None = None
|
||||
@@ -144,6 +155,12 @@ class Orchestrator:
|
||||
def _handle(self, msg: InboundMessage) -> None:
|
||||
if msg.sender not in self._operators:
|
||||
return
|
||||
# Every operator message enters the canonical transcript, so planning
|
||||
# turns see feedback and answers as conversation rather than as
|
||||
# out-of-band parameters (A10).
|
||||
self._store.append_event(
|
||||
UserMessage(text=msg.text.strip(), source=f"transport:{msg.sender}")
|
||||
)
|
||||
state = self._store.get_state()
|
||||
current = state.state if state else IDLE
|
||||
text = msg.text.strip()
|
||||
@@ -172,7 +189,10 @@ class Orchestrator:
|
||||
|
||||
def _begin_project(self, project: str) -> None:
|
||||
task_id = f"{project}-{uuid.uuid4().hex[:8]}"
|
||||
self._data = {**_fresh_data(), "task_id": task_id}
|
||||
# Pin the role mapping now (A11): a mid-task config change must not
|
||||
# split one task across two planners.
|
||||
pinned = self._roles.pinned() if self._roles is not None else None
|
||||
self._data = {**_fresh_data(), "task_id": task_id, "roles": pinned}
|
||||
self._project = project
|
||||
self._store.oplog.task_id = task_id
|
||||
self._transition(PLANNING_ARCH, cause=f"operator command: use {project}")
|
||||
@@ -182,9 +202,16 @@ class Orchestrator:
|
||||
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
|
||||
)
|
||||
try:
|
||||
action = self._agent.plan(
|
||||
gate_name, self._project, feedback=feedback, answer=answer
|
||||
)
|
||||
except PlanningError as exc:
|
||||
self._transition(AWAIT_INPUT, cause=f"planning failed: {exc}")
|
||||
self._transport.ask_question(
|
||||
f"planning for {gate_name} failed ({exc}); retry or re-plan?"
|
||||
)
|
||||
return
|
||||
if isinstance(action, AskHuman):
|
||||
self._data["question"] = {"text": action.question, "gate": gate_name}
|
||||
self._transition(
|
||||
@@ -203,6 +230,13 @@ class Orchestrator:
|
||||
await_state = (
|
||||
AWAIT_ARCH_SIGNOFF if gate_name == "arch" else AWAIT_MILESTONE_SIGNOFF
|
||||
)
|
||||
roles = self._data.get("roles") or {}
|
||||
self._store.append_event(
|
||||
PlanSubmitted(plan=action.content, model=roles.get("planner", "stub"))
|
||||
)
|
||||
self._store.append_event(
|
||||
GateOpened(gate=gate_name, reason=f"plan submitted (rev-{rev})")
|
||||
)
|
||||
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}; "
|
||||
|
||||
Reference in new issue
Block a user