Viewing files at commit 523859ca17adCommit detailsView latest

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