CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m27s
A model no longer reads another role's turns as its own (#78). The log stays one record per work item; what a role is shown is projected from it by Transcript.view() (views.py), which the planner, executor and helper now build their context from: - own events unchanged; - operator messages labelled with their channel, e.g. [operator, Matrix] or [operator, comment on PR #165]; - orchestrator facts (a new `fact` event) as [orchestrator] statements; - the executor sees a helper's handback as [helper report], not its turns; - a helper sees the approved plan and the executor's milestone work as one-line narration; - the planner sees no other role's work at all. The orchestrator now writes facts at the points the models must know about: work item opened, plan approved or rejected, milestone started, finished or stopped, published (with PR number and URL) or not, review comment answered, next milestone, task completed or abandoned. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
194 lines
7.5 KiB
Python
194 lines
7.5 KiB
Python
"""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
|