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,265 @@
1
+ """Claude Code subagent transcripts.
2
+
3
+ Layout: `<root>/<project>/<session>/subagents/**/agent-*.jsonl`, with a sibling `.meta.json`.
4
+ The format has no published schema, so unknown record types are skipped, never fatal.
5
+
6
+ Privacy boundary: JSON has to be decoded a line at a time, so content passes through memory
7
+ there. `_project` copies the few allowed values out of each parsed line, and nothing else in
8
+ this module ever sees that line. Parse errors are counted and their messages discarded, because
9
+ a decoder message can quote the text it failed on.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import itertools
15
+ import json
16
+ import os
17
+ from collections import Counter
18
+ from collections.abc import Iterable, Iterator
19
+ from dataclasses import dataclass
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from agentpace import schema
25
+ from agentpace.adapters.base import ParseStats, RawRun, Source
26
+
27
+ _EFFORTS = frozenset(schema.enum("effort"))
28
+ _EDIT_TOOLS = frozenset({"Edit", "MultiEdit", "NotebookEdit", "Write"})
29
+ _TERMINAL_TOOLS = frozenset({"StructuredOutput", "SubagentHandback"})
30
+ _CLEAN_STOPS = frozenset({"end_turn", "stop_sequence"})
31
+ _SYNTHETIC_MODEL = "<synthetic>"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class _Line:
36
+ """The allowed view of one transcript line."""
37
+
38
+ role: str
39
+ time: float | None
40
+ model: str | None
41
+ message_id: str | None
42
+ tokens: int
43
+ stop_reason: str | None
44
+ tools: tuple[str, ...]
45
+ results: int
46
+ last_result_errored: bool
47
+ errors: int
48
+
49
+
50
+ def _as_int(value: Any) -> int:
51
+ return value if isinstance(value, int) and not isinstance(value, bool) else 0
52
+
53
+
54
+ def _parse_time(value: Any) -> float | None:
55
+ if not isinstance(value, str):
56
+ return None
57
+ try:
58
+ # Python 3.10 rejects a trailing Z.
59
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
60
+ except ValueError:
61
+ return None
62
+
63
+
64
+ def _project(record: Any) -> _Line | None:
65
+ """Copy allowed values out of a parsed line. The only code that touches a parsed line."""
66
+ if not isinstance(record, dict):
67
+ return None
68
+ role = record.get("type")
69
+ if role not in ("assistant", "user"):
70
+ return None
71
+ message = record.get("message")
72
+ time = _parse_time(record.get("timestamp"))
73
+ if not isinstance(message, dict):
74
+ return _Line(role, time, None, None, 0, None, (), 0, False, 0)
75
+ usage = message.get("usage")
76
+ usage = usage if isinstance(usage, dict) else {}
77
+ tools: list[str] = []
78
+ results = errors = 0
79
+ last_errored = False
80
+ content = message.get("content")
81
+ for block in content if isinstance(content, list) else ():
82
+ if not isinstance(block, dict):
83
+ continue
84
+ kind = block.get("type")
85
+ name = block.get("name")
86
+ if kind == "tool_use" and isinstance(name, str):
87
+ tools.append(name)
88
+ elif kind == "tool_result":
89
+ results += 1
90
+ last_errored = block.get("is_error") is True
91
+ errors += last_errored
92
+ model = message.get("model")
93
+ message_id = message.get("id")
94
+ stop_reason = message.get("stop_reason")
95
+ return _Line(
96
+ role=role,
97
+ time=time,
98
+ model=model if role == "assistant" and isinstance(model, str) else None,
99
+ message_id=message_id if isinstance(message_id, str) else None,
100
+ tokens=_as_int(usage.get("input_tokens")) + _as_int(usage.get("output_tokens")),
101
+ stop_reason=stop_reason if isinstance(stop_reason, str) else None,
102
+ tools=tuple(tools),
103
+ results=results,
104
+ last_result_errored=last_errored,
105
+ errors=errors,
106
+ )
107
+
108
+
109
+ def _meta_path(transcript: str) -> str:
110
+ return transcript[: -len(".jsonl")] + ".meta.json"
111
+
112
+
113
+ def _read_meta(path: str) -> tuple[str | None, bool, str | None]:
114
+ """Return only `agentType`, `finished` and `effort`. `description` and the rest stay unread.
115
+
116
+ `effort` survives only as one of the schema's values. Any other string is dropped here, so it
117
+ never reaches a row, the cache or a message.
118
+ """
119
+ try:
120
+ with open(path, encoding="utf-8") as handle:
121
+ meta = json.load(handle)
122
+ except (OSError, ValueError):
123
+ return None, False, None
124
+ if not isinstance(meta, dict):
125
+ return None, False, None
126
+ agent_type = meta.get("agentType")
127
+ effort = meta.get("effort")
128
+ return (
129
+ agent_type if isinstance(agent_type, str) else None,
130
+ meta.get("finished") is True,
131
+ effort if isinstance(effort, str) and effort in _EFFORTS else None,
132
+ )
133
+
134
+
135
+ def _outcome(
136
+ stop_reason: str | None, finished: bool, last_tool: str | None, last_errored: bool
137
+ ) -> str:
138
+ if stop_reason == "refusal":
139
+ return "failed"
140
+ if finished or last_tool in _TERMINAL_TOOLS or stop_reason in _CLEAN_STOPS:
141
+ return "completed"
142
+ return "failed" if last_errored else "unknown"
143
+
144
+
145
+ def _active(times: list[float], idle_cap_s: int) -> tuple[int, tuple[tuple[float, float], ...]]:
146
+ """Clip idle gaps, and split the run into spans wherever a gap exceeded the cap."""
147
+ active = 0.0
148
+ spans: list[tuple[float, float]] = []
149
+ span_start = times[0]
150
+ for before, after in itertools.pairwise(times):
151
+ gap = after - before
152
+ active += min(gap, idle_cap_s)
153
+ if gap > idle_cap_s:
154
+ spans.append((span_start, before))
155
+ span_start = after
156
+ spans.append((span_start, times[-1]))
157
+ return round(active), tuple(spans)
158
+
159
+
160
+ def _extended(path: Path) -> str:
161
+ """Use the extended prefix on Windows, where transcripts exceed the 260-character limit."""
162
+ absolute = os.path.abspath(path)
163
+ if os.name == "nt" and not absolute.startswith("\\\\?\\"):
164
+ return "\\\\?\\" + absolute
165
+ return absolute
166
+
167
+
168
+ class ClaudeCodeAdapter:
169
+ name = "claude-code"
170
+ # Bump whenever `parse` would return something different for the same file, so cached
171
+ # runs from the old logic are discarded.
172
+ version = 1
173
+
174
+ def default_path(self) -> Path:
175
+ return Path.home() / ".claude" / "projects"
176
+
177
+ def discover(self, path: Path) -> Iterator[Source]:
178
+ root = _extended(path)
179
+ listing: Iterable[tuple[str, list[str], list[str]]]
180
+ if os.path.isfile(root):
181
+ listing = [(os.path.dirname(root), [], [os.path.basename(root)])]
182
+ else:
183
+ listing = os.walk(root)
184
+ for directory, _, files in listing:
185
+ parts = directory.replace("\\", "/").split("/")
186
+ if "subagents" not in parts:
187
+ continue
188
+ anchor = parts.index("subagents")
189
+ if anchor < 2:
190
+ continue
191
+ for file in sorted(files):
192
+ if file.startswith("agent-") and file.endswith(".jsonl"):
193
+ transcript = os.path.join(directory, file)
194
+ yield Source(
195
+ path=transcript,
196
+ session=parts[anchor - 1],
197
+ project=parts[anchor - 2],
198
+ companions=(_meta_path(transcript),),
199
+ )
200
+
201
+ def parse(self, source: Source, idle_cap_s: int, stats: ParseStats) -> list[RawRun]:
202
+ times: list[float] = []
203
+ models: Counter[str] = Counter()
204
+ tokens_by_message: dict[str, int] = {}
205
+ tool_calls = edit_calls = errors = 0
206
+ last_tool: str | None = None
207
+ last_stop: str | None = None
208
+ last_errored = False
209
+ try:
210
+ with open(source.path, encoding="utf-8", errors="replace") as handle:
211
+ for raw in handle:
212
+ # Cheap skip of the large request-log lines, which are never needed.
213
+ if '"assistant"' not in raw and '"user"' not in raw:
214
+ continue
215
+ try:
216
+ line = _project(json.loads(raw))
217
+ except ValueError:
218
+ stats.unparsed_lines += 1
219
+ continue
220
+ if line is None:
221
+ continue
222
+ if line.time is not None:
223
+ times.append(line.time)
224
+ if line.model and line.model != _SYNTHETIC_MODEL:
225
+ models[line.model] += 1
226
+ if line.role == "assistant" and line.message_id:
227
+ # One message repeats its usage on every content block.
228
+ seen = tokens_by_message.get(line.message_id, 0)
229
+ tokens_by_message[line.message_id] = max(seen, line.tokens)
230
+ if line.stop_reason:
231
+ last_stop = line.stop_reason
232
+ tool_calls += len(line.tools)
233
+ edit_calls += sum(tool in _EDIT_TOOLS for tool in line.tools)
234
+ if line.tools:
235
+ last_tool = line.tools[-1]
236
+ if line.results:
237
+ last_errored = line.last_result_errored
238
+ errors += line.errors
239
+ except OSError:
240
+ stats.unreadable_files += 1
241
+ return []
242
+ if len(times) < 2 or not models:
243
+ stats.empty_runs += 1
244
+ return []
245
+ times.sort()
246
+ active_s, spans = _active(times, idle_cap_s)
247
+ agent_type, finished, effort = _read_meta(_meta_path(source.path))
248
+ run = RawRun(
249
+ harness=self.name,
250
+ model=models.most_common(1)[0][0],
251
+ start=times[0],
252
+ end=times[-1],
253
+ spans=spans,
254
+ active_s=active_s,
255
+ tool_calls=tool_calls,
256
+ edit_calls=edit_calls,
257
+ tokens=sum(tokens_by_message.values()) or None,
258
+ errors=errors,
259
+ outcome=_outcome(last_stop, finished, last_tool, last_errored),
260
+ effort=effort,
261
+ agent_type=agent_type,
262
+ session=source.session,
263
+ project=source.project,
264
+ )
265
+ return [run]