154 lines
6.0 KiB
Python
154 lines
6.0 KiB
Python
"""Executor contract (docs/executor-contract.md).
|
|
|
|
agentd is not resident: a run outlives the process that started it, so `start` returns a JSON-serialisable
|
|
handle that is stored in the state file, and `poll`/`collect`/`kill` work from that handle alone."""
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Optional
|
|
|
|
from ..transports import fetch_each
|
|
|
|
|
|
@dataclass
|
|
class Run:
|
|
run_id: str
|
|
repo: str
|
|
prompt: str
|
|
model: Optional[str] = None
|
|
effort: Optional[str] = None
|
|
tier: Optional[str] = None
|
|
thread_id: Optional[str] = None # set => resume this thread
|
|
script: Optional[str] = None # shell executor
|
|
dir_name: Optional[str] = None # run directory / task name; default run_id (`<run_id>-r2` on a relaunch)
|
|
|
|
|
|
@dataclass
|
|
class ExecResult:
|
|
last_message: str
|
|
exit_code: Optional[int]
|
|
thread_id: Optional[str]
|
|
log_paths: List[str] = field(default_factory=list)
|
|
tail: str = "" # last frame: stderr tail / last event, for failure notes
|
|
|
|
|
|
class Executor:
|
|
"""Abstract adapter. `resume` says whether the executor can continue a thread."""
|
|
resume = False
|
|
|
|
def __init__(self, name, cfg, terminal, tcfg, transport):
|
|
self.name, self.cfg, self.terminal, self.tcfg, self.transport = name, cfg, terminal, tcfg, transport
|
|
|
|
def handle(self, run): # -> the handle `start(run)` will return, computed without touching the terminal
|
|
raise NotImplementedError
|
|
|
|
def start(self, run): # -> handle (dict)
|
|
raise NotImplementedError
|
|
|
|
def probe(self, handle): # -> names of launch evidence present on the terminal ([] = never launched)
|
|
raise NotImplementedError
|
|
|
|
def poll(self, handle): # -> "running" | "finished"
|
|
raise NotImplementedError
|
|
|
|
def collect(self, handle): # -> ExecResult
|
|
raise NotImplementedError
|
|
|
|
def kill(self, handle):
|
|
raise NotImplementedError
|
|
|
|
|
|
def parse_exit(text):
|
|
m = re.search(r"exit=(-?\d+)", text or "")
|
|
return int(m.group(1)) if m else None
|
|
|
|
|
|
def fill(template, params):
|
|
"""Replace {name} placeholders that exist in params; leave anything else untouched."""
|
|
return re.sub(r"\{(\w+)\}", lambda m: str(params[m.group(1)]) if m.group(1) in params else m.group(0), template)
|
|
|
|
|
|
class RunnerExecutor(Executor):
|
|
"""Common shape for CLI executors: write prompt.md + a runner script into a run directory on the terminal,
|
|
launch it detached through the transport, and treat `exit.txt` as the completion marker.
|
|
Subclasses define `stdout_name` and `collect`."""
|
|
stdout_name = "stdout.txt"
|
|
|
|
@property
|
|
def resume(self):
|
|
return isinstance(self.cfg.get("resume"), str) and bool(self.cfg["resume"])
|
|
|
|
def run_root(self):
|
|
return self.tcfg.get("run_root") or self.transport.default_run_root()
|
|
|
|
def params(self, run, run_dir):
|
|
tier = fill(self.cfg.get("tier_arg", ""), {"tier": run.tier}) if run.tier else ""
|
|
return {"repo": run.repo, "model": run.model or "", "effort": run.effort or "", "tier": tier,
|
|
"out": self.transport.join(run_dir, "last.md"), "thread": run.thread_id or "",
|
|
"perm": self.cfg.get("permission_mode", "default"), "run_dir": run_dir,
|
|
"script": run.script or self.cfg.get("script", "")}
|
|
|
|
def command(self, run, run_dir):
|
|
template = self.cfg["resume"] if run.thread_id and self.resume else self.cfg["run"]
|
|
cmd = " ".join(fill(template, self.params(run, run_dir)).split())
|
|
program, _, rest = cmd.partition(" ")
|
|
binary = self.tcfg.get("bins", {}).get(program)
|
|
if binary:
|
|
program = self.transport.quote(binary)
|
|
if self.transport.os == "windows" and (binary or program.startswith("'")):
|
|
program = "& " + program
|
|
return (program + " " + rest).strip()
|
|
|
|
def handle(self, run):
|
|
name = run.dir_name or run.run_id
|
|
return {"run_dir": self.transport.join(self.run_root(), name), "task": "agentd-" + name, "thread_id": run.thread_id}
|
|
|
|
def start(self, run):
|
|
t = self.transport
|
|
h = self.handle(run)
|
|
run_dir, task = h["run_dir"], h["task"]
|
|
script_name, script = t.runner(run_dir, run.repo, self.command(run, run_dir), self.stdout_name)
|
|
files = {"prompt.md": run.prompt, script_name: script}
|
|
if hasattr(t, "start_run"): # remote: stage + launch in one call (direct-ssh; POSIX also on the relay)
|
|
t.start_run(task, run_dir, files, script_name)
|
|
else:
|
|
t.mkdir(run_dir, clean=True)
|
|
for name, text in files.items():
|
|
t.write_text(t.join(run_dir, name), text)
|
|
t.launch(task, t.join(run_dir, script_name), run_dir)
|
|
return h
|
|
|
|
PROBE_FILES = ("exit.txt", "launch.pid", "pid.txt")
|
|
|
|
def probe(self, handle):
|
|
"""Launch evidence in the run directory (one transport call; raises TransportError when unreachable)."""
|
|
return self.transport.probe(handle["run_dir"], self.PROBE_FILES)
|
|
|
|
def poll(self, handle):
|
|
text = self.transport.read_text(self.transport.join(handle["run_dir"], "exit.txt"))
|
|
return "finished" if text and "exit=" in text else "running"
|
|
|
|
def exit_code(self, handle):
|
|
return parse_exit(self.transport.read_text(self.transport.join(handle["run_dir"], "exit.txt")))
|
|
|
|
def fetch(self, handle, ops):
|
|
"""Reads of the run directory for `collect`: one call on remote transports (see transports.fetch)."""
|
|
t = self.transport
|
|
if hasattr(t, "fetch"):
|
|
return t.fetch(handle["run_dir"], ops)
|
|
return fetch_each(t, handle["run_dir"], ops)
|
|
|
|
def kill(self, handle):
|
|
self.transport.kill(handle["task"], handle["run_dir"])
|
|
|
|
def logs(self, handle, *names):
|
|
return [self.transport.join(handle["run_dir"], n) for n in names]
|
|
|
|
def stderr_tail(self, handle, n=5):
|
|
return self.transport.tail(self.transport.join(handle["run_dir"], "stderr.txt"), n)
|
|
|
|
def forget(self, handle):
|
|
try:
|
|
self.transport.forget(handle["task"])
|
|
except Exception:
|
|
pass
|