CI / checks (push) Skipped
CI / checks (pull_request) Successful in 51s
The thresholds Part H says to tune on the real inference hardware were hardcoded, so every adjustment would have been a PR. They now live in a `tuning:` section of a YAML config file, which also replaces flat environment variables as the home for structure (#32). - The file (NISSE_CONFIG, default ./nisse-dev.yaml) carries structure and holds no secrets: credentials are ${VAR} references resolved from the environment, and a reference to an unset variable is an error - The environment still wins: every existing NISSE_* variable overrides its key, and the file is optional, so .env and CI work unchanged - Validation stays fail-fast and reports every problem at once; unknown keys are errors, so a misspelt knob fails at startup instead of silently doing nothing - The config file joins the state store and .env in the check that refuses to start when they sit inside the workspace clone - Tuning covers the executor's turn and failure limits, the consult budget, the delegation caps, the command timeout and the tool output limit, which Workspace now takes as a setting; the defaults are the values the agent already ran with - llm.max_output_tokens reaches both LLM clients from the config docs/Tuning-Guide.md explains the knobs for someone learning local model tuning: how weights, quantization and the KV cache spend VRAM, what each knob does and what goes wrong at either extreme, and an order to tune in. nisse-dev.example.yaml documents every key and is checked by a test so it cannot drift. Closes #32 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
245 lines
8.8 KiB
Python
245 lines
8.8 KiB
Python
"""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
|
|
|
|
# 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
|
|
|
|
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 _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,
|
|
max_output_chars: int = MAX_OUTPUT_CHARS,
|
|
) -> None:
|
|
self.root = Path(root)
|
|
self.root.mkdir(parents=True, exist_ok=True)
|
|
self._timeout_s = timeout_s
|
|
self._max_output_chars = max_output_chars
|
|
|
|
def _truncate(self, text: str) -> str:
|
|
limit = self._max_output_chars
|
|
if len(text) <= limit:
|
|
return text
|
|
dropped = len(text) - limit
|
|
return f"{text[:limit]}\n[... {dropped} characters truncated]"
|
|
|
|
def _resolve(self, path: str) -> Path:
|
|
candidate = Path(path)
|
|
return candidate if candidate.is_absolute() else self.root / candidate
|
|
|
|
def _inside(self, target: Path) -> bool:
|
|
try:
|
|
return target.resolve().is_relative_to(self.root.resolve())
|
|
except OSError:
|
|
return False
|
|
|
|
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(self._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)
|
|
# Not a sandbox — the VM is the trust boundary (B3). 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,
|
|
)
|
|
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(
|
|
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}. 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:
|
|
# 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 = self._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)
|