Merge pull request 'Add executor tool loop (A12/A15) — step 5b' (#12) from executor-loop into main
CI / checks (push) Successful in 34s

This commit was merged in pull request #12.
This commit is contained in:
dev-agent committed 2026-09-07 20:35:33 +00:00
commit 4025ac7875
10 files changed
+937 -30

No files matched your search

+83 -25
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,
@@ -91,7 +99,9 @@ def _fresh_data() -> dict[str, Any]:
return {
"revs": {},
"gate": None,
"question": None,
# What an operator reply in AWAIT_INPUT should resume. Every path into
# AWAIT_INPUT must set this, or the reply has nowhere to go.
"pending": None,
"milestone": 1,
"task_id": None,
"roles": None, # A11 mapping, pinned at task start
@@ -106,11 +116,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
@@ -128,11 +140,14 @@ class Orchestrator:
self._store.oplog.task_id = self._data.get("task_id")
gate = self._data.get("gate")
if report.action == "await_input":
self._transition(AWAIT_INPUT, cause="boot: unresolved intents")
self._transport.ask_question(
"restart found unresolved work: "
+ "; ".join(report.notes)
+ " — how should I proceed?"
self._await_input(
cause="boot: unresolved intents",
prompt=(
"restart found unresolved work: "
+ "; ".join(report.notes)
+ " — how should I proceed?"
),
pending={"kind": "boot"},
)
elif state.state == IDLE or (
state.state in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF) and gate
@@ -147,9 +162,10 @@ class Orchestrator:
else:
# Interrupted mid-turn (planning, executing, unanswered question):
# never blind-resume (A3.6).
self._transition(AWAIT_INPUT, cause=f"boot: restarted in {state.state}")
self._transport.ask_question(
f"restarted mid-{state.state}; continue or re-plan?"
self._await_input(
cause=f"boot: restarted in {state.state}",
prompt=f"restarted mid-{state.state}; continue or re-plan?",
pending={"kind": "boot"},
)
def _handle(self, msg: InboundMessage) -> None:
@@ -207,17 +223,18 @@ class Orchestrator:
gate_name, self._project, feedback=feedback, answer=answer
)
except PlanningError as exc:
self._transition(AWAIT_INPUT, cause=f"planning failed: {exc}")
self._transport.ask_question(
f"planning for {gate_name} failed ({exc}); retry or re-plan?"
self._await_input(
cause=f"planning failed: {exc}",
prompt=f"planning for {gate_name} failed ({exc}); how should I proceed?",
pending={"kind": "planning_failed", "gate": gate_name},
)
return
if isinstance(action, AskHuman):
self._data["question"] = {"text": action.question, "gate": gate_name}
self._transition(
AWAIT_INPUT, cause=f"agent question during {gate_name} planning"
self._await_input(
cause=f"agent question during {gate_name} planning",
prompt=action.question,
pending={"kind": "question", "gate": gate_name},
)
self._transport.ask_question(action.question)
return
rev = self._data["revs"].get(gate_name, 0) + 1
self._data["revs"][gate_name] = rev
@@ -259,7 +276,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,18 +330,59 @@ 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._await_input(
cause=f"milestone {milestone} {outcome.status}",
prompt=f"milestone {milestone} stopped ({outcome.status}): {outcome.note}",
pending={"kind": "milestone_stopped", "milestone": milestone},
)
def _await_input(self, *, cause: str, prompt: str, pending: dict[str, Any]) -> None:
self._data["pending"] = pending
self._transition(AWAIT_INPUT, cause=cause)
self._transport.ask_question(prompt)
def _handle_answer(self, text: str) -> None:
question = self._data.get("question")
if question is None:
pending = self._data.get("pending")
if pending is None:
# Reached only if state was forced externally; say what works
# rather than dead-ending the operator.
self._transport.send_message(
"awaiting operator resolution; no pending question to answer"
"waiting for direction, but I have no pending item to resume; "
"try 'status', or 'use <project>' to start over"
)
return
self._data["question"] = None
gate_name = question["gate"]
back = PLANNING_ARCH if gate_name == "arch" else MILESTONE_PLANNING
self._transition(back, cause="operator answered question")
self._run_planning_turn(gate_name, answer=text)
self._data["pending"] = None
match pending.get("kind"):
case "question" | "planning_failed":
gate_name = pending["gate"]
back = PLANNING_ARCH if gate_name == "arch" else MILESTONE_PLANNING
self._transition(back, cause="operator answered; resuming planning")
self._run_planning_turn(gate_name, answer=text)
case "milestone_stopped":
milestone = pending["milestone"]
self._transition(
AWAIT_MILESTONE_SIGNOFF,
cause="operator answered; plan still approved",
)
self._transport.send_message(
f"noted. the milestone {milestone} plan is still approved — "
f"say 'start milestone {milestone}' to run it again"
)
case _:
self._transport.send_message(
"noted. say 'status' for where things stand"
)
def _transition(self, new_state: str, *, cause: str) -> None:
state = self._store.get_state()
+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)