"""Local executor loop (spec A12 escalation triggers, A15 accommodations). Runs the approved plan with the executor role — the cheap local model doing mechanical work. Every tool call and result is checkpointed to the canonical log as it happens (A13), so a crash mid-milestone leaves a resumable record. Escalation is decided from outside the model (A12): consecutive command failures and hard caps on turns, never the model's own claim that it is stuck or finished-enough. Until delegation exists (step 7), an escalation gates to the human with the failures attached rather than engaging a frontier model. """ from dataclasses import dataclass from typing import Any, Literal from nisse_dev.canonical import ToolCall, ToolResult from nisse_dev.expert import ASK_EXPERT_TOOL, BudgetExhausted, ExpertDesk from nisse_dev.llm import LLMClient, LLMError, RoleMap from nisse_dev.state import StateStore from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, ToolOutcome, Workspace EXECUTOR_SYSTEM = """You are nisse-dev's executor, doing mechanical work. Follow the approved plan above exactly. It was written to be executed verbatim; do not redesign it, expand its scope, or skip steps. Work by calling tools. Verify your work by running the project's tests. When every step of the plan is done and the tests pass, reply with a short plain-text summary and no tool call — that ends the milestone.""" DEFAULT_MAX_TURNS = 24 DEFAULT_FAILURE_LIMIT = 2 # A turn with no tool call means "the plan is done". Believing that before # any real work has happened would record an empty milestone as a success, so # the claim is challenged once first. _NUDGE = ( "\n\nYou have not made any change yet, so the plan cannot be complete. " "Carry out the next step of the plan with a tool call, or run the tests " "to show the work is already done." ) @dataclass(frozen=True) class ExecutorOutcome: status: Literal["completed", "capped", "escalate"] note: str class LLMExecutor: def __init__( self, client: LLMClient, store: StateStore, roles: RoleMap, workspace: Workspace, *, max_turns: int = DEFAULT_MAX_TURNS, failure_limit: int = DEFAULT_FAILURE_LIMIT, expert: ExpertDesk | None = None, ) -> None: self._client = client self._store = store self._roles = roles self._workspace = workspace self._max_turns = max_turns self._failure_limit = failure_limit self._expert = expert def _tools(self) -> list[dict[str, Any]]: # A12: ask_expert exists in addition to the observable escalation # triggers, never as a replacement for them. if self._expert is None: return EXECUTOR_TOOLS return [*EXECUTOR_TOOLS, ASK_EXPERT_TOOL] def _consult(self, arguments: dict[str, Any]) -> ToolOutcome: assert self._expert is not None question = str(arguments.get("question", "")).strip() if not question: return ToolOutcome("ask_expert needs a question", success=False) try: return ToolOutcome(self._expert.ask(question).render(), success=True) except BudgetExhausted as exc: # Not an error the model should route around: it is the signal # that it is circling and should get on with the work or fail. return ToolOutcome(str(exc), success=False) except LLMError as exc: return ToolOutcome(f"the consultant did not answer: {exc}", success=False) def run(self, milestone: int) -> ExecutorOutcome: model = self._roles.resolve("executor") system = ( f"{EXECUTOR_SYSTEM}\n\n{self._workspace.describe()}" f"\n\nYou are executing milestone {milestone}." ) consecutive_failures = 0 did_work = False challenged = False for _turn in range(self._max_turns): try: produced = self._client.complete( self._store.read_events(), tools=self._tools(), model=model, system=system, ) except LLMError as exc: return ExecutorOutcome("escalate", f"executor call failed: {exc}") calls: list[ToolCall] = [] for event in produced: self._store.append_event(event) if isinstance(event, ToolCall): calls.append(event) if not calls: if did_work: return ExecutorOutcome( "completed", "executor reported the plan done" ) if not challenged: # Claiming completion without having touched anything is # not evidence of completion; ask once before believing it. challenged = True system += _NUDGE continue return ExecutorOutcome( "escalate", "executor claimed the milestone was done without making any change", ) for call in calls: if call.tool in MUTATING_TOOLS: did_work = True if call.tool == "ask_expert": outcome = self._consult(call.arguments) else: outcome = self._workspace.dispatch(call.tool, call.arguments) self._store.append_event( ToolResult( call_id=call.call_id, result=outcome.result, success=outcome.success, ) ) # Only commands count toward escalation: a failed read is the # model looking around, a failed command is work not working. if call.tool == "run_command": consecutive_failures = ( 0 if outcome.success else consecutive_failures + 1 ) if consecutive_failures >= self._failure_limit: return ExecutorOutcome( "escalate", f"{consecutive_failures} consecutive command failures; " f"last: {outcome.result[:500]}", ) return ExecutorOutcome( "capped", f"hit the {self._max_turns}-turn cap for milestone {milestone}" )