Viewing files at commit f0c34b306c79Commit detailsView latest

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