benchspec 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. benchspec/__init__.py +17 -0
  2. benchspec/__main__.py +178 -0
  3. benchspec/agents/__init__.py +106 -0
  4. benchspec/agents/base.py +305 -0
  5. benchspec/agents/claude.py +334 -0
  6. benchspec/agents/codex.py +607 -0
  7. benchspec/agents/opencode.py +653 -0
  8. benchspec/config/__init__.py +3 -0
  9. benchspec/config/arms.py +281 -0
  10. benchspec/config/sets.py +207 -0
  11. benchspec/exit_codes.py +53 -0
  12. benchspec/grading/__init__.py +6 -0
  13. benchspec/grading/binder.py +388 -0
  14. benchspec/grading/checkers.py +272 -0
  15. benchspec/grading/judge.py +219 -0
  16. benchspec/grading/judges/__init__.py +27 -0
  17. benchspec/grading/judges/config.py +164 -0
  18. benchspec/grading/judges/registry.py +79 -0
  19. benchspec/grading/trajectory.py +230 -0
  20. benchspec/grading/trigger.py +118 -0
  21. benchspec/orchestration/__init__.py +6 -0
  22. benchspec/orchestration/cases.py +225 -0
  23. benchspec/orchestration/environments.py +117 -0
  24. benchspec/orchestration/execution.py +548 -0
  25. benchspec/orchestration/results.py +165 -0
  26. benchspec/orchestration/room.py +191 -0
  27. benchspec/orchestration/workspace.py +87 -0
  28. benchspec/py.typed +0 -0
  29. benchspec/reporting/__init__.py +3 -0
  30. benchspec/reporting/analyze.py +102 -0
  31. benchspec/reporting/manifest.py +180 -0
  32. benchspec/reporting/report.py +750 -0
  33. benchspec/runners/__init__.py +3 -0
  34. benchspec/runners/pytest.py +456 -0
  35. benchspec/runners/run.py +111 -0
  36. benchspec/sandbox/__init__.py +3 -0
  37. benchspec/sandbox/backend.py +386 -0
  38. benchspec/sandbox/project.py +138 -0
  39. benchspec/sandbox/provenance.py +309 -0
  40. benchspec/sandbox/sandbox.py +639 -0
  41. benchspec/specs/__init__.py +3 -0
  42. benchspec/specs/discovery.py +263 -0
  43. benchspec/specs/lint.py +90 -0
  44. benchspec/specs/mdformat.py +220 -0
  45. benchspec/specs/schema.py +245 -0
  46. benchspec/testing.py +57 -0
  47. benchspec-0.0.1.dist-info/METADATA +210 -0
  48. benchspec-0.0.1.dist-info/RECORD +51 -0
  49. benchspec-0.0.1.dist-info/WHEEL +4 -0
  50. benchspec-0.0.1.dist-info/entry_points.txt +5 -0
  51. benchspec-0.0.1.dist-info/licenses/LICENSE +21 -0
benchspec/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """benchspec — a benchmark framework for agents.
2
+
3
+ An eval is one Markdown file: a prompt plus a checklist of prose assertions. Each eval
4
+ runs across the named arms of an eval set — harness × model × effort × environment —
5
+ as parametrized pytest tests, each cell inside its own microVM. Assertions the binder
6
+ can map to a deterministic checker are graded on the host; the rest go to an LLM judge
7
+ that sees only collected evidence. The report is a matrix of pass rates with every
8
+ arm's delta against the set's baseline.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.0.1"
14
+
15
+ from benchspec.agents import CodingAgent, make_agent
16
+
17
+ __all__ = ["CodingAgent", "make_agent", "__version__"]
benchspec/__main__.py ADDED
@@ -0,0 +1,178 @@
1
+ """`python -m benchspec <command>` / the `benchspec` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from dotenv import find_dotenv, load_dotenv
10
+
11
+ from benchspec.exit_codes import ExitCode
12
+ from benchspec.reporting import analyze
13
+ from benchspec.runners import run
14
+ from benchspec.sandbox import sandbox
15
+ from benchspec.specs import lint
16
+ from benchspec.specs.schema import SchemaError
17
+
18
+
19
+ def _add_root_argument(command_parser: argparse.ArgumentParser) -> None:
20
+ """Add the shared optional `root` positional to a subcommand parser."""
21
+ command_parser.add_argument(
22
+ "root",
23
+ nargs="?",
24
+ default=Path("."),
25
+ type=Path,
26
+ help="repo root to scan (default: cwd)",
27
+ )
28
+
29
+
30
+ def _add_run_flags(run_parser: argparse.ArgumentParser) -> None:
31
+ """Add the curated `run` flag vocabulary that forwards to the plugin's options."""
32
+ run_parser.add_argument("--set", help="eval set to run (--benchspec-set)")
33
+ run_parser.add_argument("--config", help="config file to load (--benchspec-config)")
34
+ run_parser.add_argument("--model", help="model for the single arm (--benchspec-model)")
35
+ run_parser.add_argument("--models", help="comma-separated model sweep (--benchspec-models)")
36
+ run_parser.add_argument("--harness", help="harness for the single arm (--benchspec-harness)")
37
+ run_parser.add_argument("--effort", help="reasoning effort (--benchspec-effort)")
38
+ run_parser.add_argument(
39
+ "--eval-paths", help="comma-separated search paths (--benchspec-eval-paths)"
40
+ )
41
+ run_parser.add_argument("--fail-under", help="pass-rate gate (--benchspec-fail-under)")
42
+ run_parser.add_argument(
43
+ "--judge-harness", help="judge harness (--benchspec-judge-harness)"
44
+ )
45
+ run_parser.add_argument("--judge-model", help="judge model (--benchspec-judge-model)")
46
+ run_parser.add_argument("--judge-effort", help="judge effort (--benchspec-judge-effort)")
47
+ run_parser.add_argument(
48
+ "--env",
49
+ action="append",
50
+ default=[],
51
+ metavar="KEY=VAL",
52
+ help="repeatable env var for arms (--benchspec-env); pass --env KEY=VAL per var",
53
+ )
54
+
55
+
56
+ def _split_passthrough(argv: list[str]) -> tuple[list[str], list[str]]:
57
+ """Split argv at the first standalone `--`, returning the head and verbatim tail.
58
+
59
+ Args:
60
+ argv: The command-line arguments to split.
61
+
62
+ Returns:
63
+ A `(head, tail)` pair — everything before the first standalone `--` and
64
+ everything after it. The tail is empty when no `--` is present.
65
+ """
66
+ if "--" not in argv:
67
+ return argv, []
68
+ separator = argv.index("--")
69
+ return argv[:separator], argv[separator + 1 :]
70
+
71
+
72
+ def _run_sandbox_build(args: argparse.Namespace) -> int:
73
+ """Build the agent-ready snapshot for `root`, mapping host and build errors to exit codes.
74
+
75
+ `cli_build` resolves the selected set's backend first, then preflights exactly that backend
76
+ once — so selected-backend diagnostics are not hidden behind a default-microsandbox preflight,
77
+ and a future backend can build independently. Error mapping:
78
+
79
+ - `SchemaError` (bad config, or a `docker`/unsupported set): propagates to the `main` boundary
80
+ → USAGE (2). Config error, not a build failure.
81
+ - `RuntimeError` (host preflight, including microsandbox-not-installed): USAGE (2), surfaced
82
+ before any provisioning. This branch imports no microsandbox, so a host without the package
83
+ still exits 2 cleanly rather than raising `ModuleNotFoundError`.
84
+ - `MicrosandboxError` (a genuine build/provision failure): FINDING (1). Only reachable after
85
+ preflight passed, which guarantees `import microsandbox` works, so importing the error type
86
+ here is safe.
87
+
88
+ Args:
89
+ args: The parsed `sandbox:build` namespace, with `root` a `Path`.
90
+
91
+ Returns:
92
+ `ExitCode.SUCCESS` on a built or already-present snapshot, `ExitCode.USAGE` on a
93
+ preflight or config-schema failure, `ExitCode.FINDING` on a build failure.
94
+ """
95
+ root = args.root.resolve()
96
+
97
+ try:
98
+ sandbox.cli_build(root, set_name=args.set, config=args.config)
99
+ except RuntimeError as error:
100
+ print(f"error: {error}", file=sys.stderr)
101
+ return ExitCode.USAGE
102
+ except SchemaError:
103
+ raise # a bad config / unsupported backend is a usage error; let `main` map it to 2
104
+ except Exception as error:
105
+ # Reached only after preflight passed, so microsandbox is importable here.
106
+ from microsandbox.errors import MicrosandboxError
107
+
108
+ if isinstance(error, MicrosandboxError):
109
+ print(f"error: {error}", file=sys.stderr)
110
+ return ExitCode.FINDING
111
+ raise
112
+
113
+ return ExitCode.SUCCESS
114
+
115
+
116
+ def _load_repo_dotenv() -> None:
117
+ """Load a repo-root `.env` into the environment before any subcommand runs.
118
+
119
+ `usecwd=True` walks up from the invocation directory — the same rule the pytest
120
+ plugin applies — so `lint`, `analyze`, `sandbox:build`, and `run` all see the same
121
+ credentials. Variables already exported win over `.env` values.
122
+ """
123
+ dotenv_path = find_dotenv(usecwd=True)
124
+ if dotenv_path:
125
+ load_dotenv(dotenv_path)
126
+
127
+
128
+ def main(argv: list[str] | None = None) -> int:
129
+ """Dispatch the benchspec command-line interface."""
130
+ _load_repo_dotenv()
131
+ parser = argparse.ArgumentParser(prog="benchspec")
132
+ sub = parser.add_subparsers(dest="command", required=True)
133
+ _add_root_argument(
134
+ sub.add_parser("lint", help="static checks for eval suites (unjudgeable assertions)")
135
+ )
136
+ _add_root_argument(
137
+ sub.add_parser(
138
+ "analyze",
139
+ help="classify assertions deterministic/activation/judge-backed before a run",
140
+ )
141
+ )
142
+ run_parser = sub.add_parser(
143
+ "run", help="run the eval suite (pytest); pass args after `--` verbatim to pytest"
144
+ )
145
+ _add_root_argument(run_parser)
146
+ _add_run_flags(run_parser)
147
+ sandbox_build_parser = sub.add_parser(
148
+ "sandbox:build", help="build the agent-ready sandbox snapshot"
149
+ )
150
+ _add_root_argument(sandbox_build_parser)
151
+ sandbox_build_parser.add_argument(
152
+ "--set", help="eval set whose sandbox backend + env drive the build"
153
+ )
154
+ sandbox_build_parser.add_argument(
155
+ "--config", help="config file layered over pyproject for set resolution"
156
+ )
157
+
158
+ if argv is None:
159
+ argv = sys.argv[1:]
160
+ head, passthrough = _split_passthrough(argv)
161
+ args = parser.parse_args(head)
162
+ args.passthrough = passthrough
163
+
164
+ dispatch = {
165
+ "lint": lambda parsed: lint.run(parsed.root.resolve()),
166
+ "analyze": lambda parsed: analyze.run(parsed.root.resolve()),
167
+ "run": run.run,
168
+ "sandbox:build": _run_sandbox_build,
169
+ }
170
+ try:
171
+ return dispatch[args.command](args)
172
+ except SchemaError as error:
173
+ print(f"error: {error}", file=sys.stderr)
174
+ return ExitCode.USAGE
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())
@@ -0,0 +1,106 @@
1
+ """Agents for benchspec.
2
+
3
+ `sandbox.py` talks only to the `CodingAgent` interface and to the factory/preflight
4
+ helpers here — never to a concrete agent. The active agent is resolved via
5
+ `resolve_agent_name`'s precedence chain (--benchspec-agent flag > BENCHSPEC_AGENT env >
6
+ [tool.benchspec] agent > claude-code default), with `BENCHSPEC_AGENT` being the normalized
7
+ handoff that `make_agent()` reads. Unknown values fail loudly. Adding a third agent is
8
+ additive: implement the protocol, register it in `_REGISTRY`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+
15
+ from benchspec.agents.base import AgentCapabilities, CodingAgent
16
+ from benchspec.agents.claude import ClaudeCodeAgent
17
+ from benchspec.agents.codex import CodexAgent
18
+ from benchspec.agents.opencode import OpenCodeAgent
19
+
20
+ __all__ = [
21
+ "AgentCapabilities",
22
+ "CodingAgent",
23
+ "ClaudeCodeAgent",
24
+ "CodexAgent",
25
+ "OpenCodeAgent",
26
+ "agent_class",
27
+ "make_agent",
28
+ "credential_preflight_error",
29
+ "resolve_agent_name",
30
+ "known_harnesses",
31
+ ]
32
+
33
+ _REGISTRY: dict[str, type] = {
34
+ "claude-code": ClaudeCodeAgent,
35
+ "codex": CodexAgent,
36
+ "opencode": OpenCodeAgent,
37
+ }
38
+
39
+ _DEFAULT_AGENT = "claude-code"
40
+
41
+
42
+ def known_harnesses() -> frozenset[str]:
43
+ """Registered agent names — the authoritative set of valid harness values."""
44
+ return frozenset(_REGISTRY)
45
+
46
+
47
+ def resolve_agent_name(flag: str | None = None, pyproject: str | None = None) -> str:
48
+ """The agent for this run: --benchspec-agent > BENCHSPEC_AGENT > [tool.benchspec].
49
+
50
+ agent > claude-code. An unknown value fails naming the source it came from, so a
51
+ typo dies at startup with a fixable message instead of mid-run.
52
+ """
53
+ sources = (
54
+ ("--benchspec-agent", flag),
55
+ ("BENCHSPEC_AGENT", os.environ.get("BENCHSPEC_AGENT")),
56
+ ("[tool.benchspec] agent", pyproject),
57
+ )
58
+ for source_name, value in sources:
59
+ if value:
60
+ if value not in _REGISTRY:
61
+ valid_agents = sorted(_REGISTRY)
62
+ raise RuntimeError(
63
+ f"{source_name}={value!r} is not a known agent; valid: {valid_agents}"
64
+ )
65
+ return value
66
+ return _DEFAULT_AGENT
67
+
68
+
69
+ def _selected_agent_class() -> type:
70
+ """Provide the selected agent class helper."""
71
+ return _REGISTRY[resolve_agent_name()]
72
+
73
+
74
+ def agent_class(harness: str) -> type:
75
+ """The registered adapter class for a harness name.
76
+
77
+ Judge-mode dispatch looks the class up, then binds an instance to the host
78
+ environment via `for_host()` — the judge uses the host's own credentials, so no
79
+ `from_env()` credential read is involved. An unknown `harness` raises.
80
+ """
81
+ if harness not in _REGISTRY:
82
+ raise RuntimeError(
83
+ f"harness {harness!r} is not a known agent; valid: {sorted(_REGISTRY)}"
84
+ )
85
+ return _REGISTRY[harness]
86
+
87
+
88
+ def make_agent(harness: str | None = None) -> CodingAgent:
89
+ """The agent for a run or a single arm.
90
+
91
+ With no `harness`, reads the run-level agent from `BENCHSPEC_AGENT` (the plugin
92
+ normalizes the precedence chain at configure time). With an explicit `harness` (an
93
+ arm's `harness`), builds THAT agent so one run's columns can span harnesses. An
94
+ unknown `harness` raises.
95
+ """
96
+ if harness is not None:
97
+ return agent_class(harness).from_env()
98
+ return _selected_agent_class().from_env()
99
+
100
+
101
+ def credential_preflight_error() -> str | None:
102
+ """None if a usable credential is configured for the selected agent, else a.
103
+
104
+ remediation message for preflight.
105
+ """
106
+ return _selected_agent_class().credential_error()
@@ -0,0 +1,305 @@
1
+ """The agent interface.
2
+
3
+ A `CodingAgent` hides everything agent-specific behind one boundary: where its home
4
+ lives in the guest, how to provision the CLI into a microVM (the cached step), which
5
+ secrets it needs, how to stage local skills, how to build its headless command, and how
6
+ to parse its output. `sandbox.py` drives a live sandbox through this interface and never
7
+ names a concrete agent; adding a second agent is additive, not a refactor.
8
+
9
+ One adapter per harness, transport-blind: the adapter builds commands and parses
10
+ output, and a `benchspec.orchestration.environments.ExecutionEnv` decides where the
11
+ process runs —
12
+ `GuestSandbox` for task arms (`invoke`), `Host` for grading (`judge`). Sandbox-vs-host
13
+ is a parameter of the call, not a code path baked into each harness.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import hashlib
20
+ import re
21
+ import subprocess
22
+ from dataclasses import dataclass
23
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
24
+
25
+ from benchspec.orchestration.results import RunResult
26
+
27
+ if TYPE_CHECKING:
28
+ from benchspec.grading.judges.config import JudgeConfig
29
+ from benchspec.orchestration.environments import ExecutionEnv
30
+ from benchspec.sandbox.backend import SandboxBackend
31
+
32
+ # The agent-neutral home every per-cell `setup.sh` copies skills into. Each agent
33
+ # symlinks its own load dir here once at provision, so the install path is identical
34
+ # across agents and the per-agent load dir is the only agent-specific fact.
35
+ FIXED_SKILLS_HOME = "/home/benchspec/skills"
36
+
37
+ # Matches the first dotted-numeric token in `--version` output, e.g. the "1.2.3" in
38
+ # both "claude-code 1.2.3" and "codex-cli 0.144.1". Shared by every guest-version parse
39
+ # so adapters never hand-roll their own extraction.
40
+ _VERSION_TOKEN_RE = re.compile(r"\d+(?:\.\d+)+")
41
+
42
+ # The guest `--version` probe runs before the task on every sample. A wedged guest
43
+ # command must not block the run forever, so the await is bounded and a timeout becomes
44
+ # an explained-unavailable result like any other probe failure.
45
+ GUEST_VERSION_PROBE_TIMEOUT_SECONDS = 30.0
46
+
47
+
48
+ def _parse_version_token(output: str) -> str | None:
49
+ """Extract a dotted version token (e.g. "1.2.3") from raw `--version` output."""
50
+ match = _VERSION_TOKEN_RE.search(output)
51
+ return match.group(0) if match else None
52
+
53
+
54
+ async def probe_guest_version(
55
+ backend: SandboxBackend, sandbox: object, agent: object
56
+ ) -> tuple[str | None, str | None]:
57
+ """Measure the task-harness binary's version inside the live guest sandbox.
58
+
59
+ Runs `<agent.agent_bin> --version` through the backend's guest command seam
60
+ (`backend.guest_shell`) against the already-booted `sandbox` instance. This is the
61
+ guest task-harness probe: it reads the binary actually installed in the selected
62
+ snapshot, never the host binding (`for_host()`) or the pinned install selector
63
+ (`agent.version()`, e.g. `"latest"`).
64
+
65
+ Never raises: any failure to reach the guest, or to parse a version out of what it
66
+ returns, is reported as an explained `(None, error)` pair rather than an
67
+ unexplained null (ground rule: explained unavailable).
68
+
69
+ Args:
70
+ backend: The resolved `SandboxBackend` driving this arm's sandbox.
71
+ sandbox: The live sandbox instance for the running arm session.
72
+ agent: The `CodingAgent` whose `agent_bin` is probed.
73
+
74
+ Returns:
75
+ `(version, None)` on success, or `(None, error)` describing why the probe
76
+ could not produce a version.
77
+ """
78
+ script = f"{agent.agent_bin} --version"
79
+ try:
80
+ # Bounded so a wedged guest command surfaces as unavailable instead of hanging
81
+ # every sample before its task runs.
82
+ output = await asyncio.wait_for(
83
+ backend.guest_shell(sandbox, agent, script),
84
+ timeout=GUEST_VERSION_PROBE_TIMEOUT_SECONDS,
85
+ )
86
+ except TimeoutError:
87
+ return None, f"guest version probe timed out after {GUEST_VERSION_PROBE_TIMEOUT_SECONDS}s"
88
+ except Exception as error:
89
+ # Broad on purpose: this probe's contract is "never raise" (see docstring), and
90
+ # guest_shell's own concrete failure modes vary by backend.
91
+ return None, f"guest_shell raised {type(error).__name__}: {error}"
92
+ if output is None:
93
+ return None, f"guest_shell returned no output for `{script}`"
94
+ version = _parse_version_token(output)
95
+ if version is None:
96
+ return None, f"could not parse a version from guest output: {output.strip()!r}"
97
+ return version, None
98
+
99
+
100
+ class BaseAgent:
101
+ """Store base agent data."""
102
+
103
+ # The binary THIS instance runs. An instance is bound to one execution
104
+ # environment: the default binding is the guest install path (task arms);
105
+ # for_host() rebinds to the name PATH resolves on the host (judge mode).
106
+ agent_bin: str
107
+
108
+ def provision_script(self: object) -> str:
109
+ """The fully-resolved commands this instance runs to install the CLI in the guest.
110
+
111
+ Concrete task adapters override it, baking in any instance state (e.g. a pinned
112
+ version). `provision()` runs exactly this, and `install_fingerprint()` hashes it — so
113
+ every install-affecting input is captured in the cache key structurally, with nothing
114
+ to fold by hand. Empty on the base (host/judge agents install nothing in a guest).
115
+ """
116
+ return ""
117
+
118
+ def binary_version(self: object) -> str | None:
119
+ """Best-effort `agent_bin --version` probe — never raises, never fails the run."""
120
+ try:
121
+ proc = subprocess.run(
122
+ [self.agent_bin, "--version"], capture_output=True, text=True, timeout=10
123
+ )
124
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
125
+ return None
126
+ if proc.returncode != 0:
127
+ return None
128
+ return proc.stdout.strip() or None
129
+
130
+ def install_fingerprint(self: object) -> str:
131
+ """Cache-key fingerprint of the CLI install: a hash of the resolved provision script.
132
+
133
+ `provision_script()` is the single source of truth for what installs the CLI, so
134
+ changing the installer (a new revision, package list, pinned version, or bootstrap
135
+ commands) rebuilds the snapshot. The agent version also appears directly in the
136
+ snapshot name, so a version bump rebuilds even for an adapter whose installer does not
137
+ embed the version.
138
+ """
139
+ return hashlib.sha256(self.provision_script().encode()).hexdigest()[:12]
140
+
141
+ def bridge_skills_home_script(self: object) -> str:
142
+ """Bridge skills home script."""
143
+ skill_dir = self.skill_load_dir
144
+ parent = skill_dir.rsplit("/", 1)[0]
145
+ return (
146
+ f"mkdir -p {FIXED_SKILLS_HOME} && "
147
+ f"mkdir -p {parent} && rm -rf {skill_dir} && "
148
+ f"ln -s {FIXED_SKILLS_HOME} {skill_dir}"
149
+ )
150
+
151
+ def cell_env(self: object, *, arm: str, model: str, eval_set: str = "") -> dict:
152
+ """BENCHSPEC_* are informational for setup.sh — they do NOT route the task model.
153
+
154
+ (that goes through arm.model). BENCHSPEC_SET names the explicitly-selected set
155
+ (--benchspec-set / make evals SET=) so setup.sh can branch on it; empty when the
156
+ run falls back to the pyproject default-set.
157
+ """
158
+ return {
159
+ **self.guest_env(),
160
+ "BENCHSPEC_ARM": arm,
161
+ "BENCHSPEC_MODEL": model,
162
+ "BENCHSPEC_HARNESS": self.id,
163
+ "BENCHSPEC_SET": eval_set,
164
+ }
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class AgentCapabilities:
169
+ """What the harness can honestly do with this agent.
170
+
171
+ Typed, not a dict: every field here has a consumer in the harness, and an
172
+ unknown field is a type error rather than silently ignored documentation.
173
+ Effort is deliberately not a capability: every driver forwards it verbatim and
174
+ the agent's CLI is the validator, the same way model names are handled.
175
+ """
176
+
177
+ multi_turn: bool # honors resume_session_id (session chaining across turns)
178
+ token_split: bool # reports input/output token split (enables cost estimates)
179
+
180
+
181
+ @runtime_checkable
182
+ class CodingAgent(Protocol):
183
+ """Define the agent interface."""
184
+
185
+ id: str # snapshot-cache key + report label
186
+ guest_home: str # the agent's HOME inside the guest (where skills are staged, runs cwd)
187
+ skill_load_dir: str # absolute guest path the agent loads skills from
188
+ agent_bin: str # the binary this instance runs (guest install path; for_host() rebinds)
189
+ capabilities: AgentCapabilities
190
+
191
+ def version(self: object) -> str:
192
+ """Return the agent CLI version string."""
193
+ ...
194
+
195
+ def provision_script(self: object) -> str:
196
+ """Return the fully-resolved commands that install the CLI in the guest."""
197
+ ...
198
+
199
+ def install_fingerprint(self: object) -> str:
200
+ """Return the CLI install fingerprint (a hash of `provision_script()`)."""
201
+ ...
202
+
203
+ def bridge_skills_home_script(
204
+ self: object,
205
+ ) -> str:
206
+ """Bridge skills home script."""
207
+ ...
208
+
209
+ def cell_env(self: object, *, arm: str, model: str, eval_set: str = "") -> dict:
210
+ """Return per-cell environment variables for an arm run."""
211
+ ...
212
+
213
+ def artifact_dirs(
214
+ self: object,
215
+ ) -> list[str]:
216
+ """Return guest directories that may contain agent-authored artifacts."""
217
+ ...
218
+
219
+ # artifacts (e.g. ~/.claude/skills). These live in the VM, NOT the workdir mount, so
220
+ # the session snapshots them and merges the agent-authored files (diffed against the
221
+ # staged baseline) into the judge's facts. Each agent scaffolds skills differently, so
222
+ # each owns its answer; return [] for an agent that writes only to the workdir.
223
+ def secrets(self: object) -> list:
224
+ """Return secret values that must be redacted from logs."""
225
+ ...
226
+
227
+ def guest_env(self: object) -> dict:
228
+ """Return environment variables passed to guest agent commands."""
229
+ ...
230
+
231
+ def build_command(
232
+ self: object,
233
+ prompt: str,
234
+ *,
235
+ plugin_dir: str | None,
236
+ model: str,
237
+ effort: str,
238
+ resume_session_id: str | None,
239
+ detect_skill: str | None,
240
+ harness_args: list[str] | None = None,
241
+ ) -> list[str]:
242
+ """Build the guest command used to invoke the agent."""
243
+ ...
244
+
245
+ async def provision(self: object, sandbox: object) -> None:
246
+ """Install the agent CLI and credentials inside the guest."""
247
+ ...
248
+
249
+ async def stage_project_assets(self: object, sandbox: object, project_mount: str) -> None:
250
+ """Copy project-local assets needed by the guest agent."""
251
+ ...
252
+
253
+ async def invoke(
254
+ self: object,
255
+ sandbox: object,
256
+ prompt: str,
257
+ *,
258
+ eval_id: str,
259
+ config: str,
260
+ workdir: str,
261
+ plugin_dir: str | None,
262
+ model: str,
263
+ effort: str,
264
+ resume_session_id: str | None,
265
+ detect_skill: str | None,
266
+ harness_args: list[str] | None = None,
267
+ extra_env: dict | None = None,
268
+ ) -> RunResult:
269
+ """Run one prompt through the agent inside the guest."""
270
+ ...
271
+
272
+ async def judge(
273
+ self: object,
274
+ prompt: str,
275
+ config: JudgeConfig,
276
+ *,
277
+ env: ExecutionEnv | None = None,
278
+ ) -> str:
279
+ """Grade a judge prompt in `env` (default: a fresh Host process).
280
+
281
+ Returns the {"result": "<judge-json-string>"} envelope judge.py parses.
282
+ Raises RuntimeError for the harness's infra-failure shapes.
283
+ """
284
+ ...
285
+
286
+ @classmethod
287
+ def for_host(cls: object) -> CodingAgent:
288
+ """An instance bound to the host environment: agent_bin resolves from PATH."""
289
+ ...
290
+
291
+ def binary_version(self: object) -> str | None:
292
+ """Return this instance's CLI version, or None on any failure (best effort)."""
293
+ ...
294
+
295
+ def detect_dispatch(self: object, line: str, skill_name: str | None) -> bool:
296
+ """Return whether one stream line shows a skill dispatch."""
297
+ ...
298
+
299
+ def detect_fired(self: object, lines: object, skill_name: str) -> bool:
300
+ """Return whether stream lines show the expected skill firing."""
301
+ ...
302
+
303
+ def streamed_activity(self: object, lines: object) -> bool:
304
+ """Return whether streamed output shows meaningful agent activity."""
305
+ ...