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,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
+ )