"""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.delegation import Delegation 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, delegate: Delegation | 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 self._delegate = delegate def _escalate( self, milestone: int, failures: int, last_failure: str ) -> ExecutorOutcome | None: """Hand the sub-task to a stronger model, per A12. Returns None when the delegation handed back usable work and the local loop should carry on; an ExecutorOutcome when the caller must stop. """ note = f"{failures} consecutive command failures; last: {last_failure[:500]}" if self._delegate is None: return ExecutorOutcome("escalate", note) # A12 says the failures go in the brief: the delegate must not have to # rediscover what the local model already learned the hard way. brief = ( f"Milestone {milestone} is stuck. The local executor tried and its " f"commands failed {failures} times in a row.\n\n" f"Most recent failure:\n{last_failure[:2000]}\n\n" "The approved plan and the full transcript are in the conversation " "above. Finish this sub-task, staying inside the approved plan." ) result = self._delegate.run( brief=brief, branch=f"delegation/milestone-{milestone}-{failures}" ) if result.usable: return None # A13: a cap breach or failure leaves a half-edited tree, so the local # loop must not resume on top of it. return ExecutorOutcome( "escalate", f"{note}; delegation {result.status}: {result.note}" ) 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: # A12: this is the trigger delegation exists for. # Escalating to the human is the fallback when there # is no delegate, not the intended path. escalation = self._escalate( milestone, consecutive_failures, outcome.result, ) if escalation is not None: return escalation consecutive_failures = 0 did_work = True return ExecutorOutcome( "capped", f"hit the {self._max_turns}-turn cap for milestone {milestone}" )