Add LLM-backed planning agent (A4/A11/A13) — step 5a
CI / checks (push) Successful in 12s
CI / checks (pull_request) Successful in 11s

- 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:
dongarandClaude Opus 5 committed 2026-09-07 14:47:12 +00:00
1 parent ac9ae27bdc
commit cf85648719
7 files changed
+522 -5

No files matched your search

+38 -4
View File
@@ -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}; "
+16 -1
View File
@@ -131,11 +131,26 @@ class LiteLLMClient:
# gets a locally-minted canonical ID; the provider's is dropped, since
# every future request is rendered wholly from the canonical log.
for call in message.get("tool_calls") or []:
raw = call["function"]["arguments"] or "{}"
# Weak models emit truncated or non-JSON argument strings; that is
# a bad model turn, not a client crash (A15).
try:
arguments = json.loads(raw)
except json.JSONDecodeError as exc:
raise LLMError(
f"tool call {call['function']['name']!r} had non-JSON "
f"arguments: {raw[:200]!r}"
) from exc
if not isinstance(arguments, dict):
raise LLMError(
f"tool call {call['function']['name']!r} arguments were "
f"{type(arguments).__name__}, expected object"
)
out.append(
ToolCall(
call_id=mint_call_id(),
tool=call["function"]["name"],
arguments=json.loads(call["function"]["arguments"] or "{}"),
arguments=arguments,
)
)
return out
+156
View File
@@ -0,0 +1,156 @@
"""LLM-backed planning agent (spec A4 gating, A11 roles, A13 plan quality).
Gating is structural at the tool layer: the planner is offered exactly two
tools — ``submit_plan``, which posts a plan for approval and ends the turn,
and ``ask_human``, which pauses for an answer. No "proceed" tool exists, so
the model has no path to advance itself past a gate.
Model output is appended to the canonical log as it arrives (A2/A10), so a
crash mid-planning leaves a truthful transcript.
"""
from typing import Any
from nisse_dev.canonical import AssistantText, ToolCall
from nisse_dev.control import AskHuman, PlanningError, SubmitPlan
from nisse_dev.llm import LLMClient, LLMError, RoleMap
from nisse_dev.state import StateStore
# Plans may be executed by a materially weaker model, which cannot
# reconstruct intent (A13).
SYSTEM_PROMPT = """You are nisse-dev, a software engineering agent.
You are planning, not implementing. Produce plans that are explicit and
low-inference: a materially weaker model will execute them and cannot
reconstruct your intent. Name files, commands, and acceptance criteria
concretely; never write "handle the edge cases" or "wire it up".
You must answer by calling exactly one tool:
- submit_plan: your plan is posted for human approval and your turn ends.
- ask_human: ask one question when a decision is genuinely the operator's.
You cannot approve your own plan or start work; a human gate follows every
plan you submit."""
PLANNER_TOOLS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "submit_plan",
"description": "Post a plan for human approval. Ends your turn.",
"parameters": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Which plan this is, e.g. 'arch' or 'milestone-1'.",
},
"content": {
"type": "string",
"description": "The plan itself, explicit enough to execute verbatim.",
},
},
"required": ["type", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "ask_human",
"description": "Ask the operator one question, then wait.",
"parameters": {
"type": "object",
"properties": {
"question": {"type": "string"},
},
"required": ["question"],
},
},
},
]
_NUDGE = (
"You did not call a tool. Call submit_plan or ask_human now; "
"plain prose is not accepted."
)
class LLMPlanningAgent:
def __init__(
self,
client: LLMClient,
store: StateStore,
roles: RoleMap,
*,
system_prompt: str = SYSTEM_PROMPT,
) -> None:
self._client = client
self._store = store
self._roles = roles
self._system_prompt = system_prompt
def plan(
self,
gate: str,
project: str,
feedback: str | None = None,
answer: str | None = None,
) -> SubmitPlan | AskHuman:
"""Operator feedback and answers are already durable in the canonical
log (the orchestrator appends every inbound message), so they are not
re-injected here; the log is the single source of context."""
model = self._roles.resolve("planner")
instruction = (
f"{self._system_prompt}\n\n"
f"Current task: draft the {gate} plan for project {project!r}."
)
action, first_error = self._try_turn(model, instruction)
if action is not None:
return action
action, second_error = self._try_turn(model, f"{instruction}\n\n{_NUDGE}")
if action is None:
detail = second_error or first_error
raise PlanningError(
f"planner ({model}) produced no submit_plan or ask_human call "
f"after a retry{f': {detail}' if detail else ''}"
)
return action
def _try_turn(
self, model: str, system: str
) -> tuple[SubmitPlan | AskHuman | None, str | None]:
"""A malformed or failed call is a bad turn, not a crash: the caller
retries once and then gates to the human (A12 — judgment about model
output is observable, never the model's own claim)."""
try:
return self._turn(model, system), None
except LLMError as exc:
return None, str(exc)
def _turn(self, model: str, system: str) -> SubmitPlan | AskHuman | None:
events = self._store.read_events()
produced = self._client.complete(
events, tools=PLANNER_TOOLS, model=model, system=system
)
action: SubmitPlan | AskHuman | None = None
for event in produced:
# Append before interpreting: the transcript must be truthful even
# if the call below rejects the arguments.
self._store.append_event(event)
if isinstance(event, AssistantText) or action is not None:
continue
if isinstance(event, ToolCall):
action = self._interpret(event)
return action
def _interpret(self, call: ToolCall) -> SubmitPlan | AskHuman | None:
if call.tool == "submit_plan":
content = call.arguments.get("content")
if isinstance(content, str) and content.strip():
return SubmitPlan(content.strip())
elif call.tool == "ask_human":
question = call.arguments.get("question")
if isinstance(question, str) and question.strip():
return AskHuman(question.strip())
return None