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
@@ -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
|
||||
Reference in new issue
Block a user