Log in
scripts/agent_workflow.py 2080 lines · 85.1 KB · blame Source
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1
#!/usr/bin/env python3
2
"""Run real local coding-agent workflows against Git and Oak subjects.
3
4
This runner is intentionally separate from workflow_ab.py. workflow_ab.py runs
5
deterministic agent-shaped command recipes; this file runs actual CLI coding
6
agents such as Codex, Claude Code, and Cursor Agent, then normalizes their
7
transcripts into scripts/agent_metrics_schema.py rows.
8
"""
9
10
from __future__ import annotations
11
12
import argparse
13
import hashlib
14
import json
15
import math
16
import os
17
import platform
18
import random
19
import re
20
import shutil
21
import statistics
22
import subprocess
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
23
import sys
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
24
import tempfile
25
import time
26
from dataclasses import dataclass
27
from datetime import datetime, timezone
28
from pathlib import Path, PurePosixPath
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
29
from typing import Callable, Any
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
30
31
import workflow_ab
32
from agent_metrics_schema import SCHEMA_VERSION, validate_row
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
33
from oakbench import workflow_oracles
34
from oakbench.agent_execution import DEFAULT_OUTPUT_LIMIT_BYTES, EXECUTION_VERSION, run_captured
35
from oakbench.environment import ENV_ISOLATION_VERSION, base_env as shared_base_env
36
from oakbench.runner import runner_fields
37
from oakbench.cachectl import cache_fields
38
from oakbench.agent_environment import agent_environment as filtered_agent_environment
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
39
from oakbench.classify import classify_command, unwrap_shell_command, vcs_invocations
40
from oakbench.command_semantics import semantics
41
from oakbench.configio import load_toml
42
from oakbench.results import ResultsStore
43
from oakbench.stream_adapters import adapter_for, analyze_stream, first_number, walk_json
44
from oakbench.thrash import agent_blocked_on_vcs_ms, thrash_events, vcs_share_of_task_wall
45
from oakbench.vcs_shim import create_shim_dir, measure_shim_overhead, read_sidecar_rows
46
from oakbench.tokens import (
47
    CACHE_READ_TOKEN_WEIGHT,
48
    COST_WEIGHTS_NOTE,
49
    EMITTED_TOKEN_WEIGHT,
50
    INGESTED_TOKEN_WEIGHT,
51
)
52
53
54
ROOT = Path(__file__).resolve().parents[1]
55
DEFAULT_WORKDIR = Path(tempfile.gettempdir()) / "oak-agent-workflow"
56
PROMPT_VERSION = "2026-06-09.2"
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
57
ORACLE_VERSION = workflow_oracles.VERSION
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
58
COMMAND_SEMANTICS_VERSION = semantics().version
59
TRACKS = ("agent-default", "core-equivalent")
60
INSTRUCTION_LEVELS = ("zero-shot", "cheat-sheet", "full-docs")
61
VCS_DIR_NAMES = {".git", ".oak", ".hg", ".svn"}
62
GENERATED_ARTIFACT_DIR_NAMES = {"__pycache__"}
63
GENERATED_ARTIFACT_SUFFIXES = (".pyc", ".pyo")
64
65
66
@dataclass(frozen=True)
67
class AgentConfig:
68
    name: str
69
    adapter: str
70
    provider: str | None
71
    bin: Path | None
72
    model: str | None
73
    version: str | None
74
75
76
@dataclass(frozen=True)
77
class AgentExecution:
78
    returncode: int
79
    timed_out: bool
80
    elapsed_ms: float
81
    stdout_path: Path
82
    stderr_path: Path
83
    prompt_path: Path
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
84
    output_limited: bool = False
85
    capture_complete: bool = True
86
    capture_metadata: dict[str, Any] | None = None
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
87
88
89
@dataclass(frozen=True)
90
class OracleResult:
91
    elapsed_ms: float
92
    test_ms: float
93
    vcs_ms: float
94
    success: bool
95
    outcome: str
96
    failure_reason: str | None
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
97
    checks: dict[str, bool | None]
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
98
    test_returncode: int
99
    test_output_bytes: int
100
    status_output: str
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
101
    dirty_files_at_end: int | None
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
102
    changed_files: list[str]
103
    bytes_written: int
104
    commit_delta: int
105
    artifact_paths: list[Path]
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
106
    evidence: dict[str, Any] | None = None
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
107
108
109
def parse_args() -> argparse.Namespace:
110
    parser = argparse.ArgumentParser(description=__doc__)
111
    parser.add_argument(
112
        "--workflows",
113
        default="bugfix_test_loop",
114
        help="Comma-separated workflow_ab workflow names, or 'all'. Default keeps real-agent runs cheap.",
115
    )
116
    parser.add_argument("--subjects", help="Comma-separated subject names from config/subjects.toml")
117
    parser.add_argument(
118
        "--agents",
119
        default="mock",
120
        help="Comma-separated agent names, 'available', or 'all'. Default is mock to avoid accidental model spend.",
121
    )
122
    parser.add_argument("--runs", type=int, default=1)
123
    parser.add_argument("--config", type=Path, default=ROOT / "config" / "subjects.toml")
124
    parser.add_argument("--agents-config", type=Path, default=ROOT / "config" / "agents.toml")
125
    parser.add_argument("--workdir", type=Path, default=DEFAULT_WORKDIR)
126
    parser.add_argument("--results", type=Path, default=ROOT / "results" / "agent-workflow")
127
    parser.add_argument(
128
        "--oak-repo",
129
        type=Path,
130
        default=workflow_ab.DEFAULT_OAK_REPO,
131
        help="Optional Oak source checkout for provenance metadata. Defaults to $OAK_REPO or ../oak.",
132
    )
133
    parser.add_argument("--keep-workdirs", action="store_true")
134
    parser.add_argument("--timeout-seconds", type=float, default=900.0)
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
135
    parser.add_argument("--output-limit-bytes", type=int, default=DEFAULT_OUTPUT_LIMIT_BYTES,
136
                        help="Maximum combined real-agent stdout/stderr bytes retained per trial.")
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
137
    parser.add_argument(
138
        "--agent-environment",
139
        choices=("minimal", "local-default"),
140
        default="minimal",
141
        help=(
142
            "minimal asks CLIs to avoid user/project customizations when supported; "
143
            "local-default measures the normal local agent environment."
144
        ),
145
    )
146
    parser.add_argument("--git-bin", type=Path)
147
    parser.add_argument("--oak-installed-bin", type=Path)
148
    parser.add_argument("--oak-local-bin", type=Path)
149
    parser.add_argument(
150
        "--track",
151
        choices=TRACKS,
152
        default="agent-default",
153
        help="Command semantics prompt track. Real agent runs normally use agent-default.",
154
    )
155
    parser.add_argument(
156
        "--instruction-level",
157
        choices=INSTRUCTION_LEVELS,
158
        default="full-docs",
159
        help=(
160
            "How much VCS-specific instruction the prompt and AGENTS.md carry. "
161
            "zero-shot names the VCS but gives no commands, measuring the model-familiarity tax; "
162
            "cheat-sheet gives a one-line command list; full-docs is the historical default."
163
        ),
164
    )
165
    parser.add_argument(
166
        "--vcs-shim",
167
        action="store_true",
168
        help=(
169
            "Wrap git/oak with PATH shim executables that record per-call VCS timing "
170
            "to a sidecar JSONL. Shim overhead is measured and recorded per run, never subtracted."
171
        ),
172
    )
173
    parser.add_argument("--randomize-subject-order", action="store_true")
174
    parser.add_argument("--randomize-agent-order", action="store_true")
175
    parser.add_argument("--list-agents", action="store_true", help="Print configured/discovered agents and exit.")
176
    return parser.parse_args()
177
178
179
def resolve_bin(raw: str | None) -> Path | None:
180
    if not raw:
181
        return None
182
    path = Path(raw).expanduser()
183
    if path.is_absolute() and path.exists():
184
        return path
185
    found = shutil.which(raw)
186
    return Path(found) if found else path
187
188
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
189
def sanitized_agent_env(env: dict[str, str]) -> dict[str, str]:
190
    return filtered_agent_environment(env)
191
192
193
def trial_agent_env(artifacts: Path) -> tuple[dict[str, str], dict[str, Any]]:
194
    home = Path(tempfile.mkdtemp(prefix="agent-home-", dir=artifacts))
195
    env = sanitized_agent_env(shared_base_env(home=home))
196
    override_names = ("CODEX_HOME", "CLAUDE_CONFIG_DIR", "CURSOR_CONFIG_DIR", "SSH_AUTH_SOCK")
197
    return env, {
198
        "fresh_agent_home": str(home), "environment_policy": "fresh-home-explicit-agent-allowlist-v2",
199
        "preserved_config_override_names": [name for name in override_names if name in env],
200
        "provider_environment_preserved": True,
201
        "os_sandbox_enforced": False,
202
        "limitations": "Provider/config overrides remain usable; HOME is not filesystem or network containment. "
203
                       "POSIX descendants creating new sessions can escape group cleanup; other platforms own only the direct child.",
204
    }
205
206
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
207
def command_output(command: list[str], cwd: Path | None = None, timeout: float = 10.0) -> str | None:
208
    try:
209
        return subprocess.check_output(
210
            command,
211
            cwd=cwd,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
212
            env=sanitized_agent_env(workflow_ab.base_env()),
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
213
            text=True,
214
            stderr=subprocess.STDOUT,
215
            timeout=timeout,
216
        ).strip()
217
    except Exception:
218
        return None
219
220
221
def agent_version(agent: AgentConfig) -> str | None:
222
    if agent.adapter == "deterministic_mock":
223
        return "deterministic-mock-" + PROMPT_VERSION
224
    if agent.bin is None or not agent.bin.exists():
225
        return None
226
    return command_output([str(agent.bin), "--version"])
227
228
229
def load_agents(path: Path, selector: str) -> list[AgentConfig]:
230
    data = load_toml(path)
231
    raw_agents = data.get("agents", {})
232
    configured: dict[str, AgentConfig] = {}
233
    for key, raw in raw_agents.items():
234
        if not raw.get("enabled", False):
235
            continue
236
        name = str(raw.get("name") or key)
237
        adapter = str(raw["adapter"])
238
        resolved = resolve_bin(raw.get("bin"))
239
        if adapter != "deterministic_mock" and (resolved is None or not resolved.exists()):
240
            configured[name] = AgentConfig(
241
                name=name,
242
                adapter=adapter,
243
                provider=raw.get("provider"),
244
                bin=resolved,
245
                model=raw.get("model") or None,
246
                version=None,
247
            )
248
            continue
249
        agent = AgentConfig(
250
            name=name,
251
            adapter=adapter,
252
            provider=raw.get("provider"),
253
            bin=resolved,
254
            model=raw.get("model") or None,
255
            version=None,
256
        )
257
        configured[name] = AgentConfig(
258
            name=agent.name,
259
            adapter=agent.adapter,
260
            provider=agent.provider,
261
            bin=agent.bin,
262
            model=agent.model,
263
            version=agent_version(agent),
264
        )
265
266
    if selector == "all":
267
        selected = list(configured.values())
268
    elif selector == "available":
269
        selected = [
270
            agent
271
            for agent in configured.values()
272
            if agent.adapter != "deterministic_mock" and agent.bin is not None and agent.bin.exists()
273
        ]
274
    else:
275
        wanted = [item.strip() for item in selector.split(",") if item.strip()]
276
        missing = [name for name in wanted if name not in configured]
277
        if missing:
278
            raise SystemExit("Unknown agents: " + ", ".join(missing))
279
        selected = [configured[name] for name in wanted]
280
281
    unavailable = [
282
        agent.name
283
        for agent in selected
284
        if agent.adapter != "deterministic_mock" and (agent.bin is None or not agent.bin.exists())
285
    ]
286
    if unavailable:
287
        raise SystemExit("Selected agents are not installed or not executable: " + ", ".join(unavailable))
288
    if not selected:
289
        raise SystemExit("No agents selected")
290
    return selected
291
292
293
def print_agents(agents: list[AgentConfig]) -> None:
294
    print("| Agent | Adapter | Provider | Binary | Version |")
295
    print("| --- | --- | --- | --- | --- |")
296
    for agent in agents:
297
        print(
298
            f"| `{agent.name}` | `{agent.adapter}` | `{agent.provider or ''}` | "
299
            f"`{agent.bin or ''}` | `{agent.version or ''}` |"
300
        )
301
302
303
def select_workflows(raw: str) -> list[workflow_ab.Workflow]:
304
    return workflow_ab.select_workflows(raw)
305
306
307
def vcs_mode(subject: workflow_ab.Subject) -> str:
308
    if subject.kind == "git":
309
        return "git_single_checkout"
310
    if subject.kind == "oak":
311
        return "oak_single_mount"
312
    return "git_single_checkout"
313
314
315
def strip_ansi(text: str) -> str:
316
    return re.sub(r"\x1b\[[0-9;]*m", "", text)
317
318
319
def is_workspace_file(path: Path, root: Path) -> bool:
320
    try:
321
        rel = path.relative_to(root)
322
    except ValueError:
323
        return False
324
    rel_posix = rel.as_posix()
325
    return not any(part in VCS_DIR_NAMES for part in rel.parts) and not is_generated_artifact(rel_posix)
326
327
328
def is_generated_artifact(path: str) -> bool:
329
    rel = PurePosixPath(path)
330
    return any(part in GENERATED_ARTIFACT_DIR_NAMES for part in rel.parts) or rel.name.endswith(
331
        GENERATED_ARTIFACT_SUFFIXES
332
    )
333
334
335
def workspace_manifest(root: Path) -> dict[str, dict[str, Any]]:
336
    manifest: dict[str, dict[str, Any]] = {}
337
    for path in sorted(root.rglob("*")):
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
338
        if not (path.is_file() or path.is_symlink()) or not is_workspace_file(path, root):
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
339
            continue
340
        rel = path.relative_to(root).as_posix()
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
341
        if path.is_symlink():
342
            target = os.readlink(path)
343
            target_bytes = os.fsencode(target)
344
            manifest[rel] = {
345
                "sha256": hashlib.sha256(target_bytes).hexdigest(),
346
                "bytes": len(target_bytes),
347
                "kind": "symlink",
348
                "target": target,
349
            }
350
            continue
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
351
        digest = hashlib.sha256()
352
        size = 0
353
        with path.open("rb") as fh:
354
            for chunk in iter(lambda: fh.read(1024 * 1024), b""):
355
                size += len(chunk)
356
                digest.update(chunk)
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
357
        manifest[rel] = {
358
            "sha256": digest.hexdigest(),
359
            "bytes": size,
360
            "kind": "file",
361
            "executable": bool(path.stat().st_mode & 0o111),
362
        }
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
363
    return manifest
364
365
366
def changed_files(before: dict[str, dict[str, Any]], after: dict[str, dict[str, Any]]) -> list[str]:
367
    names = sorted(set(before) | set(after))
368
    return [name for name in names if before.get(name) != after.get(name)]
369
370
371
def allowed_change(workflow: workflow_ab.Workflow, path: str) -> bool:
372
    if workflow.name == "bugfix_test_loop":
373
        return path == "app/pricing.py"
374
    if workflow.name == "wide_config_refactor":
375
        return path.startswith("services/") and path.endswith("/config.toml")
376
    if workflow.name == "large_asset_manifest":
377
        return path == "manifest.json"
378
    if workflow.name == "history_archaeology":
379
        return False
380
    if workflow.name == "vcs_error_recovery":
381
        return path == "README.md"
382
    return True
383
384
385
def expected_change_ok(workflow: workflow_ab.Workflow, changed: list[str]) -> bool:
386
    changed = [path for path in changed if not is_generated_artifact(path)]
387
    # Read-only scenarios must leave the tree untouched; everything else must
388
    # change something, and only within the allowed shape.
389
    if workflow.name == "history_archaeology":
390
        return not changed
391
    return bool(changed) and all(allowed_change(workflow, path) for path in changed)
392
393
394
def write_workspace_instructions(
395
    repo: Path, subject: workflow_ab.Subject, track: str, instruction_level: str
396
) -> None:
397
    if instruction_level == "zero-shot":
398
        vcs_name = "Git" if subject.kind == "git" else "Oak (a version control system, not Git)"
399
        text = "\n".join(
400
            [
401
                "# AGENTS.md",
402
                "",
403
                f"This benchmark workspace uses {vcs_name}.",
404
                "Use the current checkout; do not create another workspace.",
405
                "Snapshot your final work with the workspace's VCS and leave the tree clean.",
406
                "",
407
            ]
408
        )
409
    elif instruction_level == "cheat-sheet":
410
        if subject.kind == "git":
411
            cheat = "`git status --short`, `git diff --stat`, `git add . && git commit -m \"agent task\"`"
412
            header = "This benchmark workspace uses Git."
413
        else:
414
            cheat = "`oak status`, `oak diff`, `oak commit --no-verify`"
415
            header = "This benchmark workspace uses Oak, not Git. Do not run `git` commands."
416
        text = "\n".join(
417
            [
418
                "# AGENTS.md",
419
                "",
420
                header,
421
                f"VCS cheat sheet: {cheat}.",
422
                "Use the current checkout; do not create another workspace.",
423
                "Leave the working tree clean after the final snapshot.",
424
                "",
425
            ]
426
        )
427
    elif subject.kind == "git":
428
        sem = semantics()
429
        status_command = sem.status_display("git", track)
430
        diff_command = sem.diff_display("git", track)
431
        text = "\n".join(
432
            [
433
                "# AGENTS.md",
434
                "",
435
                "This benchmark workspace uses Git.",
436
                "Use the current checkout; do not create another clone or worktree.",
437
                f"Before snapshotting, inspect `{status_command}` and `{diff_command}`.",
438
                "Snapshot final work with `git add .` and `git commit -m \"agent task\"`.",
439
                "Leave the working tree clean.",
440
                "",
441
            ]
442
        )
443
    else:
444
        text = "\n".join(
445
            [
446
                "# AGENTS.md",
447
                "",
448
                "This benchmark workspace uses Oak, not Git.",
449
                "Do not run `git` commands.",
450
                "Use the current Oak checkout; do not create another mount or space.",
451
                "Before snapshotting, inspect `oak status` and `oak diff`.",
452
                "Snapshot final work with `oak commit --no-verify`.",
453
                "Leave the working tree clean.",
454
                "",
455
            ]
456
        )
457
    (repo / "AGENTS.md").write_text(text)
458
459
460
def run_command(
461
    command: list[str],
462
    cwd: Path,
463
    timeout: float = 120.0,
464
    env: dict[str, str] | None = None,
465
) -> subprocess.CompletedProcess[str]:
466
    return subprocess.run(
467
        command,
468
        cwd=cwd,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
469
        env=sanitized_agent_env(env if env is not None else workflow_ab.base_env()),
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
470
        text=True,
471
        stdout=subprocess.PIPE,
472
        stderr=subprocess.PIPE,
473
        timeout=timeout,
474
        check=False,
475
    )
476
477
478
def initialize_repo(subject: workflow_ab.Subject, repo: Path) -> tuple[float, str, int]:
479
    start = time.perf_counter()
480
    output: list[str] = []
481
    commands: list[list[str]]
482
    if subject.kind == "git":
483
        vcs = str(subject.bin)
484
        commands = [
485
            [vcs, "init"],
486
            [vcs, "add", "."],
487
            [vcs, "commit", "-m", "initial"],
488
        ]
489
    else:
490
        vcs = str(subject.bin)
491
        commands = [
492
            [vcs, "init", "."],
493
            [vcs, "commit", "--no-verify"],
494
        ]
495
496
    returncode = 0
497
    for command in commands:
498
        proc = run_command(command, repo)
499
        output.append("$ " + workflow_ab.command_display(command))
500
        if proc.stdout:
501
            output.append(proc.stdout)
502
        if proc.stderr:
503
            output.append(proc.stderr)
504
        if proc.returncode != 0:
505
            returncode = proc.returncode
506
            break
507
    return (time.perf_counter() - start) * 1000.0, "\n".join(output), returncode
508
509
510
def commit_count(subject: workflow_ab.Subject, repo: Path) -> int | None:
511
    if subject.kind == "git":
512
        text = command_output([str(subject.bin), "rev-list", "--count", "HEAD"], repo)
513
        if text and text.isdigit():
514
            return int(text)
515
        return None
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
516
    text = command_output([str(subject.bin), "log", "--json"], repo)
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
517
    if text is None:
518
        return None
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
519
    try:
520
        commits = json.loads(text)
521
    except (ValueError, TypeError):
522
        return None
523
    if not isinstance(commits, list) or any(
524
        not isinstance(commit, dict)
525
        or not isinstance(commit.get("hash"), str)
526
        or not re.fullmatch(r"[0-9a-f]{64}", commit["hash"])
527
        for commit in commits
528
    ):
529
        return None
530
    return len(commits)
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
531
532
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
533
def vcs_status(subject: workflow_ab.Subject, repo: Path) -> tuple[str, int | None, float]:
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
534
    start = time.perf_counter()
535
    if subject.kind == "git":
536
        proc = run_command([str(subject.bin), "status", "--porcelain=v1"], repo)
537
        text = proc.stdout + proc.stderr
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
538
        dirty = len([line for line in text.splitlines() if line.strip()]) if proc.returncode == 0 else None
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
539
    else:
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
540
        proc = run_command([str(subject.bin), "status", "--json"], repo)
541
        text = proc.stdout + proc.stderr
542
        dirty = None
543
        if proc.returncode == 0:
544
            try:
545
                state = json.loads(proc.stdout)
546
            except (ValueError, TypeError):
547
                state = None
548
            changes = state.get("changes") if isinstance(state, dict) else None
549
            if isinstance(changes, list) and all(
550
                isinstance(change, dict)
551
                and isinstance(change.get("path"), str)
552
                and isinstance(change.get("status"), str)
553
                for change in changes
554
            ):
555
                dirty = len(changes)
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
556
    return text, dirty, (time.perf_counter() - start) * 1000.0
557
558
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
559
def test_command_for(workflow: workflow_ab.Workflow, subject: workflow_ab.Subject | None = None) -> list[str]:
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
560
    if workflow.name == "bugfix_test_loop":
561
        return ["python3", "-m", "unittest", "discover", "-s", "tests"]
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
562
    if workflow.name in workflow_oracles.SCENARIOS:
563
        return [sys.executable, "-I", str(Path(workflow_oracles.__file__).resolve()), workflow.name, ".",
564
                "--evidence-json", *(["--git-head", "--binary", str(subject.bin)] if subject is not None and subject.kind == "git"
565
                else ["--oak-head", "--binary", str(subject.bin)] if subject is not None and subject.kind == "oak" else []),
566
                *(["--git-instrument", os.environ["OAK_BENCH_GIT_INSTRUMENT"]] if os.environ.get("OAK_BENCH_GIT_INSTRUMENT") else [])]
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
567
    return ["python3", "-c", "raise SystemExit(0)"]
568
569
570
def build_prompt(
571
    subject: workflow_ab.Subject,
572
    workflow: workflow_ab.Workflow,
573
    track: str,
574
    instruction_level: str,
575
) -> str:
576
    if instruction_level == "zero-shot":
577
        if subject.kind == "git":
578
            vcs_lines = [
579
                "This repository uses Git.",
580
                "Do not create another workspace.",
581
                "Snapshot your final work and leave the working tree clean.",
582
            ]
583
        else:
584
            vcs_lines = [
585
                "This repository uses Oak (a version control system), not Git.",
586
                "Do not run `git` commands and do not create another workspace.",
587
                "Snapshot your final work with Oak and leave the working tree clean.",
588
            ]
589
    elif instruction_level == "cheat-sheet":
590
        if subject.kind == "git":
591
            vcs_lines = [
592
                "This repository uses Git. Do not create another workspace.",
593
                "Cheat sheet: `git status --short`, `git diff --stat`, "
594
                "`git add . && git commit -m \"agent task\"`.",
595
            ]
596
        else:
597
            vcs_lines = [
598
                "This repository uses Oak, not Git. Do not run `git` commands.",
599
                "Cheat sheet: `oak status`, `oak diff`, `oak commit --no-verify`.",
600
            ]
601
    elif subject.kind == "git":
602
        sem = semantics()
603
        status_command = sem.status_display("git", track)
604
        diff_command = sem.diff_display("git", track)
605
        vcs_lines = [
606
            "This repository uses Git.",
607
            "Do not create another clone or worktree.",
608
            f"Before the final commit, inspect `{status_command}` and `{diff_command}`.",
609
            "Commit with `git add .` and `git commit -m \"agent task\"`.",
610
        ]
611
    else:
612
        vcs_lines = [
613
            "This repository uses Oak, not Git.",
614
            "Do not run `git` commands.",
615
            "Do not create another mount or space.",
616
            "Before the final snapshot, inspect `oak status` and `oak diff`.",
617
            "Snapshot with `oak commit --no-verify`.",
618
        ]
619
620
    if workflow.name == "bugfix_test_loop":
621
        task = (
622
            "Fix the failing pricing unit test by making the smallest reasonable code change. "
623
            "Run the test before changing code if possible, make the fix, then rerun the test."
624
        )
625
        success = [
626
            "`python3 -m unittest discover -s tests` passes.",
627
            "Only the necessary source file changes.",
628
        ]
629
    elif workflow.name == "wide_config_refactor":
630
        task = (
631
            "Update all service configs using the deprecated timeout value so validation passes. "
632
            "Avoid unrelated file changes."
633
        )
634
        success = ["`python3 validate_configs.py` passes.", "Only matching service config files change."]
635
    elif workflow.name == "large_asset_manifest":
636
        task = (
637
            "Inspect the manifest and mark every listed asset reviewed without rewriting binary assets. "
638
            "Avoid dumping large binary files into context."
639
        )
640
        success = ["`python3 validate_manifest.py` passes.", "Only manifest.json changes."]
641
    else:
642
        task = workflow.description
643
        success = ["The fixture validator passes.", "Unrelated files are not changed."]
644
645
    lines = [
646
        "You are running inside an isolated benchmark workspace.",
647
        "Use local files and local commands only. Do not use network access.",
648
        "Keep the final answer concise; the benchmark oracle checks the repository state.",
649
        "",
650
        "Task:",
651
        task,
652
        "",
653
        "Version-control rules:",
654
        *vcs_lines,
655
        "",
656
        "Success criteria:",
657
        *["- " + item for item in success],
658
        "- The final VCS state is clean after the commit/snapshot.",
659
        "",
660
        "Do the work now.",
661
    ]
662
    return "\n".join(lines) + "\n"
663
664
665
def build_agent_command(
666
    agent: AgentConfig,
667
    repo: Path,
668
    prompt: str,
669
    agent_environment: str,
670
) -> tuple[list[str], str | None]:
671
    if agent.adapter == "deterministic_mock":
672
        return [], None
673
    if agent.bin is None:
674
        raise RuntimeError(f"Agent {agent.name} has no binary")
675
676
    model_args: list[str] = []
677
    if agent.model:
678
        model_args = ["--model", agent.model]
679
    minimal = agent_environment == "minimal"
680
681
    if agent.adapter == "codex_exec_json":
682
        command = [
683
            str(agent.bin),
684
            "exec",
685
            "--json",
686
            "--color",
687
            "never",
688
            "-C",
689
            str(repo),
690
            "--skip-git-repo-check",
691
            "--ephemeral",
692
            "--sandbox",
693
            "danger-full-access",
694
            "--dangerously-bypass-approvals-and-sandbox",
695
            *(["--ignore-user-config", "--ignore-rules"] if minimal else []),
696
            *model_args,
697
            "-",
698
        ]
699
        return command, prompt
700
701
    if agent.adapter == "claude_print_stream_json":
702
        command = [
703
            str(agent.bin),
704
            "-p",
705
            "--output-format",
706
            "stream-json",
707
            # Required by the CLI for stream-json with --print; verified
708
            # against claude 2.1.170 (without it the run exits immediately).
709
            "--verbose",
710
            "--permission-mode",
711
            "bypassPermissions",
712
            "--dangerously-skip-permissions",
713
            "--no-session-persistence",
714
            *(["--safe-mode", "--disable-slash-commands"] if minimal else []),
715
            *model_args,
716
            prompt,
717
        ]
718
        return command, None
719
720
    if agent.adapter == "cursor_agent_stream_json":
721
        command = [
722
            str(agent.bin),
723
            "--print",
724
            "--output-format",
725
            "stream-json",
726
            "--force",
727
            "--trust",
728
            "--sandbox",
729
            "disabled",
730
            "--workspace",
731
            str(repo),
732
            *model_args,
733
            prompt,
734
        ]
735
        return command, None
736
737
    raise RuntimeError(f"Unsupported adapter: {agent.adapter}")
738
739
740
def run_mock_agent(
741
    subject: workflow_ab.Subject,
742
    workflow: workflow_ab.Workflow,
743
    repo: Path,
744
    track: str,
745
    artifacts: Path,
746
    prompt_path: Path,
747
    env: dict[str, str] | None = None,
748
) -> AgentExecution:
749
    stdout_path = artifacts / "agent.stdout.log"
750
    stderr_path = artifacts / "agent.stderr.log"
751
    start = time.perf_counter()
752
    stdout_parts: list[str] = []
753
    stderr_parts: list[str] = []
754
    returncode = 0
755
    for step in workflow.steps(subject, track):
756
        proc = run_command(step.command, repo, env=workflow_ab.step_env(step, env))
757
        stdout_parts.append("$ " + workflow_ab.command_display(step.command))
758
        if proc.stdout:
759
            stdout_parts.append(proc.stdout)
760
        if proc.stderr:
761
            stderr_parts.append(proc.stderr)
762
        if proc.returncode not in step.expected_returncodes:
763
            returncode = proc.returncode or 1
764
            break
765
    elapsed_ms = (time.perf_counter() - start) * 1000.0
766
    stdout_path.write_text("\n".join(stdout_parts))
767
    stderr_path.write_text("\n".join(stderr_parts))
768
    return AgentExecution(returncode, False, elapsed_ms, stdout_path, stderr_path, prompt_path)
769
770
771
def run_real_agent(
772
    agent: AgentConfig,
773
    repo: Path,
774
    prompt: str,
775
    artifacts: Path,
776
    timeout_seconds: float,
777
    agent_environment: str,
778
    env: dict[str, str] | None = None,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
779
    output_limit_bytes: int = DEFAULT_OUTPUT_LIMIT_BYTES,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
780
) -> AgentExecution:
781
    artifacts.mkdir(parents=True, exist_ok=True)
782
    prompt_path = artifacts / "prompt.txt"
783
    stdout_path = artifacts / "agent.stdout.log"
784
    stderr_path = artifacts / "agent.stderr.log"
785
    prompt_path.write_text(prompt)
786
787
    if agent.adapter == "deterministic_mock":
788
        raise RuntimeError("mock agent is handled separately")
789
790
    command, stdin_text = build_agent_command(agent, repo, prompt, agent_environment)
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
791
    if env is None:
792
        env, environment_metadata = trial_agent_env(artifacts)
793
    else:
794
        env = sanitized_agent_env(env)
795
        environment_metadata = {"fresh_agent_home": env.get("HOME"),
796
                                "environment_policy": "caller-supplied-sanitized-git",
797
                                "os_sandbox_enforced": False}
798
    result = run_captured(command, repo, env, stdin_text, stdout_path, stderr_path,
799
                          timeout_seconds, output_limit_bytes)
800
    capture_metadata = {
801
        "execution_version": EXECUTION_VERSION, "timeout_seconds": timeout_seconds,
802
        "output_limit_bytes": output_limit_bytes, "command": command,
803
        "stdout_observed_bytes": result.stdout_observed_bytes,
804
        "stderr_observed_bytes": result.stderr_observed_bytes,
805
        "stdout_retained_bytes": result.stdout_retained_bytes,
806
        "stderr_retained_bytes": result.stderr_retained_bytes,
807
        "capture_complete": result.capture_complete,
808
        "output_limited": result.output_limited,
809
        "byte_measurement": "observed_pipe_bytes; lower_bound_if_capture_incomplete",
810
        "cleanup_scope": "owned_posix_process_group" if os.name == "posix" else "direct_child_only",
811
        **environment_metadata,
812
    }
813
    (artifacts / "execution.json").write_text(json.dumps(capture_metadata, sort_keys=True) + "\n")
814
    return AgentExecution(result.returncode, result.timed_out, result.elapsed_ms,
815
                          stdout_path, stderr_path, prompt_path, result.output_limited,
816
                          result.capture_complete, capture_metadata)
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
817
818
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
819
def read_text(path: Path, byte_limit: int = DEFAULT_OUTPUT_LIMIT_BYTES) -> str:
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
820
    try:
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
821
        with path.open("rb") as handle:
822
            return handle.read(byte_limit).decode("utf-8", "replace")
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
823
    except FileNotFoundError:
824
        return ""
825
826
827
def parse_json_lines(text: str) -> list[Any]:
828
    events: list[Any] = []
829
    for line in text.splitlines():
830
        stripped = line.strip()
831
        if not stripped or not stripped.startswith(("{", "[")):
832
            continue
833
        try:
834
            events.append(json.loads(stripped))
835
        except json.JSONDecodeError:
836
            continue
837
    return events
838
839
840
def collect_usage(events: list[Any]) -> dict[str, int | None]:
841
    """Provider-reported usage, snake_case first.
842
843
    camelCase keys (cursor-agent 2026.06 reports {inputTokens, outputTokens,
844
    cacheReadTokens, cacheWriteTokens}) are a FALLBACK pass, never merged with
845
    snake_case: claude's result event carries both `usage` (snake_case) and a
846
    per-model `modelUsage` (camelCase) breakdown of the same tokens, and
847
    summing both would double-count.
848
    """
849
    snake = _collect_usage_with_keys(
850
        events,
851
        input_keys=("input_tokens", "prompt_tokens", "inputTokenCount", "promptTokens"),
852
        output_keys=("output_tokens", "completion_tokens", "outputTokenCount", "completionTokens"),
853
        reasoning_keys=("reasoning_tokens", "reasoning_output_tokens", "reasoningTokenCount"),
854
        cache_read_keys=("cache_read_input_tokens", "cache_read_tokens", "cached_tokens", "cached_input_tokens"),
855
        cache_write_keys=("cache_creation_input_tokens", "cache_write_tokens"),
856
        total_keys=("total_tokens", "totalTokens", "tokens"),
857
    )
858
    if any(value is not None for value in snake.values()):
859
        return snake
860
    return _collect_usage_with_keys(
861
        events,
862
        input_keys=("inputTokens",),
863
        output_keys=("outputTokens",),
864
        reasoning_keys=("reasoningTokens",),
865
        cache_read_keys=("cacheReadTokens", "cacheReadInputTokens"),
866
        cache_write_keys=("cacheWriteTokens", "cacheCreationInputTokens"),
867
        total_keys=(),
868
    )
869
870
871
def _collect_usage_with_keys(
872
    events: list[Any],
873
    *,
874
    input_keys: tuple[str, ...],
875
    output_keys: tuple[str, ...],
876
    reasoning_keys: tuple[str, ...],
877
    cache_read_keys: tuple[str, ...],
878
    cache_write_keys: tuple[str, ...],
879
    total_keys: tuple[str, ...],
880
) -> dict[str, int | None]:
881
882
    # Per-field None until that field is actually observed: a stream that
883
    # exposes only total_tokens must not report input/output/reasoning/cache
884
    # as 0 β€” that is a fabricated zero (ADR-0002) and silently corrupts
885
    # cost_weighted_total.
886
    totals: dict[str, int | None] = {
887
        "input_tokens_reported": None,
888
        "output_tokens_reported": None,
889
        "reasoning_tokens_reported": None,
890
        "cache_read_tokens_reported": None,
891
        "cache_write_tokens_reported": None,
892
        "total_tokens_reported": None,
893
    }
894
895
    result_totals = _collect_result_usage_with_keys(
896
        events,
897
        input_keys=input_keys,
898
        output_keys=output_keys,
899
        reasoning_keys=reasoning_keys,
900
        cache_read_keys=cache_read_keys,
901
        cache_write_keys=cache_write_keys,
902
        total_keys=total_keys,
903
    )
904
    if result_totals is not None:
905
        return result_totals
906
907
    for event in events:
908
        for item in walk_json(event):
909
            if not isinstance(item, dict):
910
                continue
911
            direct_keys = set(item)
912
            known = set(input_keys + output_keys + reasoning_keys + cache_read_keys + cache_write_keys + total_keys)
913
            if not direct_keys.intersection(known):
914
                continue
915
            values = {
916
                "input_tokens_reported": first_number(item, input_keys),
917
                "output_tokens_reported": first_number(item, output_keys),
918
                "reasoning_tokens_reported": first_number(item, reasoning_keys),
919
                "cache_read_tokens_reported": first_number(item, cache_read_keys),
920
                "cache_write_tokens_reported": first_number(item, cache_write_keys),
921
                "total_tokens_reported": first_number(item, total_keys),
922
            }
923
            for key, value in values.items():
924
                if value is not None:
925
                    totals[key] = (totals[key] or 0) + value
926
927
    if totals["total_tokens_reported"] is None:
928
        components = [
929
            totals["input_tokens_reported"],
930
            totals["output_tokens_reported"],
931
            totals["reasoning_tokens_reported"],
932
        ]
933
        if any(component is not None for component in components):
934
                totals["total_tokens_reported"] = sum(component or 0 for component in components)
935
    return totals
936
937
938
def _collect_result_usage_with_keys(
939
    events: list[Any],
940
    *,
941
    input_keys: tuple[str, ...],
942
    output_keys: tuple[str, ...],
943
    reasoning_keys: tuple[str, ...],
944
    cache_read_keys: tuple[str, ...],
945
    cache_write_keys: tuple[str, ...],
946
    total_keys: tuple[str, ...],
947
) -> dict[str, int | None] | None:
948
    """Return cumulative provider totals from top-level result.usage.
949
950
    Claude repeats the same message.usage across assistant content-block
951
    events, then emits the cumulative run total in result.usage. That result
952
    usage may also contain per-iteration usage beneath it, so this intentionally
953
    reads only direct keys from the top-level usage object.
954
    """
955
956
    for event in reversed(events):
957
        if not isinstance(event, dict) or str(event.get("type") or "").lower() != "result":
958
            continue
959
        usage = event.get("usage")
960
        if not isinstance(usage, dict):
961
            continue
962
        values = {
963
            "input_tokens_reported": first_number(usage, input_keys),
964
            "output_tokens_reported": first_number(usage, output_keys),
965
            "reasoning_tokens_reported": first_number(usage, reasoning_keys),
966
            "cache_read_tokens_reported": first_number(usage, cache_read_keys),
967
            "cache_write_tokens_reported": first_number(usage, cache_write_keys),
968
            "total_tokens_reported": first_number(usage, total_keys),
969
        }
970
        if not any(value is not None for value in values.values()):
971
            continue
972
        if values["total_tokens_reported"] is None:
973
            components = [
974
                values["input_tokens_reported"],
975
                values["output_tokens_reported"],
976
                values["reasoning_tokens_reported"],
977
            ]
978
            if any(component is not None for component in components):
979
                values["total_tokens_reported"] = sum(component or 0 for component in components)
980
        return values
981
    return None
982
983
984
def extract_text_chars(events: list[Any], fallback: str) -> int:
985
    texts: list[str] = []
986
    text_keys = {"text", "message", "delta", "summary", "result", "final_response"}
987
    for event in events:
988
        for item in walk_json(event):
989
            if not isinstance(item, dict):
990
                continue
991
            for key, value in item.items():
992
                if key in text_keys and isinstance(value, str):
993
                    texts.append(value)
994
                elif key == "content" and isinstance(value, str):
995
                    texts.append(value)
996
    if texts:
997
        return sum(len(text) for text in texts)
998
    return len(fallback)
999
1000
1001
def cost_weighted_total_from_usage(
1002
    usage: dict[str, int | None],
1003
    *,
1004
    cache_read_included_in_input: bool,
1005
) -> float | None:
1006
    has_direction_components = any(
1007
        usage.get(key) is not None
1008
        for key in ("input_tokens_reported", "output_tokens_reported", "reasoning_tokens_reported")
1009
    )
1010
    if not has_direction_components:
1011
        return None
1012
1013
    emitted = (usage.get("output_tokens_reported") or 0) + (usage.get("reasoning_tokens_reported") or 0)
1014
    ingested = usage.get("input_tokens_reported") or 0
1015
    cache_read = usage.get("cache_read_tokens_reported") or 0
1016
    cache_write = usage.get("cache_write_tokens_reported") or 0
1017
    if cache_read_included_in_input:
1018
        ingested = max(0, ingested - cache_read)
1019
    return round(
1020
        emitted * EMITTED_TOKEN_WEIGHT
1021
        + ingested * INGESTED_TOKEN_WEIGHT
1022
        + cache_read * CACHE_READ_TOKEN_WEIGHT
1023
        + cache_write * INGESTED_TOKEN_WEIGHT,
1024
        1,
1025
    )
1026
1027
1028
def cursor_tool_call_parts(event: Any) -> tuple[str, str | None] | None:
1029
    if not isinstance(event, dict) or str(event.get("type") or "").lower() != "tool_call":
1030
        return None
1031
    tool_call = event.get("tool_call")
1032
    if not isinstance(tool_call, dict):
1033
        return None
1034
    tool_name = next((key for key in tool_call if key.endswith("ToolCall")), None)
1035
    if tool_name is None:
1036
        return None
1037
    payload = tool_call.get(tool_name)
1038
    if not isinstance(payload, dict):
1039
        return tool_name, None
1040
    args = payload.get("args")
1041
    if isinstance(args, dict) and isinstance(args.get("command"), str):
1042
        return tool_name, unwrap_shell_command(args["command"])
1043
    result = payload.get("result")
1044
    if isinstance(result, dict):
1045
        shell_result = result.get("success") or result.get("failure")
1046
        if isinstance(shell_result, dict) and isinstance(shell_result.get("command"), str):
1047
            return tool_name, unwrap_shell_command(shell_result["command"])
1048
    return tool_name, None
1049
1050
1051
def collect_tool_metrics(stdout_text: str, stderr_text: str, events: list[Any]) -> tuple[dict[str, int], dict[str, int]]:
1052
    """One tool call counts exactly once.
1053
1054
    A Claude-style tool_use block carries name + input.command in one item;
1055
    the old walker counted the name, the input command, and the inner input
1056
    dict's command again β€” 3 tool_calls_total per actual call. Commands that
1057
    belong to a counted tool_use go to attached_commands: classified for
1058
    kind/VCS/help counts but never re-counted as separate calls.
1059
    """
1060
    tool_names: list[str] = []
1061
    commands: list[str] = []
1062
    attached_commands: list[str] = []
1063
    consumed_input_ids: set[int] = set()
1064
1065
    for event in events:
1066
        cursor_call = cursor_tool_call_parts(event)
1067
        if cursor_call is not None:
1068
            if str(event.get("subtype") or "").lower() == "started":
1069
                tool_name, command = cursor_call
1070
                tool_names.append(tool_name)
1071
                if command is not None:
1072
                    attached_commands.append(command)
1073
            continue
1074
1075
        for item in walk_json(event):
1076
            if not isinstance(item, dict):
1077
                continue
1078
            item_type = str(item.get("type") or item.get("event") or "").lower()
1079
            name = item.get("name") or item.get("tool_name") or item.get("tool")
1080
            if item_type == "file_change" and str(item.get("status") or "").lower() == "completed":
1081
                tool_names.append("file_change")
1082
            is_tool_call_item = isinstance(name, str) and (
1083
                "tool" in item_type or item_type in {"function_call", "function"}
1084
            )
1085
            if is_tool_call_item:
1086
                tool_names.append(name)
1087
            input_value = item.get("input")
1088
            if isinstance(input_value, dict):
1089
                inner_command = input_value.get("command") or input_value.get("cmd")
1090
                if isinstance(inner_command, str):
1091
                    # The walker will visit this input dict again; mark it so
1092
                    # its command is not re-counted as a standalone call.
1093
                    consumed_input_ids.add(id(input_value))
1094
                    if is_tool_call_item:
1095
                        attached_commands.append(unwrap_shell_command(inner_command))
1096
                    else:
1097
                        commands.append(unwrap_shell_command(inner_command))
1098
            if id(item) in consumed_input_ids:
1099
                continue
1100
            command = item.get("command") or item.get("cmd")
1101
            if isinstance(command, str):
1102
                status = str(item.get("status") or "").lower()
1103
                exit_code = item.get("exit_code")
1104
                if item.get("type") != "command_execution" or status not in {"in_progress", "queued"} or exit_code is not None:
1105
                    if is_tool_call_item:
1106
                        attached_commands.append(unwrap_shell_command(command))
1107
                    else:
1108
                        commands.append(unwrap_shell_command(command))
1109
1110
    for line in (stdout_text + "\n" + stderr_text).splitlines():
1111
        if line.startswith("$ "):
1112
            commands.append(unwrap_shell_command(line[2:]))
1113
1114
    counts = {
1115
        "tool_calls_total": 0,
1116
        "terminal_commands_total": 0,
1117
        "file_read_calls": 0,
1118
        "file_write_calls": 0,
1119
        "search_calls": 0,
1120
        "edit_calls": 0,
1121
        "vcs_commands_total": 0,
1122
        "test_commands_total": 0,
1123
        "network_calls_total": 0,
1124
        "privileged_calls_total": 0,
1125
        "help_calls_total": 0,
1126
    }
1127
    by_kind: dict[str, int] = {}
1128
1129
    for name in tool_names:
1130
        lowered = name.lower()
1131
        by_kind[lowered] = by_kind.get(lowered, 0) + 1
1132
        counts["tool_calls_total"] += 1
1133
        if lowered in {"bash", "shell", "terminal", "exec_command", "run_terminal_cmd"} or "shell" in lowered:
1134
            counts["terminal_commands_total"] += 1
1135
        if "read" in lowered:
1136
            counts["file_read_calls"] += 1
1137
        if "edit" in lowered or "write" in lowered or lowered == "file_change":
1138
            counts["edit_calls"] += 1
1139
            counts["file_write_calls"] += 1
1140
        if "search" in lowered or "grep" in lowered or "glob" in lowered:
1141
            counts["search_calls"] += 1
1142
1143
    def classify_into_counts(command: str, counts_as_call: bool) -> None:
1144
        kinds = classify_command(command)
1145
        if counts_as_call:
1146
            counts["tool_calls_total"] += 1
1147
            for kind in kinds:
1148
                by_kind[kind] = by_kind.get(kind, 0) + 1
1149
            if "terminal" in kinds:
1150
                counts["terminal_commands_total"] += 1
1151
        if "read" in kinds:
1152
            counts["file_read_calls"] += 1
1153
        if "file_write" in kinds:
1154
            counts["file_write_calls"] += 1
1155
        if "search" in kinds:
1156
            counts["search_calls"] += 1
1157
        if "edit" in kinds:
1158
            counts["edit_calls"] += 1
1159
        if "vcs" in kinds:
1160
            counts["vcs_commands_total"] += 1
1161
        if "test" in kinds:
1162
            counts["test_commands_total"] += 1
1163
        if "help" in kinds:
1164
            counts["help_calls_total"] += 1
1165
        if re.search(r"\b(curl|wget|gh api|npm install|pip install)\b", command):
1166
            counts["network_calls_total"] += 1
1167
        if re.search(r"\b(sudo|su\s+-)\b", command):
1168
            counts["privileged_calls_total"] += 1
1169
1170
    for command in commands:
1171
        classify_into_counts(command, counts_as_call=True)
1172
    for command in attached_commands:
1173
        # The call was already counted via its tool_use name; only the
1174
        # command's nature (VCS/test/help/...) still needs attribution.
1175
        classify_into_counts(command, counts_as_call=False)
1176
1177
    return counts, by_kind
1178
1179
1180
# PATH-shim VCS attribution fields, merged into vcs_metrics. Metric nulls carry
1181
# explicit reasons (ADR-0002: unmeasured is null, never zero). Shim overhead is
1182
# recorded, never subtracted.
1183
SHIM_REASON_FIELDS = (
1184
    "shim_sidecar_path_unavailable_reason",
1185
    "shim_overhead_unavailable_reason",
1186
    "vcs_ms_unavailable_reason",
1187
    "vcs_call_count_shim_unavailable_reason",
1188
    "agent_blocked_on_vcs_ms_unavailable_reason",
1189
    "vcs_share_of_task_wall_unavailable_reason",
1190
    "thrash_events_count_unavailable_reason",
1191
    "polling_loop_count_unavailable_reason",
1192
)
1193
1194
SHIM_NULL_VALUES: dict[str, Any] = {
1195
    "shim_sidecar_path": None,
1196
    "shim_overhead": None,
1197
    "shim_overhead_ms": None,
1198
    "vcs_ms": None,
1199
    "vcs_call_count_shim": None,
1200
    "agent_blocked_on_vcs_ms": None,
1201
    "vcs_share_of_task_wall": None,
1202
    "thrash_events_count": None,
1203
    "polling_loop_count": None,
1204
}
1205
1206
1207
def shim_metrics_unavailable(reason: str) -> dict[str, Any]:
1208
    fields = dict(SHIM_NULL_VALUES)
1209
    for key in SHIM_REASON_FIELDS:
1210
        fields[key] = reason
1211
    return fields
1212
1213
1214
def shim_real_binaries(subject: workflow_ab.Subject) -> dict[str, str]:
1215
    """Real binaries to wrap: the subject's VCS, plus git if discoverable."""
1216
    real: dict[str, str] = {}
1217
    git_found = shutil.which("git")
1218
    if git_found:
1219
        real["git"] = git_found
1220
    if subject.kind in ("git", "oak") and subject.bin is not None and Path(subject.bin).exists():
1221
        real[subject.kind] = str(subject.bin)
1222
    return real
1223
1224
1225
def shim_metrics_from_sidecar(
1226
    sidecar: Path,
1227
    shim_overhead_ms: float | None,
1228
    task_wall_ms: float,
1229
) -> dict[str, Any]:
1230
    """Shim-attributed VCS fields from the sidecar JSONL, with null reasons."""
1231
    fields = shim_metrics_unavailable("sidecar_empty")
1232
    fields["shim_sidecar_path"] = str(sidecar)
1233
    fields["shim_sidecar_path_unavailable_reason"] = None
1234
    fields["shim_overhead"] = shim_overhead_ms
1235
    fields["shim_overhead_ms"] = shim_overhead_ms
1236
    fields["shim_overhead_unavailable_reason"] = (
1237
        None if shim_overhead_ms is not None else "shim_overhead_unmeasured"
1238
    )
1239
    rows = read_sidecar_rows(sidecar)
1240
    if not rows:
1241
        return fields
1242
1243
    fields["vcs_call_count_shim"] = len(rows)
1244
    fields["vcs_call_count_shim_unavailable_reason"] = None
1245
1246
    elapsed = [row.get("elapsed_ms") for row in rows]
1247
    measured = [value for value in elapsed if isinstance(value, (int, float)) and not isinstance(value, bool)]
1248
    if measured:
1249
        fields["vcs_ms"] = round(sum(measured), 3)
1250
        fields["vcs_ms_unavailable_reason"] = None
1251
    else:
1252
        fields["vcs_ms_unavailable_reason"] = "sidecar_rows_missing_elapsed_ms"
1253
1254
    blocked = agent_blocked_on_vcs_ms(rows)
1255
    fields["agent_blocked_on_vcs_ms"] = blocked
1256
    fields["agent_blocked_on_vcs_ms_unavailable_reason"] = (
1257
        None if blocked is not None else "sidecar_rows_missing_vcs_timing_or_command"
1258
    )
1259
    fields["vcs_share_of_task_wall"] = vcs_share_of_task_wall(blocked, task_wall_ms)
1260
    if fields["vcs_share_of_task_wall"] is not None:
1261
        fields["vcs_share_of_task_wall_unavailable_reason"] = None
1262
    elif blocked is None:
1263
        fields["vcs_share_of_task_wall_unavailable_reason"] = "agent_blocked_on_vcs_ms_unavailable"
1264
    else:
1265
        fields["vcs_share_of_task_wall_unavailable_reason"] = "task_wall_ms_unavailable"
1266
1267
    events = thrash_events(rows)
1268
    if events is None:
1269
        fields["thrash_events_count_unavailable_reason"] = "sidecar_rows_missing_vcs_timestamp_or_command"
1270
        fields["polling_loop_count_unavailable_reason"] = "sidecar_rows_missing_vcs_timestamp_or_command"
1271
    else:
1272
        fields["thrash_events_count"] = len(events)
1273
        fields["polling_loop_count"] = sum(1 for event in events if event.get("type") == "vcs_polling_loop")
1274
        fields["thrash_events_count_unavailable_reason"] = None
1275
        fields["polling_loop_count_unavailable_reason"] = None
1276
    return fields
1277
1278
1279
def vcs_metrics_from_tools(
1280
    subject: workflow_ab.Subject,
1281
    stdout_text: str,
1282
    stderr_text: str,
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
1283
    dirty_files: int | None,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1284
    commit_delta: int,
1285
) -> dict[str, Any]:
1286
    text = stdout_text + "\n" + stderr_text
1287
    commands = [unwrap_shell_command(line[2:]) for line in text.splitlines() if line.startswith("$ ")]
1288
    events = parse_json_lines(text)
1289
    for event in events:
1290
        cursor_call = cursor_tool_call_parts(event)
1291
        if cursor_call is not None:
1292
            if str(event.get("subtype") or "").lower() == "started":
1293
                _, command = cursor_call
1294
                if command is not None:
1295
                    commands.append(command)
1296
            continue
1297
1298
        for item in walk_json(event):
1299
            if not isinstance(item, dict):
1300
                continue
1301
            if str(item.get("type") or "").lower() == "tool_use":
1302
                name = str(item.get("name") or item.get("tool_name") or item.get("tool") or "").lower()
1303
                input_value = item.get("input")
1304
                command = input_value.get("command") if isinstance(input_value, dict) else None
1305
                if isinstance(command, str) and name in {"bash", "shell", "terminal"}:
1306
                    commands.append(unwrap_shell_command(command))
1307
                continue
1308
            if item.get("type") != "command_execution":
1309
                continue
1310
            status = str(item.get("status") or "").lower()
1311
            if status in {"in_progress", "queued"} and item.get("exit_code") is None:
1312
                continue
1313
            command = item.get("command")
1314
            if isinstance(command, str):
1315
                commands.append(unwrap_shell_command(command))
1316
    commands_available = bool(commands)
1317
    # One classification authority: every count below derives from the SAME
1318
    # command list through oakbench.classify.vcs_invocations, so sub-counts can
1319
    # never disagree with the total (the reviewer-found vcs_commands_total=0
1320
    # alongside status_commands=1). Sub-counts are a family breakdown, not a
1321
    # partition β€” add/log/etc. count in the total only.
1322
    invocations = [
1323
        (segment, subcommand)
1324
        for command in commands
1325
        for segment, subcommand in vcs_invocations(command)
1326
    ]
1327
    subcommands = [subcommand for _, subcommand in invocations]
1328
    commands_total = sum(1 for subcommand in subcommands if subcommand is not None) if commands_available else None
1329
    status_commands = sum(1 for subcommand in subcommands if subcommand == "status") if commands_available else None
1330
    diff_commands = sum(1 for subcommand in subcommands if subcommand == "diff") if commands_available else None
1331
    snapshot_commands = sum(1 for subcommand in subcommands if subcommand == "commit") if commands_available else None
1332
    branch_commands = sum(
1333
        1
1334
        for command, subcommand in invocations
1335
        if subcommand in {"checkout", "switch"} and re.search(r"\s-[bc]\b", command)
1336
    ) if commands_available else None
1337
    merge_commands = sum(1 for subcommand in subcommands if subcommand == "merge") if commands_available else None
1338
    cleanup_commands = sum(
1339
        1
1340
        for command, subcommand in invocations
1341
        if subcommand is not None and re.search(r"\b(clean|end|remove)\b", command)
1342
    ) if commands_available else None
1343
    return {
1344
        "commands_total": commands_total,
1345
        "status_commands": status_commands,
1346
        "diff_commands": diff_commands,
1347
        "snapshot_commands": snapshot_commands,
1348
        "branch_commands": branch_commands,
1349
        "merge_commands": merge_commands,
1350
        "cleanup_commands": cleanup_commands,
1351
        "commits_created": max(0, commit_delta),
1352
        "branches_created": branch_commands or 0,
1353
        "conflicts_total": None,
1354
        "conflicts_resolved": None,
1355
        "dirty_files_at_end": dirty_files,
1356
        "cleanup_success": dirty_files == 0,
1357
        "measurement_source": (
1358
            "transcript_command_classification_plus_oracle_state"
1359
            if commands_available
1360
            else "oracle_state_only; transcript_vcs_commands_unavailable"
1361
        ),
1362
    }
1363
1364
1365
def run_oracle(
1366
    subject: workflow_ab.Subject,
1367
    workflow: workflow_ab.Workflow,
1368
    repo: Path,
1369
    before_manifest: dict[str, dict[str, Any]],
1370
    initial_commit_count: int | None,
1371
    artifacts: Path,
1372
) -> OracleResult:
1373
    start = time.perf_counter()
1374
    artifact_paths: list[Path] = []
1375
1376
    test_start = time.perf_counter()
1377
    test_proc = run_command(
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1378
        test_command_for(workflow, subject),
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1379
        repo,
1380
        timeout=300.0,
1381
        env=workflow_ab.python_no_bytecode_env(),
1382
    )
1383
    test_ms = (time.perf_counter() - test_start) * 1000.0
1384
    test_log = artifacts / "oracle.test.log"
1385
    test_log.write_text(test_proc.stdout + test_proc.stderr)
1386
    artifact_paths.append(test_log)
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1387
    evidence = None
1388
    if workflow.name in workflow_oracles.SCENARIOS:
1389
        try:
1390
            evidence = json.loads(test_proc.stdout)
1391
            passed = evidence.get("workflow_integrity_passed") if isinstance(evidence, dict) else "invalid"
1392
            valid = (isinstance(evidence, dict) and evidence.get("oracle_version") == ORACLE_VERSION
1393
                     and isinstance(evidence.get("workflow_integrity_source"), str)
1394
                     and (passed is True or passed is False or passed is None)
1395
                     and ((passed is None and bool(evidence.get("workflow_integrity_unmeasured_reason")))
1396
                          or (passed is False and bool(evidence.get("workflow_integrity_failure_reason")))
1397
                          or passed is True)
1398
                     and test_proc.returncode == (77 if passed is None else 0 if passed else 1))
1399
            if not valid:
1400
                evidence = None
1401
        except ValueError:
1402
            evidence = None
1403
        if evidence is None:
1404
            evidence = {"oracle_version": ORACLE_VERSION,
1405
                        "workflow_integrity_source": "trusted_oracle_instrument_unavailable",
1406
                        "workflow_integrity_passed": None,
1407
                        "workflow_integrity_failure_reason": None,
1408
                        "workflow_integrity_unmeasured_reason": "trusted_oracle_evidence_invalid_or_missing"}
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1409
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1410
    if test_proc.returncode == 77 and evidence is None:
1411
        evidence = {"oracle_version": ORACLE_VERSION,
1412
                    "workflow_integrity_source": "subject_status;required_check_unavailable",
1413
                    "workflow_integrity_evidence_class": "subject_status_only",
1414
                    "workflow_integrity_passed": None,
1415
                    "workflow_integrity_failure_reason": None,
1416
                    "workflow_integrity_unmeasured_reason": "required_check_instrument_unavailable"}
1417
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1418
    status_text, dirty_count, status_ms = vcs_status(subject, repo)
1419
    status_log = artifacts / "oracle.status.log"
1420
    status_log.write_text(status_text)
1421
    artifact_paths.append(status_log)
1422
1423
    after_manifest = workspace_manifest(repo)
1424
    changed = changed_files(before_manifest, after_manifest)
1425
    expected_change_shape = expected_change_ok(workflow, changed)
1426
    bytes_written = sum(int(after_manifest.get(path, {}).get("bytes", 0)) for path in changed)
1427
1428
    final_commit_count = commit_count(subject, repo)
1429
    commit_delta = 0
1430
    if initial_commit_count is not None and final_commit_count is not None:
1431
        commit_delta = final_commit_count - initial_commit_count
1432
1433
    checks = {
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1434
        "required_checks_passed": None if (evidence is not None and evidence.get("workflow_integrity_passed") is None) else test_proc.returncode == 0,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1435
        "expected_change_shape": expected_change_shape,
1436
        "final_vcs_clean": dirty_count == 0,
f86d1109f5df Review benchmark validity and add upstream-inspi 11 days ago
1437
        "snapshot_created": workflow.name == "history_archaeology" or commit_delta > 0,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1438
    }
1439
    success = all(checks.values())
1440
    failure_reason = None
1441
    for key, passed in checks.items():
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1442
        if passed is False:
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1443
            failure_reason = key
1444
            break
1445
1446
    elapsed_ms = (time.perf_counter() - start) * 1000.0
1447
    return OracleResult(
1448
        elapsed_ms=elapsed_ms,
1449
        test_ms=test_ms,
1450
        vcs_ms=status_ms,
1451
        success=success,
1452
        outcome="pass" if success else "fail",
1453
        failure_reason=failure_reason,
1454
        checks=checks,
1455
        test_returncode=test_proc.returncode,
1456
        test_output_bytes=len((test_proc.stdout + test_proc.stderr).encode("utf-8", "replace")),
1457
        status_output=status_text,
1458
        dirty_files_at_end=dirty_count,
1459
        changed_files=changed,
1460
        bytes_written=bytes_written,
1461
        commit_delta=commit_delta,
1462
        artifact_paths=artifact_paths,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1463
        evidence=evidence,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1464
    )
1465
1466
1467
def fixture_shape(workflow: workflow_ab.Workflow) -> str:
1468
    if workflow.name == "bugfix_test_loop":
1469
        return "small Python app with one failing unit test"
1470
    if workflow.name == "wide_config_refactor":
1471
        return "600 small service config files plus validator"
1472
    if workflow.name == "large_asset_manifest":
1473
        return "binary-heavy repo with 64 MiB assets plus JSON manifest"
1474
    return workflow.description
1475
1476
1477
def make_row(
1478
    bench_id: str,
1479
    timestamp_utc: str,
1480
    profile: str,
1481
    agent: AgentConfig,
1482
    subject: workflow_ab.Subject,
1483
    workflow: workflow_ab.Workflow,
1484
    track: str,
1485
    run_index: int,
1486
    repo: Path,
1487
    artifacts: Path,
1488
    setup_ms: float,
1489
    setup_output_bytes: int,
1490
    execution: AgentExecution,
1491
    oracle: OracleResult,
1492
    prompt: str,
1493
    before_manifest: dict[str, dict[str, Any]],
1494
    source: dict[str, Any],
1495
    host_meta: dict[str, str],
1496
    agent_environment: str,
1497
    instruction_level: str,
1498
    shim_info: dict[str, Any] | None = None,
1499
) -> dict[str, Any]:
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1500
    capture_metadata = dict(execution.capture_metadata or {})
1501
    parsing_limit = capture_metadata.get("output_limit_bytes", DEFAULT_OUTPUT_LIMIT_BYTES)
1502
    if type(parsing_limit) is not int or parsing_limit < 1:
1503
        raise ValueError("invalid configured output limit")
1504
    stdout_text = read_text(execution.stdout_path, parsing_limit)
1505
    stderr_text = read_text(execution.stderr_path, parsing_limit)
1506
    stored_stdout_bytes = execution.stdout_path.stat().st_size if execution.stdout_path.exists() else 0
1507
    stored_stderr_bytes = execution.stderr_path.stat().st_size if execution.stderr_path.exists() else 0
1508
    stdout_bytes = capture_metadata.get("stdout_observed_bytes", stored_stdout_bytes)
1509
    stderr_bytes = capture_metadata.get("stderr_observed_bytes", stored_stderr_bytes)
1510
    output_truncated = (execution.output_limited or not execution.capture_complete
1511
                        or stored_stdout_bytes + stored_stderr_bytes > parsing_limit)
1512
    usage_complete = not (output_truncated or execution.timed_out or execution.returncode != 0)
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1513
    events = parse_json_lines(stdout_text) + parse_json_lines(stderr_text)
1514
    usage = collect_usage(events)
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1515
    if not usage_complete:
1516
        # A terminal or cumulative event in a partial transcript is not proof
1517
        # of the complete run's usage, including any in-flight provider work.
1518
        usage = {key: None for key in usage}
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1519
    output_chars = extract_text_chars(events, stdout_text + stderr_text)
1520
    transcript_chars = len(prompt) + len(stdout_text) + len(stderr_text)
1521
    token_source = "provider_reported" if usage["total_tokens_reported"] is not None else "char_only"
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1522
    if not usage_complete:
1523
        token_source = "unknown"
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1524
1525
    analysis = analyze_stream(events, agent.adapter)
1526
    turn_timeline_path: str | None = None
1527
    if analysis.turns:
1528
        timeline_file = artifacts / "turn_timeline.jsonl"
1529
        with timeline_file.open("w") as fh:
1530
            for turn in analysis.turns:
1531
                fh.write(json.dumps(turn, sort_keys=True) + "\n")
1532
        turn_timeline_path = str(timeline_file)
1533
1534
    # Billing-direction cost. Provider-reported when available, otherwise a
1535
    # char/4 estimate split by direction: assistant output is model-emitted
1536
    # (expensive), prompt plus tool results are model-ingested.
1537
    # Direction components must actually be reported: a stream exposing only
1538
    # total_tokens has no direction split, and weighting Nones as zeros would
1539
    # produce cost_weighted_total = 0 for a run that cost real money.
1540
    has_direction_components = any(
1541
        usage[key] is not None
1542
        for key in ("input_tokens_reported", "output_tokens_reported", "reasoning_tokens_reported")
1543
    )
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1544
    if not usage_complete:
1545
        cost_weighted_total = None
1546
    elif has_direction_components:
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1547
        cost_weighted_total = cost_weighted_total_from_usage(
1548
            usage,
1549
            cache_read_included_in_input=adapter_for(agent.adapter).cache_read_included_in_input,
1550
        )
1551
    else:
1552
        emitted_est = output_chars / 4.0
1553
        ingested_est = max(0, transcript_chars - output_chars) / 4.0
1554
        cost_weighted_total = round(
1555
            emitted_est * EMITTED_TOKEN_WEIGHT + ingested_est * INGESTED_TOKEN_WEIGHT, 1
1556
        )
1557
1558
    tool_counts, by_kind = collect_tool_metrics(stdout_text, stderr_text, events)
1559
    total_ms = setup_ms + execution.elapsed_ms + oracle.elapsed_ms
1560
    logical_bytes = sum(int(item["bytes"]) for item in before_manifest.values())
1561
    logical_file_count = len(before_manifest)
1562
    output_bytes = stdout_bytes + stderr_bytes
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1563
    process_ok = (execution.returncode == 0 and not execution.timed_out
1564
                  and not execution.output_limited and execution.capture_complete)
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1565
    success = process_ok and oracle.success
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1566
    evidence = getattr(oracle, "evidence", None) or {
1567
        "oracle_version": ORACLE_VERSION, "workflow_integrity_source": "fixture_tests_and_subject_status",
1568
        "workflow_integrity_passed": oracle.success,
1569
        "workflow_integrity_failure_reason": oracle.failure_reason,
1570
        "workflow_integrity_unmeasured_reason": None,
1571
    }
1572
    if workflow.name in workflow_oracles.SCENARIOS and getattr(oracle, "evidence", None) is None:
1573
        evidence = {"oracle_version": ORACLE_VERSION,
1574
                    "workflow_integrity_source": "trusted_oracle_instrument_unavailable",
1575
                    "workflow_integrity_passed": None, "workflow_integrity_failure_reason": None,
1576
                    "workflow_integrity_unmeasured_reason": "trusted_oracle_evidence_invalid_or_missing"}
1577
    unmeasured = evidence.get("workflow_integrity_passed") is None
1578
    measured_other_failure = any(value is False for key, value in oracle.checks.items()
1579
                                 if key != "required_checks_passed")
1580
    skipped_instrument = unmeasured and process_ok and not measured_other_failure
1581
    outcome = "timeout" if execution.timed_out else (
1582
        "error" if execution.output_limited or not execution.capture_complete else ("pass" if success else "fail"))
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1583
    failure_reason = None
1584
    if execution.timed_out:
1585
        failure_reason = "agent_timeout"
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1586
    elif execution.output_limited:
1587
        failure_reason = "agent_output_limit"
1588
    elif not execution.capture_complete:
1589
        failure_reason = "agent_capture_incomplete"
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1590
    elif execution.returncode != 0:
1591
        failure_reason = "agent_exit_nonzero"
1592
    elif not oracle.success:
1593
        failure_reason = oracle.failure_reason
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1594
    if skipped_instrument:
1595
        outcome = "error"
1596
        success = False
1597
        failure_reason = evidence.get("workflow_integrity_unmeasured_reason")
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1598
1599
    row = {
1600
        "schema_version": SCHEMA_VERSION,
1601
        "bench_id": bench_id,
1602
        "timestamp_utc": timestamp_utc,
1603
        "profile": profile,
1604
        "scenario": workflow.name,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1605
        "env_isolation_version": ENV_ISOLATION_VERSION,
1606
        **runner_fields(),
1607
        **cache_fields(None, None),
1608
        "calibration_version": None,
1609
        "calibration_unmeasured_reason": "no_token_calibration_artifact_bound_to_this_trial",
1610
        "scenario_version": f"{PROMPT_VERSION}/oracle-{ORACLE_VERSION}/execution-{EXECUTION_VERSION}",
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1611
        "operation": "agent.workflow",
1612
        "benchmark_track": track,
1613
        "command_semantics_version": COMMAND_SEMANTICS_VERSION,
1614
        "elapsed_ms": round(total_ms, 3),
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1615
        "returncode": 77 if skipped_instrument else 0 if success else 1,
1616
        "skipped": skipped_instrument,
1617
        "skip_reason": failure_reason if skipped_instrument else None,
1618
        **evidence,
1619
        "workflow_integrity_evidence_class": evidence.get("workflow_integrity_evidence_class", "tests_and_subject_status" if workflow.name not in workflow_oracles.SCENARIOS else None),
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1620
        "raw_output_bytes": output_bytes,
1621
        "stdout_bytes": stdout_bytes,
1622
        "stderr_bytes": stderr_bytes,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1623
        "output_truncated": output_truncated,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1624
        "task_id": None,
1625
        "task_prompt_id": f"real-agent-{workflow.name}-{track}-{instruction_level}-{PROMPT_VERSION}",
1626
        "run": run_index,
1627
        "subject": subject.name,
1628
        "subject_kind": subject.kind,
1629
        "subject_label": subject.label,
1630
        "vcs_mode": vcs_mode(subject),
1631
        "agent": {
1632
            "name": agent.name,
1633
            "adapter": agent.adapter,
1634
            "provider": agent.provider,
1635
            "model": agent.model,
1636
            "version": agent.version,
1637
            "environment": agent_environment,
1638
            "instruction_level": instruction_level,
1639
            "temperature": None,
1640
            "max_output_tokens": None,
1641
        },
1642
        "fixture": {
1643
            "id": workflow.name,
1644
            "repo_shape": fixture_shape(workflow),
1645
            "fixture_version": workflow_ab.FIXTURE_VERSION,
1646
            "logical_file_count": logical_file_count,
1647
            "logical_bytes": logical_bytes,
1648
        },
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1649
        "source": {**source, "agent_execution": {
1650
            **capture_metadata, "execution_version": EXECUTION_VERSION,
1651
            "usage_complete": usage_complete,
1652
            "transcript_metrics_complete": usage_complete,
1653
            "counters_coverage": "complete" if usage_complete else "observed_lower_bounds",
1654
            "stdout_retained_bytes": stored_stdout_bytes,
1655
            "stderr_retained_bytes": stored_stderr_bytes,
1656
            "parsing_limit_bytes_per_stream": parsing_limit,
1657
        }},
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1658
        "outcome": outcome,
1659
        "success": success,
1660
        "failure_reason": failure_reason,
1661
        "success_criteria": {
1662
            "passed": success,
1663
            "checks": {
1664
                "agent_exit_zero": process_ok,
1665
                **oracle.checks,
1666
            },
1667
            "notes": None if success else f"changed_files={oracle.changed_files[:20]}",
1668
        },
1669
        "token_metrics": {
1670
            "token_source": token_source,
1671
            "tokenizer": None,
1672
            **usage,
1673
            "normalized_input_chars": len(prompt),
1674
            "normalized_output_chars": output_chars,
1675
            "transcript_chars": transcript_chars,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1676
            "context_truncation_events": analysis.context_truncation_events if usage_complete else None,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1677
            "token_budget": None,
1678
            "cost_weighted_total": cost_weighted_total,
1679
            "cost_weights": f"{COST_WEIGHTS_NOTE},cache_write={INGESTED_TOKEN_WEIGHT}",
1680
        },
1681
        "turn_metrics": {
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1682
            "turn_source": analysis.turn_source if usage_complete else "unmeasured",
1683
            "assistant_turns_total": analysis.assistant_turns_total if usage_complete else None,
1684
            "tool_calls_per_turn_avg": analysis.tool_calls_per_turn_avg if usage_complete else None,
1685
            "max_tool_calls_per_turn": analysis.max_tool_calls_per_turn if usage_complete else None,
1686
            "cumulative_input_tokens_reported": analysis.cumulative_input_tokens if usage_complete else None,
1687
            "peak_input_tokens_reported": analysis.peak_input_tokens if usage_complete else None,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1688
            "turn_timeline_path": turn_timeline_path,
1689
        },
1690
        "tool_call_metrics": {
1691
            **tool_counts,
1692
            "tool_calls_by_kind": by_kind,
1693
            "unknown_command_failures_total": analysis.unknown_command_failures_total,
1694
            "vcs_info_followup_calls_total": analysis.vcs_info_followup_calls_total,
1695
            "parallel_batches_total": analysis.parallel_batches_total,
1696
            "max_concurrency": analysis.max_concurrency,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1697
            "measurement_source": ("adapter_stream_events_and_transcript_lines" if usage_complete
1698
                                   else "partial_adapter_events_observed_lower_bounds"),
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1699
        },
1700
        "failed_command_retry_metrics": {
1701
            "failed_tool_calls_total": analysis.failed_tool_calls_total,
1702
            "failed_commands_total": analysis.failed_tool_calls_total,
1703
            "failed_vcs_commands_total": analysis.failed_vcs_commands_total,
1704
            "failed_test_commands_total": analysis.failed_test_commands_total,
1705
            "retry_attempts_total": analysis.same_command_retries_total,
1706
            "retry_chains_total": None,
1707
            "same_command_retries_total": analysis.same_command_retries_total,
1708
            "retry_after_failure_ms": None,
1709
            "recoverable_failures_total": None,
1710
            "unrecoverable_failures_total": None,
1711
            "turns_to_recovery": analysis.turns_to_recovery,
1712
            "tokens_to_recovery": analysis.tokens_to_recovery,
1713
            "failed_command_examples": analysis.failed_command_examples or [],
1714
            "measurement_source": (
1715
                "adapter_stream_exit_codes" if analysis.failed_tool_calls_total is not None else "not_adapter_visible"
1716
            ),
1717
        },
1718
        "wall_clock_metrics": {
1719
            "total_ms": round(total_ms, 3),
1720
            "setup_ms": round(setup_ms, 3),
1721
            "agent_active_ms": round(execution.elapsed_ms, 3),
1722
            "tool_wait_ms": None,
1723
            "vcs_ms": round(oracle.vcs_ms, 3),
1724
            "test_ms": round(oracle.test_ms, 3),
1725
            "merge_ms": None,
1726
            "cleanup_ms": None,
1727
            "idle_ms": None,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1728
            "timeout_ms": (capture_metadata["timeout_seconds"] * 1000
1729
                           if capture_metadata.get("timeout_seconds") is not None else None),
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1730
            "measurement_source": "outer_cli_wall_clock_plus_oracle_phases",
1731
        },
1732
        "bytes_metrics": {
1733
            "workspace_logical_bytes": logical_bytes,
1734
            "workspace_materialized_bytes": logical_bytes,
1735
            # Read-tool payload bytes are not adapter-visible in this lane;
1736
            # the transcript output bytes some dashboards want are already in
1737
            # command_output_bytes. A proxy here would corrupt hydration and
1738
            # read-efficiency comparisons (ADR-0002: null, never a stand-in).
1739
            "bytes_read_by_agent": None,
1740
            "bytes_written_by_agent": oracle.bytes_written,
1741
            "bytes_hydrated": None,
1742
            "files_hydrated": None,
1743
            "hydration_events": None,
1744
            "large_file_bytes_scanned": None,
1745
            "binary_bytes_touched": None,
1746
            "command_output_bytes": output_bytes + oracle.test_output_bytes + setup_output_bytes,
1747
            "measurement_source": "agent_cli_transcript_and_workspace_manifest; read-tool bytes and hydration not adapter-visible in this lane",
1748
        },
1749
        "vcs_metrics": {
1750
            **vcs_metrics_from_tools(
1751
                subject,
1752
                stdout_text,
1753
                stderr_text,
1754
                oracle.dirty_files_at_end,
1755
                oracle.commit_delta,
1756
            ),
1757
            # Shim attribution is additive: null fields when --vcs-shim is off
1758
            # (unmeasured, ADR-0002), populated from the sidecar when on.
1759
            **(shim_info if shim_info is not None else shim_metrics_unavailable("vcs_shim_disabled")),
1760
        },
1761
        "test_metrics": {
1762
            "commands_total": tool_counts["test_commands_total"] + 1,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1763
            "passed_commands": None if unmeasured else int(oracle.test_returncode == 0),
1764
            "failed_commands": None if unmeasured else int(oracle.test_returncode != 0),
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1765
            "assertions_total": None,
1766
            "assertions_failed": None,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1767
            "required_checks_passed": None if unmeasured else oracle.test_returncode == 0,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1768
        },
1769
        "parallel_metrics": {
1770
            "tasks_total": 1,
1771
            "tasks_completed": 1 if success else 0,
1772
            "max_parallel_tasks": 1,
1773
            "overlapping_files_count": None,
1774
            "lost_updates_detected": None,
1775
            "lock_wait_ms": None,
1776
            "commit_throughput_per_s": None,
1777
            "integrity_check_passed": None,
1778
            "measurement_source": "single_task_run; see parallel_contention.py for the contention lane",
1779
        },
1780
        "artifacts": {
1781
            "transcript_path": str(execution.stdout_path),
1782
            "command_log_path": str(execution.stderr_path),
1783
            "diff_path": None,
1784
            "workspace_path": str(repo),
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1785
            "extra_paths": [str(execution.prompt_path), *[str(path) for path in oracle.artifact_paths],
1786
                            *([str(artifacts / "execution.json")] if (artifacts / "execution.json").exists() else [])],
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1787
        },
1788
        "notes": (
1789
            "agent_active_ms is the outer CLI wall time and may include model time plus hidden tool wait; "
1790
            "tool counts are limited to adapter-visible transcript events. Null metric values mean "
1791
            "unmeasured, never zero. tokens_to_recovery attribution is approximate for adapters that "
1792
            "emit turn boundaries after tool results (codex-style streams)."
1793
        ),
1794
        **host_meta,
1795
    }
1796
    return row
1797
1798
1799
def copy_fixture(src: Path, dest: Path) -> None:
1800
    if dest.exists():
1801
        shutil.rmtree(dest)
1802
    shutil.copytree(src, dest)
1803
1804
1805
def run_one(
1806
    agent: AgentConfig,
1807
    subject: workflow_ab.Subject,
1808
    workflow: workflow_ab.Workflow,
1809
    fixture: Path,
1810
    run_index: int,
1811
    run_root: Path,
1812
    artifacts_root: Path,
1813
    timeout_seconds: float,
1814
    track: str,
1815
    agent_environment: str,
1816
    instruction_level: str,
1817
    bench_id: str,
1818
    timestamp_utc: str,
1819
    source: dict[str, Any],
1820
    host_meta: dict[str, str],
1821
    vcs_shim: bool = False,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1822
    output_limit_bytes: int = DEFAULT_OUTPUT_LIMIT_BYTES,
1823
    fixture_copy_validator: Callable[[Path], None] | None = None,
1824
    fixture_prepared: bool = False,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1825
) -> dict[str, Any]:
1826
    repo = run_root / workflow.name / agent.name / subject.name / f"run-{run_index}"
1827
    artifacts = artifacts_root / workflow.name / agent.name / subject.name / f"run-{run_index}"
1828
    artifacts.mkdir(parents=True, exist_ok=True)
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1829
    if not fixture_prepared:
1830
        copy_fixture(fixture, repo)
1831
    if fixture_copy_validator is not None:
1832
        fixture_copy_validator(repo)
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1833
    write_workspace_instructions(repo, subject, track, instruction_level)
1834
    setup_ms, setup_output, setup_returncode = initialize_repo(subject, repo)
1835
1836
    # Workflows with prepare steps (seeded history, pre-broken state) need them
1837
    # in the agent lane too, and untimed: they are setup, not agent work. The
1838
    # baseline manifest is taken AFTER prepare so prepare edits are not
1839
    # miscounted as agent changes.
1840
    if setup_returncode == 0 and workflow.prepare is not None:
1841
        prepare_start = time.perf_counter()
1842
        prepare_lines: list[str] = []
1843
        for step in workflow.prepare(subject, track):
1844
            proc = run_command(step.command, repo)
1845
            prepare_lines.append("$ " + workflow_ab.command_display(step.command))
1846
            if proc.stdout:
1847
                prepare_lines.append(proc.stdout)
1848
            if proc.stderr:
1849
                prepare_lines.append(proc.stderr)
1850
            if proc.returncode not in step.expected_returncodes:
1851
                setup_returncode = proc.returncode or 1
1852
                break
1853
        setup_ms += (time.perf_counter() - prepare_start) * 1000.0
1854
        setup_output = setup_output + "\n" + "\n".join(prepare_lines)
1855
1856
    before_manifest = workspace_manifest(repo)
1857
    setup_log = artifacts / "setup.log"
1858
    setup_log.write_text(setup_output)
1859
    initial_commit_count = commit_count(subject, repo)
1860
    prompt = build_prompt(subject, workflow, track, instruction_level)
1861
    prompt_path = artifacts / "prompt.txt"
1862
    prompt_path.write_text(prompt)
1863
1864
    # VCS PATH shim: wrapper git/oak executables prepended to the agent's
1865
    # PATH record per-call timing to a sidecar JSONL. The overhead calibration
1866
    # runs first against the same sidecar, which is then reset so calibration
1867
    # rows are never attributed to the agent. Overhead is recorded, never
1868
    # subtracted (ADR-0002).
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1869
    agent_env, environment_metadata = trial_agent_env(artifacts)
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1870
    sidecar: Path | None = None
1871
    shim_overhead_ms: float | None = None
1872
    if vcs_shim:
1873
        real_binaries = shim_real_binaries(subject)
1874
        sidecar = artifacts / "vcs_shim_sidecar.jsonl"
1875
        shim_dir = create_shim_dir(
1876
            artifacts / "vcs-shim", real_binaries=real_binaries, sidecar_path=sidecar
1877
        )
1878
        real_git = real_binaries.get("git")
1879
        if real_git:
1880
            shim_overhead_ms = measure_shim_overhead(shim_dir, sidecar, real_git)
1881
        sidecar.write_text("")
1882
        agent_env["PATH"] = str(shim_dir) + os.pathsep + agent_env.get("PATH", "")
1883
1884
    if setup_returncode != 0:
1885
        stdout_path = artifacts / "agent.stdout.log"
1886
        stderr_path = artifacts / "agent.stderr.log"
1887
        stdout_path.write_text("")
1888
        stderr_path.write_text("setup failed\n")
1889
        execution = AgentExecution(setup_returncode, False, 0.0, stdout_path, stderr_path, prompt_path)
1890
    elif agent.adapter == "deterministic_mock":
1891
        execution = run_mock_agent(subject, workflow, repo, track, artifacts, prompt_path, env=agent_env)
1892
    else:
1893
        execution = run_real_agent(
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1894
            agent, repo, prompt, artifacts, timeout_seconds, agent_environment, env=agent_env,
1895
            output_limit_bytes=output_limit_bytes,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1896
        )
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1897
    if execution.capture_metadata is not None:
1898
        execution.capture_metadata.update(environment_metadata)
1899
        (artifacts / "execution.json").write_text(json.dumps(execution.capture_metadata, sort_keys=True) + "\n")
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1900
1901
    shim_info: dict[str, Any] | None = None
1902
    if vcs_shim and sidecar is not None:
1903
        shim_info = shim_metrics_from_sidecar(sidecar, shim_overhead_ms, execution.elapsed_ms)
1904
1905
    oracle = run_oracle(subject, workflow, repo, before_manifest, initial_commit_count, artifacts)
1906
    return make_row(
1907
        bench_id=bench_id,
1908
        timestamp_utc=timestamp_utc,
1909
        profile="agent-workflow",
1910
        agent=agent,
1911
        subject=subject,
1912
        workflow=workflow,
1913
        track=track,
1914
        run_index=run_index,
1915
        repo=repo,
1916
        artifacts=artifacts,
1917
        setup_ms=setup_ms,
1918
        setup_output_bytes=len(setup_output.encode("utf-8", "replace")),
1919
        execution=execution,
1920
        oracle=oracle,
1921
        prompt=prompt,
1922
        before_manifest=before_manifest,
1923
        source=source,
1924
        host_meta=host_meta,
1925
        agent_environment=agent_environment,
1926
        instruction_level=instruction_level,
1927
        shim_info=shim_info,
1928
    )
1929
1930
1931
def metric_average(rows: list[dict[str, Any]], metric_getter: Any) -> dict[tuple[str, str, str], float]:
1932
    grouped: dict[tuple[str, str, str], list[float]] = {}
1933
    for row in rows:
1934
        value = metric_getter(row)
1935
        if value is None:
1936
            continue
1937
        key = (str(row["agent"]["name"]), str(row["subject"]), str(row["scenario"]))
1938
        grouped.setdefault(key, []).append(float(value))
1939
    return {key: statistics.mean(values) for key, values in grouped.items()}
1940
1941
1942
def token_total(row: dict[str, Any]) -> float:
1943
    metrics = row["token_metrics"]
1944
    reported = metrics.get("total_tokens_reported")
1945
    if reported is not None:
1946
        return float(reported)
1947
    return float(max(1, math.ceil(float(metrics["transcript_chars"]) / 4.0)))
1948
1949
1950
def summary_text(rows: list[dict[str, Any]]) -> str:
1951
    avg_ms = metric_average(rows, lambda row: row.get("elapsed_ms"))
1952
    avg_tokens = metric_average(rows, token_total)
1953
    avg_tools = metric_average(rows, lambda row: row["tool_call_metrics"]["tool_calls_total"])
1954
    avg_output = metric_average(rows, lambda row: row.get("raw_output_bytes"))
1955
    avg_turns = metric_average(rows, lambda row: row["turn_metrics"]["assistant_turns_total"])
1956
    avg_cost = metric_average(rows, lambda row: row["token_metrics"]["cost_weighted_total"])
1957
    avg_help = metric_average(rows, lambda row: row["tool_call_metrics"]["help_calls_total"])
1958
    success_rate = metric_average(rows, lambda row: 1.0 if row.get("success") else 0.0)
1959
    keys = sorted(set(avg_ms) | set(avg_tokens) | set(avg_tools) | set(avg_output) | set(success_rate))
1960
1961
    def cell(values: dict[tuple[str, str, str], float], key: tuple[str, str, str], precision: int = 1) -> str:
1962
        value = values.get(key)
1963
        if value is None:
1964
            return "unmeasured"
1965
        return f"{value:.{precision}f}"
1966
1967
    instruction_levels = sorted({str(row["agent"].get("instruction_level")) for row in rows})
1968
    lines = [
1969
        "# Oak Real Agent Workflow Summary",
1970
        "",
1971
        "Rows are actual local agent CLI runs. Token totals prefer provider-reported usage and fall back to transcript chars / 4.",
1972
        f"Instruction levels in this run: {', '.join(f'`{level}`' for level in instruction_levels)}.",
1973
        "Cost-weighted tokens use billing direction: " + COST_WEIGHTS_NOTE + ".",
1974
        "`unmeasured` means the adapter did not expose the signal; it is not zero.",
1975
        "",
1976
        "| Agent | Scenario | Subject | Success rate | Avg ms | Avg tokens | Avg cost-weighted | Avg turns | Avg tool calls | Avg help calls | Avg output bytes |",
1977
        "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
1978
    ]
1979
    for agent_name, subject, scenario in keys:
1980
        key = (agent_name, subject, scenario)
1981
        lines.append(
1982
            f"| `{agent_name}` | `{scenario}` | `{subject}` | "
1983
            f"{success_rate.get(key, 0.0) * 100.0:.1f}% | "
1984
            f"{cell(avg_ms, key)} | "
1985
            f"{cell(avg_tokens, key)} | "
1986
            f"{cell(avg_cost, key)} | "
1987
            f"{cell(avg_turns, key)} | "
1988
            f"{cell(avg_tools, key)} | "
1989
            f"{cell(avg_help, key)} | "
1990
            f"{cell(avg_output, key, 0)} |"
1991
        )
1992
    return "\n".join(lines) + "\n"
1993
1994
1995
def main() -> int:
1996
    args = parse_args()
1997
    agents = load_agents(args.agents_config, args.agents if not args.list_agents else "all")
1998
    if args.list_agents:
1999
        print_agents(agents)
2000
        return 0
2001
2002
    subjects = workflow_ab.load_subjects(args)
2003
    workflows = select_workflows(args.workflows)
2004
2005
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
2006
    timestamp_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
2007
    args.results.mkdir(parents=True, exist_ok=True)
2008
    args.workdir.mkdir(parents=True, exist_ok=True)
2009
    run_root = args.workdir / "runs" / timestamp
2010
    artifacts_root = args.results / "artifacts" / timestamp
2011
    source = workflow_ab.source_metadata(args.oak_repo)
2012
    host_meta = {
2013
        "host": platform.node(),
2014
        "platform": platform.platform(),
2015
        "machine": platform.machine(),
2016
    }
2017
2018
    rows: list[dict[str, Any]] = []
2019
    for workflow in workflows:
2020
        fixture = workflow_ab.prepare_fixture(args.workdir, workflow)
2021
        print(f"[fixture] {workflow.name}: {workflow.description}", flush=True)
2022
        for run_index in range(args.runs):
2023
            run_agents = list(agents)
2024
            run_subjects = list(subjects)
2025
            if args.randomize_agent_order:
2026
                random.Random(f"{timestamp}:{workflow.name}:{run_index}:agents").shuffle(run_agents)
2027
            if args.randomize_subject_order:
2028
                random.Random(f"{timestamp}:{workflow.name}:{run_index}:subjects").shuffle(run_subjects)
2029
            for agent in run_agents:
2030
                for subject in run_subjects:
2031
                    print(
2032
                        f"[run] {workflow.name} run={run_index} agent={agent.name} subject={subject.name}",
2033
                        flush=True,
2034
                    )
2035
                    row = run_one(
2036
                        agent=agent,
2037
                        subject=subject,
2038
                        workflow=workflow,
2039
                        fixture=fixture,
2040
                        run_index=run_index,
2041
                        run_root=run_root,
2042
                        artifacts_root=artifacts_root,
2043
                        timeout_seconds=args.timeout_seconds,
2044
                        track=args.track,
2045
                        agent_environment=args.agent_environment,
2046
                        instruction_level=args.instruction_level,
2047
                        bench_id=timestamp,
2048
                        timestamp_utc=timestamp_iso,
2049
                        source=source,
2050
                        host_meta=host_meta,
2051
                        vcs_shim=args.vcs_shim,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
2052
                        output_limit_bytes=args.output_limit_bytes,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
2053
                    )
2054
                    rows.append(row)
2055
2056
    store = ResultsStore(args.results, lane="agent")
2057
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows))
2058
2059
    # The agent lane's full contract is the versioned schema, validated on
2060
    # every run so emitter drift fails the producing run, not the dashboard.
2061
    schema_errors: list[str] = []
2062
    for index, row in enumerate(rows, 1):
2063
        for error in validate_row(row):
2064
            schema_errors.append(f"row {index}: {error}")
2065
2066
    if not args.keep_workdirs and run_root.exists():
2067
        shutil.rmtree(run_root)
2068
2069
    print(f"[result] {raw_path}")
2070
    print(f"[summary] {summary_path}")
2071
    print(f"[artifacts] {artifacts_root}")
2072
    if schema_errors:
2073
        preview = "\n  ".join(schema_errors[:20])
2074
        print(f"[schema] FAILED agent row schema validation:\n  {preview}")
2075
        return 1
2076
    return 0
2077
2078
2079
if __name__ == "__main__":
2080
    raise SystemExit(main())