"""What an agent chat needs from the machine it acts on. One interface, currently one implementation. It exists as an interface anyway because the *snapshot* is the load-bearing part: a generation outlives the request that started it, so everything a runner needs -- the host, the decrypted credential, the mode, the project directory -- has to be read while the session is open and carried, not looked up later. That is the same reason `Endpoint` is a frozen copy of a `Connection` and `ToolContext` holds an owner id rather than a `User`. """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import Any, Protocol # What a command may weigh before it is cut off. Per call; the reply also has a # total, in policy.Limits. DEFAULT_MAX_BYTES = 64 * 1024 DEFAULT_TIMEOUT = 60.0 # Terminal escape sequences, stripped from anything a command produced. They are # inert in escaped HTML, but this text also re-enters the model's context, where # they are a known way of hiding instructions, and it may end up in a log a # person later cats, where they hijack the terminal. _ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]") @dataclass(frozen=True) class ExecRequest: """One command to run.""" command: str cwd: str = "" timeout: float = DEFAULT_TIMEOUT max_bytes: int = DEFAULT_MAX_BYTES @dataclass(frozen=True) class ExecResult: """What running it produced. `output` is stdout and stderr interleaved, because a shell transcript is what the model needs to read and separating them loses the ordering that makes an error make sense. """ exit_status: int output: str truncated: bool = False timed_out: bool = False duration_ms: int = 0 @property def ok(self) -> bool: return self.exit_status == 0 and not self.timed_out class ExecError(Exception): """Nothing could be run at all: the host refused, or the credential did. Distinct from a command that ran and failed -- that is an `ExecResult` with a non-zero status, which the model should read and react to. This is the reply not being able to act, which is a message for a person. """ def __init__(self, message: str) -> None: super().__init__(message) self.message = message @dataclass(frozen=True) class Target: """A machine an agent chat acts on, read while the session was open. Holds the decrypted credential and nothing else does. `generation` clears it when the reply ends, because a finished `Generation` lingers for five minutes so late followers get the final frames, and a private key should not linger with it. """ kind: str label: str project_dir: str = "" spec: dict[str, Any] = field(default_factory=dict) class Executor(Protocol): """How a target is acted on. See `ssh.py`; there is no local variant.""" async def run(self, request: ExecRequest) -> ExecResult: ... async def read_file(self, path: str, *, max_bytes: int) -> str: ... async def write_file(self, path: str, text: str) -> int: ... async def list_dir(self, path: str) -> list[str]: ... def clean_output(data: bytes | str, *, limit: int) -> tuple[str, bool]: """Decode, strip escape sequences, and cap. Returns (text, truncated).""" text = data.decode("utf-8", "replace") if isinstance(data, bytes) else data text = _ANSI.sub("", text) if len(text) <= limit: return text, False return text[:limit].rstrip() + "\n… (truncated)", True __all__ = [ "DEFAULT_MAX_BYTES", "DEFAULT_TIMEOUT", "ExecError", "ExecRequest", "ExecResult", "Executor", "Target", "clean_output", ]