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.
- benchspec/__init__.py +17 -0
- benchspec/__main__.py +178 -0
- benchspec/agents/__init__.py +106 -0
- benchspec/agents/base.py +305 -0
- benchspec/agents/claude.py +334 -0
- benchspec/agents/codex.py +607 -0
- benchspec/agents/opencode.py +653 -0
- benchspec/config/__init__.py +3 -0
- benchspec/config/arms.py +281 -0
- benchspec/config/sets.py +207 -0
- benchspec/exit_codes.py +53 -0
- benchspec/grading/__init__.py +6 -0
- benchspec/grading/binder.py +388 -0
- benchspec/grading/checkers.py +272 -0
- benchspec/grading/judge.py +219 -0
- benchspec/grading/judges/__init__.py +27 -0
- benchspec/grading/judges/config.py +164 -0
- benchspec/grading/judges/registry.py +79 -0
- benchspec/grading/trajectory.py +230 -0
- benchspec/grading/trigger.py +118 -0
- benchspec/orchestration/__init__.py +6 -0
- benchspec/orchestration/cases.py +225 -0
- benchspec/orchestration/environments.py +117 -0
- benchspec/orchestration/execution.py +548 -0
- benchspec/orchestration/results.py +165 -0
- benchspec/orchestration/room.py +191 -0
- benchspec/orchestration/workspace.py +87 -0
- benchspec/py.typed +0 -0
- benchspec/reporting/__init__.py +3 -0
- benchspec/reporting/analyze.py +102 -0
- benchspec/reporting/manifest.py +180 -0
- benchspec/reporting/report.py +750 -0
- benchspec/runners/__init__.py +3 -0
- benchspec/runners/pytest.py +456 -0
- benchspec/runners/run.py +111 -0
- benchspec/sandbox/__init__.py +3 -0
- benchspec/sandbox/backend.py +386 -0
- benchspec/sandbox/project.py +138 -0
- benchspec/sandbox/provenance.py +309 -0
- benchspec/sandbox/sandbox.py +639 -0
- benchspec/specs/__init__.py +3 -0
- benchspec/specs/discovery.py +263 -0
- benchspec/specs/lint.py +90 -0
- benchspec/specs/mdformat.py +220 -0
- benchspec/specs/schema.py +245 -0
- benchspec/testing.py +57 -0
- benchspec-0.0.1.dist-info/METADATA +210 -0
- benchspec-0.0.1.dist-info/RECORD +51 -0
- benchspec-0.0.1.dist-info/WHEEL +4 -0
- benchspec-0.0.1.dist-info/entry_points.txt +5 -0
- 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()
|
benchspec/agents/base.py
ADDED
|
@@ -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
|
+
...
|