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,449 @@
|
|
|
1
|
+
"""The Cline adapter: one agent turn is one run, and one task is one batch. EXPERIMENTAL.
|
|
2
|
+
|
|
3
|
+
Built from the upstream source, not from real history, and shipped unverified as experimental.
|
|
4
|
+
Pinned commits of `cline/cline`: `94eecbbe1a2f899c66b118d13656651b0a884e1f` (main) and
|
|
5
|
+
`7761370240ffce9e3890cb56f6c51655f3f5b0a7` (the `legacy-extension` branch, whose writer the
|
|
6
|
+
stable VSIX still runs for most users, per `apps/vscode-rollout/README.md:6` and
|
|
7
|
+
`CHANGELOG.md:492` at main).
|
|
8
|
+
|
|
9
|
+
Layout (legacy writer, `apps/vscode/src/core/storage/disk.ts:129-133,328-339@7761370`):
|
|
10
|
+
`<storage>/tasks/<task id>/ui_messages.json`, one JSON array of messages, rewritten whole on every
|
|
11
|
+
change. `<storage>/state/taskHistory.json` (`disk.ts:454`) indexes the tasks. In VS Code,
|
|
12
|
+
`<storage>` is the extension's global storage folder (`extension.ts:645`),
|
|
13
|
+
`.../Code/User/globalStorage/saoudrizwan.claude-dev`. Tasks made by the SDK bundle ("Next") live
|
|
14
|
+
in `~/.cline/data/sessions` instead (`sdk/packages/shared/src/storage/paths.ts:194-200@94eecbb`)
|
|
15
|
+
and are not read here.
|
|
16
|
+
|
|
17
|
+
A message (`apps/vscode/src/shared/ExtensionMessage.ts:123-140@7761370`) is
|
|
18
|
+
`{ts, type: "ask"|"say", ask?, say?, text?, modelInfo?, ...}`. `ts` is epoch milliseconds, set
|
|
19
|
+
once when the message is created and never changed afterwards (`core/task/index.ts:837-903,
|
|
20
|
+
859-863,1066-1078,3300`). An `ask` waits on the human (`index.ts:931-937`).
|
|
21
|
+
|
|
22
|
+
A run is one agent turn. It opens on the first message after the start of the task or after the
|
|
23
|
+
previous turn closed. It closes on the ask that hands the task back to the human: a
|
|
24
|
+
`completion_result`, `followup` or `plan_mode_respond` ask closes it as `completed`. A
|
|
25
|
+
`resume_task` or `resume_completed_task` ask means the task was left and reopened, so it closes
|
|
26
|
+
the open turn at the message before it, as `failed` when the turn's last request failed and
|
|
27
|
+
`unknown` otherwise. The same goes for a turn still open at the end of the file.
|
|
28
|
+
|
|
29
|
+
Approval waits are not agent time. Every other ask, apart from `command_output`, waits on a
|
|
30
|
+
human decision (a tool, command or MCP approval, a failed request to retry). The gap from that
|
|
31
|
+
ask to the next message is dropped whole, and the idle cap clips what remains.
|
|
32
|
+
|
|
33
|
+
Privacy: `_project` is the only code that touches a parsed message, and it copies an allowlist:
|
|
34
|
+
`ts`, `type`, the ask or say name from the fixed upstream set, `modelInfo.modelId`, two token
|
|
35
|
+
counters and a cancel reason from a fixed set out of an `api_req_started` text, and whether a
|
|
36
|
+
`tool` text names a file edit. Prompts, responses, reasoning, commands, tool arguments and
|
|
37
|
+
output, paths, images, files and the request text are never copied. `_read_index` copies only
|
|
38
|
+
each task's id and starting folder, a hash input that sits in memory and in the local cache as
|
|
39
|
+
Codex working folders do, never in a row. `_read_models` copies only model ids and times.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import sys
|
|
47
|
+
from collections import Counter
|
|
48
|
+
from collections.abc import Iterable, Iterator
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from agentpace.adapters.base import ParseStats, RawRun, Source
|
|
54
|
+
from agentpace.adapters.claude_code import _active, _as_int, _extended
|
|
55
|
+
|
|
56
|
+
# `ExtensionMessage.ts:142-160@7761370`.
|
|
57
|
+
_ASKS = frozenset(
|
|
58
|
+
{
|
|
59
|
+
"followup",
|
|
60
|
+
"plan_mode_respond",
|
|
61
|
+
"act_mode_respond",
|
|
62
|
+
"command",
|
|
63
|
+
"command_output",
|
|
64
|
+
"completion_result",
|
|
65
|
+
"tool",
|
|
66
|
+
"api_req_failed",
|
|
67
|
+
"resume_task",
|
|
68
|
+
"resume_completed_task",
|
|
69
|
+
"mistake_limit_reached",
|
|
70
|
+
"browser_action_launch",
|
|
71
|
+
"use_mcp_server",
|
|
72
|
+
"new_task",
|
|
73
|
+
"condense",
|
|
74
|
+
"summarize_task",
|
|
75
|
+
"report_bug",
|
|
76
|
+
"use_subagents",
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
# `ExtensionMessage.ts:162-200@7761370`.
|
|
80
|
+
_SAYS = frozenset(
|
|
81
|
+
{
|
|
82
|
+
"task",
|
|
83
|
+
"error",
|
|
84
|
+
"error_retry",
|
|
85
|
+
"api_req_started",
|
|
86
|
+
"api_req_finished",
|
|
87
|
+
"text",
|
|
88
|
+
"reasoning",
|
|
89
|
+
"completion_result",
|
|
90
|
+
"user_feedback",
|
|
91
|
+
"user_feedback_diff",
|
|
92
|
+
"api_req_retried",
|
|
93
|
+
"command",
|
|
94
|
+
"command_output",
|
|
95
|
+
"tool",
|
|
96
|
+
"shell_integration_warning",
|
|
97
|
+
"shell_integration_warning_with_suggestion",
|
|
98
|
+
"browser_action_launch",
|
|
99
|
+
"browser_action",
|
|
100
|
+
"browser_action_result",
|
|
101
|
+
"mcp_server_request_started",
|
|
102
|
+
"mcp_server_response",
|
|
103
|
+
"mcp_notification",
|
|
104
|
+
"use_mcp_server",
|
|
105
|
+
"diff_error",
|
|
106
|
+
"deleted_api_reqs",
|
|
107
|
+
"clineignore_error",
|
|
108
|
+
"command_permission_denied",
|
|
109
|
+
"checkpoint_created",
|
|
110
|
+
"load_mcp_documentation",
|
|
111
|
+
"generate_explanation",
|
|
112
|
+
"info",
|
|
113
|
+
"task_progress",
|
|
114
|
+
"hook_status",
|
|
115
|
+
"hook_output_stream",
|
|
116
|
+
"subagent",
|
|
117
|
+
"use_subagents",
|
|
118
|
+
"subagent_usage",
|
|
119
|
+
"conditional_rules_applied",
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
_COMPLETING_ASKS = frozenset({"completion_result", "followup", "plan_mode_respond"})
|
|
123
|
+
_RESUME_ASKS = frozenset({"resume_task", "resume_completed_task"})
|
|
124
|
+
# A command still running, not a human decision (`index.ts:907-912`).
|
|
125
|
+
_NOT_WAITS = frozenset({"command_output"})
|
|
126
|
+
# Each tool use leaves one message, a `say` when auto-approved and an `ask` otherwise
|
|
127
|
+
# (`WriteToFileToolHandler.ts:205-247`, `ExecuteCommandToolHandler.ts:83,231`,
|
|
128
|
+
# `UseMcpToolHandler.ts:42,96@7761370`).
|
|
129
|
+
_TOOL_NAMES = frozenset({"tool", "command", "use_mcp_server", "browser_action_launch"})
|
|
130
|
+
_EDIT_TOOLS = frozenset({"editedExistingFile", "newFileCreated", "fileDeleted"})
|
|
131
|
+
_ERROR_SAYS = frozenset({"error", "diff_error", "clineignore_error", "command_permission_denied"})
|
|
132
|
+
_ERROR_ASKS = frozenset({"api_req_failed", "mistake_limit_reached"})
|
|
133
|
+
# `ExtensionMessage.ts:376@7761370`. `user_cancelled` is an interruption, not a failure.
|
|
134
|
+
_CANCEL_REASONS = frozenset({"streaming_failed", "user_cancelled", "retries_exhausted"})
|
|
135
|
+
_FAILED_CANCELS = frozenset({"streaming_failed", "retries_exhausted"})
|
|
136
|
+
_MESSAGES = "ui_messages.json"
|
|
137
|
+
_METADATA = "task_metadata.json"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class _Message:
|
|
142
|
+
time: float
|
|
143
|
+
kind: str # "ask" or "say"
|
|
144
|
+
name: str # one of `_ASKS` or `_SAYS`
|
|
145
|
+
model: str | None = None
|
|
146
|
+
tokens: int = 0
|
|
147
|
+
cancel: str | None = None
|
|
148
|
+
edit: bool = False
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def waits(self) -> bool:
|
|
152
|
+
"""The gap after this message is a human decision, never agent time."""
|
|
153
|
+
return (
|
|
154
|
+
self.kind == "ask"
|
|
155
|
+
and self.name not in _NOT_WAITS
|
|
156
|
+
and self.name not in _COMPLETING_ASKS
|
|
157
|
+
and self.name not in _RESUME_ASKS
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _seconds(value: Any) -> float | None:
|
|
162
|
+
"""Epoch milliseconds, as Cline writes them, in seconds. None for anything else."""
|
|
163
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
|
|
164
|
+
return value / 1000
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _text_json(value: Any) -> dict[str, Any]:
|
|
169
|
+
"""Decode a message's `text` when it holds a JSON object. Decoder messages are discarded,
|
|
170
|
+
because they can quote the text they failed on."""
|
|
171
|
+
if not isinstance(value, str):
|
|
172
|
+
return {}
|
|
173
|
+
try:
|
|
174
|
+
decoded = json.loads(value)
|
|
175
|
+
except (ValueError, RecursionError):
|
|
176
|
+
return {}
|
|
177
|
+
return decoded if isinstance(decoded, dict) else {}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _project(record: Any) -> _Message | None:
|
|
181
|
+
"""Copy allowed values out of one parsed message. The only code that touches a message.
|
|
182
|
+
|
|
183
|
+
None means malformed or unknown: the caller counts it and skips it.
|
|
184
|
+
"""
|
|
185
|
+
if not isinstance(record, dict):
|
|
186
|
+
return None
|
|
187
|
+
time = _seconds(record.get("ts"))
|
|
188
|
+
if time is None:
|
|
189
|
+
return None
|
|
190
|
+
kind = record.get("type")
|
|
191
|
+
if kind == "ask":
|
|
192
|
+
name = record.get("ask")
|
|
193
|
+
known = _ASKS
|
|
194
|
+
elif kind == "say":
|
|
195
|
+
name = record.get("say")
|
|
196
|
+
known = _SAYS
|
|
197
|
+
else:
|
|
198
|
+
return None
|
|
199
|
+
if not isinstance(name, str) or name not in known:
|
|
200
|
+
return None
|
|
201
|
+
info = record.get("modelInfo")
|
|
202
|
+
model = info.get("modelId") if isinstance(info, dict) else None
|
|
203
|
+
tokens = 0
|
|
204
|
+
cancel = None
|
|
205
|
+
edit = False
|
|
206
|
+
if kind == "say" and name == "api_req_started":
|
|
207
|
+
# `ClineApiReqInfo`, `ExtensionMessage.ts:350-365`, written by `core/task/utils.ts:28-49`.
|
|
208
|
+
request = _text_json(record.get("text"))
|
|
209
|
+
tokens = _as_int(request.get("tokensIn")) + _as_int(request.get("tokensOut"))
|
|
210
|
+
reason = request.get("cancelReason")
|
|
211
|
+
cancel = reason if isinstance(reason, str) and reason in _CANCEL_REASONS else None
|
|
212
|
+
elif name == "tool":
|
|
213
|
+
# `ClineSayTool.tool`, `ExtensionMessage.ts:202-215`. Only a fixed name survives.
|
|
214
|
+
edit = _text_json(record.get("text")).get("tool") in _EDIT_TOOLS
|
|
215
|
+
return _Message(
|
|
216
|
+
time=time,
|
|
217
|
+
kind=kind,
|
|
218
|
+
name=name,
|
|
219
|
+
model=model if isinstance(model, str) and model else None,
|
|
220
|
+
tokens=tokens,
|
|
221
|
+
cancel=cancel,
|
|
222
|
+
edit=edit,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class _Turn:
|
|
228
|
+
messages: list[_Message] = field(default_factory=list)
|
|
229
|
+
failing: bool = False # the latest request failed and nothing has succeeded since
|
|
230
|
+
outcome: str = "unknown"
|
|
231
|
+
|
|
232
|
+
def add(self, message: _Message) -> None:
|
|
233
|
+
self.messages.append(message)
|
|
234
|
+
if message.name == "api_req_started":
|
|
235
|
+
self.failing = message.cancel in _FAILED_CANCELS
|
|
236
|
+
elif message.kind == "ask" and message.name in _ERROR_ASKS:
|
|
237
|
+
self.failing = True
|
|
238
|
+
|
|
239
|
+
def left_open(self) -> _Turn:
|
|
240
|
+
self.outcome = "failed" if self.failing else "unknown"
|
|
241
|
+
return self
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _turns(messages: Iterable[_Message]) -> Iterator[_Turn]:
|
|
245
|
+
"""Group messages into turns by position, closing each on the ask that hands back."""
|
|
246
|
+
turn: _Turn | None = None
|
|
247
|
+
for message in messages:
|
|
248
|
+
resume = message.kind == "ask" and message.name in _RESUME_ASKS
|
|
249
|
+
completing = message.kind == "ask" and message.name in _COMPLETING_ASKS
|
|
250
|
+
if resume:
|
|
251
|
+
if turn is not None:
|
|
252
|
+
yield turn.left_open() # the turn ends at the message before the reopening
|
|
253
|
+
turn = None
|
|
254
|
+
continue
|
|
255
|
+
if turn is None:
|
|
256
|
+
if completing:
|
|
257
|
+
continue # nothing open to close
|
|
258
|
+
turn = _Turn()
|
|
259
|
+
turn.add(message)
|
|
260
|
+
if completing:
|
|
261
|
+
turn.outcome = "completed"
|
|
262
|
+
yield turn
|
|
263
|
+
turn = None
|
|
264
|
+
if turn is not None:
|
|
265
|
+
yield turn.left_open()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _timing(
|
|
269
|
+
messages: list[_Message], idle_cap_s: int
|
|
270
|
+
) -> tuple[int, tuple[tuple[float, float], ...]]:
|
|
271
|
+
"""Active seconds and spans, with every approval wait dropped and other gaps capped."""
|
|
272
|
+
segments: list[list[float]] = [[]]
|
|
273
|
+
for message in messages:
|
|
274
|
+
segments[-1].append(message.time)
|
|
275
|
+
if message.waits:
|
|
276
|
+
segments.append([])
|
|
277
|
+
active = 0
|
|
278
|
+
spans: list[tuple[float, float]] = []
|
|
279
|
+
for segment in segments:
|
|
280
|
+
if not segment:
|
|
281
|
+
continue
|
|
282
|
+
seconds, parts = _active(sorted(segment), idle_cap_s)
|
|
283
|
+
active += seconds
|
|
284
|
+
spans.extend(parts)
|
|
285
|
+
return active, tuple(sorted(spans))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _read_json(path: str) -> Any:
|
|
289
|
+
"""A whole JSON file, or None. Error messages are discarded, since they can quote content."""
|
|
290
|
+
try:
|
|
291
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
292
|
+
return json.load(handle)
|
|
293
|
+
except (OSError, ValueError, RecursionError):
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _read_index(path: str) -> dict[str, str]:
|
|
298
|
+
"""Task id to starting folder, from `state/taskHistory.json` (`HistoryItem.ts:2,14@7761370`).
|
|
299
|
+
|
|
300
|
+
Each entry also holds the task's prompt and costs. Only `id` and `cwdOnTaskInitialization`
|
|
301
|
+
are copied.
|
|
302
|
+
"""
|
|
303
|
+
folders: dict[str, str] = {}
|
|
304
|
+
items = _read_json(path)
|
|
305
|
+
for item in items if isinstance(items, list) else ():
|
|
306
|
+
if not isinstance(item, dict):
|
|
307
|
+
continue
|
|
308
|
+
task_id = item.get("id")
|
|
309
|
+
folder = item.get("cwdOnTaskInitialization")
|
|
310
|
+
if isinstance(task_id, str) and isinstance(folder, str):
|
|
311
|
+
folders[task_id] = folder
|
|
312
|
+
return folders
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _read_models(path: str) -> list[tuple[float, str]]:
|
|
316
|
+
"""`(time, model id)` pairs from `task_metadata.json` `model_usage`
|
|
317
|
+
(`ContextTrackerTypes.ts:11-32`, written by `ModelContextTracker.ts:28-33@7761370`).
|
|
318
|
+
File paths, environment details and provider ids in the same file are never copied."""
|
|
319
|
+
meta = _read_json(path)
|
|
320
|
+
usage = meta.get("model_usage") if isinstance(meta, dict) else None
|
|
321
|
+
models: list[tuple[float, str]] = []
|
|
322
|
+
for entry in usage if isinstance(usage, list) else ():
|
|
323
|
+
if not isinstance(entry, dict):
|
|
324
|
+
continue
|
|
325
|
+
time = _seconds(entry.get("ts"))
|
|
326
|
+
model = entry.get("model_id")
|
|
327
|
+
if time is not None and isinstance(model, str) and model:
|
|
328
|
+
models.append((time, model))
|
|
329
|
+
return sorted(models)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _fallback_model(models: list[tuple[float, str]], start: float) -> str | None:
|
|
333
|
+
"""The model in use when the turn started, else the first one recorded."""
|
|
334
|
+
chosen = None
|
|
335
|
+
for time, model in models:
|
|
336
|
+
if time <= start or chosen is None:
|
|
337
|
+
chosen = model
|
|
338
|
+
return chosen
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _vscode_user_dir() -> Path:
|
|
342
|
+
"""VS Code's per-user folder. This is VS Code's convention, not Cline's, and is unverified."""
|
|
343
|
+
if sys.platform == "win32":
|
|
344
|
+
appdata = os.environ.get("APPDATA")
|
|
345
|
+
return (Path(appdata) if appdata else Path.home() / "AppData" / "Roaming") / "Code" / "User"
|
|
346
|
+
if sys.platform == "darwin":
|
|
347
|
+
return Path.home() / "Library" / "Application Support" / "Code" / "User"
|
|
348
|
+
config = os.environ.get("XDG_CONFIG_HOME")
|
|
349
|
+
return (Path(config) if config else Path.home() / ".config") / "Code" / "User"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class ClineAdapter:
|
|
353
|
+
name = "cline"
|
|
354
|
+
# Bump whenever `parse` would return something different for the same file, so cached
|
|
355
|
+
# runs from the old logic are discarded.
|
|
356
|
+
version = 1
|
|
357
|
+
|
|
358
|
+
def default_path(self) -> Path:
|
|
359
|
+
# `<publisher>.<name>` from `apps/vscode/package.json:2,14@94eecbb`.
|
|
360
|
+
return _vscode_user_dir() / "globalStorage" / "saoudrizwan.claude-dev"
|
|
361
|
+
|
|
362
|
+
def discover(self, path: Path) -> Iterator[Source]:
|
|
363
|
+
root = _extended(path)
|
|
364
|
+
listing: Iterable[tuple[str, list[str], list[str]]]
|
|
365
|
+
if os.path.isfile(root):
|
|
366
|
+
listing = [(os.path.dirname(root), [], [os.path.basename(root)])]
|
|
367
|
+
else:
|
|
368
|
+
listing = os.walk(root)
|
|
369
|
+
indexes: dict[str, dict[str, str]] = {}
|
|
370
|
+
for directory, _, files in listing:
|
|
371
|
+
if _MESSAGES not in files:
|
|
372
|
+
continue
|
|
373
|
+
tasks = os.path.dirname(directory)
|
|
374
|
+
if os.path.basename(tasks) != "tasks":
|
|
375
|
+
continue
|
|
376
|
+
storage = os.path.dirname(tasks)
|
|
377
|
+
if storage not in indexes:
|
|
378
|
+
indexes[storage] = _read_index(os.path.join(storage, "state", "taskHistory.json"))
|
|
379
|
+
task_id = os.path.basename(directory)
|
|
380
|
+
yield Source(
|
|
381
|
+
path=os.path.join(directory, _MESSAGES),
|
|
382
|
+
session=task_id,
|
|
383
|
+
# Missing from the index means one shared project pseudonym, never a guess.
|
|
384
|
+
project=indexes[storage].get(task_id, ""),
|
|
385
|
+
companions=(os.path.join(directory, _METADATA),),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def parse(self, source: Source, idle_cap_s: int, stats: ParseStats) -> list[RawRun]:
|
|
389
|
+
try:
|
|
390
|
+
with open(source.path, encoding="utf-8", errors="replace") as handle:
|
|
391
|
+
data = json.load(handle)
|
|
392
|
+
except OSError:
|
|
393
|
+
stats.unreadable_files += 1
|
|
394
|
+
return []
|
|
395
|
+
except (ValueError, RecursionError):
|
|
396
|
+
stats.unparsed_lines += 1 # the whole file is one JSON array
|
|
397
|
+
return []
|
|
398
|
+
if not isinstance(data, list):
|
|
399
|
+
stats.unparsed_lines += 1
|
|
400
|
+
return []
|
|
401
|
+
messages: list[_Message] = []
|
|
402
|
+
seen: set[_Message] = set()
|
|
403
|
+
for record in data:
|
|
404
|
+
message = _project(record)
|
|
405
|
+
if message is None:
|
|
406
|
+
stats.unparsed_lines += 1
|
|
407
|
+
continue
|
|
408
|
+
if message in seen:
|
|
409
|
+
continue # a repeated message is one message
|
|
410
|
+
seen.add(message)
|
|
411
|
+
messages.append(message)
|
|
412
|
+
last_model = next((m.model for m in reversed(messages) if m.model), None)
|
|
413
|
+
recorded: list[tuple[float, str]] | None = None
|
|
414
|
+
runs: list[RawRun] = []
|
|
415
|
+
for turn in _turns(messages):
|
|
416
|
+
times = [message.time for message in turn.messages]
|
|
417
|
+
models = Counter(message.model for message in turn.messages if message.model)
|
|
418
|
+
model = models.most_common(1)[0][0] if models else last_model
|
|
419
|
+
if model is None:
|
|
420
|
+
if recorded is None:
|
|
421
|
+
recorded = _read_models(source.companions[0]) if source.companions else []
|
|
422
|
+
model = _fallback_model(recorded, min(times))
|
|
423
|
+
if model is None or len(times) < 2:
|
|
424
|
+
stats.empty_runs += 1
|
|
425
|
+
continue
|
|
426
|
+
active_s, spans = _timing(turn.messages, idle_cap_s)
|
|
427
|
+
tokens = sum(message.tokens for message in turn.messages)
|
|
428
|
+
runs.append(
|
|
429
|
+
RawRun(
|
|
430
|
+
harness=self.name,
|
|
431
|
+
model=model,
|
|
432
|
+
start=min(times),
|
|
433
|
+
end=max(times),
|
|
434
|
+
spans=spans,
|
|
435
|
+
active_s=active_s,
|
|
436
|
+
tool_calls=sum(m.name in _TOOL_NAMES for m in turn.messages),
|
|
437
|
+
edit_calls=sum(m.edit for m in turn.messages),
|
|
438
|
+
tokens=tokens or None,
|
|
439
|
+
errors=sum(
|
|
440
|
+
(m.kind == "say" and m.name in _ERROR_SAYS)
|
|
441
|
+
or (m.kind == "ask" and m.name in _ERROR_ASKS)
|
|
442
|
+
for m in turn.messages
|
|
443
|
+
),
|
|
444
|
+
outcome=turn.outcome,
|
|
445
|
+
session=source.session,
|
|
446
|
+
project=source.project,
|
|
447
|
+
)
|
|
448
|
+
)
|
|
449
|
+
return runs
|