CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m7s
Completes step 7. - expert.py: one stateless call for a question needing no repo access. Structured output is contracted at the prompt level, since vendors differ on JSON enforcement, so the reply is parsed and validated locally with one retry — tolerating a fenced code block, because models wrap JSON habitually, but not missing fields - The per-task budget is counted from the canonical log rather than memory, so it survives a restart and cannot hand a circling executor a fresh allowance. Exhaustion raises rather than answering: a caller out of consults should get on with the work or escalate - Consults are recorded as delegation_started/completed with role consultant. A16 asks for a "consult/delegation eval set", and reusing the composite events gets consult evaluation for free rather than inventing a parallel record - evals.py pairs starts with completions into eval records, with verdicts kept outside the canonical log: the log records what happened, a verdict is a later judgement about it, and judgements get revised. Schema v3 adds the outcomes table, so a v1 store now traverses two migrations — a better exercise of the hook than one - ask_expert is offered to the executor as a tool only when a desk is configured, in addition to the observable escalation triggers and never instead of them (A12). Consulting is not mutating, so a completion claim backed only by consults is still challenged Found live: passing an empty message list and putting the question in the system prompt fails outright — the Messages API requires at least one non-system message. The question now travels as a synthesised user message that is never appended to the log, which keeps the consult stateless while remaining a valid request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
6.4 KiB
Python
164 lines
6.4 KiB
Python
"""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}"
|
|
)
|