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)
+173
View File
@@ -0,0 +1,173 @@
import io
from pathlib import Path
from typing import Any
import pytest
from nisse_dev.canonical import AssistantText, CanonicalEvent, StoredEvent, ToolCall
from nisse_dev.executor import LLMExecutor
from nisse_dev.llm import LLMError, RoleMap
from nisse_dev.oplog import OpLog
from nisse_dev.state import StateStore
from nisse_dev.tools import Workspace
ROLES = RoleMap(
executor="llama3.2",
planner="claude-haiku-4-5",
delegate="claude-haiku-4-5",
consultant="claude-haiku-4-5",
)
class ScriptedClient:
def __init__(self, turns: list[list[CanonicalEvent]]) -> None:
self._turns = turns
self.calls: list[dict[str, Any]] = []
def complete(
self,
events: list[StoredEvent],
tools: list[dict[str, Any]] | None,
model: str,
system: str | None = None,
) -> list[CanonicalEvent]:
self.calls.append({"tools": tools, "model": model, "system": system})
if not self._turns:
return [AssistantText(text="done", model=model)]
return self._turns.pop(0)
class FailingClient:
def complete(self, *args: Any, **kwargs: Any) -> list[CanonicalEvent]:
raise LLMError("proxy exploded")
def call(tool: str, call_id: str = "c1", **arguments: Any) -> ToolCall:
return ToolCall(call_id=call_id, tool=tool, arguments=arguments)
@pytest.fixture
def store(tmp_path: Path) -> StateStore:
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
@pytest.fixture
def ws(tmp_path: Path) -> Workspace:
return Workspace(tmp_path / "work", timeout_s=10)
def build(client: Any, store: StateStore, ws: Workspace, **kw: Any) -> LLMExecutor:
return LLMExecutor(client, store, ROLES, ws, **kw)
def test_tool_calls_are_executed_and_results_logged(
store: StateStore, ws: Workspace
) -> None:
client = ScriptedClient(
[
[call("write_file", path="app.py", content="print(1)\n")],
[AssistantText(text="wrote the file", model="llama3.2")],
]
)
outcome = build(client, store, ws).run(milestone=1)
assert outcome.status == "completed"
assert (ws.root / "app.py").read_text() == "print(1)\n"
kinds = [type(e.event).__name__ for e in store.read_events()]
assert kinds == ["ToolCall", "ToolResult", "AssistantText"]
def test_uses_executor_role_and_reduced_manifest(
store: StateStore, ws: Workspace
) -> None:
client = ScriptedClient([])
build(client, store, ws).run(milestone=2)
assert client.calls[0]["model"] == "llama3.2" # never the planner
names = [t["function"]["name"] for t in client.calls[0]["tools"]]
assert names == ["read_file", "write_file", "list_dir", "run_command"]
assert "milestone 2" in client.calls[0]["system"]
def test_no_tool_call_ends_the_milestone(store: StateStore, ws: Workspace) -> None:
client = ScriptedClient([[AssistantText(text="all steps done", model="m")]])
assert build(client, store, ws).run(milestone=1).status == "completed"
def test_two_consecutive_command_failures_escalate(
store: StateStore, ws: Workspace
) -> None:
client = ScriptedClient(
[
[call("run_command", command="exit 1")],
[call("run_command", command="exit 1")],
[call("run_command", command="exit 1")],
]
)
outcome = build(client, store, ws, failure_limit=2).run(milestone=1)
assert outcome.status == "escalate"
assert "2 consecutive command failures" in outcome.note
assert len(client.calls) == 2 # stopped, did not keep trying
def test_a_success_resets_the_failure_count(store: StateStore, ws: Workspace) -> None:
client = ScriptedClient(
[
[call("run_command", command="exit 1")],
[call("run_command", command="true")],
[call("run_command", command="exit 1")],
[AssistantText(text="done", model="m")],
]
)
assert build(client, store, ws, failure_limit=2).run(milestone=1).status == (
"completed"
)
def test_failed_reads_do_not_count_toward_escalation(
store: StateStore, ws: Workspace
) -> None:
client = ScriptedClient(
[
[call("read_file", path="missing-a.txt")],
[call("read_file", path="missing-b.txt")],
[AssistantText(text="looked around, done", model="m")],
]
)
assert build(client, store, ws, failure_limit=2).run(milestone=1).status == (
"completed"
)
def test_turn_cap_is_enforced(store: StateStore, ws: Workspace) -> None:
forever = [[call("list_dir", path=".")] for _ in range(50)]
client = ScriptedClient(forever)
outcome = build(client, store, ws, max_turns=3).run(milestone=1)
assert outcome.status == "capped"
assert "3-turn cap" in outcome.note
assert len(client.calls) == 3
def test_llm_error_escalates_rather_than_crashing(
store: StateStore, ws: Workspace
) -> None:
outcome = build(FailingClient(), store, ws).run(milestone=1)
assert outcome.status == "escalate"
assert "proxy exploded" in outcome.note
def test_multiple_calls_in_one_turn_all_run(store: StateStore, ws: Workspace) -> None:
client = ScriptedClient(
[
[
call("write_file", call_id="a", path="one.txt", content="1"),
call("write_file", call_id="b", path="two.txt", content="2"),
],
[AssistantText(text="done", model="m")],
]
)
build(client, store, ws).run(milestone=1)
assert (ws.root / "one.txt").exists() and (ws.root / "two.txt").exists()
results = [
e.event for e in store.read_events() if type(e.event).__name__ == "ToolResult"
]
assert {r.call_id for r in results} == {"a", "b"}
+19 -2
View File
@@ -108,7 +108,18 @@ def test_model_output_is_appended_to_the_log(store: StateStore) -> None:
)
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
kinds = [type(e.event).__name__ for e in store.read_events()]
assert kinds == ["AssistantText", "ToolCall"]
assert kinds == ["AssistantText", "ToolCall", "ToolResult"]
def test_gate_tool_call_is_answered_in_the_log(store: StateStore) -> None:
# submit_plan ends the turn and has no natural result, but a tool call
# left dangling makes the next render invalid for the provider.
client = ScriptedClient([[plan_call("the plan")]])
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
events = [e.event for e in store.read_events()]
assert [type(e).__name__ for e in events] == ["ToolCall", "ToolResult"]
assert events[1].call_id == events[0].call_id
assert "posted for approval" in events[1].result
def test_prose_only_turn_retries_once_then_raises(store: StateStore) -> None:
@@ -170,7 +181,13 @@ def test_use_project_produces_llm_plan_and_transcript(tmp_path: Path) -> None:
events = [e.event for e in store.read_events()]
kinds = [type(e).__name__ for e in events]
assert kinds == ["UserMessage", "ToolCall", "PlanSubmitted", "GateOpened"]
assert kinds == [
"UserMessage",
"ToolCall",
"ToolResult",
"PlanSubmitted",
"GateOpened",
]
submitted = next(e for e in events if isinstance(e, PlanSubmitted))
assert submitted.model == "anthropic/claude-opus-5" # pinned planner
+27
View File
@@ -71,6 +71,33 @@ def test_compaction_replaces_its_range_only() -> None:
assert contents == ["kept", "[summary of earlier conversation]\nthe old stuff"]
def test_tool_call_without_a_result_gets_a_placeholder() -> None:
# Providers reject a dangling tool_use; this happens after a crash (A3)
# or with turn-ending gate tools like submit_plan.
events = stored(
[
UserMessage(text="go", source="m:1"),
ToolCall(call_id="call-x", tool="submit_plan", arguments={}),
]
)
body = OpenAIChatRenderer().render(events, tools=None, model="q")
assert [m["role"] for m in body["messages"]] == ["user", "assistant", "tool"]
assert body["messages"][2]["tool_call_id"] == "call-x"
assert body["messages"][2]["content"] == "[no result recorded]"
def test_answered_tool_call_gets_no_placeholder() -> None:
events = stored(
[
ToolCall(call_id="call-x", tool="run_command", arguments={}),
ToolResult(call_id="call-x", result="ok", success=True),
]
)
body = OpenAIChatRenderer().render(events, tools=None, model="q")
assert [m["role"] for m in body["messages"]] == ["assistant", "tool"]
assert body["messages"][1]["content"] == "ok"
def test_gate_event_renders_as_context() -> None:
events = stored([GateOpened(gate="arch", reason="plan submitted (rev-1)")])
body = OpenAIChatRenderer().render(events, tools=None, model="q")
+117
View File
@@ -0,0 +1,117 @@
import os
from pathlib import Path
import pytest
from nisse_dev.tools import EXECUTOR_TOOLS, MAX_OUTPUT_CHARS, Workspace
@pytest.fixture
def ws(tmp_path: Path) -> Workspace:
return Workspace(tmp_path / "work", timeout_s=10)
def test_manifest_is_small_and_flat() -> None:
# A15: fewer tools, flatter arguments for the local executor.
names = [t["function"]["name"] for t in EXECUTOR_TOOLS]
assert names == ["read_file", "write_file", "list_dir", "run_command"]
for tool in EXECUTOR_TOOLS:
props = tool["function"]["parameters"]["properties"]
assert all(p["type"] == "string" for p in props.values())
def test_write_then_read_round_trip(ws: Workspace) -> None:
assert ws.write_file("src/app.py", "print('hi')\n").success
outcome = ws.read_file("src/app.py")
assert outcome.success
assert outcome.result == "print('hi')\n"
def test_relative_paths_land_in_the_workspace(ws: Workspace) -> None:
ws.write_file("notes.txt", "x")
assert (ws.root / "notes.txt").exists()
def test_read_missing_file_is_a_failed_result_not_an_exception(
ws: Workspace,
) -> None:
outcome = ws.read_file("nope.txt")
assert not outcome.success
assert "could not read" in outcome.result
def test_list_dir(ws: Workspace) -> None:
ws.write_file("a.txt", "a")
ws.write_file("sub/b.txt", "b")
outcome = ws.list_dir(".")
assert outcome.success
assert outcome.result.splitlines() == ["a.txt", "sub/"]
def test_list_empty_dir(ws: Workspace) -> None:
assert ws.list_dir(".").result == "(empty)"
def test_run_command_success_reports_exit_code_and_stdout(ws: Workspace) -> None:
outcome = ws.run_command("echo hello")
assert outcome.success
assert "exit code: 0" in outcome.result
assert "hello" in outcome.result
def test_nonzero_exit_is_a_result_not_an_error(ws: Workspace) -> None:
outcome = ws.run_command("exit 3")
assert not outcome.success # feeds the A12 failure count
assert "exit code: 3" in outcome.result
def test_command_runs_in_the_workspace(ws: Workspace) -> None:
ws.write_file("marker.txt", "here")
assert "marker.txt" in ws.run_command("ls").result
def test_command_timeout_is_reported(tmp_path: Path) -> None:
ws = Workspace(tmp_path / "work", timeout_s=1)
outcome = ws.run_command("sleep 30")
assert not outcome.success
assert "timed out" in outcome.result
def test_timeout_kills_the_whole_process_group(tmp_path: Path) -> None:
ws = Workspace(tmp_path / "work", timeout_s=1)
pidfile = ws.root / "child.pid"
# A backgrounded grandchild must not outlive the timeout.
ws.run_command(f"(sleep 30 & echo $! > {pidfile}); sleep 30")
assert pidfile.exists()
pid = int(pidfile.read_text().strip())
with pytest.raises(ProcessLookupError):
os.kill(pid, 0)
def test_agent_credentials_are_not_inherited(
ws: Workspace, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("NISSE_LLM_TOKEN", "sk-secret")
monkeypatch.setenv("PATH", os.environ["PATH"])
outcome = ws.run_command("echo [${NISSE_LLM_TOKEN}]")
assert "sk-secret" not in outcome.result
assert "[]" in outcome.result
def test_stdin_is_closed_so_prompts_fail_fast(ws: Workspace) -> None:
outcome = ws.run_command("read line && echo got")
assert not outcome.success
assert "got" not in outcome.result
def test_long_output_is_truncated(ws: Workspace) -> None:
outcome = ws.run_command("printf 'x%.0s' $(seq 1 20000)")
assert len(outcome.result) < MAX_OUTPUT_CHARS + 200
assert "truncated" in outcome.result
def test_dispatch_unknown_tool_and_missing_argument(ws: Workspace) -> None:
assert not ws.dispatch("fly", {}).success
missing = ws.dispatch("read_file", {})
assert not missing.success
assert "missing argument" in missing.result