Viewing files at commit 63ed254f5df1Commit detailsView latest

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