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,676 @@
|
|
|
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 MiMoCodeRunner(AgentRunner):
|
|
21
|
+
"""MiMo Code CLI agent runner adapter."""
|
|
22
|
+
|
|
23
|
+
harness_agent_name = "harness-build"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
return "mimocode"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def binary(self) -> str:
|
|
31
|
+
return "mimo"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def config_key(self) -> str:
|
|
35
|
+
return "mimocode"
|
|
36
|
+
|
|
37
|
+
# -- command building
|
|
38
|
+
|
|
39
|
+
def default_command(self, config: dict[str, Any]) -> list[str]:
|
|
40
|
+
return [
|
|
41
|
+
"mimo",
|
|
42
|
+
"run",
|
|
43
|
+
*self.format_flags(),
|
|
44
|
+
"--dangerously-skip-permissions",
|
|
45
|
+
"--file={instruction}",
|
|
46
|
+
"--dir={workspace}",
|
|
47
|
+
"--agent",
|
|
48
|
+
self.harness_agent_name,
|
|
49
|
+
DEFAULT_ATTACHED_INSTRUCTION_PROMPT,
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
def command_with_model(self, command: list[str], model: str) -> list[str]:
|
|
53
|
+
"""Inject --model before MiMo's positional message argument."""
|
|
54
|
+
return _mimocode_set_option_before_message(command, "--model", model)
|
|
55
|
+
|
|
56
|
+
def command_with_variant(self, command: list[str], variant: str) -> list[str]:
|
|
57
|
+
"""MiMo Code uses --agent instead of --variant."""
|
|
58
|
+
if not variant:
|
|
59
|
+
return command
|
|
60
|
+
if self._looks_like_reasoning_variant(variant):
|
|
61
|
+
return command
|
|
62
|
+
return _command_set_named_arg(command, "--agent", variant)
|
|
63
|
+
|
|
64
|
+
def variant_cli_flag(self) -> str:
|
|
65
|
+
return "--agent"
|
|
66
|
+
|
|
67
|
+
def variant_from_command(self, command: list[str]) -> str:
|
|
68
|
+
return _command_get_named_arg(command, "--agent")
|
|
69
|
+
|
|
70
|
+
# -- log directory
|
|
71
|
+
|
|
72
|
+
def log_dir_name(self) -> str:
|
|
73
|
+
return "mimocode"
|
|
74
|
+
|
|
75
|
+
# -- asset installation
|
|
76
|
+
|
|
77
|
+
def default_asset_installs(self) -> list[dict[str, Any]]:
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
"source": "work/work/skills/*",
|
|
81
|
+
"target": ".mimocode/skills",
|
|
82
|
+
"kind": "skills",
|
|
83
|
+
"entries": "directories",
|
|
84
|
+
"required": False,
|
|
85
|
+
"clean": True,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"source": "work/work/skills/versions.json",
|
|
89
|
+
"target": ".mimocode/skills",
|
|
90
|
+
"kind": "skill-manifest",
|
|
91
|
+
"entries": "files",
|
|
92
|
+
"required": False,
|
|
93
|
+
"clean": False,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"source": "work/skills/*",
|
|
97
|
+
"target": ".mimocode/skills",
|
|
98
|
+
"kind": "skills-flat-fallback",
|
|
99
|
+
"entries": "directories",
|
|
100
|
+
"required": False,
|
|
101
|
+
"clean": False,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"source": "work/skills/versions.json",
|
|
105
|
+
"target": ".mimocode/skills",
|
|
106
|
+
"kind": "skill-manifest-flat-fallback",
|
|
107
|
+
"entries": "files",
|
|
108
|
+
"required": False,
|
|
109
|
+
"clean": False,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"source": "work/work/skills/*.md",
|
|
113
|
+
"target": ".mimocode/agents",
|
|
114
|
+
"kind": "agents",
|
|
115
|
+
"required": False,
|
|
116
|
+
"clean": True,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"source": "work/skills/*.md",
|
|
120
|
+
"target": ".mimocode/agents",
|
|
121
|
+
"kind": "agents-flat-fallback",
|
|
122
|
+
"required": False,
|
|
123
|
+
"clean": False,
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
# -- runtime profile seeding
|
|
128
|
+
|
|
129
|
+
def runtime_profile_candidates(
|
|
130
|
+
self, base_env: dict[str, str], runtime: dict[str, str]
|
|
131
|
+
) -> list[tuple[Path | None, Path, str]]:
|
|
132
|
+
from ..cli import existing_path
|
|
133
|
+
|
|
134
|
+
home = Path(runtime["home_dir"])
|
|
135
|
+
appdata = Path(runtime.get("appdata_dir", home / "AppData" / "Roaming"))
|
|
136
|
+
localappdata = Path(runtime.get("localappdata_dir", home / "AppData" / "Local"))
|
|
137
|
+
config_dir = Path(runtime["config_dir"])
|
|
138
|
+
data_dir = Path(runtime["data_dir"])
|
|
139
|
+
|
|
140
|
+
return [
|
|
141
|
+
(existing_path(base_env.get("USERPROFILE"), ".config", "mimocode"), config_dir / "mimocode", "USERPROFILE/.config/mimocode"),
|
|
142
|
+
(existing_path(base_env.get("USERPROFILE"), ".local", "share", "mimocode"), data_dir / "mimocode", "USERPROFILE/.local/share/mimocode"),
|
|
143
|
+
(existing_path(base_env.get("USERPROFILE"), ".cache", "mimocode"), Path(runtime["cache_dir"]) / "mimocode", "USERPROFILE/.cache/mimocode"),
|
|
144
|
+
(existing_path(base_env.get("USERPROFILE"), ".mimocode"), home / ".mimocode", "USERPROFILE/.mimocode"),
|
|
145
|
+
(existing_path(base_env.get("APPDATA"), "mimocode"), appdata / "mimocode", "APPDATA/mimocode"),
|
|
146
|
+
(existing_path(base_env.get("LOCALAPPDATA"), "mimocode"), localappdata / "mimocode", "LOCALAPPDATA/mimocode"),
|
|
147
|
+
(existing_path(base_env.get("XDG_CONFIG_HOME"), "mimocode"), config_dir / "mimocode", "XDG_CONFIG_HOME/mimocode"),
|
|
148
|
+
(existing_path(base_env.get("XDG_DATA_HOME"), "mimocode"), data_dir / "mimocode", "XDG_DATA_HOME/mimocode"),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
# -- provider config
|
|
152
|
+
|
|
153
|
+
def write_agent_config(self, manifest: dict[str, Any]) -> str | None:
|
|
154
|
+
runtime = manifest.get("runtime") or {}
|
|
155
|
+
config_dir = Path(runtime["config_dir"]) / "mimocode"
|
|
156
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
config_path = config_dir / "mimocode.jsonc"
|
|
158
|
+
|
|
159
|
+
from ..cli import execution_agent_provider, read_json, write_json
|
|
160
|
+
|
|
161
|
+
data: dict[str, Any] = {}
|
|
162
|
+
if config_path.exists():
|
|
163
|
+
try:
|
|
164
|
+
data = read_json(config_path)
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
data = {}
|
|
167
|
+
data.setdefault("$schema", "https://mimo.xiaomi.com/config.json")
|
|
168
|
+
execution = manifest.get("execution", {})
|
|
169
|
+
active_model = _mimocode_active_model(execution)
|
|
170
|
+
if active_model:
|
|
171
|
+
data["model"] = active_model
|
|
172
|
+
|
|
173
|
+
agents = data.setdefault("agent", {})
|
|
174
|
+
if not isinstance(agents, dict):
|
|
175
|
+
raise RuntimeError("mimocode agent config must be a JSON object")
|
|
176
|
+
agents[self.harness_agent_name] = self._harness_agent_config(active_model)
|
|
177
|
+
if active_model:
|
|
178
|
+
build_agent = agents.get("build")
|
|
179
|
+
if not isinstance(build_agent, dict):
|
|
180
|
+
build_agent = {}
|
|
181
|
+
build_agent["model"] = active_model
|
|
182
|
+
agents["build"] = build_agent
|
|
183
|
+
providers = data.setdefault("provider", {})
|
|
184
|
+
if not isinstance(providers, dict):
|
|
185
|
+
raise RuntimeError("mimocode provider config must be a JSON object")
|
|
186
|
+
|
|
187
|
+
provider = execution_agent_provider(execution, self)
|
|
188
|
+
if not isinstance(provider, dict):
|
|
189
|
+
return str(config_path)
|
|
190
|
+
|
|
191
|
+
provider_id = str(provider.get("id", "agent-inspector"))
|
|
192
|
+
models = provider.get("models") or {}
|
|
193
|
+
if not isinstance(models, dict):
|
|
194
|
+
raise RuntimeError("execution.opencode_provider.models must be a JSON object")
|
|
195
|
+
providers[provider_id] = {
|
|
196
|
+
"npm": str(provider.get("npm", "@ai-sdk/openai-compatible")),
|
|
197
|
+
"name": str(provider.get("name", "Agent Inspector")),
|
|
198
|
+
"options": {
|
|
199
|
+
"baseURL": str(provider.get("baseURL", "http://127.0.0.1:25947/proxy")),
|
|
200
|
+
"apiKey": str(provider.get("apiKey", "")),
|
|
201
|
+
},
|
|
202
|
+
"models": models,
|
|
203
|
+
}
|
|
204
|
+
write_json(config_path, data)
|
|
205
|
+
return str(config_path)
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _looks_like_reasoning_variant(value: str) -> bool:
|
|
209
|
+
return value.strip().lower() in {
|
|
210
|
+
"minimal",
|
|
211
|
+
"low",
|
|
212
|
+
"medium",
|
|
213
|
+
"high",
|
|
214
|
+
"max",
|
|
215
|
+
"maximum",
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
def _harness_agent_config(self, active_model: str = "") -> dict[str, Any]:
|
|
219
|
+
config: dict[str, Any] = {
|
|
220
|
+
"description": (
|
|
221
|
+
"Eval Harness non-interactive build agent. It mirrors MiMo Code's "
|
|
222
|
+
"build mode, but pre-approves external_directory access so Jenkins runs "
|
|
223
|
+
"do not block on permission prompts."
|
|
224
|
+
),
|
|
225
|
+
"mode": "primary",
|
|
226
|
+
"permission": {
|
|
227
|
+
"read": "allow",
|
|
228
|
+
"edit": "allow",
|
|
229
|
+
"glob": "allow",
|
|
230
|
+
"grep": "allow",
|
|
231
|
+
"list": "allow",
|
|
232
|
+
"bash": "allow",
|
|
233
|
+
"task": "allow",
|
|
234
|
+
"external_directory": "allow",
|
|
235
|
+
"todowrite": "allow",
|
|
236
|
+
"question": "deny",
|
|
237
|
+
"webfetch": "allow",
|
|
238
|
+
"websearch": "allow",
|
|
239
|
+
"lsp": "allow",
|
|
240
|
+
"doom_loop": "allow",
|
|
241
|
+
"skill": "allow",
|
|
242
|
+
},
|
|
243
|
+
"tools": {
|
|
244
|
+
"invalid": True,
|
|
245
|
+
"question": True,
|
|
246
|
+
"bash": True,
|
|
247
|
+
"read": True,
|
|
248
|
+
"glob": True,
|
|
249
|
+
"grep": True,
|
|
250
|
+
"edit": True,
|
|
251
|
+
"write": True,
|
|
252
|
+
"notebook_edit": True,
|
|
253
|
+
"actor": True,
|
|
254
|
+
"webfetch": True,
|
|
255
|
+
"skill": True,
|
|
256
|
+
"change_directory": True,
|
|
257
|
+
"plan_exit": True,
|
|
258
|
+
"plan_enter": True,
|
|
259
|
+
"memory": True,
|
|
260
|
+
"history": True,
|
|
261
|
+
"task": True,
|
|
262
|
+
"workflow": True,
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
if active_model:
|
|
266
|
+
config["model"] = active_model
|
|
267
|
+
return config
|
|
268
|
+
|
|
269
|
+
# -- log parsing: sessions
|
|
270
|
+
|
|
271
|
+
def extract_sessions_from_text(
|
|
272
|
+
self, text: str, *, inspector_base_url: str = "http://127.0.0.1:25947"
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
from ..cli import (
|
|
275
|
+
OPENCODE_SESSION_SCHEMA,
|
|
276
|
+
dt,
|
|
277
|
+
plain_log_text,
|
|
278
|
+
utc_now_seconds_text,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
records: dict[str, dict[str, Any]] = {}
|
|
282
|
+
order: list[str] = []
|
|
283
|
+
primary_session_id: str | None = None
|
|
284
|
+
|
|
285
|
+
def remember(session_id: str) -> dict[str, Any]:
|
|
286
|
+
if session_id not in records:
|
|
287
|
+
order.append(session_id)
|
|
288
|
+
return _ensure_session_record(records, session_id)
|
|
289
|
+
|
|
290
|
+
for raw_line in text.splitlines():
|
|
291
|
+
line = plain_log_text(raw_line).strip()
|
|
292
|
+
if not line:
|
|
293
|
+
continue
|
|
294
|
+
if line.startswith("{"):
|
|
295
|
+
try:
|
|
296
|
+
payload = json.loads(line)
|
|
297
|
+
except json.JSONDecodeError:
|
|
298
|
+
continue
|
|
299
|
+
if not isinstance(payload, dict):
|
|
300
|
+
continue
|
|
301
|
+
# MiMo Code JSON stream: sessionID / part.sessionID
|
|
302
|
+
session_id = normalize_agent_value(str(payload.get("sessionID") or ""))
|
|
303
|
+
part = payload.get("part")
|
|
304
|
+
if not session_id and isinstance(part, dict):
|
|
305
|
+
session_id = normalize_agent_value(str(part.get("sessionID") or ""))
|
|
306
|
+
# Also try MiMo-specific: turnID / session.id
|
|
307
|
+
if not session_id:
|
|
308
|
+
session_id = normalize_agent_value(str(payload.get("turnID") or ""))
|
|
309
|
+
if not session_id and isinstance(part, dict):
|
|
310
|
+
session_id = normalize_agent_value(str(part.get("turnID") or ""))
|
|
311
|
+
if not session_id:
|
|
312
|
+
continue
|
|
313
|
+
record = remember(session_id)
|
|
314
|
+
_update_session_seen(record, _session_timestamp_from_json(payload))
|
|
315
|
+
event_type = str(payload.get("type") or "")
|
|
316
|
+
if event_type in ("tool_use", "tool-result"):
|
|
317
|
+
record["tool_call_count"] = int(record.get("tool_call_count", 0)) + 1
|
|
318
|
+
elif event_type in ("text", "content_block_start", "content_block_delta"):
|
|
319
|
+
record["text_part_count"] = int(record.get("text_part_count", 0)) + 1
|
|
320
|
+
elif event_type == "error":
|
|
321
|
+
record["error_count"] = int(record.get("error_count", 0)) + 1
|
|
322
|
+
if isinstance(part, dict):
|
|
323
|
+
if part.get("type") in ("step-finish", "turn-finish", "done"):
|
|
324
|
+
record["step_count"] = int(record.get("step_count", 0)) + 1
|
|
325
|
+
tokens = part.get("tokens") or part.get("usage")
|
|
326
|
+
if isinstance(tokens, dict):
|
|
327
|
+
_apply_step_tokens(record, tokens)
|
|
328
|
+
if not record.get("agent"):
|
|
329
|
+
record["agent"] = normalize_agent_value(
|
|
330
|
+
str(part.get("agent") or part.get("name") or "")
|
|
331
|
+
)
|
|
332
|
+
if primary_session_id is None:
|
|
333
|
+
primary_session_id = session_id
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
# key=value log lines (shared format)
|
|
337
|
+
values = parse_log_key_values(line)
|
|
338
|
+
timestamp = normalize_agent_value(values.get("timestamp"))
|
|
339
|
+
created_id = normalize_agent_value(values.get("id")) if "message=created" in line else None
|
|
340
|
+
stream_session_id = normalize_agent_value(values.get("session.id"))
|
|
341
|
+
session_id = created_id or stream_session_id
|
|
342
|
+
if not session_id:
|
|
343
|
+
continue
|
|
344
|
+
record = remember(session_id)
|
|
345
|
+
_update_session_seen(record, timestamp)
|
|
346
|
+
if created_id:
|
|
347
|
+
parent_id = normalize_agent_value(values.get("parentID"))
|
|
348
|
+
record["parent_id"] = parent_id
|
|
349
|
+
record["title"] = normalize_agent_value(values.get("title")) or record.get("title")
|
|
350
|
+
record["agent"] = normalize_agent_value(values.get("agent")) or record.get("agent")
|
|
351
|
+
model = normalize_agent_value(values.get("model"))
|
|
352
|
+
if model:
|
|
353
|
+
record["model"] = model
|
|
354
|
+
if parent_id is None and primary_session_id is None:
|
|
355
|
+
primary_session_id = session_id
|
|
356
|
+
if stream_session_id:
|
|
357
|
+
record["provider"] = (
|
|
358
|
+
normalize_agent_value(values.get("providerID"))
|
|
359
|
+
or normalize_agent_value(values.get("llm.provider"))
|
|
360
|
+
or record.get("provider")
|
|
361
|
+
)
|
|
362
|
+
record["model"] = (
|
|
363
|
+
normalize_agent_value(values.get("modelID"))
|
|
364
|
+
or normalize_agent_value(values.get("llm.model"))
|
|
365
|
+
or record.get("model")
|
|
366
|
+
)
|
|
367
|
+
record["agent"] = normalize_agent_value(values.get("agent")) or record.get("agent")
|
|
368
|
+
record["mode"] = normalize_agent_value(values.get("mode")) or record.get("mode")
|
|
369
|
+
if primary_session_id is None:
|
|
370
|
+
primary_session_id = session_id
|
|
371
|
+
|
|
372
|
+
sessions = [records[session_id] for session_id in order]
|
|
373
|
+
base = inspector_base_url.rstrip("/") or "http://127.0.0.1:25947"
|
|
374
|
+
for record in sessions:
|
|
375
|
+
record["inspector_url"] = f"{base}/?sessionId={urllib.parse.quote(str(record['session_id']))}"
|
|
376
|
+
if not primary_session_id and sessions:
|
|
377
|
+
primary_session_id = str(sessions[0]["session_id"])
|
|
378
|
+
return {
|
|
379
|
+
"schema": OPENCODE_SESSION_SCHEMA,
|
|
380
|
+
"generated_at": utc_now_seconds_text(),
|
|
381
|
+
"inspector_base_url": base,
|
|
382
|
+
"inspector_sessions_api": f"{base}/api/sessions",
|
|
383
|
+
"primary_session_id": primary_session_id,
|
|
384
|
+
"session_count": len(sessions),
|
|
385
|
+
"subagent_session_count": sum(1 for item in sessions if item.get("parent_id")),
|
|
386
|
+
"sessions": sessions,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
def render_sessions_md(self, summary: dict[str, Any]) -> str:
|
|
390
|
+
from ..cli import md_artifact_external_link, md_cell
|
|
391
|
+
|
|
392
|
+
lines = [
|
|
393
|
+
f"# MiMo Code Sessions",
|
|
394
|
+
"",
|
|
395
|
+
f"- Generated at: {summary.get('generated_at')}",
|
|
396
|
+
f"- Inspector: {summary.get('inspector_base_url')}",
|
|
397
|
+
f"- Sessions API: {summary.get('inspector_sessions_api')}",
|
|
398
|
+
f"- Primary session: `{summary.get('primary_session_id') or ''}`",
|
|
399
|
+
f"- Session count: {summary.get('session_count', 0)}",
|
|
400
|
+
f"- Subagent session count: {summary.get('subagent_session_count', 0)}",
|
|
401
|
+
"",
|
|
402
|
+
"| session | parent | agent | mode | model | steps | tools | errors | inspector |",
|
|
403
|
+
"| --- | --- | --- | --- | --- | ---: | ---: | ---: | --- |",
|
|
404
|
+
]
|
|
405
|
+
for item in summary.get("sessions", []) or []:
|
|
406
|
+
lines.append(
|
|
407
|
+
"| "
|
|
408
|
+
+ " | ".join(
|
|
409
|
+
[
|
|
410
|
+
md_cell(item.get("session_id")),
|
|
411
|
+
md_cell(item.get("parent_id")),
|
|
412
|
+
md_cell(item.get("agent")),
|
|
413
|
+
md_cell(item.get("mode")),
|
|
414
|
+
md_cell(item.get("model")),
|
|
415
|
+
md_cell(item.get("step_count")),
|
|
416
|
+
md_cell(item.get("tool_call_count")),
|
|
417
|
+
md_cell(item.get("error_count")),
|
|
418
|
+
md_artifact_external_link(str(item.get("inspector_url") or ""), "open"),
|
|
419
|
+
]
|
|
420
|
+
)
|
|
421
|
+
+ " |"
|
|
422
|
+
)
|
|
423
|
+
if not summary.get("sessions"):
|
|
424
|
+
lines.append("| none | | | | | 0 | 0 | 0 | |")
|
|
425
|
+
if summary.get("inspector_backfill_status") and summary.get("inspector_backfill_status") != "SKIPPED":
|
|
426
|
+
lines.extend(
|
|
427
|
+
[
|
|
428
|
+
"",
|
|
429
|
+
"## Inspector Log Backfill",
|
|
430
|
+
"",
|
|
431
|
+
f"- Status: `{summary.get('inspector_backfill_status') or ''}`",
|
|
432
|
+
f"- Query mode: `{summary.get('inspector_log_query_mode') or ''}`",
|
|
433
|
+
f"- Logs URL: {md_artifact_external_link(str(summary.get('inspector_logs_url') or ''), 'open')}",
|
|
434
|
+
f"- Log count: {summary.get('inspector_log_count', 0)}",
|
|
435
|
+
f"- Preview count: {summary.get('inspector_log_preview_count', 0)} / {summary.get('inspector_log_preview_limit', 0)}",
|
|
436
|
+
]
|
|
437
|
+
)
|
|
438
|
+
if summary.get("inspector_logs_artifact"):
|
|
439
|
+
lines.append(f"- Full log artifact: `{summary.get('inspector_logs_artifact')}`")
|
|
440
|
+
if summary.get("inspector_backfill_reason"):
|
|
441
|
+
lines.append(f"- Reason: {summary.get('inspector_backfill_reason')}")
|
|
442
|
+
inspector_logs = summary.get("inspector_logs") or []
|
|
443
|
+
if inspector_logs:
|
|
444
|
+
lines.extend(
|
|
445
|
+
[
|
|
446
|
+
"",
|
|
447
|
+
"| log | session | model | status | path | inspector |",
|
|
448
|
+
"| ---: | --- | --- | ---: | --- | --- |",
|
|
449
|
+
]
|
|
450
|
+
)
|
|
451
|
+
for item in inspector_logs:
|
|
452
|
+
lines.append(
|
|
453
|
+
"| "
|
|
454
|
+
+ " | ".join(
|
|
455
|
+
[
|
|
456
|
+
md_cell(item.get("id")),
|
|
457
|
+
md_cell(item.get("session_id")),
|
|
458
|
+
md_cell(item.get("model")),
|
|
459
|
+
md_cell(item.get("status")),
|
|
460
|
+
md_cell(item.get("path")),
|
|
461
|
+
md_artifact_external_link(str(item.get("inspector_url") or ""), "open"),
|
|
462
|
+
]
|
|
463
|
+
)
|
|
464
|
+
+ " |"
|
|
465
|
+
)
|
|
466
|
+
lines.append("")
|
|
467
|
+
return "\n".join(lines)
|
|
468
|
+
|
|
469
|
+
# -- log parsing: model
|
|
470
|
+
|
|
471
|
+
def model_from_log(self, run_dir: Path) -> str:
|
|
472
|
+
log_path = run_dir / self.log_dir_name() / "opencode.log"
|
|
473
|
+
if not log_path.exists():
|
|
474
|
+
return ""
|
|
475
|
+
from ..cli import read_trace_text
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
text = read_trace_text(log_path)
|
|
479
|
+
except OSError:
|
|
480
|
+
return ""
|
|
481
|
+
command_match = re.search(r"^\$\s+.+?\s--model\s+(\S+)", text, re.MULTILINE)
|
|
482
|
+
if command_match:
|
|
483
|
+
return command_match.group(1)
|
|
484
|
+
stream_match = re.search(r"providerID=(\S+)\s+modelID=(\S+)", text)
|
|
485
|
+
if stream_match:
|
|
486
|
+
return f"{stream_match.group(1)}/{stream_match.group(2)}"
|
|
487
|
+
runtime_match = re.search(r"llm\.provider=(\S+)\s+llm\.model=(\S+)", text)
|
|
488
|
+
if runtime_match:
|
|
489
|
+
return f"{runtime_match.group(1)}/{runtime_match.group(2)}"
|
|
490
|
+
return ""
|
|
491
|
+
|
|
492
|
+
# -- log parsing: activity
|
|
493
|
+
|
|
494
|
+
def extract_activity_from_text(self, text: str, *, tail_lines: int = 500) -> dict[str, Any]:
|
|
495
|
+
from ..cli import plain_log_text
|
|
496
|
+
|
|
497
|
+
lines = text.splitlines()[-tail_lines:]
|
|
498
|
+
sessions: set[str] = set()
|
|
499
|
+
subagent_sessions: set[str] = set()
|
|
500
|
+
touched_files: list[str] = []
|
|
501
|
+
latest: dict[str, Any] = {}
|
|
502
|
+
event_count = 0
|
|
503
|
+
|
|
504
|
+
def remember(
|
|
505
|
+
session_id: str | None,
|
|
506
|
+
*,
|
|
507
|
+
mode: str | None = None,
|
|
508
|
+
agent: str | None = None,
|
|
509
|
+
model: str | None = None,
|
|
510
|
+
message: str | None = None,
|
|
511
|
+
timestamp: str | None = None,
|
|
512
|
+
) -> None:
|
|
513
|
+
nonlocal event_count, latest
|
|
514
|
+
normalized_session = normalize_agent_value(session_id)
|
|
515
|
+
if not normalized_session:
|
|
516
|
+
return
|
|
517
|
+
event_count += 1
|
|
518
|
+
sessions.add(normalized_session)
|
|
519
|
+
normalized_mode = normalize_agent_value(mode)
|
|
520
|
+
if normalized_mode == "subagent":
|
|
521
|
+
subagent_sessions.add(normalized_session)
|
|
522
|
+
latest = {
|
|
523
|
+
"latest_session_id": normalized_session,
|
|
524
|
+
"latest_mode": normalized_mode,
|
|
525
|
+
"latest_agent": normalize_agent_value(agent),
|
|
526
|
+
"latest_model": normalize_agent_value(model),
|
|
527
|
+
"latest_message": normalize_agent_value(message),
|
|
528
|
+
"latest_timestamp": normalize_agent_value(timestamp),
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
for raw_line in lines:
|
|
532
|
+
line = plain_log_text(raw_line).strip()
|
|
533
|
+
if not line:
|
|
534
|
+
continue
|
|
535
|
+
if line.startswith("{"):
|
|
536
|
+
try:
|
|
537
|
+
payload = json.loads(line)
|
|
538
|
+
except json.JSONDecodeError:
|
|
539
|
+
payload = None
|
|
540
|
+
if isinstance(payload, dict):
|
|
541
|
+
part = payload.get("part") if isinstance(payload.get("part"), dict) else {}
|
|
542
|
+
session_id = payload.get("sessionID") or part.get("sessionID")
|
|
543
|
+
if not session_id:
|
|
544
|
+
session_id = payload.get("turnID") or (part or {}).get("turnID")
|
|
545
|
+
remember(
|
|
546
|
+
str(session_id or ""),
|
|
547
|
+
mode=str(part.get("mode", "") if isinstance(part, dict) else ""),
|
|
548
|
+
agent=str(part.get("agent", "") if isinstance(part, dict) else ""),
|
|
549
|
+
message=str(payload.get("type") or ""),
|
|
550
|
+
timestamp=str(payload.get("timestamp") or ""),
|
|
551
|
+
)
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
values = parse_log_key_values(line)
|
|
555
|
+
session_id = values.get("session.id")
|
|
556
|
+
if not session_id and "message=created" in line:
|
|
557
|
+
session_id = values.get("id")
|
|
558
|
+
if session_id:
|
|
559
|
+
remember(
|
|
560
|
+
session_id,
|
|
561
|
+
mode=values.get("mode"),
|
|
562
|
+
agent=values.get("agent"),
|
|
563
|
+
model=values.get("modelID") or values.get("llm.model") or values.get("model"),
|
|
564
|
+
message=values.get("message"),
|
|
565
|
+
timestamp=values.get("timestamp"),
|
|
566
|
+
)
|
|
567
|
+
touched = values.get("file") if values.get("message") == "touching file" else None
|
|
568
|
+
if touched:
|
|
569
|
+
touched_files.append(touched)
|
|
570
|
+
|
|
571
|
+
latest.update(
|
|
572
|
+
{
|
|
573
|
+
"event_count": event_count,
|
|
574
|
+
"session_count": len(sessions),
|
|
575
|
+
"subagent_session_count": len(subagent_sessions),
|
|
576
|
+
"has_subagent_activity": bool(subagent_sessions) or latest.get("latest_mode") == "subagent",
|
|
577
|
+
"touched_file_count": len(touched_files),
|
|
578
|
+
"latest_touched_file": touched_files[-1] if touched_files else "",
|
|
579
|
+
}
|
|
580
|
+
)
|
|
581
|
+
return latest
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _mimocode_active_model(execution: dict[str, Any]) -> str:
|
|
585
|
+
for key in ("actual_model", "model"):
|
|
586
|
+
value = normalize_agent_value(str(execution.get(key) or ""))
|
|
587
|
+
if value and value != "inherit":
|
|
588
|
+
return value
|
|
589
|
+
return ""
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _mimocode_set_option_before_message(command: list[str], flag: str, value: str) -> list[str]:
|
|
593
|
+
for index, item in enumerate(command):
|
|
594
|
+
text = str(item)
|
|
595
|
+
if text == flag:
|
|
596
|
+
if index + 1 >= len(command):
|
|
597
|
+
raise RuntimeError(f"{flag} is present without a following value")
|
|
598
|
+
result = list(command)
|
|
599
|
+
result[index + 1] = value
|
|
600
|
+
return result
|
|
601
|
+
if text.startswith(f"{flag}="):
|
|
602
|
+
result = list(command)
|
|
603
|
+
result[index] = f"{flag}={value}"
|
|
604
|
+
return result
|
|
605
|
+
insert_at = len(command)
|
|
606
|
+
for index, item in enumerate(command):
|
|
607
|
+
if str(item) == DEFAULT_ATTACHED_INSTRUCTION_PROMPT:
|
|
608
|
+
insert_at = index
|
|
609
|
+
break
|
|
610
|
+
return [*command[:insert_at], flag, value, *command[insert_at:]]
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# -- shared session helpers (same as opencode.py)
|
|
614
|
+
|
|
615
|
+
def _ensure_session_record(
|
|
616
|
+
records: dict[str, dict[str, Any]], session_id: str
|
|
617
|
+
) -> dict[str, Any]:
|
|
618
|
+
record = records.get(session_id)
|
|
619
|
+
if record is None:
|
|
620
|
+
record = {
|
|
621
|
+
"session_id": session_id,
|
|
622
|
+
"parent_id": None,
|
|
623
|
+
"agent": None,
|
|
624
|
+
"title": None,
|
|
625
|
+
"provider": None,
|
|
626
|
+
"model": None,
|
|
627
|
+
"mode": None,
|
|
628
|
+
"first_seen": None,
|
|
629
|
+
"last_seen": None,
|
|
630
|
+
"step_count": 0,
|
|
631
|
+
"tool_call_count": 0,
|
|
632
|
+
"text_part_count": 0,
|
|
633
|
+
"error_count": 0,
|
|
634
|
+
"tokens": {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0},
|
|
635
|
+
}
|
|
636
|
+
records[session_id] = record
|
|
637
|
+
return record
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _update_session_seen(record: dict[str, Any], timestamp: str | None) -> None:
|
|
641
|
+
if not timestamp:
|
|
642
|
+
return
|
|
643
|
+
if record.get("first_seen") is None or str(timestamp) < str(record.get("first_seen")):
|
|
644
|
+
record["first_seen"] = timestamp
|
|
645
|
+
if record.get("last_seen") is None or str(timestamp) > str(record.get("last_seen")):
|
|
646
|
+
record["last_seen"] = timestamp
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _session_timestamp_from_json(payload: dict[str, Any]) -> str | None:
|
|
650
|
+
import datetime as dt
|
|
651
|
+
|
|
652
|
+
value = payload.get("timestamp")
|
|
653
|
+
if isinstance(value, (int, float)):
|
|
654
|
+
try:
|
|
655
|
+
return dt.datetime.fromtimestamp(value / 1000, tz=dt.timezone.utc).isoformat()
|
|
656
|
+
except (OverflowError, OSError, ValueError):
|
|
657
|
+
return str(value)
|
|
658
|
+
if value is None:
|
|
659
|
+
return None
|
|
660
|
+
return str(value)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _apply_step_tokens(record: dict[str, Any], tokens: dict[str, Any]) -> None:
|
|
664
|
+
bucket = record.setdefault("tokens", {})
|
|
665
|
+
for key in ("input", "output", "reasoning"):
|
|
666
|
+
value = tokens.get(key)
|
|
667
|
+
if isinstance(value, int):
|
|
668
|
+
bucket[key] = int(bucket.get(key, 0)) + value
|
|
669
|
+
cache = tokens.get("cache")
|
|
670
|
+
if isinstance(cache, dict):
|
|
671
|
+
read = cache.get("read")
|
|
672
|
+
write = cache.get("write")
|
|
673
|
+
if isinstance(read, int):
|
|
674
|
+
bucket["cache_read"] = int(bucket.get("cache_read", 0)) + read
|
|
675
|
+
if isinstance(write, int):
|
|
676
|
+
bucket["cache_write"] = int(bucket.get("cache_write", 0)) + write
|