Viewing files at commit c10cefe7abe5Commit detailsView latest

Browse files
scripts/oakbench/stream_adapters.py 495 lines · 20.1 KB · python
1
"""Agent CLI stream parsing behind an adapter seam.
2
3
Each agent CLI emits a different event stream shape. The shared engine in
4
analyze_stream handles the lane-level concerns (turn timeline, failure trace,
5
recovery cost); a StreamAdapter supplies the per-CLI knowledge:
6
7
- how turn boundaries appear, and whether a turn's tool calls are carried
8
  inside the turn event (Claude-style: assistant message holds tool_use
9
  blocks, results follow) or precede the boundary (Codex-style:
10
  turn.completed arrives after the turn's command items);
11
- whether the absence of compaction markers is a measured zero (Claude
12
  streams emit explicit compact boundaries) or unmeasured (ADR-0002).
13
14
Adding a new agent CLI means adding an adapter and a golden transcript
15
fixture under tests/ — never editing the shared engine heuristics.
16
17
All values are None when the stream does not expose the underlying signal.
18
Never fabricate zeros here: a zero must mean "measured as zero" (ADR-0002).
19
"""
20
21
from __future__ import annotations
22
23
from dataclasses import dataclass
24
from typing import Any, Iterable
25
26
from oakbench.classify import (
27
    UNKNOWN_COMMAND_PATTERN,
28
    classify_command,
29
    unwrap_shell_command,
30
    vcs_info_family,
31
)
32
33
34
@dataclass
35
class StreamAnalysis:
36
    turn_source: str = "unmeasured"
37
    turns: list[dict[str, Any]] | None = None
38
    assistant_turns_total: int | None = None
39
    tool_calls_per_turn_avg: float | None = None
40
    max_tool_calls_per_turn: int | None = None
41
    cumulative_input_tokens: int | None = None
42
    peak_input_tokens: int | None = None
43
    context_truncation_events: int | None = None
44
    failed_tool_calls_total: int | None = None
45
    failed_vcs_commands_total: int | None = None
46
    failed_test_commands_total: int | None = None
47
    same_command_retries_total: int | None = None
48
    unknown_command_failures_total: int | None = None
49
    turns_to_recovery: int | None = None
50
    tokens_to_recovery: int | None = None
51
    vcs_info_followup_calls_total: int | None = None
52
    failed_command_examples: list[str] | None = None
53
    parallel_batches_total: int | None = None
54
    max_concurrency: int | None = None
55
56
57
def walk_json(value: Any) -> Iterable[Any]:
58
    yield value
59
    if isinstance(value, dict):
60
        for child in value.values():
61
            yield from walk_json(child)
62
    elif isinstance(value, list):
63
        for child in value:
64
            yield from walk_json(child)
65
66
67
def number(value: Any) -> int | None:
68
    if value is None or isinstance(value, bool):
69
        return None
70
    try:
71
        parsed = int(value)
72
    except (TypeError, ValueError):
73
        return None
74
    return parsed if parsed >= 0 else None
75
76
77
def first_number(mapping: dict[str, Any], keys: tuple[str, ...]) -> int | None:
78
    for key in keys:
79
        if key in mapping:
80
            value = number(mapping[key])
81
            if value is not None:
82
                return value
83
    return None
84
85
86
def usage_from_subtree(value: Any) -> dict[str, int | None] | None:
87
    input_keys = ("input_tokens", "prompt_tokens", "inputTokenCount", "promptTokens")
88
    output_keys = ("output_tokens", "completion_tokens", "outputTokenCount", "completionTokens")
89
    cache_read_keys = ("cache_read_input_tokens", "cache_read_tokens", "cached_tokens", "cached_input_tokens")
90
    for item in walk_json(value):
91
        if not isinstance(item, dict):
92
            continue
93
        if set(item).intersection(input_keys + output_keys):
94
            return {
95
                "input_tokens": first_number(item, input_keys),
96
                "output_tokens": first_number(item, output_keys),
97
                "cache_read_tokens": first_number(item, cache_read_keys),
98
            }
99
    return None
100
101
102
def is_assistant_turn_event(event: Any) -> bool:
103
    if not isinstance(event, dict):
104
        return False
105
    event_type = str(event.get("type") or "").lower()
106
    if event_type == "assistant":
107
        return True
108
    if event_type in {"turn.completed", "turn_completed"}:
109
        return True
110
    if event_type == "item.completed":
111
        item = event.get("item")
112
        if isinstance(item, dict) and str(item.get("type") or "") in {"agent_message", "assistant_message"}:
113
            return True
114
    return False
115
116
117
def assistant_event_tool_use_count(event: Any) -> int:
118
    return len(assistant_event_tool_use_refs(event))
119
120
121
def assistant_event_tool_use_refs(event: Any) -> list[str]:
122
    refs: list[str] = []
123
    anonymous = 0
124
    for item in walk_json(event):
125
        if not isinstance(item, dict) or str(item.get("type") or "") != "tool_use":
126
            continue
127
        tool_id = item.get("id")
128
        if tool_id:
129
            refs.append(str(tool_id))
130
        else:
131
            refs.append(f"anonymous:{anonymous}")
132
            anonymous += 1
133
    return refs
134
135
136
def cursor_tool_call(event: Any) -> tuple[str, str | None, bool | None] | None:
137
    if not isinstance(event, dict) or str(event.get("type") or "").lower() != "tool_call":
138
        return None
139
    tool_call = event.get("tool_call")
140
    if not isinstance(tool_call, dict):
141
        return None
142
    tool_name = next((key for key in tool_call if key.endswith("ToolCall")), None)
143
    if tool_name is None:
144
        return None
145
    payload = tool_call.get(tool_name)
146
    if not isinstance(payload, dict):
147
        return tool_name, None, None
148
149
    command = None
150
    args = payload.get("args")
151
    if isinstance(args, dict) and isinstance(args.get("command"), str):
152
        command = unwrap_shell_command(args["command"])
153
154
    failed: bool | None = None
155
    result = payload.get("result")
156
    if isinstance(result, dict):
157
        if "error" in result or "failure" in result:
158
            failed = True
159
        elif "success" in result:
160
            failed = False
161
        shell_result = result.get("success") or result.get("failure")
162
        if isinstance(shell_result, dict):
163
            if command is None and isinstance(shell_result.get("command"), str):
164
                command = unwrap_shell_command(shell_result["command"])
165
            exit_code = shell_result.get("exitCode")
166
            if isinstance(exit_code, int) and not isinstance(exit_code, bool):
167
                failed = exit_code != 0
168
    return tool_name, command, failed
169
170
171
def iter_tool_observations(event: Any) -> Iterable[tuple[bool, str]]:
172
    """Yield (failed, command_or_name) for adapter-visible tool executions with a known outcome."""
173
    cursor_call = cursor_tool_call(event)
174
    if cursor_call is not None:
175
        tool_name, command, failed = cursor_call
176
        if str(event.get("subtype") or "").lower() == "completed" and failed is not None:
177
            yield failed, command or tool_name
178
        return
179
180
    for item in walk_json(event):
181
        if not isinstance(item, dict):
182
            continue
183
        item_type = str(item.get("type") or "").lower()
184
        if item_type == "tool_result":
185
            yield bool(item.get("is_error")), str(item.get("tool_use_id") or "tool_result")
186
            continue
187
        exit_code = item.get("exit_code")
188
        if exit_code is None and item_type == "command_execution":
189
            status = str(item.get("status") or "").lower()
190
            if status == "failed":
191
                exit_code = 1
192
            elif status == "completed":
193
                exit_code = 0
194
        if exit_code is not None and not isinstance(exit_code, bool) and isinstance(exit_code, int):
195
            command = item.get("command") or item.get("cmd") or item_type or "command"
196
            yield exit_code != 0, unwrap_shell_command(str(command))
197
198
199
def event_output_text(event: Any) -> str:
200
    texts: list[str] = []
201
    for item in walk_json(event):
202
        if not isinstance(item, dict):
203
            continue
204
        for key in ("aggregated_output", "output", "stderr", "error", "content", "text"):
205
            value = item.get(key)
206
            if isinstance(value, str):
207
                texts.append(value)
208
    return "\n".join(texts)
209
210
211
class StreamAdapter:
212
    """Per-CLI stream knowledge. The base class implements the shared heuristics."""
213
214
    name = "generic"
215
    # Claude streams emit explicit compaction markers, so absence there is a
216
    # measured zero. Streams without such markers leave truncation unmeasured.
217
    missing_compaction_is_measured_zero = False
218
    # OpenAI-style usage reports cached tokens as a SUBSET of input_tokens;
219
    # Anthropic-style reports them separately. Adding cache reads to input is
220
    # double counting for the former.
221
    cache_read_included_in_input = False
222
223
    def is_turn_event(self, event: Any) -> bool:
224
        return is_assistant_turn_event(event)
225
226
    def is_session_usage_event(self, event: Any) -> bool:
227
        """Events that carry session-level usage without marking a model turn."""
228
        return False
229
230
    def turn_event_key(self, event: Any) -> str | None:
231
        """Stable key for streams that repeat one logical turn across events."""
232
        return None
233
234
    def stream_carries_tool_use_blocks(self, events: list[Any]) -> bool:
235
        # Claude-style streams carry a turn's tool_use blocks inside the
236
        # assistant event itself (results follow); Codex-style streams emit
237
        # turn boundaries after the turn's command items. Decide per stream,
238
        # not per event — mixing the two heuristics double-counts text-only turns.
239
        return any(
240
            self.is_turn_event(event) and assistant_event_tool_use_count(event) > 0 for event in events
241
        )
242
243
    def turn_tool_calls(self, event: Any, tools_since_last_turn: int, carries_tool_use: bool) -> int:
244
        if carries_tool_use:
245
            return assistant_event_tool_use_count(event)
246
        return tools_since_last_turn
247
248
249
class ClaudeStreamAdapter(StreamAdapter):
250
    name = "claude_print_stream_json"
251
    missing_compaction_is_measured_zero = True
252
253
    def turn_event_key(self, event: Any) -> str | None:
254
        if not isinstance(event, dict):
255
            return None
256
        message = event.get("message")
257
        if not isinstance(message, dict):
258
            return None
259
        message_id = message.get("id")
260
        return str(message_id) if message_id else None
261
262
263
class CodexStreamAdapter(StreamAdapter):
264
    """codex exec --json, verified against codex-cli 0.139 transcripts.
265
266
    Codex's `turn.completed` covers the WHOLE session (one per run) and its
267
    `input_tokens` already includes the cached subset (OpenAI semantics).
268
    Model round-trips are the `agent_message` items, which carry no usage —
269
    so turns come from agent_message and usage from the session event.
270
    """
271
272
    name = "codex_exec_json"
273
    cache_read_included_in_input = True
274
275
    def is_turn_event(self, event: Any) -> bool:
276
        if not isinstance(event, dict):
277
            return False
278
        if str(event.get("type") or "").lower() != "item.completed":
279
            return False
280
        item = event.get("item")
281
        return isinstance(item, dict) and str(item.get("type") or "") in {"agent_message", "assistant_message"}
282
283
    def is_session_usage_event(self, event: Any) -> bool:
284
        if not isinstance(event, dict):
285
            return False
286
        return str(event.get("type") or "").lower() in {"turn.completed", "turn_completed"}
287
288
289
class CursorStreamAdapter(StreamAdapter):
290
    name = "cursor_agent_stream_json"
291
292
293
ADAPTERS: dict[str, StreamAdapter] = {
294
    adapter.name: adapter
295
    for adapter in (ClaudeStreamAdapter(), CodexStreamAdapter(), CursorStreamAdapter())
296
}
297
298
299
def adapter_for(adapter_name: str) -> StreamAdapter:
300
    return ADAPTERS.get(adapter_name, StreamAdapter())
301
302
303
def analyze_stream(events: list[Any], adapter_name: str) -> StreamAnalysis:
304
    adapter = adapter_for(adapter_name)
305
    analysis = StreamAnalysis()
306
    if not events:
307
        return analysis
308
309
    carries_tool_use = adapter.stream_carries_tool_use_blocks(events)
310
311
    turns: list[dict[str, Any]] = []
312
    session_usages: list[dict[str, int | None]] = []
313
    # Ordered trace mixing turn boundaries and tool outcomes so recovery cost
314
    # can be measured as turns between first failure and next success.
315
    trace: list[tuple[str, Any]] = []
316
    truncations = 0
317
    tools_since_last_turn = 0
318
    tool_use_seen = False
319
    failed_examples: list[str] = []
320
    failed_total = 0
321
    failed_vcs = 0
322
    failed_test = 0
323
    unknown_failures = 0
324
    failed_commands_seen: dict[str, int] = {}
325
    same_command_retries = 0
326
    # Compact-output cost: a successful read-only VCS command immediately
327
    # re-asked in the same family with different arguments means the first
328
    # output lacked the information the agent needed. Claude-style streams
329
    # report results by tool_use_id, so the actual command text comes from a
330
    # pre-pass mapping ids to tool_use input commands; when no observation
331
    # resolves to real command text the metric stays None (ADR-0002), never a
332
    # fabricated zero.
333
    vcs_info_followups = 0
334
    previous_ok_info: tuple[str, str] | None = None
335
    commands_resolvable = False
336
    id_to_command: dict[str, str] = {}
337
    turn_key_to_index: dict[str, int] = {}
338
    turn_tool_refs: dict[str, set[str]] = {}
339
    for event in events:
340
        for item in walk_json(event):
341
            if not isinstance(item, dict) or str(item.get("type") or "") != "tool_use":
342
                continue
343
            tool_id = item.get("id")
344
            command = (item.get("input") or {}).get("command") if isinstance(item.get("input"), dict) else None
345
            if tool_id and isinstance(command, str):
346
                id_to_command[str(tool_id)] = unwrap_shell_command(command)
347
348
    for event in events:
349
        if isinstance(event, dict):
350
            event_type = str(event.get("type") or "").lower()
351
            subtype = str(event.get("subtype") or "").lower()
352
            if "compact" in event_type or "compact" in subtype or "truncat" in event_type:
353
                truncations += 1
354
355
        observed_tools = list(iter_tool_observations(event))
356
        for failed, command in observed_tools:
357
            tool_use_seen = True
358
            tools_since_last_turn += 1
359
            trace.append(("tool_fail" if failed else "tool_ok", command))
360
            resolved_command = id_to_command.get(command, command)
361
            if " " in resolved_command:
362
                commands_resolvable = True
363
            if not failed:
364
                family = vcs_info_family(resolved_command)
365
                if (
366
                    family is not None
367
                    and previous_ok_info is not None
368
                    and previous_ok_info[0] == family
369
                    and previous_ok_info[1] != resolved_command
370
                ):
371
                    vcs_info_followups += 1
372
                previous_ok_info = (family, resolved_command) if family is not None else None
373
            else:
374
                previous_ok_info = None
375
            if failed:
376
                failed_total += 1
377
                kinds = classify_command(resolved_command)
378
                if "vcs" in kinds:
379
                    failed_vcs += 1
380
                if "test" in kinds:
381
                    failed_test += 1
382
                if UNKNOWN_COMMAND_PATTERN.search(event_output_text(event)):
383
                    unknown_failures += 1
384
                if len(failed_examples) < 5:
385
                    failed_examples.append(resolved_command[:160])
386
                previous = failed_commands_seen.get(resolved_command, 0)
387
                if previous:
388
                    same_command_retries += 1
389
                failed_commands_seen[resolved_command] = previous + 1
390
391
        if adapter.is_session_usage_event(event):
392
            usage = usage_from_subtree(event)
393
            if usage is not None:
394
                session_usages.append(usage)
395
396
        if adapter.is_turn_event(event):
397
            turn_key = adapter.turn_event_key(event)
398
            tool_refs = assistant_event_tool_use_refs(event)
399
            if turn_key is not None:
400
                if turn_key in turn_key_to_index:
401
                    turn_index = turn_key_to_index[turn_key]
402
                    if carries_tool_use:
403
                        refs = turn_tool_refs.setdefault(turn_key, set())
404
                        refs.update(tool_refs)
405
                        turns[turn_index]["tool_calls_observed"] = len(refs)
406
                    usage = usage_from_subtree(event)
407
                    if usage is not None:
408
                        turn = turns[turn_index]
409
                        if turn["input_tokens"] is None:
410
                            turn["input_tokens"] = usage.get("input_tokens")
411
                        if turn["output_tokens"] is None:
412
                            turn["output_tokens"] = usage.get("output_tokens")
413
                        if turn["cache_read_tokens"] is None:
414
                            turn["cache_read_tokens"] = usage.get("cache_read_tokens")
415
                    continue
416
                turn_key_to_index[turn_key] = len(turns)
417
                if carries_tool_use:
418
                    turn_tool_refs[turn_key] = set(tool_refs)
419
            usage = usage_from_subtree(event)
420
            turns.append(
421
                {
422
                    "turn": len(turns),
423
                    "input_tokens": usage.get("input_tokens") if usage else None,
424
                    "output_tokens": usage.get("output_tokens") if usage else None,
425
                    "cache_read_tokens": usage.get("cache_read_tokens") if usage else None,
426
                    "tool_calls_observed": len(tool_refs)
427
                    if carries_tool_use
428
                    else adapter.turn_tool_calls(event, tools_since_last_turn, carries_tool_use),
429
                }
430
            )
431
            tools_since_last_turn = 0
432
            trace.append(("turn", len(turns) - 1))
433
434
    if turns:
435
        analysis.turn_source = "adapter_stream"
436
        analysis.turns = turns
437
        analysis.assistant_turns_total = len(turns)
438
        tool_counts = [int(turn["tool_calls_observed"]) for turn in turns]
439
        if tool_use_seen or any(tool_counts):
440
            analysis.tool_calls_per_turn_avg = round(sum(tool_counts) / len(turns), 3)
441
            analysis.max_tool_calls_per_turn = max(tool_counts)
442
        def input_total(usage: dict[str, Any]) -> int:
443
            base = usage["input_tokens"] or 0
444
            if adapter.cache_read_included_in_input:
445
                return base
446
            return base + (usage["cache_read_tokens"] or 0)
447
448
        input_totals = [input_total(turn) for turn in turns if turn["input_tokens"] is not None]
449
        if not input_totals and session_usages:
450
            # Session-level usage (codex-style): already cumulative across the
451
            # run's model calls; peak per round-trip is not adapter-visible.
452
            input_totals = [
453
                input_total(usage) for usage in session_usages if usage.get("input_tokens") is not None
454
            ]
455
            if input_totals:
456
                analysis.cumulative_input_tokens = sum(input_totals)
457
        elif input_totals:
458
            analysis.cumulative_input_tokens = sum(input_totals)
459
            analysis.peak_input_tokens = max(input_totals)
460
        if truncations or adapter.missing_compaction_is_measured_zero:
461
            analysis.context_truncation_events = truncations
462
463
    if tool_use_seen:
464
        analysis.failed_tool_calls_total = failed_total
465
        analysis.failed_vcs_commands_total = failed_vcs
466
        analysis.failed_test_commands_total = failed_test
467
        analysis.same_command_retries_total = same_command_retries
468
        analysis.unknown_command_failures_total = unknown_failures
469
        analysis.vcs_info_followup_calls_total = vcs_info_followups if commands_resolvable else None
470
        analysis.failed_command_examples = failed_examples
471
472
        first_failure_index = next((i for i, (kind, _) in enumerate(trace) if kind == "tool_fail"), None)
473
        if first_failure_index is not None:
474
            recovery_turns = 0
475
            recovered = False
476
            recovery_turn_ids: list[int] = []
477
            for kind, payload in trace[first_failure_index + 1 :]:
478
                if kind == "turn":
479
                    recovery_turns += 1
480
                    recovery_turn_ids.append(int(payload))
481
                elif kind == "tool_ok":
482
                    recovered = True
483
                    break
484
            if recovered:
485
                analysis.turns_to_recovery = recovery_turns
486
                if turns and recovery_turn_ids:
487
                    outputs = [
488
                        turns[turn_id]["output_tokens"]
489
                        for turn_id in recovery_turn_ids
490
                        if turn_id < len(turns) and turns[turn_id]["output_tokens"] is not None
491
                    ]
492
                    if outputs:
493
                        analysis.tokens_to_recovery = sum(outputs)
494
495
    return analysis