From ac5d4d5ff697b66f4dc8442c41fdb2a5dde32424 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:39:57 +0000 Subject: [PATCH 1/2] Add browser REPL toolset, attempts, arm and suite args, and per-trial site-config proxies --- .github/workflows/_run.yml | 22 +++- README.md | 8 +- configurators/site-config/configure | 29 ++--- harness/cli.py | 35 ++++- harness/definitions.py | 13 +- harness/environment.py | 66 +++++++++- harness/run.py | 46 ++++++- report/publish-braintrust.ts | 1 + report/report.ts | 17 +++ report/results.test.ts | 40 +++++- report/results.ts | 26 ++++ tests/test_environment.py | 89 +++++++++++++ tests/test_kernel_mcp_local.py | 122 +++++++++++++++++- tests/test_run.py | 69 ++++++++++ .../kernel-mcp-local/image/bin/kernel-mcp | 8 +- .../image/bin/kernel-mcp-entrypoint | 3 + .../image/bin/kernel-mcp-verify | 58 +++++---- .../kernel-mcp-local/image/bin/mcp-call-log | 82 ++++++++++++ .../kernel-mcp-local/image/layer.Dockerfile | 4 +- toolsets/kernel-mcp-local/toolset.py | 56 ++++++-- 20 files changed, 716 insertions(+), 78 deletions(-) create mode 100644 tests/test_run.py create mode 100755 toolsets/kernel-mcp-local/image/bin/mcp-call-log diff --git a/.github/workflows/_run.yml b/.github/workflows/_run.yml index 79768d7..e0ee4b5 100644 --- a/.github/workflows/_run.yml +++ b/.github/workflows/_run.yml @@ -27,6 +27,21 @@ on: required: false default: "" type: string + suite_args: + description: Space-separated KEY=VALUE overrides of the benchmark's suite_args + required: false + default: "" + type: string + toolset_args: + description: Space-separated KEY=VALUE overrides of the arms' toolset_args + required: false + default: "" + type: string + attempts: + description: Trials per task and arm + required: false + default: "1" + type: string permissions: contents: read @@ -79,14 +94,19 @@ jobs: TASKS: ${{ inputs.tasks }} CONCURRENCY: ${{ inputs.concurrency }} TOOLSET_REF: ${{ inputs.toolset_ref }} + SUITE_ARGS: ${{ inputs.suite_args }} + TOOLSET_ARGS: ${{ inputs.toolset_args }} + ATTEMPTS: ${{ inputs.attempts }} run: | run_id="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" jobs_dir="$RUNNER_TEMP/jobs" echo "run_file=$jobs_dir/run-$run_id.json" >>"$GITHUB_OUTPUT" echo "experiment=$BENCHMARK_NAME-$run_id" >>"$GITHUB_OUTPUT" - args=(--tasks "$TASKS" --concurrency "$CONCURRENCY" --jobs-dir "$jobs_dir" --run-id "$run_id") + args=(--tasks "$TASKS" --concurrency "$CONCURRENCY" --attempts "$ATTEMPTS" --jobs-dir "$jobs_dir" --run-id "$run_id") [[ -n "$ARMS" ]] && args+=(--arms "$ARMS") [[ -n "$TOOLSET_REF" ]] && args+=(--toolset-ref "$TOOLSET_REF") + for pair in $SUITE_ARGS; do args+=(--suite-arg "$pair"); done + for pair in $TOOLSET_ARGS; do args+=(--toolset-arg "$pair"); done uv run bench run "$BENCHMARK_NAME" "${args[@]}" - name: Report diff --git a/README.md b/README.md index ccd7b5f..3eb3e98 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ uv run bench run kernel-mcp-clawbench \ Harbor writes one job directory per arm under `jobs/`, containing rewards, trajectories, and verifier output. To summarize jobs, run `bun report/report.ts --arm =` (needs [Bun](https://bun.sh)). -`--tasks` takes comma-separated task IDs or `all`. `--concurrency` sets concurrent trials per arm. `--toolset-ref` builds a toolset from a specific git ref, e.g. a Kernel MCP server branch. +`--tasks` takes comma-separated task IDs or `all`. `--concurrency` sets concurrent trials per arm. `--attempts` runs each task that many times per arm. `--toolset-ref` builds a toolset from a specific git ref, e.g. a Kernel MCP server branch. The run also writes `jobs/run-.json`, which maps each arm to its job directory, exit status, and the browser settings and site guidance each task got. @@ -140,6 +140,8 @@ model = "anthropic/claude-haiku-5-5" steering = true ``` +`toolset_args` are passed to the toolset; `kernel-mcp-local` takes `toolsets`, the Kernel MCP toolsets to enable (`playwright`, the default, or `repl`). `agent_kwargs` are passed to the Harbor agent. + `config` takes any [`POST /browsers`](https://kernel.sh/docs/api-reference/browsers/create-a-browser-session) parameter, such as `stealth`, `proxy`, `region`, `extensions`, or `viewport`. Browsers are always headful. ### Add a configurator @@ -151,12 +153,12 @@ stdin: {"task": {"config": "", "instruction": ""}, "arg stdout: {"browser": {"stealth": true}, "steering": "advice for the agent" | null, "provenance": {...}} ``` -It never sees the verifier's files. `steering` is appended to the task instruction under "Site guidance". Each task's result is recorded in `jobs/run-.json`. Configurators may be nondeterministic, e.g. when they call an LLM. +`browser` may include `proxy_create`, a [`POST /proxies`](https://kernel.sh/docs/proxies/overview) body: each trial then gets its own proxy, deleted when the trial ends. A trial whose proxy cannot be created fails with `ProxyCreationError`, which the report lists under infrastructure failures. It never sees the verifier's files. `steering` is appended to the task instruction under "Site guidance". Each task's result is recorded in `jobs/run-.json`. Configurators may be nondeterministic, e.g. when they call an LLM. `site-config` works like this: 1. A model names the task's primary website. 2. The [config registry](https://kernel.sh/docs/config-registry) looks it up. The registry is Kernel's record of which browser and proxy settings have reached a site, and what its bot protection reacted to. -3. A model translates the recommended proxy recipe into a Kernel proxy, which is created once and reused. +3. A model translates the recommended proxy recipe into a Kernel proxy. Each trial creates its own proxy before its browser and deletes it afterwards, so trials never share an ISP IP; `bench run` deletes any a crashed trial left behind. 4. With `steering = true`, the registry's guidance for the site is added to the instruction. ### Add a toolset diff --git a/configurators/site-config/configure b/configurators/site-config/configure index 1bf67f7..25b2e9c 100755 --- a/configurators/site-config/configure +++ b/configurators/site-config/configure @@ -5,8 +5,10 @@ website the task runs on, if there is one. 2. Kernel's config registry looks that site up and returns a recommended browser and proxy recipe, plus guidance for navigating the site. -3. An LLM translates the proxy recipe into a Kernel proxy, which is created - once per distinct recipe and then reused by name. +3. An LLM translates the proxy recipe into Kernel proxy settings. A recipe + that needs a proxy becomes `proxy_create`, the POST /proxies body, which + harness.environment creates for each trial and deletes when it ends, so + no two trials share a proxy's IP. 4. The guidance becomes steering for the agent when `steering` is true. stdin: {"task": {"config": str, "instruction": str}, "args": {"model": str, "steering": bool}} @@ -15,7 +17,6 @@ stdout: {"browser": {...}, "steering": str | null, "provenance": {...}} from __future__ import annotations -import hashlib import json import re import sys @@ -94,23 +95,11 @@ def parse_proxy(answer: dict[str, Any]) -> dict[str, Any]: return {"mode": answer["mode"]} -def proxy_settings(client: Kernel, model: str, recipe: dict[str, Any]) -> dict[str, Any]: +def proxy_settings(model: str, recipe: dict[str, Any]) -> dict[str, Any]: proxy = ask(model, PROXY_PROMPT.format(recipe=json.dumps(recipe, indent=2)), parse_proxy) - if "create" not in proxy: - return proxy - body = proxy["create"] - digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:12] - name = f"site-config-{body['type']}-{digest}" - for existing in client.proxies.list(): - if existing.name == name: - return {"name": name} - try: - client.proxies.create(**body, name=name) - except Exception: - # Another task may have created the same proxy concurrently. - if not any(existing.name == name for existing in client.proxies.list()): - raise - return {"name": name} + if "create" in proxy: + return {"proxy_create": proxy["create"]} + return {"proxy": proxy} def main() -> int: @@ -138,7 +127,7 @@ def main() -> int: ) if recommendation.get("type") == "recommendation": output["browser"] = dict(recommendation["browser"]) - output["browser"]["proxy"] = proxy_settings(client, model, recommendation["proxy"]) + output["browser"].update(proxy_settings(model, recommendation["proxy"])) if args.get("steering", False): output["steering"] = lookup.get("guidance") json.dump(output, sys.stdout) diff --git a/harness/cli.py b/harness/cli.py index ceb1b87..171f982 100644 --- a/harness/cli.py +++ b/harness/cli.py @@ -16,6 +16,16 @@ def _split(value: str | None) -> list[str] | None: return [item.strip() for item in value.split(",") if item.strip()] +def _pairs(values: list[str]) -> dict[str, str]: + pairs = {} + for value in values: + key, separator, item = value.partition("=") + if not separator or not key: + raise SystemExit(f"Expected key=value, got {value!r}") + pairs[key] = item + return pairs + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="bench") commands = parser.add_subparsers(dest="command", required=True) @@ -28,6 +38,20 @@ def main(argv: list[str] | None = None) -> int: ) command.add_argument("--tasks", default="all", help="comma-separated task IDs, or all") command.add_argument("--toolset-ref", help="git ref for toolsets built from source") + command.add_argument( + "--suite-arg", + action="append", + default=[], + metavar="KEY=VALUE", + help="override one of the benchmark's suite_args, e.g. split=validation", + ) + command.add_argument( + "--toolset-arg", + action="append", + default=[], + metavar="KEY=VALUE", + help="override a toolset_args key in the arms that set it, e.g. toolsets=repl", + ) commands.choices["prepare"].add_argument("--out", type=Path, required=True) run_command = commands.choices["run"] @@ -35,14 +59,20 @@ def main(argv: list[str] | None = None) -> int: run_command.add_argument( "--concurrency", type=int, default=10, help="concurrent trials per arm" ) + run_command.add_argument("--attempts", type=int, default=1, help="trials per task and arm") run_command.add_argument("--run-id", default=datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")) args = parser.parse_args(argv) arms = _split(args.arms) tasks = _split(args.tasks) + suite_args = _pairs(args.suite_arg) + toolset_args = _pairs(args.toolset_arg) if args.command == "prepare": - for item in prepare(args.benchmark, arms, tasks, args.out, args.toolset_ref): + prepared = prepare( + args.benchmark, arms, tasks, args.out, args.toolset_ref, suite_args, toolset_args + ) + for item in prepared: print(f"{item.arm.name}: {item.dataset}") return 0 @@ -56,6 +86,9 @@ def main(argv: list[str] | None = None) -> int: args.run_id, args.concurrency, args.toolset_ref, + suite_args, + toolset_args, + args.attempts, ) for arm, status in statuses.items(): print(f"{arm}: harbor exited {status} -> {args.jobs_dir / f'{arm}-{args.run_id}'}") diff --git a/harness/definitions.py b/harness/definitions.py index a6f1fd8..57e12d4 100644 --- a/harness/definitions.py +++ b/harness/definitions.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util +import sys import tomllib from dataclasses import dataclass, field from pathlib import Path @@ -26,7 +27,9 @@ class Arm: model: str toolset: str agent_version: str | None = None + agent_kwargs: dict[str, Any] = field(default_factory=dict) browser: Browser = field(default_factory=Browser) + toolset_args: dict[str, Any] = field(default_factory=dict) @dataclass(frozen=True) @@ -34,6 +37,7 @@ class Benchmark: name: str suite: str arms: list[str] + suite_args: dict[str, Any] = field(default_factory=dict) def _read(path: Path) -> dict[str, Any]: @@ -44,7 +48,12 @@ def _read(path: Path) -> dict[str, Any]: def load_benchmark(name: str) -> Benchmark: data = _read(ROOT / "benchmarks" / f"{name}.toml") - return Benchmark(name=name, suite=data["suite"], arms=list(data["arms"])) + return Benchmark( + name=name, + suite=data["suite"], + arms=list(data["arms"]), + suite_args=data.get("suite_args", {}), + ) def load_arm(name: str) -> Arm: @@ -69,5 +78,7 @@ def _load_module(path: Path) -> ModuleType: ) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) + # Registered before it runs, as an import would be, so dataclasses work. + sys.modules[spec.name] = module spec.loader.exec_module(module) return module diff --git a/harness/environment.py b/harness/environment.py index 8c832f2..9a33116 100644 --- a/harness/environment.py +++ b/harness/environment.py @@ -4,22 +4,86 @@ `environment/kernel-config.json`. Harbor applies one set of environment kwargs to a whole job, so this subclass reads the file per trial and passes it to the provider as `browser_create_overrides`. + +A `proxy_create` entry in the file is a POST /proxies body rather than a +browser param. Each trial creates its own proxy from it before the browser, +and deletes it after the browser, so no two trials share a proxy's IP. The +proxy's name starts with `proxy_prefix(run_id)` when `BENCH_RUN_ID` is set, +so `delete_run_proxies` can remove any a crashed trial left behind. A +trial whose proxy cannot be created fails with `ProxyCreationError`, so +reports can count those trials apart from other infrastructure failures. """ from __future__ import annotations import json +import os +import uuid from pathlib import Path from typing import Any from harbor.environments.kernel import KernelEnvironment +from kernel import AsyncKernel, Kernel CONFIG_FILE = "kernel-config.json" +PROXY_CREATE = "proxy_create" +RUN_ID_ENV = "BENCH_RUN_ID" + + +class ProxyCreationError(RuntimeError): + """The trial's proxy could not be created, so no browser was started.""" + + +def proxy_prefix(run_id: str) -> str: + return f"bench-{run_id}-" + + +def delete_run_proxies(run_id: str) -> list[str]: + """Delete the proxies trials of `run_id` created and did not delete themselves.""" + client = Kernel() + prefix = proxy_prefix(run_id) + leftovers = [ + proxy for proxy in client.proxies.list(query=prefix) if proxy.name.startswith(prefix) + ] + for proxy in leftovers: + client.proxies.delete(proxy.id) + return [proxy.name for proxy in leftovers] class KernelTaskEnvironment(KernelEnvironment): def __init__(self, environment_dir: Path, *args: Any, **kwargs: Any) -> None: + self._proxy_create: dict[str, Any] | None = None + self._proxy_id: str | None = None config_path = Path(environment_dir) / CONFIG_FILE if config_path.is_file(): - kwargs["browser_create_overrides"] = json.loads(config_path.read_text()) + overrides = json.loads(config_path.read_text()) + self._proxy_create = overrides.pop(PROXY_CREATE, None) + kwargs["browser_create_overrides"] = overrides super().__init__(environment_dir, *args, **kwargs) + + async def start(self, force_build: bool) -> None: + if self._proxy_create is not None: + run_id = os.environ.get(RUN_ID_ENV) or "local" + name = f"{proxy_prefix(run_id)}{uuid.uuid4().hex[:12]}" + try: + async with AsyncKernel(api_key=self._api_key) as client: + proxy = await client.proxies.create(**self._proxy_create, name=name) + except Exception as error: + raise ProxyCreationError( + f"Could not create {self._proxy_create.get('type')} proxy: {error}" + ) from error + self._proxy_id = proxy.id + self._browser_create_overrides["proxy_id"] = proxy.id + await super().start(force_build) + + async def stop(self, delete: bool) -> None: + try: + await super().stop(delete) + finally: + if self._proxy_id is not None: + try: + async with AsyncKernel(api_key=self._api_key) as client: + await client.proxies.delete(self._proxy_id) + self._proxy_id = None + except Exception as error: + self.logger.error(f"Failed to delete Kernel proxy {self._proxy_id}: {error}") diff --git a/harness/run.py b/harness/run.py index 57861c4..f18b53f 100644 --- a/harness/run.py +++ b/harness/run.py @@ -3,15 +3,18 @@ from __future__ import annotations import json +import os import shutil import subprocess import sys from dataclasses import dataclass from pathlib import Path +from typing import Any from harness import preflight from harness.configure import configure_tasks from harness.definitions import Arm, load_arm, load_benchmark, load_suite, load_toolset +from harness.environment import RUN_ID_ENV, delete_run_proxies # Transient failures worth retrying as a whole trial: Kernel API hiccups and # slow agent installs. Image build and task setup failures are not retried. @@ -38,13 +41,26 @@ def prepare( task_ids: list[str] | None, work_dir: Path, toolset_ref: str | None = None, + suite_args: dict[str, Any] | None = None, + toolset_args: dict[str, Any] | None = None, ) -> list[PreparedArm]: + """Generate the suite's tasks and apply each arm's browser settings and toolset. + + `suite_args` overrides the benchmark's `suite_args`. `toolset_args` + overrides the keys an arm's `toolset_args` already sets, so an arm whose + toolset does not take an argument keeps running without it. A key no arm + of the benchmark sets is refused as a likely typo. + """ benchmark = load_benchmark(benchmark_name) arms = [load_arm(name) for name in (arm_names or benchmark.arms)] + known = {key for arm in [*arms, *map(load_arm, benchmark.arms)] for key in arm.toolset_args} + unknown = set(toolset_args or {}) - known + if unknown: + raise SystemExit(f"No arm takes toolset args: {', '.join(sorted(unknown))}") suite = load_suite(benchmark.suite) base = work_dir / "tasks" - task_dirs = suite.generate(task_ids, base) + task_dirs = suite.generate(task_ids, base, **{**benchmark.suite_args, **(suite_args or {})}) if not task_dirs: raise SystemExit("The suite generated no tasks") @@ -54,7 +70,10 @@ def prepare( shutil.copytree(base, dataset) arm_tasks = sorted(path for path in dataset.iterdir() if path.is_dir()) configurations = configure_tasks(arm_tasks, arm.browser) - load_toolset(arm.toolset).apply(arm_tasks, ref=toolset_ref) + args = { + key: (toolset_args or {}).get(key, value) for key, value in arm.toolset_args.items() + } + load_toolset(arm.toolset).apply(arm_tasks, ref=toolset_ref, **args) prepared.append(PreparedArm(arm, dataset, configurations)) return prepared @@ -68,17 +87,23 @@ def run( run_id: str, concurrency: int, toolset_ref: str | None = None, + suite_args: dict[str, Any] | None = None, + toolset_args: dict[str, Any] | None = None, + attempts: int = 1, ) -> dict[str, int]: """Run every arm in parallel and return each arm's Harbor exit status. `jobs_dir/run-.json` maps each arm to its Harbor job directory, browser configurations, and exit status. It is written before Harbor starts and updated as each arm finishes, so an interrupted run can still - be reported. + be reported. Proxies that trials created and could not delete are + removed once every arm has finished. """ preflight.check_kernel_project() load_suite(load_benchmark(benchmark_name).suite).preflight() - prepared = prepare(benchmark_name, arm_names, task_ids, work_dir, toolset_ref) + prepared = prepare( + benchmark_name, arm_names, task_ids, work_dir, toolset_ref, suite_args, toolset_args + ) jobs_dir.mkdir(parents=True, exist_ok=True) manifest_path = jobs_dir / f"run-{run_id}.json" @@ -92,22 +117,28 @@ def run( } manifest_path.write_text(json.dumps(manifest, indent=2)) + env = {**os.environ, RUN_ID_ENV: run_id} processes = {} for item in prepared: job_name = f"{item.arm.name}-{run_id}" with (jobs_dir / f"{job_name}.log").open("w") as log: processes[item.arm.name] = subprocess.Popen( - harbor_command(item, jobs_dir, job_name, concurrency), + harbor_command(item, jobs_dir, job_name, concurrency, attempts), stdout=log, stderr=subprocess.STDOUT, + env=env, ) for name, process in processes.items(): manifest[name]["status"] = process.wait() manifest_path.write_text(json.dumps(manifest, indent=2)) + for name in delete_run_proxies(run_id): + print(f"Deleted leftover proxy {name}") return {name: entry["status"] for name, entry in manifest.items()} -def harbor_command(item: PreparedArm, jobs_dir: Path, job_name: str, concurrency: int) -> list[str]: +def harbor_command( + item: PreparedArm, jobs_dir: Path, job_name: str, concurrency: int, attempts: int = 1 +) -> list[str]: arm = item.arm command = [ str(Path(sys.executable).parent / "harbor"), @@ -119,12 +150,15 @@ def harbor_command(item: PreparedArm, jobs_dir: Path, job_name: str, concurrency "--job-name", job_name, "--jobs-dir", str(jobs_dir), "--n-concurrent", str(concurrency), + "--n-attempts", str(attempts), "--max-retries", "5", "--delete", "--yes", ] # fmt: skip if arm.agent_version: command += ["--agent-kwarg", f"version={arm.agent_version}"] + for key, value in arm.agent_kwargs.items(): + command += ["--agent-kwarg", f"{key}={json.dumps(value)}"] for exception in RETRY_EXCEPTIONS: command += ["--retry-include", exception] return command diff --git a/report/publish-braintrust.ts b/report/publish-braintrust.ts index 66e1597..b66a043 100644 --- a/report/publish-braintrust.ts +++ b/report/publish-braintrust.ts @@ -203,6 +203,7 @@ function trialMetadata(trial: BenchmarkTrial): Record { model: trial.model, kernelMcpSha: trial.kernelMcpSha, errorClass: trial.errorClass, + errorType: trial.errorType, rewards: trial.rewards, durationMs: trial.metrics.durationMs, }; diff --git a/report/report.ts b/report/report.ts index 9db9679..b605c81 100644 --- a/report/report.ts +++ b/report/report.ts @@ -114,6 +114,23 @@ export function renderMarkdown( ); } + const failures = summaries.flatMap((summary) => + summary.infraFailures.map( + (failure) => + `| ${summary.arm} | ${failure.errorType} | ${failure.taskName} | ${failure.count} |`, + ), + ); + if (failures.length > 0) { + lines.push( + "", + "### Infrastructure failures", + "", + "| Arm | Error | Task | Trials |", + "|---|---|---|---:|", + ...failures, + ); + } + if (typeof publication?.url === "string") { lines.push("", `[Open the Braintrust experiment](${publication.url})`); } diff --git a/report/results.test.ts b/report/results.test.ts index d7c6522..63c18b3 100644 --- a/report/results.test.ts +++ b/report/results.test.ts @@ -10,7 +10,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildExperimentEvents, publishBenchmark } from "./publish-braintrust"; import { renderMarkdown } from "./report"; -import { readBenchmarkArm, selectPrimaryReward, summarizeArm } from "./results"; +import { + infraFailures, + readBenchmarkArm, + selectPrimaryReward, + summarizeArm, +} from "./results"; import { assertSafeToPublish, collectSensitiveValues, @@ -478,6 +483,39 @@ describe("Harbor result ingestion", () => { }, ]); expect(infra).toContain("candidate had 1 infrastructure failure"); + + const proxy = renderMarkdown("test", [ + { + ...summary, + infraFailures: [ + { errorType: "ProxyCreationError", taskName: "task-two", count: 3 }, + ], + }, + ]); + expect(proxy).toContain( + "| candidate | ProxyCreationError | task-two | 3 |", + ); + }); + + test("counts infrastructure failures by exception type and task", () => { + const root = fixture(); + const failedPath = join(root, "task-two__def", "result.json"); + const failed = JSON.parse(readFileSync(failedPath, "utf8")) as { + exception_info: unknown; + }; + failed.exception_info = { + exception_type: "ProxyCreationError", + exception_message: "Could not create mobile proxy", + }; + writeJson(failedPath, failed); + + const arm = readBenchmarkArm({ name: "candidate", path: root }); + expect(arm.trials.find((trial) => trial.error)?.errorType).toBe( + "ProxyCreationError", + ); + expect(infraFailures(arm)).toEqual([ + { errorType: "ProxyCreationError", taskName: "v2-task-two", count: 1 }, + ]); }); test("keeps full errors until redaction and clamps derived scores", () => { diff --git a/report/results.ts b/report/results.ts index 7910222..8f3d77a 100644 --- a/report/results.ts +++ b/report/results.ts @@ -42,6 +42,7 @@ export interface BenchmarkTrial { scores: Record; error?: string; errorClass?: "infra"; + errorType?: string; metrics: BenchmarkMetrics; phases: { environmentSetup?: BenchmarkPhase; @@ -80,6 +81,7 @@ export interface ArmSummary { strict?: number; strictScored: number; infraErrors: number; + infraFailures: { errorType: string; taskName: string; count: number }[]; retries: number; ungraded: number; complete: boolean; @@ -258,6 +260,7 @@ function parseTrial(arm: string, trialDir: string): BenchmarkTrial { Object.keys(rewards).length === 0 ? steps.map((step) => object(step).exception_info).find(Boolean) : undefined; + const exceptionInfo = result.exception_info ?? stepException; const error = errorText( result.exception_info ?? stepException ?? @@ -296,6 +299,10 @@ function parseTrial(arm: string, trialDir: string): BenchmarkTrial { scores: trialScores(rewards, error !== undefined), error, errorClass: error === undefined ? undefined : "infra", + errorType: + error === undefined + ? undefined + : string(object(exceptionInfo).exception_type), metrics: { inputTokens: sumStepMetric(steps, "n_input_tokens"), cacheTokens: sumStepMetric(steps, "n_cache_tokens"), @@ -444,6 +451,7 @@ export function summarizeArm(arm: BenchmarkArm): ArmSummary { ), strictScored: strict.length, infraErrors, + infraFailures: infraFailures(arm), retries: arm.nRetries, ungraded, complete: incompleteReasons.length === 0, @@ -480,3 +488,21 @@ export function summarizeArm(arm: BenchmarkArm): ArmSummary { : `mixed: ${configurations.join(", ")}`, }; } + +/** Count each arm's infrastructure failures by exception type and task. */ +export function infraFailures( + arm: BenchmarkArm, +): { errorType: string; taskName: string; count: number }[] { + const counts = new Map(); + for (const trial of arm.trials) { + if (trial.errorClass !== "infra") continue; + const key = JSON.stringify([trial.errorType ?? "unknown", trial.taskName]); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts] + .map(([key, count]) => { + const [errorType, taskName] = JSON.parse(key) as [string, string]; + return { errorType, taskName, count }; + }) + .sort((a, b) => b.count - a.count || a.taskName.localeCompare(b.taskName)); +} diff --git a/tests/test_environment.py b/tests/test_environment.py index 557e31f..92e564e 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -1,7 +1,10 @@ +import asyncio import json +import pytest from harbor.environments.kernel import KernelEnvironment +from harness import environment from harness.environment import KernelTaskEnvironment @@ -17,3 +20,89 @@ def test_task_browser_config_becomes_create_overrides(tmp_path, monkeypatch): KernelTaskEnvironment(environment_dir=tmp_path, session_id="s") assert captured == {"session_id": "s", "browser_create_overrides": {"stealth": False}} + + +class FakeProxies: + def __init__(self, calls): + self.calls = calls + + async def create(self, **body): + self.calls.append(("create", body)) + return type("Proxy", (), {"id": "p1"})() + + async def delete(self, proxy_id): + self.calls.append(("delete", proxy_id)) + + +class FakeKernel: + calls: list = [] + + def __init__(self, **kwargs): + self.proxies = FakeProxies(FakeKernel.calls) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +def _environment(tmp_path, monkeypatch, calls): + def init(self, environment_dir, **kwargs): + self._api_key = "k" + self._browser_create_overrides = kwargs["browser_create_overrides"] + + async def start(self, force_build): + calls.append(("browser", dict(self._browser_create_overrides))) + + async def stop(self, delete): + calls.append(("stop browser", None)) + + monkeypatch.setattr(KernelEnvironment, "__init__", init) + monkeypatch.setattr(KernelEnvironment, "start", start) + monkeypatch.setattr(KernelEnvironment, "stop", stop) + monkeypatch.setattr(environment, "AsyncKernel", FakeKernel) + FakeKernel.calls = calls + config = {"stealth": True, "proxy_create": {"type": "isp", "config": {"country": "US"}}} + (tmp_path / "kernel-config.json").write_text(json.dumps(config)) + return KernelTaskEnvironment(environment_dir=tmp_path) + + +def test_each_trial_creates_its_own_proxy_and_deletes_it_after_the_browser(tmp_path, monkeypatch): + calls = [] + monkeypatch.setenv("BENCH_RUN_ID", "r1") + env = _environment(tmp_path, monkeypatch, calls) + + asyncio.run(env.start(force_build=False)) + asyncio.run(env.stop(delete=True)) + + (kind, body), browser, stop, delete = calls + assert kind == "create" + assert body["type"] == "isp" and body["name"].startswith("bench-r1-") + assert browser == ("browser", {"stealth": True, "proxy_id": "p1"}) + assert stop == ("stop browser", None) + assert delete == ("delete", "p1") + + +def test_proxy_names_differ_between_trials(tmp_path, monkeypatch): + calls = [] + for _ in range(2): + asyncio.run(_environment(tmp_path, monkeypatch, calls).start(force_build=False)) + names = [body["name"] for kind, body in calls if kind == "create"] + assert len(set(names)) == 2 + + +def test_a_proxy_that_cannot_be_created_fails_the_trial_with_proxy_creation_error( + tmp_path, monkeypatch +): + calls = [] + env = _environment(tmp_path, monkeypatch, calls) + + async def refuse(self, **body): + raise ValueError("403") + + monkeypatch.setattr(FakeProxies, "create", refuse) + + with pytest.raises(environment.ProxyCreationError, match="isp proxy: 403"): + asyncio.run(env.start(force_build=False)) + assert calls == [] diff --git a/tests/test_kernel_mcp_local.py b/tests/test_kernel_mcp_local.py index 90d1afe..5032901 100644 --- a/tests/test_kernel_mcp_local.py +++ b/tests/test_kernel_mcp_local.py @@ -1,6 +1,9 @@ +import json +import os +import subprocess import tomllib -from harness.definitions import load_toolset +from harness.definitions import ROOT, load_toolset def test_apply_adds_server_image_layer_instruction_and_check(task_dir, monkeypatch): @@ -26,3 +29,120 @@ def fake_fetch(ref, dest): assert config["metadata"]["kernel_mcp_server_sha"] == "abc123" assert "execute_playwright_code" in (task_dir / "steps/run/instruction.md").read_text() assert (task_dir / "steps/run/tests/test.sh").read_text().endswith("kernel-mcp-verify\n") + + +def test_repl_toolset_enables_the_repl_and_keeps_other_servers(task_dir, monkeypatch): + toolset = load_toolset("kernel-mcp-local") + monkeypatch.setattr(toolset, "_fetch_source", lambda ref, dest: dest.mkdir() or "abc123") + (task_dir / "task.toml").write_text( + '[task]\nname = "example"\n\n[environment]\n\n' + '[[environment.mcp_servers]]\nname = "bench"\ntransport = "stdio"\n' + 'command = "/opt/bench/finalize-mcp"\nargs = []\n' + ) + toolset.apply([task_dir], toolsets=["repl"]) + + config = tomllib.loads((task_dir / "task.toml").read_text()) + assert [server["name"] for server in config["environment"]["mcp_servers"]] == [ + "bench", + "kernel", + ] + dockerfile = (task_dir / "environment" / "Dockerfile").read_text() + assert dockerfile.endswith("ENV KERNEL_MCP_ENABLED_TOOLSETS=repl\n") + instruction = (task_dir / "steps/run/instruction.md").read_text() + assert "`browser_repl` tool" in instruction + assert "repl.help()" in instruction + assert "execute_playwright_code" not in instruction + + +def test_unknown_toolset_is_refused(task_dir): + toolset = load_toolset("kernel-mcp-local") + try: + toolset.apply([task_dir], toolsets=["shell"]) + except SystemExit as error: + assert "shell" in str(error) + else: + raise AssertionError("expected SystemExit") + + +def test_verify_reads_the_server_call_log(tmp_path): + logs, verifier = tmp_path / "kernel-mcp", tmp_path / "verifier" + logs.mkdir() + verifier.mkdir() + (verifier / "reward.json").write_text('{"reward": 1.0}') + calls = [ + { + "tool": "browser_repl", + "arguments": {"session_id": "s1", "code": "await page.goto(url)"}, + "duration_ms": 900, + }, + { + "tool": "browser_repl", + "arguments": {"session_id": "s1", "code": "repl.write(1)"}, + "duration_ms": 100, + "is_error": True, + }, + {"tool": "finalize", "arguments": {}}, + ] + (logs / "calls.jsonl").write_text("\n".join(json.dumps(call) for call in calls) + "\n") + script = ( + (ROOT / "toolsets/kernel-mcp-local/image/bin/kernel-mcp-verify") + .read_text() + .replace('Path("/var/log/kernel-mcp")', f"Path({str(logs)!r})") + .replace('Path("/logs/verifier")', f"Path({str(verifier)!r})") + ) + env = {**os.environ, "KERNEL_MCP_ENABLED_TOOLSETS": "repl", "KERNEL_SESSION_ID": "s1"} + subprocess.run(["python3", "-c", script], check=True, env=env) + + assert json.loads((verifier / "reward.json").read_text()) == { + "reward": 1.0, + "kernel_mcp_valid": 1.0, + } + result = json.loads((verifier / "kernel-mcp-result.json").read_text()) + assert result["browser_calls"] == 2 + assert result["browser_call_ms"] == 1000 + assert result["browser_call_errors"] == 1 + + +def test_call_log_passes_messages_through_and_records_calls(tmp_path): + server = tmp_path / "server.py" + server.write_text( + "import json, sys\n" + "for line in sys.stdin:\n" + " m = json.loads(line)\n" + " print(json.dumps({'jsonrpc': '2.0', 'id': m['id'], 'result': {'isError': m['id'] == 2}}), flush=True)\n" + ) + log = tmp_path / "calls.jsonl" + messages = [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "browser_repl", "arguments": {"code": "1"}}, + }, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "browser_repl", "arguments": {}}, + }, + {"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, + ] + result = subprocess.run( + [ + str(ROOT / "toolsets/kernel-mcp-local/image/bin/mcp-call-log"), + str(log), + "python3", + str(server), + ], + input="".join(json.dumps(m) + "\n" for m in messages), + capture_output=True, + text=True, + check=True, + ) + assert [json.loads(line)["id"] for line in result.stdout.splitlines()] == [1, 2, 3] + logged = [json.loads(line) for line in log.read_text().splitlines()] + assert [(call["tool"], call["is_error"]) for call in logged] == [ + ("browser_repl", False), + ("browser_repl", True), + ] + assert logged[0]["arguments"] == {"code": "1"} diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..e74d2bb --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,69 @@ +import pytest + +from harness import run +from harness.definitions import Arm, Benchmark + + +def test_toolset_args_override_only_the_keys_an_arm_sets(tmp_path, monkeypatch): + applied = {} + + class Suite: + @staticmethod + def generate(task_ids, out_dir, split="test"): + (out_dir / f"task-{split}").mkdir(parents=True) + return [out_dir / f"task-{split}"] + + class Toolset: + def __init__(self, name): + self.name = name + + def apply(self, task_dirs, ref=None, **args): + applied[self.name] = ({path.name for path in task_dirs}, args) + + arms = { + "control": Arm(name="control", agent="codex", model="m", toolset="plain"), + "treatment": Arm( + name="treatment", + agent="codex", + model="m", + toolset="extra", + toolset_args={"source_ref": "main"}, + ), + } + monkeypatch.setattr( + run, + "load_benchmark", + lambda name: Benchmark(name=name, suite="s", arms=list(arms), suite_args={"split": "test"}), + ) + monkeypatch.setattr(run, "load_arm", lambda name: arms[name]) + monkeypatch.setattr(run, "load_suite", lambda name: Suite) + monkeypatch.setattr(run, "load_toolset", Toolset) + monkeypatch.setattr(run, "configure_tasks", lambda task_dirs, browser: {}) + + run.prepare("b", None, None, tmp_path, None, {"split": "train"}, {"source_ref": "branch"}) + assert applied == { + "plain": ({"task-train"}, {}), + "extra": ({"task-train"}, {"source_ref": "branch"}), + } + + applied.clear() + run.prepare("b", ["control"], None, tmp_path / "control", None, None, {"source_ref": "x"}) + assert applied == {"plain": ({"task-test"}, {})} + + with pytest.raises(SystemExit, match="unknown_arg"): + run.prepare("b", None, None, tmp_path / "again", None, None, {"unknown_arg": "x"}) + + +def test_harbor_command_passes_agent_kwargs_as_json(tmp_path): + arm = Arm( + name="a", + agent="codex", + model="gpt-6-luna", + toolset="t", + agent_version="0.163.0", + agent_kwargs={"disable_web_search": True}, + ) + command = run.harbor_command(run.PreparedArm(arm, tmp_path, {}), tmp_path, "job", 2, 3) + assert "version=0.163.0" in command + assert "disable_web_search=true" in command + assert command[command.index("--n-attempts") + 1] == "3" diff --git a/toolsets/kernel-mcp-local/image/bin/kernel-mcp b/toolsets/kernel-mcp-local/image/bin/kernel-mcp index 8965ec9..a871e74 100755 --- a/toolsets/kernel-mcp-local/image/bin/kernel-mcp +++ b/toolsets/kernel-mcp-local/image/bin/kernel-mcp @@ -1,7 +1,8 @@ #!/bin/sh # stdio MCP server for the agent: waits for the local Kernel MCP server and -# proxies to it with the project API key. Nothing may be written to stdout -# before mcp-remote starts, because stdout carries the MCP protocol. +# proxies to it with the project API key, logging each tool call for +# kernel-mcp-verify. Nothing may be written to stdout before mcp-remote +# starts, because stdout carries the MCP protocol. set -eu # mcp-remote expands ${KERNEL_MCP_AUTHORIZATION} itself, which keeps the key @@ -10,7 +11,8 @@ KERNEL_MCP_AUTHORIZATION="Bearer ${KERNEL_API_KEY:-$(cat /run/kernel-mcp/api-key export KERNEL_MCP_AUTHORIZATION for _ in $(seq 1 120); do if curl -s -o /dev/null http://127.0.0.1:3002/; then - exec mcp-remote http://127.0.0.1:3002/mcp \ + exec mcp-call-log /var/log/kernel-mcp/calls.jsonl \ + mcp-remote http://127.0.0.1:3002/mcp \ --header 'Authorization:${KERNEL_MCP_AUTHORIZATION}' fi sleep 1 diff --git a/toolsets/kernel-mcp-local/image/bin/kernel-mcp-entrypoint b/toolsets/kernel-mcp-local/image/bin/kernel-mcp-entrypoint index ceee214..f6d91e3 100755 --- a/toolsets/kernel-mcp-local/image/bin/kernel-mcp-entrypoint +++ b/toolsets/kernel-mcp-local/image/bin/kernel-mcp-entrypoint @@ -8,6 +8,9 @@ set -eu state=/run/kernel-mcp logs=/var/log/kernel-mcp mkdir -p "$state" "$logs" +# The agent may run as another user; its MCP servers log calls here. +touch "$logs/calls.jsonl" +chmod 0666 "$logs/calls.jsonl" printf '%s' "${KERNEL_API_KEY:-}" >"$state/api-key" chmod 0600 "$state/api-key" printf '%s\n' "${KERNEL_SESSION_ID:-}" >"$state/session-id" diff --git a/toolsets/kernel-mcp-local/image/bin/kernel-mcp-verify b/toolsets/kernel-mcp-local/image/bin/kernel-mcp-verify index 2db4c1a..1b202c0 100755 --- a/toolsets/kernel-mcp-local/image/bin/kernel-mcp-verify +++ b/toolsets/kernel-mcp-local/image/bin/kernel-mcp-verify @@ -2,10 +2,14 @@ """Add `kernel_mcp_valid` to the task's rewards. The suite's verifier owns the task reward. This check records whether the -agent called Kernel MCP's `execute_playwright_code`, and whether every call -used the browser Harbor assigned to the trial, so a failed task can be told -apart from a trial that never exercised the toolset. It does not see browser -control outside Kernel MCP, such as direct CDP connections. +agent called the Kernel MCP browser tools its arm enables +(`execute_playwright_code`, `browser_repl`), and whether every call used the +browser Harbor assigned to the trial, so a failed task can be told apart from +a trial that never exercised the toolset. It reads the calls mcp-call-log +recorded at the server, which every agent harness goes through, and does not +see browser control outside Kernel MCP, such as direct CDP connections. + +kernel-mcp-result.json also counts the browser calls and their total time. """ from __future__ import annotations @@ -16,35 +20,33 @@ import shutil from pathlib import Path from typing import Any -LOGS = Path("/logs") -VERIFIER = LOGS / "verifier" -PLAYWRIGHT_TOOLS = { - "execute_playwright_code", - "kernel__execute_playwright_code", - "mcp__kernel__execute_playwright_code", -} +LOGS = Path("/var/log/kernel-mcp") +VERIFIER = Path("/logs/verifier") +TOOLS = {"playwright": "execute_playwright_code", "repl": "browser_repl"} -def read_object(path: Path) -> dict[str, Any]: +def read_calls(path: Path) -> list[dict[str, Any]]: try: - value = json.loads(path.read_text()) - except (OSError, json.JSONDecodeError): - return {} - return value if isinstance(value, dict) else {} + lines = path.read_text().splitlines() + except OSError: + return [] + calls = [] + for line in lines: + try: + calls.append(json.loads(line)) + except json.JSONDecodeError: + continue + return calls def main() -> int: VERIFIER.mkdir(parents=True, exist_ok=True) - if Path("/var/log/kernel-mcp").is_dir(): - shutil.copytree("/var/log/kernel-mcp", VERIFIER / "kernel-mcp", dirs_exist_ok=True) + if LOGS.is_dir(): + shutil.copytree(LOGS, VERIFIER / "kernel-mcp", dirs_exist_ok=True) - trajectory = read_object(LOGS / "agent" / "trajectory.json") - calls = [ - call - for step in trajectory.get("steps") or [] - for call in step.get("tool_calls") or [] - if call.get("function_name") in PLAYWRIGHT_TOOLS - ] + enabled = os.environ.get("KERNEL_MCP_ENABLED_TOOLSETS", "playwright").split(",") + browser_tools = {TOOLS[name] for name in enabled if name in TOOLS} + calls = [call for call in read_calls(LOGS / "calls.jsonl") if call.get("tool") in browser_tools] called_sessions = {(call.get("arguments") or {}).get("session_id") for call in calls} expected_session = os.environ.get("KERNEL_SESSION_ID") checks = { @@ -57,8 +59,12 @@ def main() -> int: result = { "valid": valid, "checks": checks, + "enabled_toolsets": enabled, "expected_session_id": expected_session, "called_session_ids": sorted(str(value) for value in called_sessions), + "browser_calls": len(calls), + "browser_call_ms": sum(call.get("duration_ms") or 0 for call in calls), + "browser_call_errors": sum(1 for call in calls if call.get("is_error")), "kernel_mcp_server_sha": sha_file.read_text().strip() if sha_file.exists() else None, } (VERIFIER / "kernel-mcp-result.json").write_text(json.dumps(result, indent=2)) @@ -67,7 +73,7 @@ def main() -> int: # reward.txt would hide the task reward. reward_path = VERIFIER / "reward.json" if reward_path.exists(): - rewards = read_object(reward_path) + rewards = json.loads(reward_path.read_text()) rewards["kernel_mcp_valid"] = float(valid) reward_path.write_text(json.dumps(rewards, indent=2)) return 0 diff --git a/toolsets/kernel-mcp-local/image/bin/mcp-call-log b/toolsets/kernel-mcp-local/image/bin/mcp-call-log new file mode 100755 index 0000000..c245737 --- /dev/null +++ b/toolsets/kernel-mcp-local/image/bin/mcp-call-log @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run a stdio MCP server and log every tools/call the agent makes to it. + +Usage: mcp-call-log [args...] + +Messages pass through unchanged. Each completed call appends one line with the +tool name, its arguments, when it started, how long it took and whether it +failed. Agents wrap MCP calls differently (Codex can nest them in a code-mode +cell), so this log, not the agent's trajectory, is the record of what reached +the server. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import threading +import time +from datetime import UTC, datetime +from typing import IO, Any + + +def parse(line: bytes) -> dict[str, Any]: + try: + message = json.loads(line) + except ValueError: + return {} + return message if isinstance(message, dict) else {} + + +def main() -> int: + log_path, command = sys.argv[1], sys.argv[2:] + try: + log: IO[str] | None = open(log_path, "a") # noqa: SIM115 + except OSError as error: + print(f"mcp-call-log: not logging calls: {error}", file=sys.stderr) + log = None + lock = threading.Lock() + pending: dict[Any, tuple[float, str, dict[str, Any]]] = {} + child = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE) + assert child.stdin is not None and child.stdout is not None + + def to_server() -> None: + for line in sys.stdin.buffer: + message = parse(line) + if message.get("method") == "tools/call" and "id" in message: + with lock: + pending[message["id"]] = ( + time.monotonic(), + datetime.now(UTC).isoformat(), + message.get("params") or {}, + ) + child.stdin.write(line) + child.stdin.flush() + child.stdin.close() + + threading.Thread(target=to_server, daemon=True).start() + for line in child.stdout: + message = parse(line) + with lock: + call = pending.pop(message.get("id"), None) if message else None + if call is not None and log is not None: + started, started_at, params = call + result = message.get("result") or {} + entry = { + "tool": params.get("name"), + "arguments": params.get("arguments"), + "started_at": started_at, + "duration_ms": round((time.monotonic() - started) * 1000), + "is_error": "error" in message or result.get("isError") is True, + } + with lock: + log.write(json.dumps(entry) + "\n") + log.flush() + sys.stdout.buffer.write(line) + sys.stdout.buffer.flush() + return child.wait() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/toolsets/kernel-mcp-local/image/layer.Dockerfile b/toolsets/kernel-mcp-local/image/layer.Dockerfile index fcd089d..4e66a1d 100644 --- a/toolsets/kernel-mcp-local/image/layer.Dockerfile +++ b/toolsets/kernel-mcp-local/image/layer.Dockerfile @@ -1,5 +1,6 @@ # kernel-mcp-local toolset. Appended to the suite's Dockerfile, which must be -# Debian-based with Node.js and Python 3. +# Debian-based with Node.js and Python 3. The toolset appends the +# KERNEL_MCP_ENABLED_TOOLSETS line for the arm. RUN apt-get update \ && apt-get install -y --no-install-recommends redis-server \ && rm -rf /var/lib/apt/lists/* \ @@ -15,7 +16,6 @@ ENV CLERK_SECRET_KEY=sk_test_kernel_mcp_benchmark_local_only \ KERNEL_CLI_PROD_CLIENT_ID=kernel-mcp-benchmark \ KERNEL_CLI_STAGING_CLIENT_ID=kernel-mcp-benchmark \ KERNEL_CLI_DEV_CLIENT_ID=kernel-mcp-benchmark \ - KERNEL_MCP_ENABLED_TOOLSETS=playwright \ REDIS_URL=redis://127.0.0.1:6379 \ NEXT_TELEMETRY_DISABLED=1 RUN bun run build diff --git a/toolsets/kernel-mcp-local/toolset.py b/toolsets/kernel-mcp-local/toolset.py index a1d8093..a492b2e 100644 --- a/toolsets/kernel-mcp-local/toolset.py +++ b/toolsets/kernel-mcp-local/toolset.py @@ -1,8 +1,13 @@ """Kernel MCP server, built from source and run inside the task container. -The agent connects over stdio and drives the trial's browser with -`execute_playwright_code`. Browser lifecycle tools are disabled, so the agent -can only use the browser Harbor created for the trial. +The agent connects over stdio and drives the trial's browser with the tools of +the Kernel MCP toolsets the arm enables: `execute_playwright_code` (the +default `playwright` toolset) or `browser_repl` (`repl`). Browser lifecycle +tools are disabled, so the agent can only use the browser Harbor created for +the trial. + +Arm args: + toolsets: Kernel MCP toolsets to enable, default ["playwright"]. """ from __future__ import annotations @@ -10,6 +15,7 @@ import shutil import subprocess import tempfile +from collections.abc import Sequence from pathlib import Path import tomlkit @@ -19,22 +25,39 @@ REPOSITORY = "https://github.com/kernel/kernel-mcp-server" IMAGE = Path(__file__).with_name("image") +# Tool each supported Kernel MCP toolset gives the agent, and how to use it. +TOOLS = { + "playwright": ( + "execute_playwright_code", + "- Interact through page navigation and DOM/UI actions. Do not call `fetch`, `XMLHttpRequest`, Playwright request APIs, or other HTTP clients inside `execute_playwright_code`.", + ), + "repl": ( + "browser_repl", + "- `browser_repl` runs JavaScript in a persistent REPL attached to your browser. If you have not used it before, start with `repl.help()`.", + ), +} + INSTRUCTION = """ ## Browser tools -- Use the `kernel` MCP server's `execute_playwright_code` tool for all browser interaction. Wait for the server to finish initializing before starting; in Claude Code, call `WaitForMcpServers` if it is still pending. +- Use the `kernel` MCP server's {tools} for all browser interaction. Wait for the server to finish initializing before starting; in Claude Code, call `WaitForMcpServers` if it is still pending. - Before your first browser call, read your browser's session ID from the file `/run/kernel-mcp/session-id` and pass it as `session_id` on every call. - Do not create, list, update, or delete browsers, and do not use Kernel managed auth. -- Interact through page navigation and DOM/UI actions. Do not call `fetch`, `XMLHttpRequest`, Playwright request APIs, or other HTTP clients inside `execute_playwright_code`. +{guidance} """ -def apply(task_dirs: list[Path], ref: str | None = None) -> None: +def apply( + task_dirs: list[Path], ref: str | None = None, toolsets: Sequence[str] = ("playwright",) +) -> None: + unknown = [name for name in toolsets if name not in TOOLS] + if unknown or not toolsets: + raise SystemExit(f"kernel-mcp-local supports toolsets {sorted(TOOLS)}, got {toolsets}") with tempfile.TemporaryDirectory() as tmp: source = Path(tmp) / "source" sha = _fetch_source(ref or "main", source) for task_dir in task_dirs: - _apply(task_dir, source, sha) + _apply(task_dir, source, sha, list(toolsets)) def _fetch_source(ref: str, dest: Path) -> str: @@ -54,13 +77,16 @@ def git(*args: str) -> str: return sha -def _apply(task_dir: Path, source: Path, sha: str) -> None: +def _apply(task_dir: Path, source: Path, sha: str, toolsets: list[str]) -> None: context = task_dir / "environment" / "kernel-mcp-local" shutil.copytree(IMAGE / "bin", context / "bin") shutil.copytree(source, context / "source") dockerfile = task_dir / "environment" / "Dockerfile" dockerfile.write_text( - dockerfile.read_text().rstrip() + "\n\n" + (IMAGE / "layer.Dockerfile").read_text() + dockerfile.read_text().rstrip() + + "\n\n" + + (IMAGE / "layer.Dockerfile").read_text() + + f"ENV KERNEL_MCP_ENABLED_TOOLSETS={','.join(toolsets)}\n" ) config = read_config(task_dir) @@ -68,11 +94,17 @@ def _apply(task_dir: Path, source: Path, sha: str) -> None: server.update( {"name": "kernel", "transport": "stdio", "command": "/usr/local/bin/kernel-mcp", "args": []} ) - servers = tomlkit.aot() + servers = config["environment"].setdefault("mcp_servers", tomlkit.aot()) servers.append(server) - config["environment"]["mcp_servers"] = servers config.setdefault("metadata", tomlkit.table())["kernel_mcp_server_sha"] = sha write_config(task_dir, config) - append_instruction(task_dir, INSTRUCTION) + tools = " and ".join(f"`{TOOLS[name][0]}`" for name in toolsets) + guidance = "\n".join(TOOLS[name][1] for name in toolsets) + append_instruction( + task_dir, + INSTRUCTION.format( + tools=f"{tools} tool{'s' if len(toolsets) > 1 else ''}", guidance=guidance + ), + ) append_test_command(task_dir, "kernel-mcp-verify") From a893604569c557e18fc8051bb22f76c706813e22 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:54:58 +0000 Subject: [PATCH 2/2] Add computer controls, Chromium launcher variables and VM setup per trial, and create GPU browsers without a memory request --- README.md | 4 +- harness/environment.py | 123 ++++++++++++++++++ tests/test_environment.py | 89 +++++++++++++ .../image/bin/kernel-mcp-verify | 8 +- toolsets/kernel-mcp-local/toolset.py | 4 + 5 files changed, 224 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3eb3e98..6ec807c 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ model = "anthropic/claude-haiku-5-5" steering = true ``` -`toolset_args` are passed to the toolset; `kernel-mcp-local` takes `toolsets`, the Kernel MCP toolsets to enable (`playwright`, the default, or `repl`). `agent_kwargs` are passed to the Harbor agent. +`toolset_args` are passed to the toolset; `kernel-mcp-local` takes `toolsets`, the Kernel MCP toolsets to enable: `playwright` (the default), `repl`, `computer`, or several. `agent_kwargs` are passed to the Harbor agent. `config` takes any [`POST /browsers`](https://kernel.sh/docs/api-reference/browsers/create-a-browser-session) parameter, such as `stealth`, `proxy`, `region`, `extensions`, or `viewport`. Browsers are always headful. @@ -153,7 +153,7 @@ stdin: {"task": {"config": "", "instruction": ""}, "arg stdout: {"browser": {"stealth": true}, "steering": "advice for the agent" | null, "provenance": {...}} ``` -`browser` may include `proxy_create`, a [`POST /proxies`](https://kernel.sh/docs/proxies/overview) body: each trial then gets its own proxy, deleted when the trial ends. A trial whose proxy cannot be created fails with `ProxyCreationError`, which the report lists under infrastructure failures. It never sees the verifier's files. `steering` is appended to the task instruction under "Site guidance". Each task's result is recorded in `jobs/run-.json`. Configurators may be nondeterministic, e.g. when they call an LLM. +`browser` may include `proxy_create`, a [`POST /proxies`](https://kernel.sh/docs/proxies/overview) body: each trial then gets its own proxy, deleted when the trial ends. A trial whose proxy cannot be created fails with `ProxyCreationError`, which the report lists under infrastructure failures. `browser` may also include `chromium_env`, `KERNEL_*` Chromium launcher variables (or a list of sets, one picked per trial), and `vm_setup`, shell commands run as root in the browser's VM. Both are applied right after the browser is created, before the first page load; a trial where either fails fails with `VmSetupError`. The configurator never sees the verifier's files. `steering` is appended to the task instruction under "Site guidance". Each task's result is recorded in `jobs/run-.json`. Configurators may be nondeterministic, e.g. when they call an LLM. `site-config` works like this: 1. A model names the task's primary website. diff --git a/harness/environment.py b/harness/environment.py index 9a33116..bf47afc 100644 --- a/harness/environment.py +++ b/harness/environment.py @@ -12,12 +12,23 @@ so `delete_run_proxies` can remove any a crashed trial left behind. A trial whose proxy cannot be created fails with `ProxyCreationError`, so reports can count those trials apart from other infrastructure failures. + +Two entries change the browser's VM right after it is created, before the +task container starts and before the first page load. `chromium_env` is a set +of `KERNEL_*` launcher variables, or a list of sets of which each trial picks +one at random: Chromium is restarted with them and the page is checked for +the persona or WebGL renderer they ask for. `vm_setup` is a list of shell +commands then run as root. A trial where either fails fails with +`VmSetupError`. """ from __future__ import annotations +import asyncio import json import os +import random +import shlex import uuid from pathlib import Path from typing import Any @@ -27,6 +38,8 @@ CONFIG_FILE = "kernel-config.json" PROXY_CREATE = "proxy_create" +CHROMIUM_ENV = "chromium_env" +VM_SETUP = "vm_setup" RUN_ID_ENV = "BENCH_RUN_ID" @@ -34,6 +47,44 @@ class ProxyCreationError(RuntimeError): """The trial's proxy could not be created, so no browser was started.""" +class VmSetupError(RuntimeError): + """The browser's VM could not be set up for the trial before the first page load.""" + + +SUPERVISOR_PROGRAM = """[program:chromium] +command=/usr/bin/env {env} /usr/local/bin/chromium-launcher +autostart=false +autorestart=true +startsecs=0 +stopsignal=KILL +stopasgroup=true +killasgroup=true +stopwaitsecs=1 +stdout_logfile=/var/log/supervisord/chromium +redirect_stderr=true +""" + +RESTART_CHROMIUM = """#!/bin/bash +set -euo pipefail +mv /tmp/bench-chromium.conf /etc/supervisor/conf.d/services/chromium.conf +supervisorctl reread +supervisorctl update +sleep 2 +supervisorctl start chromium +sleep 3 +supervisorctl status chromium +""" + +PAGE_IDENTITY = """return await page.evaluate(() => { + const gl = document.createElement("canvas").getContext("webgl"); + const info = gl && gl.getExtension("WEBGL_debug_renderer_info"); + return { + userAgent: navigator.userAgent, + renderer: info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : null, + }; +});""" + + def proxy_prefix(run_id: str) -> str: return f"bench-{run_id}-" @@ -54,10 +105,17 @@ class KernelTaskEnvironment(KernelEnvironment): def __init__(self, environment_dir: Path, *args: Any, **kwargs: Any) -> None: self._proxy_create: dict[str, Any] | None = None self._proxy_id: str | None = None + self._chromium_env: dict[str, str] | None = None + self._vm_setup: list[str] = [] config_path = Path(environment_dir) / CONFIG_FILE if config_path.is_file(): overrides = json.loads(config_path.read_text()) self._proxy_create = overrides.pop(PROXY_CREATE, None) + chromium_env = overrides.pop(CHROMIUM_ENV, None) + if isinstance(chromium_env, list): + chromium_env = random.choice(chromium_env) + self._chromium_env = chromium_env + self._vm_setup = overrides.pop(VM_SETUP, None) or [] kwargs["browser_create_overrides"] = overrides super().__init__(environment_dir, *args, **kwargs) @@ -76,6 +134,71 @@ async def start(self, force_build: bool) -> None: self._browser_create_overrides["proxy_id"] = proxy.id await super().start(force_build) + def _browser_create_kwargs(self) -> dict[str, Any]: + kwargs = super()._browser_create_kwargs() + # GPU browsers have a fixed memory size and reject a memory request. + if kwargs.get("gpu"): + kwargs.pop("memory", None) + return kwargs + + async def _create_browser(self) -> Any: + response = await super()._create_browser() + try: + if self._chromium_env: + await self._restart_chromium(response.session_id) + for command in self._vm_setup: + await self._run_as_root(response.session_id, command) + except Exception as error: + raise VmSetupError(f"Could not set up the browser VM: {error}") from error + return response + + async def _run_as_root(self, session_id: str, command: str) -> None: + result = await self._client.browsers.process.exec( + session_id, command="bash", args=["-c", command], as_root=True, timeout_sec=60 + ) + if result.exit_code != 0: + raise RuntimeError(f"{command!r} exited {result.exit_code}") + self.logger.info(f"Ran in the browser VM: {command}") + + async def _restart_chromium(self, session_id: str) -> None: + env = " ".join( + f"{key}={shlex.quote(str(value))}" for key, value in self._chromium_env.items() + ) + browsers = self._client.browsers + await browsers.fs.write_file( + session_id, SUPERVISOR_PROGRAM.format(env=env).encode(), path="/tmp/bench-chromium.conf" + ) + await browsers.fs.write_file( + session_id, RESTART_CHROMIUM.encode(), path="/tmp/bench-restart-chromium.sh" + ) + result = await browsers.process.exec( + session_id, + command="bash", + args=["/tmp/bench-restart-chromium.sh"], + as_root=True, + timeout_sec=60, + ) + if result.exit_code != 0: + raise RuntimeError(f"restart script exited {result.exit_code}") + identity = None + for _ in range(15): + check = await browsers.playwright.execute(session_id, code=PAGE_IDENTITY) + if check.success: + identity = check.result or {} + break + await asyncio.sleep(2) + if identity is None: + raise RuntimeError("Chromium did not come back after the restart") + if ( + self._chromium_env.get("KERNEL_MAC_PERSONA") + and "Macintosh" not in identity["userAgent"] + ): + raise RuntimeError(f"expected a macOS user agent, got {identity['userAgent']}") + renderer = self._chromium_env.get("KERNEL_WEBGL_RENDERER") + if renderer and identity["renderer"] != renderer: + raise RuntimeError(f"expected WebGL renderer {renderer}, got {identity['renderer']}") + self.logger.info(f"Chromium restarted with {sorted(self._chromium_env)}: {identity}") + async def stop(self, delete: bool) -> None: try: await super().stop(delete) diff --git a/tests/test_environment.py b/tests/test_environment.py index 92e564e..2f282eb 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -106,3 +106,92 @@ async def refuse(self, **body): with pytest.raises(environment.ProxyCreationError, match="isp proxy: 403"): asyncio.run(env.start(force_build=False)) assert calls == [] + + +class FakeBrowsers: + def __init__(self, calls, identity): + self.calls = calls + self.fs = type("Fs", (), {"write_file": self._write})() + self.process = type("Process", (), {"exec": self._exec})() + self.playwright = type("Playwright", (), {"execute": self._execute})() + self.identity = identity + + async def _write(self, session_id, contents, path): + self.calls.append(("write", path, contents.decode())) + + async def _exec(self, session_id, **kwargs): + self.calls.append(("exec", kwargs["args"], kwargs["as_root"])) + return type("Result", (), {"exit_code": 0})() + + async def _execute(self, session_id, code): + return type("Result", (), {"success": True, "result": self.identity})() + + +def _chromium_environment(tmp_path, monkeypatch, chromium_env, identity, calls): + async def create(self): + return type("Response", (), {"session_id": "s1"})() + + def init(self, environment_dir, **kwargs): + self._client = type("Client", (), {"browsers": FakeBrowsers(calls, identity)})() + self.logger = type("Logger", (), {"info": lambda self, message: None})() + + monkeypatch.setattr(KernelEnvironment, "__init__", init) + monkeypatch.setattr(KernelEnvironment, "_create_browser", create) + (tmp_path / "kernel-config.json").write_text(json.dumps({"chromium_env": chromium_env})) + return KernelTaskEnvironment(environment_dir=tmp_path) + + +def test_chromium_restarts_with_the_trials_launcher_variables(tmp_path, monkeypatch): + calls = [] + personas = [ + {"KERNEL_WEBGL_RENDERER": "ANGLE (NVIDIA)", "KERNEL_CANVAS_AA_OFFSET": "-2"}, + {"KERNEL_WEBGL_RENDERER": "ANGLE (NVIDIA)", "KERNEL_CANVAS_AA_OFFSET": "3"}, + ] + identity = {"userAgent": "X11; Linux", "renderer": "ANGLE (NVIDIA)"} + env = _chromium_environment(tmp_path, monkeypatch, personas, identity, calls) + + asyncio.run(env._create_browser()) + + program = next(contents for kind, path, *contents in calls if path.endswith(".conf")) + assert "KERNEL_WEBGL_RENDERER='ANGLE (NVIDIA)'" in program[0] + assert ("exec", ["/tmp/bench-restart-chromium.sh"], True) in calls + + +def test_a_persona_that_did_not_apply_fails_the_trial(tmp_path, monkeypatch): + identity = {"userAgent": "X11; Linux x86_64", "renderer": "ANGLE (Mesa)"} + env = _chromium_environment(tmp_path, monkeypatch, {"KERNEL_MAC_PERSONA": "1"}, identity, []) + + with pytest.raises(environment.VmSetupError, match="macOS user agent"): + asyncio.run(env._create_browser()) + + +def test_gpu_browsers_are_created_without_a_memory_request(tmp_path, monkeypatch): + monkeypatch.setattr( + KernelEnvironment, + "_browser_create_kwargs", + lambda self: {**self._browser_create_overrides, "memory": "8GiB"}, + ) + monkeypatch.setattr( + KernelEnvironment, + "__init__", + lambda self, environment_dir, **kwargs: setattr( + self, "_browser_create_overrides", kwargs["browser_create_overrides"] + ), + ) + (tmp_path / "kernel-config.json").write_text(json.dumps({"gpu": True})) + assert KernelTaskEnvironment(environment_dir=tmp_path)._browser_create_kwargs() == {"gpu": True} + (tmp_path / "kernel-config.json").write_text(json.dumps({"gpu": False})) + assert ( + KernelTaskEnvironment(environment_dir=tmp_path)._browser_create_kwargs()["memory"] == "8GiB" + ) + + +def test_vm_setup_commands_run_as_root_after_the_browser_is_created(tmp_path, monkeypatch): + calls = [] + env = _chromium_environment(tmp_path, monkeypatch, None, {}, calls) + (tmp_path / "kernel-config.json").write_text(json.dumps({"vm_setup": ["echo hi", "true"]})) + env = KernelTaskEnvironment(environment_dir=tmp_path) + + asyncio.run(env._create_browser()) + + assert calls == [("exec", ["-c", "echo hi"], True), ("exec", ["-c", "true"], True)] diff --git a/toolsets/kernel-mcp-local/image/bin/kernel-mcp-verify b/toolsets/kernel-mcp-local/image/bin/kernel-mcp-verify index 1b202c0..945888f 100755 --- a/toolsets/kernel-mcp-local/image/bin/kernel-mcp-verify +++ b/toolsets/kernel-mcp-local/image/bin/kernel-mcp-verify @@ -3,7 +3,7 @@ The suite's verifier owns the task reward. This check records whether the agent called the Kernel MCP browser tools its arm enables -(`execute_playwright_code`, `browser_repl`), and whether every call used the +(`execute_playwright_code`, `browser_repl`, `computer_action`), and whether every call used the browser Harbor assigned to the trial, so a failed task can be told apart from a trial that never exercised the toolset. It reads the calls mcp-call-log recorded at the server, which every agent harness goes through, and does not @@ -22,7 +22,11 @@ from typing import Any LOGS = Path("/var/log/kernel-mcp") VERIFIER = Path("/logs/verifier") -TOOLS = {"playwright": "execute_playwright_code", "repl": "browser_repl"} +TOOLS = { + "playwright": "execute_playwright_code", + "repl": "browser_repl", + "computer": "computer_action", +} def read_calls(path: Path) -> list[dict[str, Any]]: diff --git a/toolsets/kernel-mcp-local/toolset.py b/toolsets/kernel-mcp-local/toolset.py index a492b2e..518dac8 100644 --- a/toolsets/kernel-mcp-local/toolset.py +++ b/toolsets/kernel-mcp-local/toolset.py @@ -35,6 +35,10 @@ "browser_repl", "- `browser_repl` runs JavaScript in a persistent REPL attached to your browser. If you have not used it before, start with `repl.help()`.", ), + "computer": ( + "computer_action", + "- `computer_action` moves the mouse, clicks, types, scrolls and takes screenshots at the operating-system level, as a person would. Use it when a site reacts to scripted clicks or typing, or when they do not take effect; take a screenshot first to find coordinates.", + ), } INSTRUCTION = """