agentpace 0.9.2__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.
@@ -0,0 +1,293 @@
1
+ """The Codex adapter: one agent turn is one run, and one rollout file is one batch.
2
+
3
+ Layout: `~/.codex/sessions/<year>/<month>/<day>/rollout-*.jsonl`. Every line is
4
+ `{timestamp, type, payload}`. A turn opens with an `event_msg` of type `task_started` and closes
5
+ with `task_complete` or `turn_aborted` carrying the same `turn_id`.
6
+
7
+ Privacy: `_project` is the only code that touches a parsed line, and it copies an allowlist of
8
+ keys. Session metadata yields the session id, the working folder (a hash input only, which sits in
9
+ memory and in the local cache as Claude Code folder names do, never in a row), whether the file is
10
+ a fork, and the session source reduced to a fixed word. Turn context yields the turn id, model and
11
+ an effort from the schema enum. Events yield turn ids, epoch times, an error flag, an abort reason
12
+ from a fixed set, and the type and completed/failed status of finished items. Response items yield
13
+ their type only, and token records two counters. Prompts, messages, reasoning, commands, patches,
14
+ tool arguments and outputs, instructions, git data and agent names are never copied.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ from collections.abc import Iterable, Iterator
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from agentpace import schema
27
+ from agentpace.adapters.base import ParseStats, RawRun, Source
28
+ from agentpace.adapters.claude_code import _active, _as_int, _extended, _parse_time
29
+
30
+ _EFFORTS = frozenset(schema.enum("effort"))
31
+ _SOURCES = frozenset(
32
+ {"cli", "vscode", "exec", "mcp", "custom", "review", "compact", "memory_consolidation"}
33
+ )
34
+ _SKIPPED_SOURCES = frozenset({"compact", "memory_consolidation"}) # upkeep, not agent work
35
+ _ABORT_REASONS = frozenset({"interrupted", "replaced", "review_ended", "budget_limited"})
36
+ _TOOL_ITEMS = frozenset({"function_call", "custom_tool_call", "local_shell_call"})
37
+ _STATUS_ITEMS = frozenset({"CommandExecution", "FileChange", "McpToolCall", "Extension"})
38
+ # Codex's automatic approval check on a command: seconds long, no tool call, not a task
39
+ # anyone plans. Its turns are skipped and counted, never rows.
40
+ _APPROVAL_MODEL = "codex-auto-review"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class _Line:
45
+ kind: str
46
+ time: float | None
47
+ turn_id: str | None = None
48
+ epoch: float | None = None # started_at or completed_at, when the line has one
49
+ model: str | None = None
50
+ effort: str | None = None
51
+ failed: bool = False # a task_complete error, or an item whose status is "failed"
52
+ reason: str | None = None
53
+ item: str | None = None
54
+ tokens: int = 0
55
+ session_id: str | None = None
56
+ folder: str | None = None
57
+ forked: bool = False
58
+ source: str | None = None
59
+
60
+
61
+ def _text(value: Any) -> str | None:
62
+ return value if isinstance(value, str) else None
63
+
64
+
65
+ def _epoch(value: Any) -> float | None:
66
+ return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
67
+
68
+
69
+ def _reduce_source(value: Any) -> str | None:
70
+ """A fixed word for where the session came from. Only key names are read from a dict."""
71
+ if isinstance(value, str):
72
+ return value if value in _SOURCES else None
73
+ if isinstance(value, dict) and set(value) == {"subagent"}:
74
+ inner = value["subagent"]
75
+ if isinstance(inner, dict) and "thread_spawn" in inner:
76
+ return "thread_spawn"
77
+ return "other"
78
+ return None
79
+
80
+
81
+ def _project(record: Any) -> _Line | None:
82
+ """Copy allowed values out of one parsed line. The only code that touches a parsed line."""
83
+ if not isinstance(record, dict):
84
+ return None
85
+ kind = record.get("type")
86
+ payload = record.get("payload")
87
+ if not isinstance(kind, str) or not isinstance(payload, dict):
88
+ return None
89
+ time = _parse_time(record.get("timestamp"))
90
+ if kind == "session_meta":
91
+ return _Line(
92
+ "session_meta",
93
+ time,
94
+ session_id=_text(payload.get("id")),
95
+ folder=_text(payload.get("cwd")),
96
+ forked=payload.get("forked_from_id") is not None,
97
+ source=_reduce_source(payload.get("source")),
98
+ )
99
+ if kind == "turn_context":
100
+ effort = payload.get("effort")
101
+ return _Line(
102
+ "turn_context",
103
+ time,
104
+ turn_id=_text(payload.get("turn_id")),
105
+ model=_text(payload.get("model")),
106
+ effort=effort if isinstance(effort, str) and effort in _EFFORTS else None,
107
+ )
108
+ if kind == "token_usage_record":
109
+ usage = payload.get("turn_token_usage")
110
+ usage = usage if isinstance(usage, dict) else {}
111
+ return _Line(
112
+ "tokens",
113
+ time,
114
+ turn_id=_text(payload.get("turn_id")),
115
+ tokens=_as_int(usage.get("input_tokens")) + _as_int(usage.get("output_tokens")),
116
+ )
117
+ if kind == "response_item":
118
+ return _Line("tool" if payload.get("type") in _TOOL_ITEMS else "line", time)
119
+ if kind != "event_msg":
120
+ return _Line("line", time)
121
+ event = payload.get("type")
122
+ if event == "task_started":
123
+ return _Line(
124
+ "start",
125
+ time,
126
+ turn_id=_text(payload.get("turn_id")),
127
+ epoch=_epoch(payload.get("started_at")),
128
+ )
129
+ if event == "task_complete":
130
+ return _Line(
131
+ "complete",
132
+ time,
133
+ turn_id=_text(payload.get("turn_id")),
134
+ epoch=_epoch(payload.get("completed_at")),
135
+ failed=payload.get("error") is not None,
136
+ )
137
+ if event == "turn_aborted":
138
+ reason = payload.get("reason")
139
+ return _Line(
140
+ "abort",
141
+ time,
142
+ turn_id=_text(payload.get("turn_id")),
143
+ epoch=_epoch(payload.get("completed_at")),
144
+ reason=reason if isinstance(reason, str) and reason in _ABORT_REASONS else None,
145
+ )
146
+ if event == "item_completed":
147
+ item = payload.get("item")
148
+ item = item if isinstance(item, dict) else {}
149
+ item_type = item.get("type")
150
+ status = item.get("status")
151
+ return _Line(
152
+ "item",
153
+ time,
154
+ item=item_type if item_type in _STATUS_ITEMS else None,
155
+ failed=item_type in _STATUS_ITEMS and status == "failed",
156
+ )
157
+ return _Line("line", time)
158
+
159
+
160
+ @dataclass
161
+ class _Turn:
162
+ turn_id: str | None
163
+ start: float | None
164
+ times: list[float] = field(default_factory=list)
165
+ tool_calls: int = 0
166
+ edit_calls: int = 0
167
+ errors: int = 0
168
+ tokens: int = 0
169
+ end: float | None = None
170
+ outcome: str = "unknown"
171
+
172
+
173
+ def _turns(lines: Iterable[_Line]) -> Iterator[_Turn]:
174
+ """Group lines into turns by position, closing each on its own complete or abort."""
175
+ turn: _Turn | None = None
176
+ for line in lines:
177
+ if line.kind == "start":
178
+ if turn is not None:
179
+ yield turn # a new turn began before this one closed: outcome stays unknown
180
+ turn = _Turn(line.turn_id, line.epoch if line.epoch is not None else line.time)
181
+ if turn is None:
182
+ continue
183
+ if line.time is not None:
184
+ turn.times.append(line.time)
185
+ if line.kind == "tool":
186
+ turn.tool_calls += 1
187
+ elif line.kind == "item":
188
+ turn.edit_calls += line.item == "FileChange"
189
+ turn.errors += line.failed
190
+ elif line.kind == "tokens" and line.turn_id is None:
191
+ turn.tokens = max(turn.tokens, line.tokens)
192
+ elif line.kind in ("complete", "abort"):
193
+ if line.turn_id is not None and line.turn_id != turn.turn_id:
194
+ continue # it closes some other turn, never the open one
195
+ turn.end = line.epoch if line.epoch is not None else line.time
196
+ if line.kind == "complete":
197
+ turn.outcome = "failed" if line.failed else "completed"
198
+ yield turn
199
+ turn = None
200
+ if turn is not None:
201
+ yield turn
202
+
203
+
204
+ class CodexAdapter:
205
+ name = "codex"
206
+ # Bump whenever `parse` would return something different for the same file, so cached
207
+ # runs from the old logic are discarded.
208
+ version = 2
209
+
210
+ def default_path(self) -> Path:
211
+ return Path.home() / ".codex" / "sessions"
212
+
213
+ def discover(self, path: Path) -> Iterator[Source]:
214
+ root = _extended(path)
215
+ if os.path.isfile(root):
216
+ listing: Iterable[tuple[str, list[str], list[str]]] = [
217
+ (os.path.dirname(root), [], [os.path.basename(root)])
218
+ ]
219
+ else:
220
+ listing = os.walk(root)
221
+ for directory, _, files in listing:
222
+ for file in sorted(files):
223
+ if file.startswith("rollout-") and file.endswith(".jsonl"):
224
+ # Session and project come from the file's own metadata, in `parse`.
225
+ yield Source(path=os.path.join(directory, file), session="", project="")
226
+
227
+ def parse(self, source: Source, idle_cap_s: int, stats: ParseStats) -> list[RawRun]:
228
+ lines: list[_Line] = []
229
+ try:
230
+ with open(source.path, encoding="utf-8", errors="replace") as handle:
231
+ for raw in handle:
232
+ try:
233
+ line = _project(json.loads(raw))
234
+ except ValueError:
235
+ stats.unparsed_lines += 1
236
+ continue
237
+ if line is not None:
238
+ lines.append(line)
239
+ except OSError:
240
+ stats.unreadable_files += 1
241
+ return []
242
+ metas = [line for line in lines if line.kind == "session_meta"]
243
+ if not metas or metas[0].session_id is None:
244
+ stats.empty_runs += 1
245
+ return []
246
+ first = metas[0]
247
+ # Copied history would count the same turns twice, so a fork, or a file whose metadata
248
+ # names more than one session, is skipped whole. Upkeep sessions are not agent work.
249
+ forked = any(meta.forked for meta in metas)
250
+ mixed = len({meta.session_id for meta in metas}) != 1
251
+ if forked or mixed or first.source in _SKIPPED_SOURCES:
252
+ stats.skipped_files += 1
253
+ return []
254
+ contexts = {line.turn_id: line for line in lines if line.kind == "turn_context"}
255
+ last_model = next((line.model for line in reversed(lines) if line.model), None)
256
+ tokens_by_turn: dict[str, int] = {}
257
+ for line in lines:
258
+ if line.kind == "tokens" and line.turn_id is not None:
259
+ tokens_by_turn[line.turn_id] = max(tokens_by_turn.get(line.turn_id, 0), line.tokens)
260
+ runs: list[RawRun] = []
261
+ for turn in _turns(lines):
262
+ context = contexts.get(turn.turn_id)
263
+ model = (context.model if context else None) or last_model
264
+ if model == _APPROVAL_MODEL:
265
+ stats.skipped_runs += 1
266
+ continue
267
+ times = sorted(t for t in (turn.start, *turn.times, turn.end) if t is not None)
268
+ if model is None or len(times) < 2:
269
+ stats.empty_runs += 1
270
+ continue
271
+ active_s, spans = _active(times, idle_cap_s)
272
+ tokens = max(turn.tokens, tokens_by_turn.get(turn.turn_id or "", 0))
273
+ review = first.source == "review"
274
+ runs.append(
275
+ RawRun(
276
+ harness=self.name,
277
+ model=model,
278
+ start=times[0],
279
+ end=times[-1],
280
+ spans=spans,
281
+ active_s=active_s,
282
+ tool_calls=turn.tool_calls,
283
+ edit_calls=turn.edit_calls,
284
+ tokens=tokens or None,
285
+ errors=turn.errors,
286
+ outcome=turn.outcome,
287
+ effort=context.effort if context else None,
288
+ agent_type="reviewer" if review else None,
289
+ session=first.session_id or "",
290
+ project=first.folder or "",
291
+ )
292
+ )
293
+ return runs
@@ -0,0 +1,332 @@
1
+ """The Gemini CLI adapter: one agent turn is one run, and one session file is one batch.
2
+
3
+ EXPERIMENTAL. Built from the Gemini CLI source at commit
4
+ google-gemini/gemini-cli@62bab5b232c15b925d31e655ce844ab939c882b7, cited below as `file:line`
5
+ under `packages/core/src/`, and shipped unverified against real session files.
6
+
7
+ Layout: `~/.gemini/tmp/<project>/chats/session-<time>-<id>.jsonl` for a main session, and
8
+ `chats/<parent session id>/<session id>.jsonl` for a subagent
9
+ (services/chatRecordingService.ts:797-840, config/storage.ts:54-60, 195-197, 230-234).
10
+ `GEMINI_CLI_HOME` replaces the home folder (utils/paths.ts:22-28). Each line is one JSON record
11
+ (chatRecordingService.ts:959-971): a metadata record `{sessionId, projectHash, startTime, ...}`
12
+ first (:873-882), then message records `{id, timestamp, type, content, ...}`
13
+ (services/chatRecordingTypes.ts:44-87) and `$set`, `$patch` and `$rewindTo` updates
14
+ (chatRecordingTypes.ts:123-154). A message is appended again, whole, each time its tokens or tool
15
+ calls change (chatRecordingService.ts:1068-1087, 1186, 1259), so one id can appear many times and
16
+ its last copy wins (:463). Legacy `.json` files are not read: resuming one copies its whole
17
+ history into a new `.jsonl` (:748-772), so reading both would count it twice.
18
+
19
+ What one run is: Gemini CLI writes no turn-start or turn-end event, so the turn is inferred.
20
+
21
+ - A user message opens a turn when it is not a tool response and the next user message or model
22
+ response after it is a model response. Tool results are recorded as user messages too
23
+ (core/geminiChat.ts:600-609), and context the CLI injects as user turns (the environment,
24
+ IDE context, a compression summary) is never answered by a model call of its own before the next
25
+ user message (geminiChat.ts:868-888, 1418-1437; utils/environmentContext.ts:84-111).
26
+ - A model response is a `gemini` message carrying a model, a token count or tool calls
27
+ (chatRecordingService.ts:1127-1133, 1221-1227). Copies the CLI writes when it rewrites history
28
+ carry none of them (:1579-1591).
29
+ - The turn runs until the next opening prompt. Its times are the prompt, each response and each
30
+ tool call (a tool call's time is when it finished, geminiChat.ts:1892). Info, warning and error
31
+ messages, injected context and placeholder replies add no time, because they can be written
32
+ after the human has been away.
33
+ - `completed` when the last response asked for no tool call. Otherwise `failed` when the turn holds
34
+ an `error` message, else `unknown`: an interrupted or failed stream is rolled back with a
35
+ `$rewindTo` or `$patch` (geminiChat.ts:836-851), which this adapter does not replay, so the work
36
+ done before the rollback still counts.
37
+
38
+ Privacy: `_project` is the only code that touches a parsed line, and it copies an allowlist. The
39
+ metadata yields the session id and `projectHash`, a SHA-256 of the project folder written by the
40
+ CLI itself (chatRecordingService.ts:707, utils/paths.ts:365-367), used only as the input of the
41
+ `project` pseudonym. A message yields its id, timestamp, a type from a fixed set, whether a user
42
+ message is a tool response (only the presence of a `functionResponse` part is tested), the model,
43
+ three token counters, and per tool call its id, whether its name is an edit tool, whether its
44
+ status is `error`, and its timestamp. Prompts,
45
+ replies, thoughts, tool names, arguments, results, descriptions, summaries, directories and the
46
+ memory scratchpad are never copied.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import json
52
+ import os
53
+ from collections import Counter
54
+ from collections.abc import Iterable, Iterator, Sequence
55
+ from dataclasses import dataclass, field
56
+ from pathlib import Path
57
+ from typing import Any
58
+
59
+ from agentpace.adapters.base import ParseStats, RawRun, Source
60
+ from agentpace.adapters.claude_code import _active, _as_int, _extended, _parse_time
61
+
62
+ # chatRecordingTypes.ts:72-82.
63
+ _MESSAGE_TYPES = frozenset({"user", "info", "error", "warning", "gemini"})
64
+ # tools/definitions/base-declarations.ts:61 and :65.
65
+ _EDIT_TOOLS = frozenset({"write_file", "replace"})
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class _Call:
70
+ call_id: str
71
+ edit: bool
72
+ failed: bool # status "error" (scheduler/types.ts:26-34, 206-209)
73
+ time: float | None
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class _Record:
78
+ kind: str # "meta", "message" or "update"
79
+ session_id: str | None = None
80
+ project: str | None = None
81
+ message_id: str | None = None
82
+ time: float | None = None
83
+ type: str | None = None
84
+ tool_response: bool = False
85
+ model: str | None = None
86
+ tokens: int | None = None
87
+ calls: tuple[_Call, ...] = ()
88
+
89
+
90
+ def _text(value: Any) -> str | None:
91
+ return value if isinstance(value, str) else None
92
+
93
+
94
+ def _is_tool_response(content: Any) -> bool:
95
+ """True when any part holds a `functionResponse` (utils/messageInspectors.ts:9-15)."""
96
+ if not isinstance(content, list):
97
+ return False
98
+ return any(isinstance(part, dict) and bool(part.get("functionResponse")) for part in content)
99
+
100
+
101
+ def _tokens(value: Any) -> int | None:
102
+ """Input plus output, without cache reads. `input` is the prompt count, which includes the
103
+ cached part, so `cached` is taken off (chatRecordingTypes.ts:19-26). Thought and tool-prompt
104
+ tokens are left out, as reasoning tokens are for Codex."""
105
+ if not isinstance(value, dict):
106
+ return None
107
+ prompt = _as_int(value.get("input"))
108
+ return max(prompt - _as_int(value.get("cached")), 0) + _as_int(value.get("output"))
109
+
110
+
111
+ def _calls(value: Any) -> tuple[_Call, ...]:
112
+ """Each tool call's id, edit flag, error flag and time (chatRecordingTypes.ts:54-67)."""
113
+ calls: list[_Call] = []
114
+ for call in value if isinstance(value, list) else ():
115
+ if not isinstance(call, dict) or not isinstance(call.get("id"), str):
116
+ continue
117
+ calls.append(
118
+ _Call(
119
+ call_id=call["id"],
120
+ edit=call.get("name") in _EDIT_TOOLS,
121
+ failed=call.get("status") == "error",
122
+ time=_parse_time(call.get("timestamp")),
123
+ )
124
+ )
125
+ return tuple(calls)
126
+
127
+
128
+ def _project(record: Any) -> _Record | None:
129
+ """Copy allowed values out of one parsed line. The only code that touches a parsed line.
130
+
131
+ Shapes are told apart in the order the CLI's own loader uses (chatRecordingService.ts:319-563).
132
+ None means the line matches no known shape, or names an unknown message type."""
133
+ if not isinstance(record, dict):
134
+ return None
135
+ if isinstance(record.get("$rewindTo"), str) or isinstance(record.get("$patch"), dict):
136
+ return _Record("update")
137
+ if isinstance(record.get("id"), str):
138
+ kind = record.get("type")
139
+ if kind not in _MESSAGE_TYPES:
140
+ return None
141
+ time = _parse_time(record.get("timestamp"))
142
+ if kind == "user":
143
+ return _Record(
144
+ "message",
145
+ message_id=record["id"],
146
+ time=time,
147
+ type="user",
148
+ tool_response=_is_tool_response(record.get("content")),
149
+ )
150
+ if kind == "gemini":
151
+ return _Record(
152
+ "message",
153
+ message_id=record["id"],
154
+ time=time,
155
+ type="gemini",
156
+ model=_text(record.get("model")),
157
+ tokens=_tokens(record.get("tokens")),
158
+ calls=_calls(record.get("toolCalls")),
159
+ )
160
+ return _Record("message", message_id=record["id"], time=time, type=kind)
161
+ update = record.get("$set")
162
+ if isinstance(update, dict):
163
+ session_id = _text(update.get("sessionId"))
164
+ return _Record("meta", session_id=session_id) if session_id else _Record("update")
165
+ if isinstance(record.get("sessionId"), str) and isinstance(record.get("projectHash"), str):
166
+ return _Record("meta", session_id=record["sessionId"], project=record["projectHash"])
167
+ return None
168
+
169
+
170
+ @dataclass
171
+ class _Message:
172
+ """One message id, merged across every copy of it in the file."""
173
+
174
+ type: str
175
+ time: float | None
176
+ tool_response: bool
177
+ model: str | None
178
+ tokens: int | None
179
+ calls: dict[str, _Call] = field(default_factory=dict)
180
+
181
+ @property
182
+ def response(self) -> bool:
183
+ return self.type == "gemini" and (
184
+ self.model is not None or self.tokens is not None or bool(self.calls)
185
+ )
186
+
187
+
188
+ def _merge(records: Iterable[_Record]) -> list[_Message]:
189
+ """One entry per message id, at its first position. Later copies update tokens, the model
190
+ and tool calls, as the CLI's own loader keeps the last copy."""
191
+ merged: dict[str, _Message] = {}
192
+ for record in records:
193
+ if record.kind != "message" or record.message_id is None or record.type is None:
194
+ continue
195
+ message = merged.get(record.message_id)
196
+ if message is None:
197
+ merged[record.message_id] = _Message(
198
+ record.type,
199
+ record.time,
200
+ record.tool_response,
201
+ record.model,
202
+ record.tokens,
203
+ {call.call_id: call for call in record.calls},
204
+ )
205
+ continue
206
+ if message.time is None:
207
+ message.time = record.time
208
+ message.tool_response = record.tool_response
209
+ message.model = record.model or message.model
210
+ message.tokens = record.tokens if record.tokens is not None else message.tokens
211
+ message.calls.update({call.call_id: call for call in record.calls})
212
+ return list(merged.values())
213
+
214
+
215
+ def _opens(messages: Sequence[_Message], index: int) -> bool:
216
+ message = messages[index]
217
+ if message.type != "user" or message.tool_response:
218
+ return False
219
+ for later in messages[index + 1 :]:
220
+ if later.response:
221
+ return True
222
+ if later.type == "user":
223
+ return False
224
+ return False
225
+
226
+
227
+ def _turns(messages: Sequence[_Message]) -> Iterator[tuple[_Message, Sequence[_Message]]]:
228
+ """Each opening prompt with every message up to the next one."""
229
+ openers = [index for index in range(len(messages)) if _opens(messages, index)]
230
+ for position, start in enumerate(openers):
231
+ stop = openers[position + 1] if position + 1 < len(openers) else len(messages)
232
+ yield messages[start], messages[start + 1 : stop]
233
+
234
+
235
+ class GeminiCliAdapter:
236
+ name = "gemini-cli"
237
+ # Bump whenever `parse` would return something different for the same file, so cached
238
+ # runs from the old logic are discarded.
239
+ version = 1
240
+
241
+ def default_path(self) -> Path:
242
+ home = os.environ.get("GEMINI_CLI_HOME")
243
+ return (Path(home) if home else Path.home()) / ".gemini" / "tmp"
244
+
245
+ def discover(self, path: Path) -> Iterator[Source]:
246
+ root = _extended(path)
247
+ if os.path.isfile(root):
248
+ if root.endswith(".jsonl"):
249
+ yield Source(path=root, session="", project="")
250
+ return
251
+ for directory, _, files in os.walk(root):
252
+ parts = directory.replace("\\", "/").split("/")
253
+ # A main session sits in `chats`, a subagent one level below it.
254
+ if "chats" not in parts[-2:]:
255
+ continue
256
+ for file in sorted(files):
257
+ if file.endswith(".jsonl"):
258
+ # Session and project come from the file's own metadata, in `parse`.
259
+ yield Source(path=os.path.join(directory, file), session="", project="")
260
+
261
+ def parse(self, source: Source, idle_cap_s: int, stats: ParseStats) -> list[RawRun]:
262
+ records: list[_Record] = []
263
+ try:
264
+ with open(source.path, encoding="utf-8", errors="replace") as handle:
265
+ for raw in handle:
266
+ if not raw.strip():
267
+ continue
268
+ try:
269
+ record = _project(json.loads(raw))
270
+ except ValueError:
271
+ record = None
272
+ if record is None:
273
+ stats.unparsed_lines += 1
274
+ continue
275
+ records.append(record)
276
+ except OSError:
277
+ stats.unreadable_files += 1
278
+ return []
279
+ sessions = [r.session_id for r in records if r.kind == "meta" and r.session_id]
280
+ if not sessions:
281
+ stats.empty_runs += 1
282
+ return []
283
+ if len(set(sessions)) != 1:
284
+ # Metadata naming two sessions would mix their turns, so the file is skipped whole.
285
+ stats.skipped_files += 1
286
+ return []
287
+ project = next((r.project for r in records if r.kind == "meta" and r.project), None)
288
+ messages = _merge(records)
289
+ last_model = next((m.model for m in reversed(messages) if m.model), None)
290
+ turns = list(_turns(messages))
291
+ if not turns:
292
+ stats.empty_runs += 1 # as a cached file with no runs is counted
293
+ return []
294
+ runs: list[RawRun] = []
295
+ for prompt, body in turns:
296
+ responses = [message for message in body if message.response]
297
+ calls: dict[str, _Call] = {}
298
+ for response in responses:
299
+ calls.update(response.calls)
300
+ models = Counter(response.model for response in responses if response.model)
301
+ model = models.most_common(1)[0][0] if models else last_model
302
+ stamps = [prompt.time, *(r.time for r in responses), *(c.time for c in calls.values())]
303
+ times = sorted(t for t in stamps if t is not None)
304
+ if model is None or len(times) < 2:
305
+ stats.empty_runs += 1
306
+ continue
307
+ if not responses[-1].calls:
308
+ outcome = "completed"
309
+ elif any(message.type == "error" for message in body):
310
+ outcome = "failed"
311
+ else:
312
+ outcome = "unknown"
313
+ active_s, spans = _active(times, idle_cap_s)
314
+ tokens = sum(response.tokens or 0 for response in responses)
315
+ runs.append(
316
+ RawRun(
317
+ harness=self.name,
318
+ model=model,
319
+ start=times[0],
320
+ end=times[-1],
321
+ spans=spans,
322
+ active_s=active_s,
323
+ tool_calls=len(calls),
324
+ edit_calls=sum(call.edit for call in calls.values()),
325
+ tokens=tokens or None,
326
+ errors=sum(call.failed for call in calls.values()),
327
+ outcome=outcome,
328
+ session=sessions[0],
329
+ project=project or "",
330
+ )
331
+ )
332
+ return runs