CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m8s
Two gaps found by auditing spec against code, the second created by 7b landing the harness without its boot-time half. **The harness was not connected to anything.** Only a live test constructed a Delegation; A12's failure-count trigger still gated to the human, which A13 describes as the fallback rather than the path. Consecutive command failures now hand the sub-task to the delegate, with the failures in the brief so it need not rediscover what the local model learned the hard way. A usable handback lets the local loop carry on; anything else escalates, because a cap breach leaves a half-edited tree. **A3 step 5 was unimplemented.** A crash mid-delegation failed safe — boot gated on "restarted mid-EXECUTING" — but always gated, even on the clean cut the spec says should resume, and left the branch orphaned because _abandon only runs while the process lives. - A delegation now records an intent before the branch exists, and settles it whichever way the delegation ends; an aborted delegation is a finished one, and leaving it pending would make the next boot believe work was still in flight - delegation_verdict implements the clean-cut test as an observable check, never model self-assessment: settled if a completion follows the start, absent if the branch was never created, gate on a tool_call with no recorded result, resume otherwise - recover() sweeps at boot, announcing a resume with a notice rather than a question, since a clean cut needs no decision from the operator, and leaving an unclean one pending so the generic sweep gates on it - resume() continues the same delegation rather than starting a second: no new branch, no second delegation_started, because A10 defines a session as the delegation, not the process lifetime Honest limit, stated in the code: "the branch sits at the expected commit" is approximated by the branch existing and carrying the work, since per-turn shas are not recorded in the log, so a branch rewritten underneath us would go undetected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
8.3 KiB
Python
206 lines
8.3 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.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}"
|
|
)
|