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.
- agentpace/__init__.py +3 -0
- agentpace/__main__.py +8 -0
- agentpace/_bundled/DATA-LICENSE +121 -0
- agentpace/_bundled/example-plan.json +11 -0
- agentpace/_bundled/run.schema.json +100 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W40-2026-W45.jsonl +3219 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W44-2026-W45.jsonl +211 -0
- agentpace/_bundled/turn.schema.json +30 -0
- agentpace/adapters/__init__.py +31 -0
- agentpace/adapters/base.py +74 -0
- agentpace/adapters/claude_code.py +265 -0
- agentpace/adapters/cline.py +449 -0
- agentpace/adapters/codex.py +293 -0
- agentpace/adapters/gemini_cli.py +332 -0
- agentpace/adapters/opencode.py +397 -0
- agentpace/backtest.py +801 -0
- agentpace/cache.py +115 -0
- agentpace/charts.py +191 -0
- agentpace/claims.py +164 -0
- agentpace/classify.py +54 -0
- agentpace/cli.py +754 -0
- agentpace/estimate.py +694 -0
- agentpace/forecasts.py +284 -0
- agentpace/ingest.py +337 -0
- agentpace/paths.py +62 -0
- agentpace/pseudonym.py +51 -0
- agentpace/py.typed +0 -0
- agentpace/render.py +152 -0
- agentpace/report.py +502 -0
- agentpace/schema.py +131 -0
- agentpace/share.py +130 -0
- agentpace/site.py +133 -0
- agentpace/turns.py +282 -0
- agentpace/validate.py +115 -0
- agentpace-0.9.2.dist-info/METADATA +324 -0
- agentpace-0.9.2.dist-info/RECORD +39 -0
- agentpace-0.9.2.dist-info/WHEEL +4 -0
- agentpace-0.9.2.dist-info/entry_points.txt +2 -0
- agentpace-0.9.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""The OpenCode adapter: one user message and the agent's replies to it is one run, and one
|
|
2
|
+
session is one batch.
|
|
3
|
+
|
|
4
|
+
EXPERIMENTAL. Built from the upstream source of `opencode-ai` 1.19.5, tag `v1.19.5` of
|
|
5
|
+
`anomalyco/opencode` at commit 810c0eba1a7348036f8bdf61e6632d0c36c6876c, and shipped unverified
|
|
6
|
+
against real session history. Citations below are `file:line` at that commit.
|
|
7
|
+
|
|
8
|
+
Layout. OpenCode keeps history in one SQLite database, `opencode.db`, or `opencode-<channel>.db`
|
|
9
|
+
for a preview channel (`packages/core/src/database/database.ts:43-55`). It sits in
|
|
10
|
+
`$XDG_DATA_HOME/opencode`, else `~/.local/share/opencode`, on every OS, Windows included,
|
|
11
|
+
because `xdg-basedir` 5.1.0 has no Windows branch (`packages/core/src/global.ts:3-11`). The tables
|
|
12
|
+
read here are `session`, `message` and `part` (`packages/core/src/session/sql.ts:22-98`). A message
|
|
13
|
+
row's `data` is the v1 message without its ids (`packages/core/src/session/projector.ts:77-82`),
|
|
14
|
+
and a part row's `data` is the v1 part without its ids (`projector.ts:84-87`). The shapes are in
|
|
15
|
+
`packages/schema/src/v1/session.ts`.
|
|
16
|
+
|
|
17
|
+
A run. For each user message the agent loop writes one assistant message per model step, every
|
|
18
|
+
one with `parentID` set to that user message (`packages/opencode/src/session/prompt.ts:1186-1201`),
|
|
19
|
+
and stops once a step finishes with a reason other than `tool-calls` or `unknown`
|
|
20
|
+
(`prompt.ts:1111-1130`). So the assistant messages sharing a `parentID` are one run. The human
|
|
21
|
+
wait sits before the user message, outside the run. A task subagent runs in a child session whose
|
|
22
|
+
`parent_id` names the parent (`packages/opencode/src/tool/task.ts:156-172`). Child sessions are
|
|
23
|
+
runs in their own batch, as Codex subagent threads are, and never summed into the parent turn.
|
|
24
|
+
|
|
25
|
+
Privacy: `_project_message` and `_project_part` are the only code that touches a decoded record,
|
|
26
|
+
and they copy an allowlist. From an assistant message: `parentID`, the epoch times, the model id,
|
|
27
|
+
the agent name, a variant from the schema's effort enum, a summary flag, an aborted flag, an error
|
|
28
|
+
flag, whether the finish reason is `stop`, and three token counters. From a part: its type, and
|
|
29
|
+
for a tool part whether its name is an edit tool, whether it failed (not merely interrupted), and
|
|
30
|
+
its start and end times; for a step-finish part three token counters. From a session row: its id,
|
|
31
|
+
whether it has a parent, its creation time and its folder (a hash input only, which sits in
|
|
32
|
+
memory and in the local cache as Codex working folders do, never in a row). Prompts, text,
|
|
33
|
+
reasoning, tool names beyond that comparison, tool input, output and error text, error messages,
|
|
34
|
+
titles, paths, summaries and system prompts are never copied. SQLite messages are discarded,
|
|
35
|
+
because they can quote what they failed on.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import math
|
|
42
|
+
import os
|
|
43
|
+
import sqlite3
|
|
44
|
+
from collections import Counter, defaultdict
|
|
45
|
+
from collections.abc import Iterable, Iterator
|
|
46
|
+
from contextlib import closing
|
|
47
|
+
from dataclasses import dataclass, replace
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any, Literal
|
|
50
|
+
|
|
51
|
+
from agentpace import schema
|
|
52
|
+
from agentpace.adapters.base import ParseStats, RawRun, Source
|
|
53
|
+
from agentpace.adapters.claude_code import _active
|
|
54
|
+
|
|
55
|
+
_EFFORTS = frozenset(schema.enum("effort"))
|
|
56
|
+
# `packages/schema/src/v1/session.ts:357-370`. Any other part type is counted as unknown.
|
|
57
|
+
_PART_TYPES = frozenset(
|
|
58
|
+
{
|
|
59
|
+
"text",
|
|
60
|
+
"subtask",
|
|
61
|
+
"reasoning",
|
|
62
|
+
"file",
|
|
63
|
+
"tool",
|
|
64
|
+
"step-start",
|
|
65
|
+
"step-finish",
|
|
66
|
+
"snapshot",
|
|
67
|
+
"patch",
|
|
68
|
+
"agent",
|
|
69
|
+
"retry",
|
|
70
|
+
"compaction",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
# `packages/opencode/src/tool/edit.ts:59`, `write.ts:28`, `apply_patch.ts:23`.
|
|
74
|
+
_EDIT_TOOLS = frozenset({"edit", "write", "apply_patch"})
|
|
75
|
+
_TOOL_STATUSES = frozenset({"pending", "running", "completed", "error"})
|
|
76
|
+
# `packages/schema/src/v1/session.ts:43`, set on a user interrupt (`prompt.ts:1203-1211`).
|
|
77
|
+
_ABORTED = "MessageAbortedError"
|
|
78
|
+
# The hidden agent that writes compaction summaries
|
|
79
|
+
# (`packages/opencode/src/session/compaction.ts:393-401`).
|
|
80
|
+
_COMPACTION_AGENT = "compaction"
|
|
81
|
+
# A cheap superset of tool and step-finish parts, so most text parts are never loaded. Python
|
|
82
|
+
# still decides each row by its own `type`.
|
|
83
|
+
_PART_FILTER = "SELECT message_id, time_created, data FROM part WHERE data LIKE ? OR data LIKE ?"
|
|
84
|
+
_PART_PATTERNS = ('%"tool"%', '%"step-finish"%')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class _Message:
|
|
89
|
+
role: str # "assistant" or "user". A user message is never read past its role.
|
|
90
|
+
parent: str | None = None
|
|
91
|
+
created: float | None = None
|
|
92
|
+
completed: float | None = None
|
|
93
|
+
model: str | None = None
|
|
94
|
+
agent: str | None = None
|
|
95
|
+
effort: str | None = None
|
|
96
|
+
summary: bool = False
|
|
97
|
+
aborted: bool = False
|
|
98
|
+
failed: bool = False
|
|
99
|
+
clean: bool = False # the finish reason is `stop`
|
|
100
|
+
tokens: int = 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class _Part:
|
|
105
|
+
kind: str # "tool", "step" or "other"
|
|
106
|
+
edit: bool = False
|
|
107
|
+
failed: bool = False
|
|
108
|
+
times: tuple[float, ...] = ()
|
|
109
|
+
tokens: int = 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class _Session:
|
|
114
|
+
child: bool
|
|
115
|
+
created: float | None
|
|
116
|
+
folder: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _number(value: Any) -> float | None:
|
|
120
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
121
|
+
return None
|
|
122
|
+
return float(value) if math.isfinite(value) and value >= 0 else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ms(value: Any) -> float | None:
|
|
126
|
+
number = _number(value)
|
|
127
|
+
return number / 1000 if number is not None else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _tokens(value: Any) -> int:
|
|
131
|
+
"""Input plus output plus reasoning. OpenCode stores input without cache reads and writes,
|
|
132
|
+
and output without reasoning (`packages/opencode/src/session/session.ts:361-377`), so this is
|
|
133
|
+
the provider's input plus output, as the Claude Code and Codex rules count."""
|
|
134
|
+
if not isinstance(value, dict):
|
|
135
|
+
return 0
|
|
136
|
+
return sum(int(_number(value.get(key)) or 0) for key in ("input", "output", "reasoning"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _text(value: Any) -> str | None:
|
|
140
|
+
return value if isinstance(value, str) and value else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _project_message(record: Any) -> _Message | None:
|
|
144
|
+
"""Copy allowed values out of one decoded message. None means malformed or unknown."""
|
|
145
|
+
if not isinstance(record, dict):
|
|
146
|
+
return None
|
|
147
|
+
role = record.get("role")
|
|
148
|
+
if role == "user":
|
|
149
|
+
return _Message("user")
|
|
150
|
+
if role != "assistant":
|
|
151
|
+
return None
|
|
152
|
+
time = record.get("time")
|
|
153
|
+
time = time if isinstance(time, dict) else {}
|
|
154
|
+
parent = _text(record.get("parentID"))
|
|
155
|
+
created = _ms(time.get("created"))
|
|
156
|
+
if parent is None or created is None:
|
|
157
|
+
return None
|
|
158
|
+
error = record.get("error")
|
|
159
|
+
aborted = isinstance(error, dict) and error.get("name") == _ABORTED
|
|
160
|
+
variant = record.get("variant")
|
|
161
|
+
return _Message(
|
|
162
|
+
"assistant",
|
|
163
|
+
parent=parent,
|
|
164
|
+
created=created,
|
|
165
|
+
completed=_ms(time.get("completed")),
|
|
166
|
+
model=_text(record.get("modelID")),
|
|
167
|
+
agent=_text(record.get("agent")),
|
|
168
|
+
effort=variant if isinstance(variant, str) and variant in _EFFORTS else None,
|
|
169
|
+
summary=record.get("summary") is True or record.get("agent") == _COMPACTION_AGENT,
|
|
170
|
+
aborted=aborted,
|
|
171
|
+
failed=error is not None and not aborted,
|
|
172
|
+
clean=record.get("finish") == "stop",
|
|
173
|
+
tokens=_tokens(record.get("tokens")),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _project_part(record: Any) -> _Part | None:
|
|
178
|
+
"""Copy allowed values out of one decoded part. None means malformed or unknown."""
|
|
179
|
+
if not isinstance(record, dict):
|
|
180
|
+
return None
|
|
181
|
+
kind = record.get("type")
|
|
182
|
+
if kind not in _PART_TYPES:
|
|
183
|
+
return None
|
|
184
|
+
if kind == "step-finish":
|
|
185
|
+
return _Part("step", tokens=_tokens(record.get("tokens")))
|
|
186
|
+
if kind != "tool":
|
|
187
|
+
return _Part("other")
|
|
188
|
+
state = record.get("state")
|
|
189
|
+
if not isinstance(state, dict) or state.get("status") not in _TOOL_STATUSES:
|
|
190
|
+
return None
|
|
191
|
+
time = state.get("time")
|
|
192
|
+
time = time if isinstance(time, dict) else {}
|
|
193
|
+
metadata = state.get("metadata")
|
|
194
|
+
# Tools still running when the user interrupts are closed as errors marked `interrupted`
|
|
195
|
+
# (`packages/opencode/src/session/processor.ts:591-607`). They are not failures.
|
|
196
|
+
interrupted = isinstance(metadata, dict) and metadata.get("interrupted") is True
|
|
197
|
+
times = tuple(t for t in (_ms(time.get("start")), _ms(time.get("end"))) if t is not None)
|
|
198
|
+
return _Part(
|
|
199
|
+
"tool",
|
|
200
|
+
edit=record.get("tool") in _EDIT_TOOLS,
|
|
201
|
+
failed=state.get("status") == "error" and not interrupted,
|
|
202
|
+
times=times,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _read_only_uri(path: str) -> str:
|
|
207
|
+
return Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _decode(raw: bytes) -> str:
|
|
211
|
+
return raw.decode("utf-8", errors="replace")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class _Tables:
|
|
216
|
+
sessions: dict[str, _Session]
|
|
217
|
+
messages: list[tuple[str, str, Any]] # message id, session id, data
|
|
218
|
+
parts: list[tuple[Any, Any, Any]] # message id, time created, data
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _read(path: str) -> _Tables:
|
|
222
|
+
"""Read the three tables, read-only. Raises `sqlite3.Error`, whose message is never shown."""
|
|
223
|
+
with closing(sqlite3.connect(_read_only_uri(path), uri=True)) as connection:
|
|
224
|
+
connection.text_factory = _decode
|
|
225
|
+
sessions: dict[str, _Session] = {}
|
|
226
|
+
for sid, parent, folder, created in connection.execute(
|
|
227
|
+
"SELECT id, parent_id, directory, time_created FROM session"
|
|
228
|
+
):
|
|
229
|
+
if isinstance(sid, str):
|
|
230
|
+
sessions[sid] = _Session(
|
|
231
|
+
child=parent is not None,
|
|
232
|
+
created=_ms(created),
|
|
233
|
+
folder=folder if isinstance(folder, str) else "",
|
|
234
|
+
)
|
|
235
|
+
messages = [
|
|
236
|
+
(mid, sid, data)
|
|
237
|
+
for mid, sid, data in connection.execute(
|
|
238
|
+
"SELECT id, session_id, data FROM message ORDER BY time_created, id"
|
|
239
|
+
)
|
|
240
|
+
if isinstance(mid, str) and isinstance(sid, str)
|
|
241
|
+
]
|
|
242
|
+
parts = list(connection.execute(_PART_FILTER, _PART_PATTERNS))
|
|
243
|
+
return _Tables(sessions, messages, parts)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _decoded(data: Any) -> Any:
|
|
247
|
+
"""The decoded value, or None when the text is not JSON. Decoder messages are discarded."""
|
|
248
|
+
if not isinstance(data, str):
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
return json.loads(data)
|
|
252
|
+
except ValueError:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _outcome(last: _Message) -> str:
|
|
257
|
+
"""A user interrupt is unknown, as a Codex abort is. Any other error failed. A step that ended
|
|
258
|
+
on `stop` completed. Anything else, such as `length` or no end at all, is unknown."""
|
|
259
|
+
if last.aborted:
|
|
260
|
+
return "unknown"
|
|
261
|
+
if last.failed:
|
|
262
|
+
return "failed"
|
|
263
|
+
return "completed" if last.clean and last.completed is not None else "unknown"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class OpenCodeAdapter:
|
|
267
|
+
name = "opencode"
|
|
268
|
+
# Bump whenever `parse` would return something different for the same file, so cached
|
|
269
|
+
# runs from the old logic are discarded.
|
|
270
|
+
version = 1
|
|
271
|
+
|
|
272
|
+
def default_path(self) -> Path:
|
|
273
|
+
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
274
|
+
return Path(base) / "opencode"
|
|
275
|
+
|
|
276
|
+
def discover(self, path: Path) -> Iterator[Source]:
|
|
277
|
+
root = os.path.abspath(path)
|
|
278
|
+
if os.path.isfile(root):
|
|
279
|
+
names: Iterable[str] = [os.path.basename(root)]
|
|
280
|
+
root = os.path.dirname(root)
|
|
281
|
+
else:
|
|
282
|
+
# Top level only: the data folder also holds snapshot repositories and logs.
|
|
283
|
+
try:
|
|
284
|
+
names = sorted(os.listdir(root))
|
|
285
|
+
except OSError:
|
|
286
|
+
return
|
|
287
|
+
for name in names:
|
|
288
|
+
if name == "opencode.db" or (name.startswith("opencode-") and name.endswith(".db")):
|
|
289
|
+
database = os.path.join(root, name)
|
|
290
|
+
# Recent writes sit in the write-ahead log (`database.ts:27`), so a change to it
|
|
291
|
+
# makes the cached runs stale. Session and project are set per run, in `parse`.
|
|
292
|
+
yield Source(path=database, session="", project="", companions=(database + "-wal",))
|
|
293
|
+
|
|
294
|
+
def parse(self, source: Source, idle_cap_s: int, stats: ParseStats) -> list[RawRun]:
|
|
295
|
+
try:
|
|
296
|
+
tables = _read(source.path)
|
|
297
|
+
except sqlite3.Error:
|
|
298
|
+
stats.unreadable_files += 1
|
|
299
|
+
return []
|
|
300
|
+
# Assistant messages grouped by session and the user message they answer, in row order.
|
|
301
|
+
groups: dict[tuple[str, str], list[tuple[_Message, str]]] = defaultdict(list)
|
|
302
|
+
for mid, sid, data in tables.messages:
|
|
303
|
+
message = _project_message(_decoded(data))
|
|
304
|
+
if message is None:
|
|
305
|
+
stats.unparsed_lines += 1
|
|
306
|
+
continue
|
|
307
|
+
if message.role == "assistant" and message.parent is not None:
|
|
308
|
+
groups[(sid, message.parent)].append((message, mid))
|
|
309
|
+
parts: dict[str, list[_Part]] = defaultdict(list)
|
|
310
|
+
for mid, stamp, data in tables.parts:
|
|
311
|
+
part = _project_part(_decoded(data))
|
|
312
|
+
if part is None:
|
|
313
|
+
stats.unparsed_lines += 1
|
|
314
|
+
continue
|
|
315
|
+
# The row's own creation time is when the part was first written (`projector.ts:319`).
|
|
316
|
+
created = _ms(stamp)
|
|
317
|
+
if part.kind != "other" and isinstance(mid, str):
|
|
318
|
+
extra = (created,) if created is not None else ()
|
|
319
|
+
parts[mid].append(replace(part, times=(*part.times, *extra)))
|
|
320
|
+
runs: list[RawRun] = []
|
|
321
|
+
for key, members in groups.items():
|
|
322
|
+
run = self._run(key, members, tables.sessions, parts, idle_cap_s)
|
|
323
|
+
if isinstance(run, RawRun):
|
|
324
|
+
runs.append(run)
|
|
325
|
+
elif run == "skipped":
|
|
326
|
+
stats.skipped_runs += 1
|
|
327
|
+
else:
|
|
328
|
+
stats.empty_runs += 1
|
|
329
|
+
runs.sort(key=lambda run: (run.start, run.end))
|
|
330
|
+
return runs
|
|
331
|
+
|
|
332
|
+
def _run(
|
|
333
|
+
self,
|
|
334
|
+
key: tuple[str, str],
|
|
335
|
+
pairs: list[tuple[_Message, str]],
|
|
336
|
+
sessions: dict[str, _Session],
|
|
337
|
+
parts: dict[str, list[_Part]],
|
|
338
|
+
idle_cap_s: int,
|
|
339
|
+
) -> RawRun | Literal["skipped", "empty"]:
|
|
340
|
+
session = sessions.get(key[0])
|
|
341
|
+
if session is None:
|
|
342
|
+
return "empty"
|
|
343
|
+
# A fork copies earlier messages, with their original times, into a new session
|
|
344
|
+
# (`packages/opencode/src/session/session.ts:691-731`, created at :529). A message older
|
|
345
|
+
# than its own session is such a copy, and counting it would count the run twice.
|
|
346
|
+
if session.created is not None:
|
|
347
|
+
pairs = [(m, i) for m, i in pairs if m.created is None or m.created >= session.created]
|
|
348
|
+
if not pairs:
|
|
349
|
+
return "skipped"
|
|
350
|
+
# Compaction summaries are upkeep, not agent work, as Codex compact sessions are.
|
|
351
|
+
pairs = [(m, i) for m, i in pairs if not m.summary]
|
|
352
|
+
if not pairs:
|
|
353
|
+
return "skipped"
|
|
354
|
+
models = Counter(m.model for m, _ in pairs if m.model)
|
|
355
|
+
if not models:
|
|
356
|
+
return "empty"
|
|
357
|
+
times: list[float] = []
|
|
358
|
+
tool_calls = edit_calls = errors = tokens = 0
|
|
359
|
+
for message, mid in pairs:
|
|
360
|
+
times += [t for t in (message.created, message.completed) if t is not None]
|
|
361
|
+
steps = 0
|
|
362
|
+
for part in parts.get(mid, ()):
|
|
363
|
+
times += part.times
|
|
364
|
+
if part.kind == "tool":
|
|
365
|
+
tool_calls += 1
|
|
366
|
+
edit_calls += part.edit
|
|
367
|
+
errors += part.failed
|
|
368
|
+
else:
|
|
369
|
+
steps += part.tokens
|
|
370
|
+
# Each step-finish part carries one step's usage, and the message keeps only the
|
|
371
|
+
# last step's (`packages/opencode/src/session/processor.ts:452-469`).
|
|
372
|
+
tokens += steps or message.tokens
|
|
373
|
+
times.sort()
|
|
374
|
+
if len(times) < 2:
|
|
375
|
+
return "empty"
|
|
376
|
+
active_s, spans = _active(times, idle_cap_s)
|
|
377
|
+
last = pairs[-1][0] # rows come in creation order
|
|
378
|
+
agents = Counter(m.agent for m, _ in pairs if m.agent)
|
|
379
|
+
return RawRun(
|
|
380
|
+
harness=self.name,
|
|
381
|
+
model=models.most_common(1)[0][0],
|
|
382
|
+
start=times[0],
|
|
383
|
+
end=times[-1],
|
|
384
|
+
spans=spans,
|
|
385
|
+
active_s=active_s,
|
|
386
|
+
tool_calls=tool_calls,
|
|
387
|
+
edit_calls=edit_calls,
|
|
388
|
+
tokens=tokens or None,
|
|
389
|
+
errors=errors,
|
|
390
|
+
outcome=_outcome(last),
|
|
391
|
+
effort=last.effort,
|
|
392
|
+
# A task subagent's agent name is a lookup key for classification, as Claude Code's
|
|
393
|
+
# agentType is. A top-level run has none, as a Codex turn has none.
|
|
394
|
+
agent_type=agents.most_common(1)[0][0] if session.child and agents else None,
|
|
395
|
+
session=key[0],
|
|
396
|
+
project=session.folder,
|
|
397
|
+
)
|