Add executor tool loop (A12/A15) — step 5b
CI / checks (push) Failing after 13s
CI / checks (pull_request) Failing after 13s

- tools.py: four flat tools (read_file, write_file, list_dir,
  run_command) per A15's reduced manifest. Deliberately unsandboxed:
  the VM is the trust boundary (B3) and tool use is auto-approved
  inside an approved unit of work (A4), so path confinement would add
  failure modes without adding safety. What is enforced is
  operational — timeout with process-group kill so a backgrounded
  server cannot outlive its command, output truncation so a build log
  cannot eat the context window, closed stdin so prompts fail fast,
  and a scrubbed environment so project code never sees the agent's
  credentials
- executor.py: renders the log, calls the executor role with the
  reduced manifest, dispatches tool calls, and checkpoints every
  result (A13). Escalation is observable, never self-assessed (A12):
  consecutive command failures and a hard turn cap. Failed reads do
  not count — looking around is not failing
- Until delegation exists (step 7), escalation gates to the human
  with the failures attached
- control.py: start milestone now runs the executor; a stopped
  milestone gates to AWAIT_INPUT

Two bugs found by running it live against real models:

- A turn-ending gate tool (submit_plan) left a tool_call with no
  tool_result, which providers reject outright, so every planning
  turn after the first failed with a 400. The planner now records the
  gate as the call's outcome, which is also the truthful transcript
- The renderer now emits a placeholder for any tool_call lacking a
  result, so a crash between call and result (A3) cannot poison the
  transcript permanently

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dongarandClaude Opus 5 committed 2026-09-07 16:35:39 +00:00
1 parent 47b2bdfdbd
commit 0fd0b2e4a5
10 files changed
+784 -5

No files matched your search

+27 -1
View File
@@ -59,6 +59,14 @@ class AskHuman:
question: str
class MilestoneExecutor(Protocol):
"""What the orchestrator needs from an executor loop; the concrete one
lives in nisse_dev.executor (imported there, not here, to keep the
dependency pointing one way)."""
def run(self, milestone: int) -> Any: ...
class PlanningAgent(Protocol):
def plan(
self,
@@ -106,11 +114,13 @@ class Orchestrator:
agent: PlanningAgent,
operators: Iterable[str],
roles: RoleMap | None = None,
executor: "MilestoneExecutor | None" = None,
) -> None:
self._store = store
self._transport = transport
self._agent = agent
self._roles = roles
self._executor = executor
self._operators = set(operators)
self._data = _fresh_data()
self._project: str | None = None
@@ -259,7 +269,7 @@ class Orchestrator:
self._transition(
EXECUTING, cause=f"operator command: start milestone {milestone}"
)
self._transport.send_message(f"[stub] executing milestone {milestone}")
self._execute_milestone(milestone)
else:
self._transport.send_message(
f"plan approved; say 'start milestone {milestone}' to begin"
@@ -313,6 +323,22 @@ class Orchestrator:
f"milestone plan approved; say 'start milestone {milestone}' to begin"
)
def _execute_milestone(self, milestone: int) -> None:
if self._executor is None:
self._transport.send_message(f"[stub] executing milestone {milestone}")
return
self._transport.send_message(f"executing milestone {milestone}")
outcome = self._executor.run(milestone)
if outcome.status == "completed":
# PR_OPEN and the review loop arrive with the Gitea layer (B2);
# until then a finished milestone simply reports and waits.
self._transport.send_message(f"milestone {milestone} done: {outcome.note}")
return
self._transition(AWAIT_INPUT, cause=f"milestone {milestone} {outcome.status}")
self._transport.ask_question(
f"milestone {milestone} stopped ({outcome.status}): {outcome.note}"
)
def _handle_answer(self, text: str) -> None:
question = self._data.get("question")
if question is None:
+107
View File
@@ -0,0 +1,107 @@
"""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 Literal
from nisse_dev.canonical import ToolCall, ToolResult
from nisse_dev.llm import LLMClient, LLMError, RoleMap
from nisse_dev.state import StateStore
from nisse_dev.tools import EXECUTOR_TOOLS, 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
@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,
) -> None:
self._client = client
self._store = store
self._roles = roles
self._workspace = workspace
self._max_turns = max_turns
self._failure_limit = failure_limit
def run(self, milestone: int) -> ExecutorOutcome:
model = self._roles.resolve("executor")
system = f"{EXECUTOR_SYSTEM}\n\nYou are executing milestone {milestone}."
consecutive_failures = 0
for _turn in range(self._max_turns):
try:
produced = self._client.complete(
self._store.read_events(),
tools=EXECUTOR_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:
return ExecutorOutcome("completed", "executor reported the plan done")
for call in calls:
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}"
)
+16 -1
View File
@@ -11,7 +11,7 @@ crash mid-planning leaves a truthful transcript.
from typing import Any
from nisse_dev.canonical import AssistantText, ToolCall
from nisse_dev.canonical import AssistantText, ToolCall, ToolResult
from nisse_dev.control import AskHuman, PlanningError, SubmitPlan
from nisse_dev.llm import LLMClient, LLMError, RoleMap
from nisse_dev.state import StateStore
@@ -142,6 +142,21 @@ class LLMPlanningAgent:
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.
self._store.append_event(
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:
+17 -1
View File
@@ -57,8 +57,24 @@ class OpenAIChatRenderer:
messages: list[dict[str, Any]] = []
if system is not None:
messages.append({"role": "system", "content": system})
for stored in effective_events(events):
kept = effective_events(events)
answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)}
for stored in kept:
messages.append(self._message_for(stored.event))
# Providers reject a tool call with no result following it. One
# can legitimately be missing: a crash between the call and its
# result (A3), or a turn-ending gate tool. Render a placeholder so
# the transcript stays replayable rather than poisoned.
if isinstance(stored.event, ToolCall) and (
stored.event.call_id not in answered
):
messages.append(
{
"role": "tool",
"tool_call_id": stored.event.call_id,
"content": "[no result recorded]",
}
)
body: dict[str, Any] = {"model": model, "messages": messages}
if tools:
body["tools"] = tools
+204
View File
@@ -0,0 +1,204 @@
"""Executor tools (spec A15: reduced schema, flat arguments).
Deliberately unsandboxed. The VM is the trust boundary (B3) and tool use is
auto-approved inside an approved unit of work (A4), so per-command
confinement would add failure modes without adding safety. The workspace is
where work happens — the default for relative paths and the command cwd — not
a wall. Protecting agent state from the agent is a mount-layout concern
(A2/B3), not this module's job.
What *is* enforced here is operational: commands cannot hang the loop, cannot
consume the context window with megabytes of build log, and do not inherit
the agent's own credentials.
"""
import os
import shutil
import signal
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULT_TIMEOUT_S = 120
MAX_OUTPUT_CHARS = 4000
EXECUTOR_TOOLS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a UTF-8 text file.",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write a UTF-8 text file, creating parent directories.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "List the entries of a directory.",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "run_command",
"description": (
"Run a shell command in the workspace. Returns its exit code "
"and output; a non-zero exit is a result, not an error."
),
"parameters": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
},
]
def _truncate(text: str) -> str:
if len(text) <= MAX_OUTPUT_CHARS:
return text
dropped = len(text) - MAX_OUTPUT_CHARS
return f"{text[:MAX_OUTPUT_CHARS]}\n[... {dropped} characters truncated]"
def _child_env() -> dict[str, str]:
"""The project's build and test code is arbitrary; it has no business
reading the agent's LiteLLM, Matrix, or Gitea credentials."""
return {k: v for k, v in os.environ.items() if not k.startswith("NISSE_")}
@dataclass(frozen=True)
class ToolOutcome:
result: str
success: bool
class Workspace:
def __init__(self, root: Path, *, timeout_s: int = DEFAULT_TIMEOUT_S) -> None:
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
self._timeout_s = timeout_s
def _resolve(self, path: str) -> Path:
candidate = Path(path)
return candidate if candidate.is_absolute() else self.root / candidate
def dispatch(self, tool: str, arguments: dict[str, Any]) -> ToolOutcome:
try:
match tool:
case "read_file":
return self.read_file(str(arguments["path"]))
case "write_file":
return self.write_file(
str(arguments["path"]), str(arguments["content"])
)
case "list_dir":
return self.list_dir(str(arguments.get("path", ".")))
case "run_command":
return self.run_command(str(arguments["command"]))
case _:
return ToolOutcome(f"no such tool: {tool}", success=False)
except KeyError as exc:
return ToolOutcome(f"missing argument {exc} for {tool}", success=False)
def read_file(self, path: str) -> ToolOutcome:
target = self._resolve(path)
try:
return ToolOutcome(_truncate(target.read_text()), success=True)
except (OSError, UnicodeDecodeError) as exc:
return ToolOutcome(f"could not read {path}: {exc}", success=False)
def write_file(self, path: str, content: str) -> ToolOutcome:
target = self._resolve(path)
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
except OSError as exc:
return ToolOutcome(f"could not write {path}: {exc}", success=False)
return ToolOutcome(f"wrote {len(content)} characters to {path}", success=True)
def list_dir(self, path: str) -> ToolOutcome:
target = self._resolve(path)
try:
entries = sorted(
f"{p.name}/" if p.is_dir() else p.name for p in target.iterdir()
)
except OSError as exc:
return ToolOutcome(f"could not list {path}: {exc}", success=False)
return ToolOutcome(_truncate("\n".join(entries) or "(empty)"), success=True)
def run_command(self, command: str) -> ToolOutcome:
try:
# Popen rather than run(): on timeout, run() kills only the direct
# child, and TimeoutExpired carries no pid, so a backgrounded
# grandchild (a dev server, say) would survive. Holding the pid
# lets us kill the whole session.
process = subprocess.Popen(
command,
shell=True,
cwd=self.root,
env=_child_env(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
text=True,
start_new_session=True,
)
except OSError as exc:
return ToolOutcome(f"could not run command: {exc}", success=False)
try:
stdout, stderr = process.communicate(timeout=self._timeout_s)
except subprocess.TimeoutExpired:
self._kill_group(process)
return ToolOutcome(
f"command timed out after {self._timeout_s}s: {command}",
success=False,
)
body = _truncate(
f"exit code: {process.returncode}\nstdout:\n{stdout}\nstderr:\n{stderr}"
)
return ToolOutcome(body, success=process.returncode == 0)
@staticmethod
def _kill_group(process: "subprocess.Popen[str]") -> None:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
process.kill()
# Drain the pipes of the now-dead group so no zombie is left behind.
try:
process.communicate(timeout=5)
except subprocess.TimeoutExpired:
pass
def reset(self) -> None:
"""Discard workspace contents; used by tests, not by the agent."""
shutil.rmtree(self.root, ignore_errors=True)
self.root.mkdir(parents=True, exist_ok=True)