eval-harness 0.2.18
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.
- package/README.md +1046 -0
- package/README.zh-CN.md +597 -0
- package/bin/eval-harness.js +65 -0
- package/config/eval.example.json +70 -0
- package/docs/agent-runner-compatibility.md +47 -0
- package/docs/runners.md +82 -0
- package/package.json +43 -0
- package/prompts/judge.md +27 -0
- package/pyproject.toml +48 -0
- package/schemas/batch-comparison.schema.json +64 -0
- package/schemas/batch-summary.schema.json +85 -0
- package/schemas/evaluation.schema.json +81 -0
- package/schemas/experiment.schema.json +63 -0
- package/schemas/history-index.schema.json +37 -0
- package/schemas/history-record.schema.json +56 -0
- package/schemas/model-report.schema.json +38 -0
- package/schemas/next-action.schema.json +62 -0
- package/schemas/skill-improvement-input.schema.json +65 -0
- package/src/eval/__init__.py +3 -0
- package/src/eval/__main__.py +5 -0
- package/src/eval/cli.py +11150 -0
- package/src/eval/runners/__init__.py +0 -0
- package/src/eval/runners/base.py +197 -0
- package/src/eval/runners/claude.py +516 -0
- package/src/eval/runners/codex.py +412 -0
- package/src/eval/runners/mimocode.py +676 -0
- package/src/eval/runners/opencode.py +554 -0
- package/src/eval/schema.py +151 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .base import (
|
|
11
|
+
AgentRunner,
|
|
12
|
+
DEFAULT_ATTACHED_INSTRUCTION_PROMPT,
|
|
13
|
+
_command_get_named_arg,
|
|
14
|
+
_command_set_named_arg,
|
|
15
|
+
normalize_agent_value,
|
|
16
|
+
parse_log_key_values,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenCodeRunner(AgentRunner):
|
|
21
|
+
"""OpenCode CLI agent runner adapter."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def name(self) -> str:
|
|
25
|
+
return "opencode"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def binary(self) -> str:
|
|
29
|
+
return "opencode"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def config_key(self) -> str:
|
|
33
|
+
return "opencode"
|
|
34
|
+
|
|
35
|
+
# -- command building
|
|
36
|
+
|
|
37
|
+
def default_command(self, config: dict[str, Any]) -> list[str]:
|
|
38
|
+
return [
|
|
39
|
+
"opencode",
|
|
40
|
+
"run",
|
|
41
|
+
DEFAULT_ATTACHED_INSTRUCTION_PROMPT,
|
|
42
|
+
*self.format_flags(),
|
|
43
|
+
"--dangerously-skip-permissions",
|
|
44
|
+
"--file={instruction}",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
def command_with_variant(self, command: list[str], variant: str) -> list[str]:
|
|
48
|
+
if not variant:
|
|
49
|
+
return command
|
|
50
|
+
return _command_set_named_arg(command, "--variant", variant)
|
|
51
|
+
|
|
52
|
+
def variant_cli_flag(self) -> str:
|
|
53
|
+
return "--variant"
|
|
54
|
+
|
|
55
|
+
def variant_from_command(self, command: list[str]) -> str:
|
|
56
|
+
return _command_get_named_arg(command, "--variant")
|
|
57
|
+
|
|
58
|
+
# -- log directory
|
|
59
|
+
|
|
60
|
+
def log_dir_name(self) -> str:
|
|
61
|
+
return "opencode"
|
|
62
|
+
|
|
63
|
+
# -- asset installation
|
|
64
|
+
|
|
65
|
+
def default_asset_installs(self) -> list[dict[str, Any]]:
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
"source": "work/work/skills/*",
|
|
69
|
+
"target": ".opencode/skills",
|
|
70
|
+
"kind": "skills",
|
|
71
|
+
"entries": "directories",
|
|
72
|
+
"required": False,
|
|
73
|
+
"clean": True,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"source": "work/work/skills/versions.json",
|
|
77
|
+
"target": ".opencode/skills",
|
|
78
|
+
"kind": "skill-manifest",
|
|
79
|
+
"entries": "files",
|
|
80
|
+
"required": False,
|
|
81
|
+
"clean": False,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"source": "work/skills/*",
|
|
85
|
+
"target": ".opencode/skills",
|
|
86
|
+
"kind": "skills-flat-fallback",
|
|
87
|
+
"entries": "directories",
|
|
88
|
+
"required": False,
|
|
89
|
+
"clean": False,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"source": "work/skills/versions.json",
|
|
93
|
+
"target": ".opencode/skills",
|
|
94
|
+
"kind": "skill-manifest-flat-fallback",
|
|
95
|
+
"entries": "files",
|
|
96
|
+
"required": False,
|
|
97
|
+
"clean": False,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"source": "work/work/skills/*.md",
|
|
101
|
+
"target": ".opencode/agents",
|
|
102
|
+
"kind": "agents",
|
|
103
|
+
"required": False,
|
|
104
|
+
"clean": True,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"source": "work/skills/*.md",
|
|
108
|
+
"target": ".opencode/agents",
|
|
109
|
+
"kind": "agents-flat-fallback",
|
|
110
|
+
"required": False,
|
|
111
|
+
"clean": False,
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
# -- runtime profile seeding
|
|
116
|
+
|
|
117
|
+
def runtime_profile_candidates(
|
|
118
|
+
self, base_env: dict[str, str], runtime: dict[str, str]
|
|
119
|
+
) -> list[tuple[Path | None, Path, str]]:
|
|
120
|
+
from ..cli import existing_path
|
|
121
|
+
|
|
122
|
+
# Dest paths use the isolated runtime dirs; source paths use the real env
|
|
123
|
+
home = Path(runtime["home_dir"])
|
|
124
|
+
appdata = Path(runtime.get("appdata_dir", home / "AppData" / "Roaming"))
|
|
125
|
+
localappdata = Path(runtime.get("localappdata_dir", home / "AppData" / "Local"))
|
|
126
|
+
config_dir = Path(runtime["config_dir"])
|
|
127
|
+
data_dir = Path(runtime["data_dir"])
|
|
128
|
+
|
|
129
|
+
return [
|
|
130
|
+
(existing_path(base_env.get("USERPROFILE"), ".config", "opencode"), config_dir / "opencode", "USERPROFILE/.config/opencode"),
|
|
131
|
+
(existing_path(base_env.get("USERPROFILE"), ".local", "share", "opencode"), data_dir / "opencode", "USERPROFILE/.local/share/opencode"),
|
|
132
|
+
(existing_path(base_env.get("USERPROFILE"), ".cache", "opencode"), Path(runtime["cache_dir"]) / "opencode", "USERPROFILE/.cache/opencode"),
|
|
133
|
+
(existing_path(base_env.get("USERPROFILE"), ".opencode"), home / ".opencode", "USERPROFILE/.opencode"),
|
|
134
|
+
(existing_path(base_env.get("APPDATA"), "opencode"), appdata / "opencode", "APPDATA/opencode"),
|
|
135
|
+
(existing_path(base_env.get("LOCALAPPDATA"), "opencode"), localappdata / "opencode", "LOCALAPPDATA/opencode"),
|
|
136
|
+
(existing_path(base_env.get("XDG_CONFIG_HOME"), "opencode"), config_dir / "opencode", "XDG_CONFIG_HOME/opencode"),
|
|
137
|
+
(existing_path(base_env.get("XDG_DATA_HOME"), "opencode"), data_dir / "opencode", "XDG_DATA_HOME/opencode"),
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
# -- provider config
|
|
141
|
+
|
|
142
|
+
def write_agent_config(self, manifest: dict[str, Any]) -> str | None:
|
|
143
|
+
runtime = manifest.get("runtime") or {}
|
|
144
|
+
config_dir = Path(runtime["config_dir"]) / "opencode"
|
|
145
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
config_path = config_dir / "opencode.jsonc"
|
|
147
|
+
|
|
148
|
+
from ..cli import execution_agent_provider, read_json, write_json # local to avoid circular
|
|
149
|
+
|
|
150
|
+
data: dict[str, Any] = {}
|
|
151
|
+
if config_path.exists():
|
|
152
|
+
try:
|
|
153
|
+
data = read_json(config_path)
|
|
154
|
+
except json.JSONDecodeError:
|
|
155
|
+
data = {}
|
|
156
|
+
data.setdefault("$schema", "https://opencode.ai/config.json")
|
|
157
|
+
providers = data.setdefault("provider", {})
|
|
158
|
+
if not isinstance(providers, dict):
|
|
159
|
+
raise RuntimeError("opencode provider config must be a JSON object")
|
|
160
|
+
|
|
161
|
+
execution = manifest.get("execution", {})
|
|
162
|
+
active_model = _opencode_active_model(execution)
|
|
163
|
+
if active_model:
|
|
164
|
+
data["model"] = active_model
|
|
165
|
+
provider = execution_agent_provider(execution, self)
|
|
166
|
+
if not isinstance(provider, dict):
|
|
167
|
+
return str(config_path)
|
|
168
|
+
|
|
169
|
+
provider_id = str(provider.get("id", "agent-inspector"))
|
|
170
|
+
models = provider.get("models") or {}
|
|
171
|
+
if not isinstance(models, dict):
|
|
172
|
+
raise RuntimeError("execution.opencode_provider.models must be a JSON object")
|
|
173
|
+
providers[provider_id] = {
|
|
174
|
+
"npm": str(provider.get("npm", "@ai-sdk/openai-compatible")),
|
|
175
|
+
"name": str(provider.get("name", "Agent Inspector")),
|
|
176
|
+
"options": {
|
|
177
|
+
"baseURL": str(provider.get("baseURL", "http://127.0.0.1:25947/proxy")),
|
|
178
|
+
"apiKey": str(provider.get("apiKey", "")),
|
|
179
|
+
},
|
|
180
|
+
"models": models,
|
|
181
|
+
}
|
|
182
|
+
write_json(config_path, data)
|
|
183
|
+
return str(config_path)
|
|
184
|
+
|
|
185
|
+
# -- log parsing: sessions
|
|
186
|
+
|
|
187
|
+
def extract_sessions_from_text(
|
|
188
|
+
self, text: str, *, inspector_base_url: str = "http://127.0.0.1:25947"
|
|
189
|
+
) -> dict[str, Any]:
|
|
190
|
+
from ..cli import (
|
|
191
|
+
OPENCODE_SESSION_SCHEMA,
|
|
192
|
+
dt,
|
|
193
|
+
plain_log_text,
|
|
194
|
+
utc_now_seconds_text,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
records: dict[str, dict[str, Any]] = {}
|
|
198
|
+
order: list[str] = []
|
|
199
|
+
primary_session_id: str | None = None
|
|
200
|
+
|
|
201
|
+
def remember(session_id: str) -> dict[str, Any]:
|
|
202
|
+
if session_id not in records:
|
|
203
|
+
order.append(session_id)
|
|
204
|
+
return _ensure_session_record(records, session_id)
|
|
205
|
+
|
|
206
|
+
for raw_line in text.splitlines():
|
|
207
|
+
line = plain_log_text(raw_line).strip()
|
|
208
|
+
if not line:
|
|
209
|
+
continue
|
|
210
|
+
if line.startswith("{"):
|
|
211
|
+
try:
|
|
212
|
+
payload = json.loads(line)
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
continue
|
|
215
|
+
if not isinstance(payload, dict):
|
|
216
|
+
continue
|
|
217
|
+
session_id = normalize_agent_value(str(payload.get("sessionID") or ""))
|
|
218
|
+
part = payload.get("part")
|
|
219
|
+
if not session_id and isinstance(part, dict):
|
|
220
|
+
session_id = normalize_agent_value(str(part.get("sessionID") or ""))
|
|
221
|
+
if not session_id:
|
|
222
|
+
continue
|
|
223
|
+
record = remember(session_id)
|
|
224
|
+
_update_session_seen(record, _session_timestamp_from_json(payload))
|
|
225
|
+
event_type = str(payload.get("type") or "")
|
|
226
|
+
if event_type == "tool_use":
|
|
227
|
+
record["tool_call_count"] = int(record.get("tool_call_count", 0)) + 1
|
|
228
|
+
elif event_type == "text":
|
|
229
|
+
record["text_part_count"] = int(record.get("text_part_count", 0)) + 1
|
|
230
|
+
elif event_type == "error":
|
|
231
|
+
record["error_count"] = int(record.get("error_count", 0)) + 1
|
|
232
|
+
if isinstance(part, dict):
|
|
233
|
+
if part.get("type") == "step-finish":
|
|
234
|
+
record["step_count"] = int(record.get("step_count", 0)) + 1
|
|
235
|
+
tokens = part.get("tokens")
|
|
236
|
+
if isinstance(tokens, dict):
|
|
237
|
+
_apply_step_tokens(record, tokens)
|
|
238
|
+
if not record.get("agent"):
|
|
239
|
+
record["agent"] = normalize_agent_value(str(part.get("agent") or ""))
|
|
240
|
+
if primary_session_id is None:
|
|
241
|
+
primary_session_id = session_id
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
values = parse_log_key_values(line)
|
|
245
|
+
timestamp = normalize_agent_value(values.get("timestamp"))
|
|
246
|
+
created_id = normalize_agent_value(values.get("id")) if "message=created" in line else None
|
|
247
|
+
stream_session_id = normalize_agent_value(values.get("session.id"))
|
|
248
|
+
session_id = created_id or stream_session_id
|
|
249
|
+
if not session_id:
|
|
250
|
+
continue
|
|
251
|
+
record = remember(session_id)
|
|
252
|
+
_update_session_seen(record, timestamp)
|
|
253
|
+
if created_id:
|
|
254
|
+
parent_id = normalize_agent_value(values.get("parentID"))
|
|
255
|
+
record["parent_id"] = parent_id
|
|
256
|
+
record["title"] = normalize_agent_value(values.get("title")) or record.get("title")
|
|
257
|
+
record["agent"] = normalize_agent_value(values.get("agent")) or record.get("agent")
|
|
258
|
+
model = normalize_agent_value(values.get("model"))
|
|
259
|
+
if model:
|
|
260
|
+
record["model"] = model
|
|
261
|
+
if parent_id is None and primary_session_id is None:
|
|
262
|
+
primary_session_id = session_id
|
|
263
|
+
if stream_session_id:
|
|
264
|
+
record["provider"] = normalize_agent_value(values.get("providerID")) or normalize_agent_value(values.get("llm.provider")) or record.get("provider")
|
|
265
|
+
record["model"] = normalize_agent_value(values.get("modelID")) or normalize_agent_value(values.get("llm.model")) or record.get("model")
|
|
266
|
+
record["agent"] = normalize_agent_value(values.get("agent")) or record.get("agent")
|
|
267
|
+
record["mode"] = normalize_agent_value(values.get("mode")) or record.get("mode")
|
|
268
|
+
if primary_session_id is None:
|
|
269
|
+
primary_session_id = session_id
|
|
270
|
+
|
|
271
|
+
sessions = [records[session_id] for session_id in order]
|
|
272
|
+
base = inspector_base_url.rstrip("/") or "http://127.0.0.1:25947"
|
|
273
|
+
for record in sessions:
|
|
274
|
+
record["inspector_url"] = f"{base}/?sessionId={urllib.parse.quote(str(record['session_id']))}"
|
|
275
|
+
if not primary_session_id and sessions:
|
|
276
|
+
primary_session_id = str(sessions[0]["session_id"])
|
|
277
|
+
return {
|
|
278
|
+
"schema": OPENCODE_SESSION_SCHEMA,
|
|
279
|
+
"generated_at": utc_now_seconds_text(),
|
|
280
|
+
"inspector_base_url": base,
|
|
281
|
+
"inspector_sessions_api": f"{base}/api/sessions",
|
|
282
|
+
"primary_session_id": primary_session_id,
|
|
283
|
+
"session_count": len(sessions),
|
|
284
|
+
"subagent_session_count": sum(1 for item in sessions if item.get("parent_id")),
|
|
285
|
+
"sessions": sessions,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
def render_sessions_md(self, summary: dict[str, Any]) -> str:
|
|
289
|
+
from ..cli import md_artifact_external_link, md_cell
|
|
290
|
+
|
|
291
|
+
lines = [
|
|
292
|
+
f"# {self.binary.title()} Sessions",
|
|
293
|
+
"",
|
|
294
|
+
f"- Generated at: {summary.get('generated_at')}",
|
|
295
|
+
f"- Inspector: {summary.get('inspector_base_url')}",
|
|
296
|
+
f"- Sessions API: {summary.get('inspector_sessions_api')}",
|
|
297
|
+
f"- Primary session: `{summary.get('primary_session_id') or ''}`",
|
|
298
|
+
f"- Session count: {summary.get('session_count', 0)}",
|
|
299
|
+
f"- Subagent session count: {summary.get('subagent_session_count', 0)}",
|
|
300
|
+
"",
|
|
301
|
+
"| session | parent | agent | mode | model | steps | tools | errors | inspector |",
|
|
302
|
+
"| --- | --- | --- | --- | --- | ---: | ---: | ---: | --- |",
|
|
303
|
+
]
|
|
304
|
+
for item in summary.get("sessions", []) or []:
|
|
305
|
+
lines.append(
|
|
306
|
+
"| "
|
|
307
|
+
+ " | ".join(
|
|
308
|
+
[
|
|
309
|
+
md_cell(item.get("session_id")),
|
|
310
|
+
md_cell(item.get("parent_id")),
|
|
311
|
+
md_cell(item.get("agent")),
|
|
312
|
+
md_cell(item.get("mode")),
|
|
313
|
+
md_cell(item.get("model")),
|
|
314
|
+
md_cell(item.get("step_count")),
|
|
315
|
+
md_cell(item.get("tool_call_count")),
|
|
316
|
+
md_cell(item.get("error_count")),
|
|
317
|
+
md_artifact_external_link(str(item.get("inspector_url") or ""), "open"),
|
|
318
|
+
]
|
|
319
|
+
)
|
|
320
|
+
+ " |"
|
|
321
|
+
)
|
|
322
|
+
if not summary.get("sessions"):
|
|
323
|
+
lines.append("| none | | | | | 0 | 0 | 0 | |")
|
|
324
|
+
if summary.get("inspector_backfill_status") and summary.get("inspector_backfill_status") != "SKIPPED":
|
|
325
|
+
lines.extend(
|
|
326
|
+
[
|
|
327
|
+
"",
|
|
328
|
+
"## Inspector Log Backfill",
|
|
329
|
+
"",
|
|
330
|
+
f"- Status: `{summary.get('inspector_backfill_status') or ''}`",
|
|
331
|
+
f"- Query mode: `{summary.get('inspector_log_query_mode') or ''}`",
|
|
332
|
+
f"- Logs URL: {md_artifact_external_link(str(summary.get('inspector_logs_url') or ''), 'open')}",
|
|
333
|
+
f"- Log count: {summary.get('inspector_log_count', 0)}",
|
|
334
|
+
f"- Preview count: {summary.get('inspector_log_preview_count', 0)} / {summary.get('inspector_log_preview_limit', 0)}",
|
|
335
|
+
]
|
|
336
|
+
)
|
|
337
|
+
if summary.get("inspector_logs_artifact"):
|
|
338
|
+
lines.append(f"- Full log artifact: `{summary.get('inspector_logs_artifact')}`")
|
|
339
|
+
if summary.get("inspector_backfill_reason"):
|
|
340
|
+
lines.append(f"- Reason: {summary.get('inspector_backfill_reason')}")
|
|
341
|
+
inspector_logs = summary.get("inspector_logs") or []
|
|
342
|
+
if inspector_logs:
|
|
343
|
+
lines.extend(
|
|
344
|
+
[
|
|
345
|
+
"",
|
|
346
|
+
"| log | session | model | status | path | inspector |",
|
|
347
|
+
"| ---: | --- | --- | ---: | --- | --- |",
|
|
348
|
+
]
|
|
349
|
+
)
|
|
350
|
+
for item in inspector_logs:
|
|
351
|
+
lines.append(
|
|
352
|
+
"| "
|
|
353
|
+
+ " | ".join(
|
|
354
|
+
[
|
|
355
|
+
md_cell(item.get("id")),
|
|
356
|
+
md_cell(item.get("session_id")),
|
|
357
|
+
md_cell(item.get("model")),
|
|
358
|
+
md_cell(item.get("status")),
|
|
359
|
+
md_cell(item.get("path")),
|
|
360
|
+
md_artifact_external_link(str(item.get("inspector_url") or ""), "open"),
|
|
361
|
+
]
|
|
362
|
+
)
|
|
363
|
+
+ " |"
|
|
364
|
+
)
|
|
365
|
+
lines.append("")
|
|
366
|
+
return "\n".join(lines)
|
|
367
|
+
|
|
368
|
+
# -- log parsing: model
|
|
369
|
+
|
|
370
|
+
def model_from_log(self, run_dir: Path) -> str:
|
|
371
|
+
log_path = run_dir / self.log_dir_name() / "opencode.log"
|
|
372
|
+
if not log_path.exists():
|
|
373
|
+
return ""
|
|
374
|
+
from ..cli import read_trace_text
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
text = read_trace_text(log_path)
|
|
378
|
+
except OSError:
|
|
379
|
+
return ""
|
|
380
|
+
command_match = re.search(r"^\$\s+.+?\s--model\s+(\S+)", text, re.MULTILINE)
|
|
381
|
+
if command_match:
|
|
382
|
+
return command_match.group(1)
|
|
383
|
+
stream_match = re.search(r"providerID=(\S+)\s+modelID=(\S+)", text)
|
|
384
|
+
if stream_match:
|
|
385
|
+
return f"{stream_match.group(1)}/{stream_match.group(2)}"
|
|
386
|
+
runtime_match = re.search(r"llm\.provider=(\S+)\s+llm\.model=(\S+)", text)
|
|
387
|
+
if runtime_match:
|
|
388
|
+
return f"{runtime_match.group(1)}/{runtime_match.group(2)}"
|
|
389
|
+
return ""
|
|
390
|
+
|
|
391
|
+
# -- log parsing: activity
|
|
392
|
+
|
|
393
|
+
def extract_activity_from_text(self, text: str, *, tail_lines: int = 500) -> dict[str, Any]:
|
|
394
|
+
from ..cli import plain_log_text
|
|
395
|
+
|
|
396
|
+
lines = text.splitlines()[-tail_lines:]
|
|
397
|
+
sessions: set[str] = set()
|
|
398
|
+
subagent_sessions: set[str] = set()
|
|
399
|
+
touched_files: list[str] = []
|
|
400
|
+
latest: dict[str, Any] = {}
|
|
401
|
+
event_count = 0
|
|
402
|
+
|
|
403
|
+
def remember(
|
|
404
|
+
session_id: str | None,
|
|
405
|
+
*,
|
|
406
|
+
mode: str | None = None,
|
|
407
|
+
agent: str | None = None,
|
|
408
|
+
model: str | None = None,
|
|
409
|
+
message: str | None = None,
|
|
410
|
+
timestamp: str | None = None,
|
|
411
|
+
) -> None:
|
|
412
|
+
nonlocal event_count, latest
|
|
413
|
+
normalized_session = normalize_agent_value(session_id)
|
|
414
|
+
if not normalized_session:
|
|
415
|
+
return
|
|
416
|
+
event_count += 1
|
|
417
|
+
sessions.add(normalized_session)
|
|
418
|
+
normalized_mode = normalize_agent_value(mode)
|
|
419
|
+
if normalized_mode == "subagent":
|
|
420
|
+
subagent_sessions.add(normalized_session)
|
|
421
|
+
latest = {
|
|
422
|
+
"latest_session_id": normalized_session,
|
|
423
|
+
"latest_mode": normalized_mode,
|
|
424
|
+
"latest_agent": normalize_agent_value(agent),
|
|
425
|
+
"latest_model": normalize_agent_value(model),
|
|
426
|
+
"latest_message": normalize_agent_value(message),
|
|
427
|
+
"latest_timestamp": normalize_agent_value(timestamp),
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
for raw_line in lines:
|
|
431
|
+
line = plain_log_text(raw_line).strip()
|
|
432
|
+
if not line:
|
|
433
|
+
continue
|
|
434
|
+
if line.startswith("{"):
|
|
435
|
+
try:
|
|
436
|
+
payload = json.loads(line)
|
|
437
|
+
except json.JSONDecodeError:
|
|
438
|
+
payload = None
|
|
439
|
+
if isinstance(payload, dict):
|
|
440
|
+
part = payload.get("part") if isinstance(payload.get("part"), dict) else {}
|
|
441
|
+
session_id = payload.get("sessionID") or part.get("sessionID")
|
|
442
|
+
remember(
|
|
443
|
+
str(session_id or ""),
|
|
444
|
+
mode=str(part.get("mode") or ""),
|
|
445
|
+
agent=str(part.get("agent") or ""),
|
|
446
|
+
message=str(payload.get("type") or part.get("type") or ""),
|
|
447
|
+
timestamp=str(payload.get("timestamp") or ""),
|
|
448
|
+
)
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
values = parse_log_key_values(line)
|
|
452
|
+
session_id = values.get("session.id")
|
|
453
|
+
if not session_id and "message=created" in line:
|
|
454
|
+
session_id = values.get("id")
|
|
455
|
+
if session_id:
|
|
456
|
+
remember(
|
|
457
|
+
session_id,
|
|
458
|
+
mode=values.get("mode"),
|
|
459
|
+
agent=values.get("agent"),
|
|
460
|
+
model=values.get("modelID") or values.get("llm.model") or values.get("model"),
|
|
461
|
+
message=values.get("message"),
|
|
462
|
+
timestamp=values.get("timestamp"),
|
|
463
|
+
)
|
|
464
|
+
touched = values.get("file") if values.get("message") == "touching file" else None
|
|
465
|
+
if touched:
|
|
466
|
+
touched_files.append(touched)
|
|
467
|
+
|
|
468
|
+
latest.update(
|
|
469
|
+
{
|
|
470
|
+
"event_count": event_count,
|
|
471
|
+
"session_count": len(sessions),
|
|
472
|
+
"session_ids": sorted(sessions),
|
|
473
|
+
"subagent_session_count": len(subagent_sessions),
|
|
474
|
+
"subagent_session_ids": sorted(subagent_sessions),
|
|
475
|
+
"has_subagent_activity": bool(subagent_sessions) or latest.get("latest_mode") == "subagent",
|
|
476
|
+
"touched_file_count": len(touched_files),
|
|
477
|
+
"latest_touched_file": touched_files[-1] if touched_files else "",
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
return latest
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# -- shared session helpers
|
|
484
|
+
|
|
485
|
+
def _opencode_active_model(execution: dict[str, Any]) -> str:
|
|
486
|
+
for key in ("actual_model", "model"):
|
|
487
|
+
value = normalize_agent_value(str(execution.get(key) or ""))
|
|
488
|
+
if value and value != "inherit":
|
|
489
|
+
return value
|
|
490
|
+
return ""
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _ensure_session_record(
|
|
494
|
+
records: dict[str, dict[str, Any]], session_id: str
|
|
495
|
+
) -> dict[str, Any]:
|
|
496
|
+
record = records.get(session_id)
|
|
497
|
+
if record is None:
|
|
498
|
+
record = {
|
|
499
|
+
"session_id": session_id,
|
|
500
|
+
"parent_id": None,
|
|
501
|
+
"agent": None,
|
|
502
|
+
"title": None,
|
|
503
|
+
"provider": None,
|
|
504
|
+
"model": None,
|
|
505
|
+
"mode": None,
|
|
506
|
+
"first_seen": None,
|
|
507
|
+
"last_seen": None,
|
|
508
|
+
"step_count": 0,
|
|
509
|
+
"tool_call_count": 0,
|
|
510
|
+
"text_part_count": 0,
|
|
511
|
+
"error_count": 0,
|
|
512
|
+
"tokens": {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0},
|
|
513
|
+
}
|
|
514
|
+
records[session_id] = record
|
|
515
|
+
return record
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _update_session_seen(record: dict[str, Any], timestamp: str | None) -> None:
|
|
519
|
+
if not timestamp:
|
|
520
|
+
return
|
|
521
|
+
if record.get("first_seen") is None or str(timestamp) < str(record.get("first_seen")):
|
|
522
|
+
record["first_seen"] = timestamp
|
|
523
|
+
if record.get("last_seen") is None or str(timestamp) > str(record.get("last_seen")):
|
|
524
|
+
record["last_seen"] = timestamp
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _session_timestamp_from_json(payload: dict[str, Any]) -> str | None:
|
|
528
|
+
import datetime as dt
|
|
529
|
+
|
|
530
|
+
value = payload.get("timestamp")
|
|
531
|
+
if isinstance(value, (int, float)):
|
|
532
|
+
try:
|
|
533
|
+
return dt.datetime.fromtimestamp(value / 1000, tz=dt.timezone.utc).isoformat()
|
|
534
|
+
except (OverflowError, OSError, ValueError):
|
|
535
|
+
return str(value)
|
|
536
|
+
if value is None:
|
|
537
|
+
return None
|
|
538
|
+
return str(value)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _apply_step_tokens(record: dict[str, Any], tokens: dict[str, Any]) -> None:
|
|
542
|
+
bucket = record.setdefault("tokens", {})
|
|
543
|
+
for key in ("input", "output", "reasoning"):
|
|
544
|
+
value = tokens.get(key)
|
|
545
|
+
if isinstance(value, int):
|
|
546
|
+
bucket[key] = int(bucket.get(key, 0)) + value
|
|
547
|
+
cache = tokens.get("cache")
|
|
548
|
+
if isinstance(cache, dict):
|
|
549
|
+
read = cache.get("read")
|
|
550
|
+
write = cache.get("write")
|
|
551
|
+
if isinstance(read, int):
|
|
552
|
+
bucket["cache_read"] = int(bucket.get("cache_read", 0)) + read
|
|
553
|
+
if isinstance(write, int):
|
|
554
|
+
bucket["cache_write"] = int(bucket.get("cache_write", 0)) + write
|