@tonyclaw/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.
@@ -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