Viewing files at commit 2310099b696dCommit detailsView latest

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