pluginbench 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pluginbench/__init__.py +3 -0
- pluginbench/__main__.py +3 -0
- pluginbench/assessment-charts.html +790 -0
- pluginbench/assessment.py +316 -0
- pluginbench/assessment_charts.py +1225 -0
- pluginbench/assessment_metrics.py +1956 -0
- pluginbench/assessment_models.py +127 -0
- pluginbench/baselines.py +792 -0
- pluginbench/claude.py +374 -0
- pluginbench/cli.py +672 -0
- pluginbench/code_diff.py +207 -0
- pluginbench/codex.py +451 -0
- pluginbench/codex_events.py +585 -0
- pluginbench/collectors.py +591 -0
- pluginbench/compare.py +927 -0
- pluginbench/components.py +129 -0
- pluginbench/coverage_report.py +610 -0
- pluginbench/datasets.py +207 -0
- pluginbench/evaluation.example.yaml +45 -0
- pluginbench/evaluation.py +1651 -0
- pluginbench/evaluation_report.py +1004 -0
- pluginbench/fallow_parse.mjs +101 -0
- pluginbench/harbor_backend.py +2946 -0
- pluginbench/isolation.py +314 -0
- pluginbench/judge.py +683 -0
- pluginbench/language_analysis.py +915 -0
- pluginbench/ledger.py +217 -0
- pluginbench/measurement_profiles.py +616 -0
- pluginbench/native_treatment_runtime.js +221 -0
- pluginbench/native_treatments.py +155 -0
- pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
- pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
- pluginbench/observer_data/go-analysis-v1/main.go +64 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
- pluginbench/parallel.py +101 -0
- pluginbench/processes.py +100 -0
- pluginbench/remeasure.py +992 -0
- pluginbench/run_lock.py +227 -0
- pluginbench/run_status.py +518 -0
- pluginbench/study.py +1858 -0
- pluginbench/study_review.py +924 -0
- pluginbench/submissions.py +486 -0
- pluginbench/task_sources.py +403 -0
- pluginbench/treatments.py +388 -0
- pluginbench/trial_containers.py +94 -0
- pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench/vendor/ZRENDER-LICENSE +29 -0
- pluginbench/vendor/echarts.min.js +48 -0
- pluginbench-0.5.0.dist-info/METADATA +294 -0
- pluginbench-0.5.0.dist-info/RECORD +58 -0
- pluginbench-0.5.0.dist-info/WHEEL +4 -0
- pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
- pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""Codex session event and rollout parsing: usage, models, effort and version evidence.
|
|
2
|
+
|
|
3
|
+
Kept out of `codex.py` so the vanilla cache's launcher hash covers only how attempts are
|
|
4
|
+
launched. A parser-only change re-reads saved traces; it does not change how a model was called.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def observed_cli_version(startup: object, *, host: str = "codex") -> str | None:
|
|
15
|
+
"""Return the exact agreed version from native session metadata, or unknown."""
|
|
16
|
+
if not isinstance(startup, list):
|
|
17
|
+
return None
|
|
18
|
+
if host == "claude":
|
|
19
|
+
from pluginbench import claude
|
|
20
|
+
|
|
21
|
+
return claude.observed_cli_version(startup)
|
|
22
|
+
versions: set[str] = set()
|
|
23
|
+
for event in startup:
|
|
24
|
+
if not isinstance(event, dict) or event.get("type") != "session_meta":
|
|
25
|
+
continue
|
|
26
|
+
payload = event.get("payload")
|
|
27
|
+
if not isinstance(payload, dict):
|
|
28
|
+
return None
|
|
29
|
+
version = payload.get("cli_version")
|
|
30
|
+
if not isinstance(version, str) or not version.strip():
|
|
31
|
+
return None
|
|
32
|
+
versions.add(version)
|
|
33
|
+
return next(iter(versions)) if len(versions) == 1 else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_events(
|
|
37
|
+
stdout: str,
|
|
38
|
+
traces: list[str],
|
|
39
|
+
host: str,
|
|
40
|
+
*,
|
|
41
|
+
requested_effort: str = "medium",
|
|
42
|
+
include_descendants: bool = False,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
if host == "claude":
|
|
45
|
+
from pluginbench import claude
|
|
46
|
+
|
|
47
|
+
return claude.parse_events(stdout, traces, requested_effort=requested_effort)
|
|
48
|
+
if host != "codex":
|
|
49
|
+
raise ValueError(f"unsupported native host: {host}")
|
|
50
|
+
commands: list[dict[str, Any]] = []
|
|
51
|
+
models = set()
|
|
52
|
+
usage = None
|
|
53
|
+
startup = []
|
|
54
|
+
errors: list[Any] = []
|
|
55
|
+
tools = []
|
|
56
|
+
final = []
|
|
57
|
+
successful_completion = False
|
|
58
|
+
turn_usage: list[dict[str, int]] = []
|
|
59
|
+
stdout_events: list[dict[str, Any]] = []
|
|
60
|
+
for line in stdout.splitlines():
|
|
61
|
+
try:
|
|
62
|
+
event = json.loads(line)
|
|
63
|
+
except ValueError:
|
|
64
|
+
errors.append("malformed stdout JSONL")
|
|
65
|
+
continue
|
|
66
|
+
if not isinstance(event, dict):
|
|
67
|
+
errors.append("stdout JSONL event is not an object")
|
|
68
|
+
continue
|
|
69
|
+
stdout_events.append(event)
|
|
70
|
+
item = event.get("item", {})
|
|
71
|
+
if not isinstance(item, dict):
|
|
72
|
+
errors.append("stdout item is not an object")
|
|
73
|
+
continue
|
|
74
|
+
if event.get("type") == "item.completed" and item.get("type") == "command_execution":
|
|
75
|
+
commands.append(item)
|
|
76
|
+
if item.get("type") == "agent_message" and event.get("type") == "item.completed":
|
|
77
|
+
message_text = item.get("text", "")
|
|
78
|
+
if isinstance(message_text, str):
|
|
79
|
+
final.append(message_text)
|
|
80
|
+
else:
|
|
81
|
+
errors.append("invalid agent message text")
|
|
82
|
+
if event.get("type") == "turn.completed":
|
|
83
|
+
reported = event.get("usage")
|
|
84
|
+
if isinstance(reported, dict) and all(
|
|
85
|
+
isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
86
|
+
for value in reported.values()
|
|
87
|
+
):
|
|
88
|
+
turn_usage.append(reported)
|
|
89
|
+
else:
|
|
90
|
+
errors.append("invalid turn usage")
|
|
91
|
+
successful_completion = True
|
|
92
|
+
if event.get("type") in {"error", "turn.failed"}:
|
|
93
|
+
errors.append(event)
|
|
94
|
+
if item.get("type") not in {None, "command_execution", "agent_message", "reasoning"}:
|
|
95
|
+
tools.append(item)
|
|
96
|
+
effort = set()
|
|
97
|
+
partial_usage = []
|
|
98
|
+
trace_events: list[list[dict[str, Any]]] = []
|
|
99
|
+
for trace in traces:
|
|
100
|
+
last_observed_usage = None
|
|
101
|
+
decoded_trace = []
|
|
102
|
+
for line in trace.splitlines():
|
|
103
|
+
try:
|
|
104
|
+
event = json.loads(line)
|
|
105
|
+
except ValueError:
|
|
106
|
+
errors.append("malformed trace JSONL")
|
|
107
|
+
continue
|
|
108
|
+
if not isinstance(event, dict):
|
|
109
|
+
errors.append("trace JSONL event is not an object")
|
|
110
|
+
continue
|
|
111
|
+
decoded_trace.append(event)
|
|
112
|
+
if event.get("type") == "event_msg":
|
|
113
|
+
payload = event.get("payload", {})
|
|
114
|
+
info = payload.get("info") if isinstance(payload, dict) else None
|
|
115
|
+
observed = info.get("total_token_usage") if isinstance(info, dict) else None
|
|
116
|
+
if (
|
|
117
|
+
isinstance(payload, dict)
|
|
118
|
+
and payload.get("type") == "token_count"
|
|
119
|
+
and isinstance(observed, dict)
|
|
120
|
+
):
|
|
121
|
+
if observed and all(
|
|
122
|
+
isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
123
|
+
for value in observed.values()
|
|
124
|
+
):
|
|
125
|
+
last_observed_usage = observed
|
|
126
|
+
if event.get("type") in {"session_meta", "turn_context"}:
|
|
127
|
+
payload = event.get("payload", {})
|
|
128
|
+
startup.append({"type": event["type"], "payload": payload})
|
|
129
|
+
if not isinstance(payload, dict):
|
|
130
|
+
errors.append("trace payload is not an object")
|
|
131
|
+
continue
|
|
132
|
+
if event["type"] == "turn_context":
|
|
133
|
+
if isinstance(payload.get("model"), str):
|
|
134
|
+
models.add(payload["model"])
|
|
135
|
+
elif payload.get("model") is not None:
|
|
136
|
+
errors.append("invalid trace model")
|
|
137
|
+
if isinstance(payload.get("effort"), str):
|
|
138
|
+
effort.add(payload["effort"])
|
|
139
|
+
elif payload.get("effort") is not None:
|
|
140
|
+
errors.append("invalid trace effort")
|
|
141
|
+
trace_events.append(decoded_trace)
|
|
142
|
+
if last_observed_usage is not None:
|
|
143
|
+
partial_usage.append(
|
|
144
|
+
{
|
|
145
|
+
"trace_sha256": "sha256:" + hashlib.sha256(trace.encode()).hexdigest(),
|
|
146
|
+
"tokens": last_observed_usage,
|
|
147
|
+
"status": "last observed session total; incomplete usage lower bound",
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
if turn_usage:
|
|
151
|
+
# Missing usage fields stay unknown instead of being silently undercounted.
|
|
152
|
+
common_keys = set.intersection(*(set(turn) for turn in turn_usage))
|
|
153
|
+
usage = {key: sum(turn[key] for turn in turn_usage) for key in sorted(common_keys)}
|
|
154
|
+
result = {
|
|
155
|
+
"command_items": commands,
|
|
156
|
+
"actual_primary_models": sorted(models),
|
|
157
|
+
"usage": usage,
|
|
158
|
+
"partial_usage": partial_usage if usage is None else [],
|
|
159
|
+
"native_dollars": None,
|
|
160
|
+
"dollars_status": "unknown",
|
|
161
|
+
"requested_effort": requested_effort,
|
|
162
|
+
"runtime_effort_evidence": sorted(effort),
|
|
163
|
+
"startup": startup,
|
|
164
|
+
"observed_cli_version": observed_cli_version(startup),
|
|
165
|
+
"other_tools": tools,
|
|
166
|
+
"stream_errors": errors,
|
|
167
|
+
"final": final[-1] if final else "",
|
|
168
|
+
"agent_messages": final,
|
|
169
|
+
"successful_native_completion": successful_completion and not errors,
|
|
170
|
+
}
|
|
171
|
+
if include_descendants:
|
|
172
|
+
accounting = _descendant_usage(stdout_events, trace_events, errors)
|
|
173
|
+
result.update(accounting)
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def failed_before_first_turn(execution: dict[str, Any]) -> str | None:
|
|
178
|
+
"""The error of a Codex session that failed before any model turn, else None.
|
|
179
|
+
|
|
180
|
+
Such a session, typically a network or backend outage, never let the agent work, so its
|
|
181
|
+
empty submission is not an outcome. A timeout, or a failure after any usage, command, or
|
|
182
|
+
agent message, is the agent's attempt and stays scored.
|
|
183
|
+
"""
|
|
184
|
+
if (
|
|
185
|
+
execution.get("timed_out") is True
|
|
186
|
+
or execution.get("usage") is not None
|
|
187
|
+
or any(execution.get(key) for key in ("partial_usage", "command_items", "agent_messages"))
|
|
188
|
+
):
|
|
189
|
+
return None
|
|
190
|
+
for error in execution.get("stream_errors") or []:
|
|
191
|
+
if isinstance(error, dict) and error.get("type") == "turn.failed":
|
|
192
|
+
detail = error.get("error")
|
|
193
|
+
message = detail.get("message") if isinstance(detail, dict) else None
|
|
194
|
+
return message if isinstance(message, str) and message else "turn.failed"
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _sum_usage(totals: list[dict[str, int]]) -> dict[str, int] | None:
|
|
199
|
+
if not totals:
|
|
200
|
+
return None
|
|
201
|
+
common_keys = set.intersection(*(set(total) for total in totals))
|
|
202
|
+
return {key: sum(total[key] for total in totals) for key in sorted(common_keys)}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _token_usage(value: object) -> dict[str, int] | None:
|
|
206
|
+
if (
|
|
207
|
+
not isinstance(value, dict)
|
|
208
|
+
or not value
|
|
209
|
+
or not all(
|
|
210
|
+
isinstance(count, int) and not isinstance(count, bool) and count >= 0
|
|
211
|
+
for count in value.values()
|
|
212
|
+
)
|
|
213
|
+
):
|
|
214
|
+
return None
|
|
215
|
+
return value
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# Codex versions whose forked-child rollout layout has been checked against real traces.
|
|
219
|
+
_INHERITED_HISTORY_VERSIONS = frozenset({"0.162.1", "0.167.0"})
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def admit_version(version: str) -> None:
|
|
223
|
+
"""Refuse a Codex CLI whose session traces the usage parser has not been checked against."""
|
|
224
|
+
if version not in _INHERITED_HISTORY_VERSIONS:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"Codex CLI {version} is not supported: token usage is parsed only for "
|
|
227
|
+
+ ", ".join(sorted(_INHERITED_HISTORY_VERSIONS))
|
|
228
|
+
+ ". Install a supported version, or check this version's rollouts and add it."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _session_history(
|
|
233
|
+
events: list[dict[str, Any]], reasons: set[str]
|
|
234
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]], bool] | None:
|
|
235
|
+
metadata = [event.get("payload") for event in events if event.get("type") == "session_meta"]
|
|
236
|
+
first = events[0].get("payload") if events and events[0].get("type") == "session_meta" else None
|
|
237
|
+
inherited = isinstance(first, dict) and (
|
|
238
|
+
first.get("forked_from_id") is not None
|
|
239
|
+
or first.get("history_base") is not None
|
|
240
|
+
or first.get("subagent_history_start_ordinal") is not None
|
|
241
|
+
)
|
|
242
|
+
if not inherited:
|
|
243
|
+
if len(metadata) != 1 or not isinstance(metadata[0], dict):
|
|
244
|
+
reasons.add("trace lacks one unambiguous session identity")
|
|
245
|
+
return None
|
|
246
|
+
return metadata[0], events, False
|
|
247
|
+
|
|
248
|
+
# 0.162.1 protocol.rs defines this ordinal as the first own-history record.
|
|
249
|
+
# Earlier records are inherited model context, including other session headers.
|
|
250
|
+
# 0.167.0 child rollouts keep the same header fields, boundary, and ordinals.
|
|
251
|
+
assert isinstance(first, dict)
|
|
252
|
+
boundary = first.get("subagent_history_start_ordinal")
|
|
253
|
+
source = first.get("source")
|
|
254
|
+
subagent = source.get("subagent") if isinstance(source, dict) else None
|
|
255
|
+
spawned = subagent.get("thread_spawn") if isinstance(subagent, dict) else None
|
|
256
|
+
parent = first.get("parent_thread_id")
|
|
257
|
+
path = first.get("agent_path")
|
|
258
|
+
valid = (
|
|
259
|
+
first.get("cli_version") in _INHERITED_HISTORY_VERSIONS
|
|
260
|
+
and first.get("history_mode") == "paginated"
|
|
261
|
+
and first.get("history_base") is None
|
|
262
|
+
and isinstance(parent, str)
|
|
263
|
+
and bool(parent)
|
|
264
|
+
and first.get("forked_from_id") == parent
|
|
265
|
+
and isinstance(first.get("session_id"), str)
|
|
266
|
+
and isinstance(path, str)
|
|
267
|
+
and bool(path)
|
|
268
|
+
and isinstance(spawned, dict)
|
|
269
|
+
and spawned.get("parent_thread_id") == parent
|
|
270
|
+
and spawned.get("agent_path") == path
|
|
271
|
+
and isinstance(boundary, int)
|
|
272
|
+
and not isinstance(boundary, bool)
|
|
273
|
+
and 1 <= boundary < len(events)
|
|
274
|
+
and all(
|
|
275
|
+
isinstance(event.get("ordinal"), int)
|
|
276
|
+
and not isinstance(event.get("ordinal"), bool)
|
|
277
|
+
and event["ordinal"] == ordinal
|
|
278
|
+
for ordinal, event in enumerate(events)
|
|
279
|
+
)
|
|
280
|
+
and not any(event.get("type") == "session_meta" for event in events[boundary:])
|
|
281
|
+
)
|
|
282
|
+
if not valid:
|
|
283
|
+
reasons.add(f"inherited history is unsupported for session {first.get('id')}")
|
|
284
|
+
return None
|
|
285
|
+
return first, [events[0], *events[boundary:]], True
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _descendant_usage(
|
|
289
|
+
stdout: list[dict[str, Any]],
|
|
290
|
+
traces: list[list[dict[str, Any]]],
|
|
291
|
+
stream_errors: list[Any],
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
"""Account once per native thread; unproven coverage remains a lower bound.
|
|
294
|
+
|
|
295
|
+
Codex 0.162.1 protocol.rs defines session_meta.id as the thread identity,
|
|
296
|
+
source.subagent.thread_spawn as lineage, and token_count as cumulative usage.
|
|
297
|
+
A successful task_complete closes a turn, not a delta to add to that usage.
|
|
298
|
+
"""
|
|
299
|
+
reasons: set[str] = set()
|
|
300
|
+
sessions: dict[str, dict[str, Any]] = {}
|
|
301
|
+
for events in traces:
|
|
302
|
+
history = _session_history(events, reasons)
|
|
303
|
+
if history is None:
|
|
304
|
+
continue
|
|
305
|
+
meta, events, inherited = history
|
|
306
|
+
identity = meta.get("id")
|
|
307
|
+
if not isinstance(identity, str) or not identity:
|
|
308
|
+
reasons.add("trace lacks one unambiguous session identity")
|
|
309
|
+
continue
|
|
310
|
+
previous = sessions.get(identity)
|
|
311
|
+
if previous is not None:
|
|
312
|
+
old_events = previous["events"]
|
|
313
|
+
if events[: len(old_events)] == old_events:
|
|
314
|
+
previous["events"] = events
|
|
315
|
+
elif old_events[: len(events)] != events:
|
|
316
|
+
reasons.add(f"conflicting traces for session {identity}")
|
|
317
|
+
continue
|
|
318
|
+
source = meta.get("source")
|
|
319
|
+
subagent = source.get("subagent") if isinstance(source, dict) else None
|
|
320
|
+
spawned = subagent.get("thread_spawn") if isinstance(subagent, dict) else None
|
|
321
|
+
source_parent = spawned.get("parent_thread_id") if isinstance(spawned, dict) else None
|
|
322
|
+
parent = meta.get("parent_thread_id", source_parent)
|
|
323
|
+
if parent is None:
|
|
324
|
+
parent = source_parent
|
|
325
|
+
if source_parent is not None and parent != source_parent:
|
|
326
|
+
reasons.add(f"conflicting parent identity for session {identity}")
|
|
327
|
+
if parent is not None and (not isinstance(parent, str) or not parent):
|
|
328
|
+
reasons.add(f"invalid parent identity for session {identity}")
|
|
329
|
+
parent = None
|
|
330
|
+
if subagent is not None and parent is None:
|
|
331
|
+
reasons.add(f"unresolved subagent lineage for session {identity}")
|
|
332
|
+
sessions[identity] = {
|
|
333
|
+
"events": events,
|
|
334
|
+
"parent_thread_id": parent,
|
|
335
|
+
"inherited": inherited,
|
|
336
|
+
"agent_path": meta.get("agent_path"),
|
|
337
|
+
"root_session_id": meta.get("session_id"),
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
identity_safe = not reasons
|
|
341
|
+
roots = sorted(
|
|
342
|
+
identity for identity, session in sessions.items() if session["parent_thread_id"] is None
|
|
343
|
+
)
|
|
344
|
+
root = roots[0] if len(roots) == 1 else None
|
|
345
|
+
if root is None:
|
|
346
|
+
reasons.add("expected exactly one root session")
|
|
347
|
+
identity_safe = False
|
|
348
|
+
stdout_roots = set()
|
|
349
|
+
for event in stdout:
|
|
350
|
+
if event.get("type") == "thread.started":
|
|
351
|
+
if isinstance(event.get("thread_id"), str):
|
|
352
|
+
stdout_roots.add(event["thread_id"])
|
|
353
|
+
else:
|
|
354
|
+
reasons.add("invalid stdout root identity")
|
|
355
|
+
identity_safe = False
|
|
356
|
+
if stdout_roots and stdout_roots != {root}:
|
|
357
|
+
reasons.add("stdout root identity does not match captured sessions")
|
|
358
|
+
identity_safe = False
|
|
359
|
+
|
|
360
|
+
expected_children: set[str] = set()
|
|
361
|
+
expected_agent_paths: set[tuple[str, str]] = set()
|
|
362
|
+
models: set[str] = set()
|
|
363
|
+
child_models: set[str] = set()
|
|
364
|
+
effort: set[str] = set()
|
|
365
|
+
records = []
|
|
366
|
+
for identity, session in sorted(sessions.items()):
|
|
367
|
+
prior_reasons = set(reasons)
|
|
368
|
+
parent = session["parent_thread_id"]
|
|
369
|
+
ancestors = {identity}
|
|
370
|
+
while parent is not None and parent in sessions and parent not in ancestors:
|
|
371
|
+
ancestors.add(parent)
|
|
372
|
+
parent = sessions[parent]["parent_thread_id"]
|
|
373
|
+
if parent is not None or root not in ancestors:
|
|
374
|
+
reasons.add(f"session {identity} is not linked to the root")
|
|
375
|
+
identity_safe = False
|
|
376
|
+
if session["inherited"] and session["root_session_id"] != root:
|
|
377
|
+
reasons.add(f"inherited root identity does not match session {identity}")
|
|
378
|
+
identity_safe = False
|
|
379
|
+
total: dict[str, int] | None = None
|
|
380
|
+
complete = False
|
|
381
|
+
failed = False
|
|
382
|
+
pending_spawns: set[str] = set()
|
|
383
|
+
named_spawns: dict[str, str | None] = {}
|
|
384
|
+
own_turn: str | None = None
|
|
385
|
+
for event in session["events"]:
|
|
386
|
+
payload = event.get("payload")
|
|
387
|
+
if not isinstance(payload, dict):
|
|
388
|
+
continue
|
|
389
|
+
kind = payload.get("type") if event.get("type") == "event_msg" else None
|
|
390
|
+
if event.get("type") == "response_item":
|
|
391
|
+
call_id = payload.get("call_id")
|
|
392
|
+
if (
|
|
393
|
+
payload.get("type") == "function_call"
|
|
394
|
+
and payload.get("namespace") == "collaboration"
|
|
395
|
+
and payload.get("name") == "spawn_agent"
|
|
396
|
+
):
|
|
397
|
+
if isinstance(call_id, str) and call_id:
|
|
398
|
+
if call_id in named_spawns:
|
|
399
|
+
reasons.add(f"duplicate named spawn for session {identity}")
|
|
400
|
+
named_spawns[call_id] = None
|
|
401
|
+
else:
|
|
402
|
+
reasons.add(f"unidentified named spawn for session {identity}")
|
|
403
|
+
if (
|
|
404
|
+
payload.get("type") == "function_call_output"
|
|
405
|
+
and isinstance(call_id, str)
|
|
406
|
+
and call_id in named_spawns
|
|
407
|
+
):
|
|
408
|
+
try:
|
|
409
|
+
output = (
|
|
410
|
+
json.loads(payload["output"])
|
|
411
|
+
if isinstance(payload.get("output"), str)
|
|
412
|
+
else None
|
|
413
|
+
)
|
|
414
|
+
except ValueError:
|
|
415
|
+
output = None
|
|
416
|
+
path = output.get("task_name") if isinstance(output, dict) else None
|
|
417
|
+
if not isinstance(path, str) or not path:
|
|
418
|
+
reasons.add(f"unresolved named spawn for session {identity}")
|
|
419
|
+
else:
|
|
420
|
+
if named_spawns[call_id] not in (None, path):
|
|
421
|
+
reasons.add(f"conflicting named spawn result for session {identity}")
|
|
422
|
+
named_spawns[call_id] = path
|
|
423
|
+
expected_agent_paths.add((identity, path))
|
|
424
|
+
if event.get("type") == "turn_context":
|
|
425
|
+
complete = False
|
|
426
|
+
if session["inherited"] and (
|
|
427
|
+
own_turn is None or payload.get("turn_id") != own_turn
|
|
428
|
+
):
|
|
429
|
+
reasons.add(f"unverified own turn for session {identity}")
|
|
430
|
+
identity_safe = False
|
|
431
|
+
model = payload.get("model")
|
|
432
|
+
if isinstance(model, str):
|
|
433
|
+
if identity == root:
|
|
434
|
+
models.add(model)
|
|
435
|
+
elif session["parent_thread_id"] is not None:
|
|
436
|
+
child_models.add(model)
|
|
437
|
+
if identity == root and isinstance(payload.get("effort"), str):
|
|
438
|
+
effort.add(payload["effort"])
|
|
439
|
+
if kind in {"task_started", "turn_started"}:
|
|
440
|
+
complete = False
|
|
441
|
+
own_turn = (
|
|
442
|
+
payload.get("turn_id") if isinstance(payload.get("turn_id"), str) else None
|
|
443
|
+
)
|
|
444
|
+
if kind in {"task_complete", "turn_complete"}:
|
|
445
|
+
complete = True
|
|
446
|
+
if session["inherited"] and (
|
|
447
|
+
own_turn is None or payload.get("turn_id") != own_turn
|
|
448
|
+
):
|
|
449
|
+
reasons.add(f"unverified own turn for session {identity}")
|
|
450
|
+
identity_safe = False
|
|
451
|
+
if kind in {"error", "turn_aborted", "turn_failed"}:
|
|
452
|
+
failed = True
|
|
453
|
+
if kind == "token_count":
|
|
454
|
+
info = payload.get("info")
|
|
455
|
+
observed = (
|
|
456
|
+
_token_usage(info.get("total_token_usage")) if isinstance(info, dict) else None
|
|
457
|
+
)
|
|
458
|
+
if observed is not None:
|
|
459
|
+
if session["inherited"] and own_turn is None:
|
|
460
|
+
reasons.add(f"unverified own turn for session {identity}")
|
|
461
|
+
identity_safe = False
|
|
462
|
+
if session["inherited"] and observed != total:
|
|
463
|
+
last = (
|
|
464
|
+
_token_usage(info.get("last_token_usage"))
|
|
465
|
+
if isinstance(info, dict)
|
|
466
|
+
else None
|
|
467
|
+
)
|
|
468
|
+
if last is None or any(
|
|
469
|
+
last.get(key) != value - (total.get(key, 0) if total else 0)
|
|
470
|
+
for key, value in observed.items()
|
|
471
|
+
):
|
|
472
|
+
reasons.add(f"unverified cumulative origin for session {identity}")
|
|
473
|
+
identity_safe = False
|
|
474
|
+
if total is not None and any(
|
|
475
|
+
key not in observed or observed[key] < value for key, value in total.items()
|
|
476
|
+
):
|
|
477
|
+
reasons.add(f"nonmonotonic cumulative usage for session {identity}")
|
|
478
|
+
total = observed
|
|
479
|
+
elif isinstance(info, dict):
|
|
480
|
+
reasons.add(f"invalid cumulative usage for session {identity}")
|
|
481
|
+
if kind == "collab_agent_spawn_begin":
|
|
482
|
+
call_id = payload.get("call_id")
|
|
483
|
+
if isinstance(call_id, str):
|
|
484
|
+
pending_spawns.add(call_id)
|
|
485
|
+
else:
|
|
486
|
+
reasons.add(f"unidentified spawn for session {identity}")
|
|
487
|
+
if kind == "collab_agent_spawn_end":
|
|
488
|
+
call_id = payload.get("call_id")
|
|
489
|
+
if isinstance(call_id, str):
|
|
490
|
+
pending_spawns.discard(call_id)
|
|
491
|
+
child = payload.get("new_thread_id")
|
|
492
|
+
if isinstance(child, str) and child:
|
|
493
|
+
expected_children.add(child)
|
|
494
|
+
if isinstance(payload.get("model"), str):
|
|
495
|
+
child_models.add(payload["model"])
|
|
496
|
+
if child in sessions and sessions[child]["parent_thread_id"] != identity:
|
|
497
|
+
reasons.add(f"spawn lineage does not match session {child}")
|
|
498
|
+
identity_safe = False
|
|
499
|
+
has_usage = total is not None and {"input_tokens", "output_tokens"}.issubset(total)
|
|
500
|
+
if not has_usage:
|
|
501
|
+
reasons.add(f"missing usage for session {identity}")
|
|
502
|
+
if pending_spawns:
|
|
503
|
+
reasons.add(f"unresolved spawns for session {identity}")
|
|
504
|
+
if any(path is None for path in named_spawns.values()):
|
|
505
|
+
reasons.add(f"unresolved named spawn for session {identity}")
|
|
506
|
+
if not complete or failed:
|
|
507
|
+
reasons.add(f"incomplete or failed session {identity}")
|
|
508
|
+
session_reasons = reasons - prior_reasons
|
|
509
|
+
records.append(
|
|
510
|
+
{
|
|
511
|
+
"session_id": identity,
|
|
512
|
+
"parent_thread_id": session["parent_thread_id"],
|
|
513
|
+
"observed_usage": total,
|
|
514
|
+
"status": "complete"
|
|
515
|
+
if complete and not failed and has_usage and not session_reasons
|
|
516
|
+
else "incomplete",
|
|
517
|
+
"reasons": sorted(session_reasons),
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# exec's stdout exposes collab tool receivers even if a child rollout was lost.
|
|
522
|
+
for event in stdout:
|
|
523
|
+
item = event.get("item")
|
|
524
|
+
if isinstance(item, dict) and item.get("type") == "collab_tool_call":
|
|
525
|
+
receivers = item.get("receiver_thread_ids")
|
|
526
|
+
if isinstance(receivers, list):
|
|
527
|
+
expected_children.update(value for value in receivers if isinstance(value, str))
|
|
528
|
+
for child in sorted(expected_children - sessions.keys()):
|
|
529
|
+
reasons.add(f"missing descendant session {child}")
|
|
530
|
+
for parent, path in sorted(expected_agent_paths):
|
|
531
|
+
matching = [
|
|
532
|
+
identity
|
|
533
|
+
for identity, session in sessions.items()
|
|
534
|
+
if session["parent_thread_id"] == parent and session["agent_path"] == path
|
|
535
|
+
]
|
|
536
|
+
if not matching:
|
|
537
|
+
reasons.add(f"missing descendant agent {path} from session {parent}")
|
|
538
|
+
elif len(matching) != 1:
|
|
539
|
+
reasons.add(f"conflicting descendant identities for agent {path} from session {parent}")
|
|
540
|
+
identity_safe = False
|
|
541
|
+
if stream_errors:
|
|
542
|
+
reasons.add("native event stream contains errors")
|
|
543
|
+
root_turns = [
|
|
544
|
+
reported
|
|
545
|
+
for event in stdout
|
|
546
|
+
if event.get("type") == "turn.completed"
|
|
547
|
+
if (reported := _token_usage(event.get("usage"))) is not None
|
|
548
|
+
]
|
|
549
|
+
root_usage = _sum_usage(root_turns)
|
|
550
|
+
if root is not None and root_usage is not None:
|
|
551
|
+
captured = next(
|
|
552
|
+
record["observed_usage"] for record in records if record["session_id"] == root
|
|
553
|
+
)
|
|
554
|
+
if captured is None or any(captured.get(key) != value for key, value in root_usage.items()):
|
|
555
|
+
reasons.add("root stdout usage does not match cumulative session usage")
|
|
556
|
+
observed_totals = [
|
|
557
|
+
record["observed_usage"] for record in records if record["observed_usage"] is not None
|
|
558
|
+
]
|
|
559
|
+
observed_usage = _sum_usage(observed_totals)
|
|
560
|
+
# An ambiguous identity/history can include the same work more than once.
|
|
561
|
+
# Retain individual observations, but do not label their sum a lower bound.
|
|
562
|
+
complete = not reasons
|
|
563
|
+
return {
|
|
564
|
+
"usage": observed_usage if complete else None,
|
|
565
|
+
"partial_usage": [
|
|
566
|
+
{
|
|
567
|
+
"session_id": record["session_id"],
|
|
568
|
+
"tokens": record["observed_usage"],
|
|
569
|
+
"status": "last observed session total; incomplete usage lower bound",
|
|
570
|
+
}
|
|
571
|
+
for record in records
|
|
572
|
+
if not complete and identity_safe and record["observed_usage"] is not None
|
|
573
|
+
],
|
|
574
|
+
"actual_primary_models": sorted(models),
|
|
575
|
+
"actual_descendant_models": sorted(child_models),
|
|
576
|
+
"runtime_effort_evidence": sorted(effort),
|
|
577
|
+
"usage_accounting": {
|
|
578
|
+
"status": "complete" if complete else "incomplete",
|
|
579
|
+
"scope": "root_and_descendants",
|
|
580
|
+
"root_session_id": root,
|
|
581
|
+
"reasons": sorted(reasons),
|
|
582
|
+
"sessions": records,
|
|
583
|
+
"observed_usage_lower_bound": observed_usage if identity_safe else None,
|
|
584
|
+
},
|
|
585
|
+
}
|