Files
nisse/nisse-dev/src/nisse_dev/tools.py
T
dongarandClaude Opus 5.5 9968a0922a
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m59s
CI / e2e (pull_request) Successful in 10m33s
M4.1: toolchain preflight
A model works out and sets up what a project needs on its build VM;
code makes sure that happens, bounds it and checks the result (D3).

- Before each driver run on a project, one round trip checks the build
  VM: the harness's own baseline (missing: the item waits, a template
  problem), the workspace clone (missing: cloned again and said; the
  item resumes from what it pushed) and a per-project marker.
- No marker (a new project, a new or wiped VM): the item's driver model
  runs a bounded preflight first, with its own prompt, the execution
  tools (screened as ever) and toolchain_ready. The harness runs each
  reported version command itself (version-shaped commands only, even
  from the marker, which the model's commands could edit) and writes
  the marker. Gaps and an unfinished preflight are facts and work goes
  on; the thread hears only of installs and gaps. A rebuilt VM starts
  from the last toolchain.
- Later runs check the versions again; a change is a fact and a thread
  line. The PR body says what the work was tested with.
- A changed host key is its own failure (HostKeyChanged): the item
  waits for the operator's `trust new host key`, which drops the pin;
  then the VM is set up again.
- A prompt line: install what the work needs on the build VM, say why,
  and put lasting needs in the project's own pins.
- tuning.preflight: max_turns, command_timeout_s.
- The property walk gains a VM wipe and ready_before_work (checked
  against a mutant that skips preflight); parked_work_safe accepts that
  a wipe loses what wasn't pushed (D3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-08 15:43:30 +00:00

278 lines
10 KiB
Python

"""Executor tools (architecture L9: reduced schema, flat arguments; L13).
Every tool runs on the work item's build VM over SSH (execution.py); the
agent's own host never runs a command a model chose. Within the VM nothing is
sandboxed: the VM is the trust boundary (D7), 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's directory, not a wall.
What *is* enforced here is operational: commands cannot hang the loop and
cannot flood the context window with megabytes of build log. The agent's own
credentials are never on the build VM to inherit.
"""
import base64
import io
import shlex
import tarfile
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nisse_dev.context import head_and_tail
from nisse_dev.execution import HostUnavailable, Result, SSHHost
# Tools that change or verify something. Reading a file or listing a
# directory is a model looking around, not doing the work, so neither counts
# as evidence that work was carried out.
MUTATING_TOOLS = frozenset({"write_file", "run_command"})
DEFAULT_TIMEOUT_S = 120
MAX_OUTPUT_CHARS = 4000
EXECUTION_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"],
},
},
},
]
@dataclass(frozen=True)
class ToolOutcome:
result: str
success: bool
class Workspace:
"""The work item's workspace: a directory on its tool VM (L13, D3).
Every operation runs there over SSH; nothing touches the agent's own
host. ``root`` is the path on that host.
"""
def __init__(
self,
host: SSHHost,
root: Path | str,
*,
timeout_s: int = DEFAULT_TIMEOUT_S,
max_output_chars: int = MAX_OUTPUT_CHARS,
secrets: Mapping[str, str] | None = None,
) -> None:
self.host = host
self.root = Path(root)
self._timeout_s = timeout_s
self._max_output_chars = max_output_chars
# The project secrets the model's commands may see (D7); redacted from
# whatever comes back.
self._secrets = dict(secrets or {})
self._run(f"mkdir -p -- {shlex.quote(str(self.root))}")
def with_timeout(self, timeout_s: int) -> "Workspace":
"""The same workspace with a longer limit per command (installs)."""
return Workspace(
self.host,
self.root,
timeout_s=timeout_s,
max_output_chars=self._max_output_chars,
secrets=self._secrets,
)
def _run(
self, command: str, *, stdin: str | None = None, cwd: str | None = None
) -> Result:
return self.host.execute(
command, cwd=cwd, stdin=stdin, timeout_s=self._timeout_s
)
def _truncate(self, text: str) -> str:
return head_and_tail(text, self._max_output_chars)
def _resolve(self, path: str) -> str:
candidate = Path(path)
return str(candidate if candidate.is_absolute() else self.root / candidate)
def _inside(self, target: str) -> bool:
result = self._run(
f"realpath -m -- {shlex.quote(target)} {shlex.quote(str(self.root))}"
)
lines = result.stdout.splitlines()
if not result.ok or len(lines) != 2:
return False
resolved, root = (Path(line) for line in lines)
return resolved == root or root in resolved.parents
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)
except HostUnavailable as exc:
# Not the work failing: the build VM is out of reach.
return ToolOutcome(f"the build VM is unreachable: {exc}", success=False)
def read_file(self, path: str) -> ToolOutcome:
target = self._resolve(path)
# base64 on the wire, so the bytes arrive intact and decoding is
# strict here, as it was for a local file.
result = self._run(f"base64 -w0 -- {shlex.quote(target)}")
if not result.ok:
return ToolOutcome(
f"could not read {path}: {result.stderr.strip()}", success=False
)
try:
text = base64.b64decode(result.stdout).decode()
except (ValueError, UnicodeDecodeError) as exc:
return ToolOutcome(f"could not read {path}: {exc}", success=False)
return ToolOutcome(self._truncate(text), success=True)
def write_file(self, path: str, content: str) -> ToolOutcome:
target = self._resolve(path)
# Not a sandbox — the VM is the trust boundary (D7). This is about the
# work existing at all: a file written outside the repo is invisible
# to git, so the milestone would publish an empty PR while the model
# truthfully reports success. Models do guess plausible absolute paths
# like /workspace, so say where the workspace actually is.
if not self._inside(target):
return ToolOutcome(
f"refusing to write outside the workspace: {path}. "
f"The workspace root is {self.root}; write there, using "
"relative paths.",
success=False,
)
quoted = shlex.quote(target)
result = self._run(
f'mkdir -p -- "$(dirname -- {quoted})" && base64 -d > {quoted}',
stdin=base64.b64encode(content.encode()).decode(),
)
if not result.ok:
return ToolOutcome(
f"could not write {path}: {result.stderr.strip()}", success=False
)
return ToolOutcome(f"wrote {len(content)} characters to {path}", success=True)
def list_dir(self, path: str) -> ToolOutcome:
target = self._resolve(path)
result = self._run(f"ls -1Ap -- {shlex.quote(target)}")
if not result.ok:
return ToolOutcome(
f"could not list {path}: {result.stderr.strip()}", success=False
)
entries = sorted(line for line in result.stdout.splitlines() if line)
return ToolOutcome(
self._truncate("\n".join(entries) or "(empty)"), success=True
)
def describe(self) -> str:
"""Told to the model, so it does not have to guess where it is."""
return (
f"Your workspace is {self.root} on the build VM. It is a git "
"repository and the only place your work is recorded; commands run "
"there by default. Use relative paths."
)
def run_command(self, command: str) -> ToolOutcome:
try:
result = self.host.execute(
command,
cwd=str(self.root),
secrets=self._secrets,
timeout_s=self._timeout_s,
)
except HostUnavailable as exc:
return ToolOutcome(f"the build VM is unreachable: {exc}", success=False)
if result.timed_out:
return ToolOutcome(
f"command timed out after {self._timeout_s}s: {command}",
success=False,
)
body = self._truncate(
f"exit code: {result.exit_code}\nstdout:\n{result.stdout}"
f"\nstderr:\n{result.stderr}"
)
return ToolOutcome(body, success=result.exit_code == 0)
def upload_tree(self, local: Path, dest: str = ".") -> None:
"""Copy a local directory's contents into the workspace (the bench
seeding a task); raises RuntimeError if it fails."""
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
for entry in sorted(Path(local).iterdir()):
archive.add(entry, arcname=entry.name)
target = shlex.quote(self._resolve(dest))
result = self._run(
f"mkdir -p -- {target} && base64 -d | tar -xzf - -C {target}",
stdin=base64.b64encode(buffer.getvalue()).decode(),
)
if not result.ok:
raise RuntimeError(f"upload to {dest} failed: {result.stderr.strip()}")
def reset(self) -> None:
"""Discard workspace contents; used by tests, not by the agent."""
root = shlex.quote(str(self.root))
self._run(f"rm -rf -- {root} && mkdir -p -- {root}")