"""LLM-backed planning agent (architecture L3 approvals, L9 roles, D2 plans). 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 (L8/L7), so a crash mid-planning leaves a truthful transcript. """ from typing import Any from nisse_dev.cancel import Cancel from nisse_dev.canonical import AssistantText, ToolCall, ToolResult from nisse_dev.control import AskHuman, PlanningError, PlanningStopped, SubmitPlan from nisse_dev.llm import BudgetExhausted, LLMClient, LLMError, RoleMap from nisse_dev.state import Transcript # Plans may be executed by a materially weaker model, which cannot # reconstruct intent. This predates the architecture's capability profiles # and plans that specify rather than implement (L9, D2); Development Plan # M2.3 replaces it. 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, roles: RoleMap, *, system_prompt: str = SYSTEM_PROMPT, cancel: Cancel | None = None, ) -> None: self._client = client self._roles = roles self._system_prompt = system_prompt # Checked before each turn only. A turn is one model call, and a turn # that completes ends at a gate or a question, which is a halt anyway. self._cancel = cancel or Cancel() def plan( self, transcript: Transcript, 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. The transcript is the work item's, written as the planner (L6, L7).""" model = self._roles.resolve("planner") instruction = ( f"{self._system_prompt}\n\n" f"Current task: draft the {gate} plan for project {project!r}." ) self._check_stop() action, first_error = self._try_turn(transcript, model, instruction) if action is not None: return action self._check_stop() action, second_error = self._try_turn( transcript, 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 _check_stop(self) -> None: if self._cancel.requested: raise PlanningStopped("stopped by the operator") def _try_turn( self, transcript: Transcript, 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 (L9 — judgment about model output is observable, never the model's own claim).""" try: return self._turn(transcript, model, system), None except BudgetExhausted: # Retrying cannot help: the budget will not reappear within a # turn. Surface it now so the operator is told rather than the # loop spending a second call to fail identically. raise except LLMError as exc: return None, str(exc) def _turn( self, transcript: Transcript, model: str, system: str ) -> SubmitPlan | AskHuman | None: events = transcript.view() 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. transcript.append(event) if isinstance(event, AssistantText) or action is not None: continue if isinstance(event, ToolCall): action = self._interpret(event) if action is not None: # The gate is this call's outcome. Recording it keeps the # transcript renderable: providers reject a tool call with # no result, and the next planning turn replays this log. transcript.append( ToolResult( call_id=event.call_id, result=( "plan posted for approval; turn ended" if isinstance(action, SubmitPlan) else "question posted; awaiting the operator" ), success=True, ) ) 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