Viewing files at commit 1a441edafc71Commit detailsView latest

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