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,516 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import urllib.parse
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .base import DEFAULT_AGENT_PROMPT, _command_get_named_arg, _command_set_named_arg, normalize_agent_value
10
+ from .opencode import OpenCodeRunner
11
+
12
+
13
+ class ClaudeCodeRunner(OpenCodeRunner):
14
+ """Claude Code CLI agent runner adapter."""
15
+
16
+ @property
17
+ def name(self) -> str:
18
+ return "claude"
19
+
20
+ @property
21
+ def binary(self) -> str:
22
+ return "claude"
23
+
24
+ @property
25
+ def config_key(self) -> str:
26
+ return "claude"
27
+
28
+ # -- command building
29
+
30
+ def default_command(self, config: dict[str, Any]) -> list[str]:
31
+ executable = self.executable(config)
32
+ return [
33
+ executable,
34
+ "-p",
35
+ "--output-format",
36
+ "stream-json",
37
+ "--verbose",
38
+ "--dangerously-skip-permissions",
39
+ DEFAULT_AGENT_PROMPT,
40
+ ]
41
+
42
+ def executable(self, config: dict[str, Any]) -> str:
43
+ for value in (
44
+ (config.get("agent") or {}).get("executable") if isinstance(config.get("agent"), dict) else None,
45
+ (config.get("claude") or {}).get("executable") if isinstance(config.get("claude"), dict) else None,
46
+ (config.get("execution") or {}).get("claude_executable") if isinstance(config.get("execution"), dict) else None,
47
+ ((config.get("execution") or {}).get("claude") or {}).get("executable")
48
+ if isinstance(config.get("execution"), dict) and isinstance((config.get("execution") or {}).get("claude"), dict)
49
+ else None,
50
+ ):
51
+ text = str(value or "").strip()
52
+ if text:
53
+ return text
54
+ return "claude"
55
+
56
+ def format_flags(self) -> list[str]:
57
+ return ["--output-format", "stream-json"]
58
+
59
+ def command_with_model(self, command: list[str], model: str) -> list[str]:
60
+ model = _claude_model_name(model)
61
+ if _command_get_named_arg(command, "--model"):
62
+ return _command_set_named_arg(command, "--model", model)
63
+ if _command_get_named_arg(command, "-m"):
64
+ return _command_set_named_arg(command, "-m", model)
65
+ if command and _is_claude_executable(str(command[0])):
66
+ previous = str(command[-2]) if len(command) >= 2 else ""
67
+ last = str(command[-1])
68
+ if last and not last.startswith("-") and previous not in _CLAUDE_VALUE_FLAGS:
69
+ return [*command[:-1], "--model", model, command[-1]]
70
+ return [*command, "--model", model]
71
+
72
+ def command_with_variant(self, command: list[str], variant: str) -> list[str]:
73
+ return command
74
+
75
+ def variant_cli_flag(self) -> str:
76
+ return ""
77
+
78
+ def variant_from_command(self, command: list[str]) -> str:
79
+ return ""
80
+
81
+ def default_worker_tools(self, config: dict[str, Any] | None = None) -> list[str]:
82
+ return [self.executable(config or {})]
83
+
84
+ # -- log directory
85
+
86
+ def log_dir_name(self) -> str:
87
+ return "claude"
88
+
89
+ # -- asset installation
90
+
91
+ def default_asset_installs(self) -> list[dict[str, Any]]:
92
+ return [
93
+ {
94
+ "source": "work/work/skills/*",
95
+ "target": ".claude/skills",
96
+ "kind": "skills",
97
+ "entries": "directories",
98
+ "required": False,
99
+ "clean": True,
100
+ },
101
+ {
102
+ "source": "work/work/skills/versions.json",
103
+ "target": ".claude/skills",
104
+ "kind": "skill-manifest",
105
+ "entries": "files",
106
+ "required": False,
107
+ "clean": False,
108
+ },
109
+ {
110
+ "source": "work/skills/*",
111
+ "target": ".claude/skills",
112
+ "kind": "skills-flat-fallback",
113
+ "entries": "directories",
114
+ "required": False,
115
+ "clean": False,
116
+ },
117
+ {
118
+ "source": "work/skills/versions.json",
119
+ "target": ".claude/skills",
120
+ "kind": "skill-manifest-flat-fallback",
121
+ "entries": "files",
122
+ "required": False,
123
+ "clean": False,
124
+ },
125
+ {
126
+ "source": "work/work/skills/*.md",
127
+ "target": ".claude/agents",
128
+ "kind": "agents",
129
+ "required": False,
130
+ "clean": True,
131
+ },
132
+ {
133
+ "source": "work/skills/*.md",
134
+ "target": ".claude/agents",
135
+ "kind": "agents-flat-fallback",
136
+ "required": False,
137
+ "clean": False,
138
+ },
139
+ ]
140
+
141
+ # -- runtime profile seeding
142
+
143
+ def runtime_profile_candidates(
144
+ self, base_env: dict[str, str], runtime: dict[str, str]
145
+ ) -> list[tuple[Path | None, Path, str]]:
146
+ from ..cli import existing_path
147
+
148
+ home = Path(runtime["home_dir"])
149
+ appdata = Path(runtime.get("appdata_dir", home / "AppData" / "Roaming"))
150
+ localappdata = Path(runtime.get("localappdata_dir", home / "AppData" / "Local"))
151
+ config_dir = Path(runtime["config_dir"])
152
+ data_dir = Path(runtime["data_dir"])
153
+
154
+ return [
155
+ (existing_path(base_env.get("USERPROFILE"), ".claude"), home / ".claude", "USERPROFILE/.claude"),
156
+ (existing_path(base_env.get("HOME"), ".claude"), home / ".claude", "HOME/.claude"),
157
+ (existing_path(base_env.get("USERPROFILE"), ".config", "claude"), config_dir / "claude", "USERPROFILE/.config/claude"),
158
+ (existing_path(base_env.get("USERPROFILE"), ".local", "share", "claude"), data_dir / "claude", "USERPROFILE/.local/share/claude"),
159
+ (existing_path(base_env.get("USERPROFILE"), ".cache", "claude"), Path(runtime["cache_dir"]) / "claude", "USERPROFILE/.cache/claude"),
160
+ (existing_path(base_env.get("APPDATA"), "Claude"), appdata / "Claude", "APPDATA/Claude"),
161
+ (existing_path(base_env.get("APPDATA"), "claude"), appdata / "claude", "APPDATA/claude"),
162
+ (existing_path(base_env.get("LOCALAPPDATA"), "Claude"), localappdata / "Claude", "LOCALAPPDATA/Claude"),
163
+ (existing_path(base_env.get("LOCALAPPDATA"), "claude"), localappdata / "claude", "LOCALAPPDATA/claude"),
164
+ (existing_path(base_env.get("XDG_CONFIG_HOME"), "claude"), config_dir / "claude", "XDG_CONFIG_HOME/claude"),
165
+ (existing_path(base_env.get("XDG_DATA_HOME"), "claude"), data_dir / "claude", "XDG_DATA_HOME/claude"),
166
+ ]
167
+
168
+ # -- provider config
169
+
170
+ def write_agent_config(self, manifest: dict[str, Any]) -> str | None:
171
+ runtime = manifest.get("runtime") or {}
172
+ home = runtime.get("home_dir")
173
+ config_dir = Path(home) / ".claude" if home else None
174
+ if config_dir:
175
+ config_dir.mkdir(parents=True, exist_ok=True)
176
+ settings_path = config_dir / "settings.json" if config_dir else None
177
+
178
+ from ..cli import agent_inspector_settings, execution_agent_provider
179
+
180
+ execution = manifest.setdefault("execution", {})
181
+ if not isinstance(execution, dict):
182
+ return str(config_dir) if config_dir else None
183
+
184
+ provider = execution_agent_provider(execution, self)
185
+ inspector = agent_inspector_settings(execution)
186
+ base_url = ""
187
+ api_key = ""
188
+ if isinstance(provider, dict):
189
+ base_url = str(provider.get("baseURL") or provider.get("base_url") or "").strip()
190
+ api_key = str(provider.get("apiKey") or provider.get("api_key") or "").strip()
191
+ if not base_url and inspector.get("enabled"):
192
+ base_url = str(inspector.get("proxy_url") or "").strip()
193
+ if base_url:
194
+ env = execution.setdefault("env", {})
195
+ if not isinstance(env, dict):
196
+ raise RuntimeError("execution.env must be a JSON object")
197
+ env.setdefault("ANTHROPIC_BASE_URL", base_url.rstrip("/"))
198
+ env.setdefault("ANTHROPIC_API_KEY", api_key or "agent-inspector")
199
+ env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
200
+ env.setdefault("DISABLE_TELEMETRY", "1")
201
+ active_model = _claude_active_model(execution)
202
+ if active_model:
203
+ env.setdefault("ANTHROPIC_MODEL", active_model)
204
+ env.setdefault("CLAUDE_CODE_SUBAGENT_MODEL", active_model)
205
+ for key in _CLAUDE_ALIAS_MODEL_ENV_KEYS:
206
+ env.setdefault(key, active_model)
207
+ if settings_path:
208
+ _write_claude_settings_model_pin(settings_path, active_model, env)
209
+ return str(config_dir) if config_dir else None
210
+
211
+ # -- log parsing: sessions
212
+
213
+ def extract_sessions_from_text(
214
+ self, text: str, *, inspector_base_url: str = "http://127.0.0.1:25947"
215
+ ) -> dict[str, Any]:
216
+ from ..cli import OPENCODE_SESSION_SCHEMA, plain_log_text, utc_now_seconds_text
217
+
218
+ records: dict[str, dict[str, Any]] = {}
219
+ order: list[str] = []
220
+ primary_session_id: str | None = None
221
+
222
+ def remember(session_id: str) -> dict[str, Any]:
223
+ if session_id not in records:
224
+ order.append(session_id)
225
+ records[session_id] = {
226
+ "session_id": session_id,
227
+ "parent_id": None,
228
+ "agent": None,
229
+ "title": None,
230
+ "provider": "anthropic",
231
+ "model": None,
232
+ "mode": None,
233
+ "first_seen": None,
234
+ "last_seen": None,
235
+ "step_count": 0,
236
+ "tool_call_count": 0,
237
+ "text_part_count": 0,
238
+ "error_count": 0,
239
+ "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0},
240
+ }
241
+ return records[session_id]
242
+
243
+ for raw_line in text.splitlines():
244
+ line = plain_log_text(raw_line).strip()
245
+ if not line.startswith("{"):
246
+ continue
247
+ try:
248
+ payload = json.loads(line)
249
+ except json.JSONDecodeError:
250
+ continue
251
+ if not isinstance(payload, dict):
252
+ continue
253
+ session_id = normalize_agent_value(
254
+ str(payload.get("session_id") or payload.get("sessionID") or "")
255
+ )
256
+ if not session_id:
257
+ continue
258
+ record = remember(session_id)
259
+ timestamp = normalize_agent_value(str(payload.get("timestamp") or ""))
260
+ _update_seen(record, timestamp)
261
+ event_type = str(payload.get("type") or "")
262
+ if event_type == "result":
263
+ record["step_count"] = int(record.get("step_count", 0)) + 1
264
+ elif event_type == "error":
265
+ record["error_count"] = int(record.get("error_count", 0)) + 1
266
+ message = payload.get("message")
267
+ if isinstance(message, dict):
268
+ model = normalize_agent_value(str(message.get("model") or ""))
269
+ if model:
270
+ record["model"] = model
271
+ _apply_claude_usage(record, message.get("usage"))
272
+ content = message.get("content")
273
+ if isinstance(content, list):
274
+ for block in content:
275
+ if not isinstance(block, dict):
276
+ continue
277
+ block_type = str(block.get("type") or "")
278
+ if block_type == "tool_use":
279
+ record["tool_call_count"] = int(record.get("tool_call_count", 0)) + 1
280
+ elif block_type == "text":
281
+ record["text_part_count"] = int(record.get("text_part_count", 0)) + 1
282
+ model = normalize_agent_value(str(payload.get("model") or ""))
283
+ if model:
284
+ record["model"] = model
285
+ if primary_session_id is None:
286
+ primary_session_id = session_id
287
+
288
+ sessions = [records[session_id] for session_id in order]
289
+ base = inspector_base_url.rstrip("/") or "http://127.0.0.1:25947"
290
+ for record in sessions:
291
+ record["inspector_url"] = f"{base}/?sessionId={urllib.parse.quote(str(record['session_id']))}"
292
+ if not primary_session_id and sessions:
293
+ primary_session_id = str(sessions[0]["session_id"])
294
+ return {
295
+ "schema": OPENCODE_SESSION_SCHEMA,
296
+ "generated_at": utc_now_seconds_text(),
297
+ "inspector_base_url": base,
298
+ "inspector_sessions_api": f"{base}/api/sessions",
299
+ "primary_session_id": primary_session_id,
300
+ "session_count": len(sessions),
301
+ "subagent_session_count": sum(1 for item in sessions if item.get("parent_id")),
302
+ "sessions": sessions,
303
+ }
304
+
305
+ def render_sessions_md(self, summary: dict[str, Any]) -> str:
306
+ text = super().render_sessions_md(summary)
307
+ return text.replace("# Claude Sessions", "# Claude Code Sessions", 1)
308
+
309
+ # -- log parsing: model
310
+
311
+ def model_from_log(self, run_dir: Path) -> str:
312
+ log_path = run_dir / self.log_dir_name() / "opencode.log"
313
+ if not log_path.exists():
314
+ return ""
315
+ from ..cli import read_trace_text
316
+
317
+ try:
318
+ text = read_trace_text(log_path)
319
+ except OSError:
320
+ return ""
321
+ command_match = re.search(r"^\$\s+.+?\s--model\s+(\S+)", text, re.MULTILINE)
322
+ if command_match:
323
+ return command_match.group(1)
324
+ for line in text.splitlines():
325
+ line = line.strip()
326
+ if not line.startswith("{"):
327
+ continue
328
+ try:
329
+ payload = json.loads(line)
330
+ except json.JSONDecodeError:
331
+ continue
332
+ if not isinstance(payload, dict):
333
+ continue
334
+ model = normalize_agent_value(str(payload.get("model") or ""))
335
+ message = payload.get("message")
336
+ if not model and isinstance(message, dict):
337
+ model = normalize_agent_value(str(message.get("model") or ""))
338
+ if model:
339
+ return model
340
+ return ""
341
+
342
+ # -- log parsing: activity
343
+
344
+ def extract_activity_from_text(self, text: str, *, tail_lines: int = 500) -> dict[str, Any]:
345
+ from ..cli import plain_log_text
346
+
347
+ lines = text.splitlines()[-tail_lines:]
348
+ sessions: set[str] = set()
349
+ latest: dict[str, Any] = {}
350
+ event_count = 0
351
+ touched_files: list[str] = []
352
+
353
+ for raw_line in lines:
354
+ line = plain_log_text(raw_line).strip()
355
+ if not line.startswith("{"):
356
+ continue
357
+ try:
358
+ payload = json.loads(line)
359
+ except json.JSONDecodeError:
360
+ continue
361
+ if not isinstance(payload, dict):
362
+ continue
363
+ session_id = normalize_agent_value(str(payload.get("session_id") or payload.get("sessionID") or ""))
364
+ if not session_id:
365
+ continue
366
+ event_count += 1
367
+ sessions.add(session_id)
368
+ message = payload.get("message")
369
+ model = normalize_agent_value(str(payload.get("model") or ""))
370
+ if not model and isinstance(message, dict):
371
+ model = normalize_agent_value(str(message.get("model") or ""))
372
+ latest = {
373
+ "latest_session_id": session_id,
374
+ "latest_mode": None,
375
+ "latest_agent": None,
376
+ "latest_model": model,
377
+ "latest_message": normalize_agent_value(str(payload.get("type") or "")),
378
+ "latest_timestamp": normalize_agent_value(str(payload.get("timestamp") or "")),
379
+ }
380
+ if isinstance(message, dict) and isinstance(message.get("content"), list):
381
+ for block in message["content"]:
382
+ if isinstance(block, dict) and block.get("type") == "tool_use":
383
+ name = str(block.get("name") or "")
384
+ if name in {"Edit", "Write", "MultiEdit"}:
385
+ tool_input = block.get("input")
386
+ if isinstance(tool_input, dict) and tool_input.get("file_path"):
387
+ touched_files.append(str(tool_input["file_path"]))
388
+
389
+ latest.update(
390
+ {
391
+ "event_count": event_count,
392
+ "session_count": len(sessions),
393
+ "subagent_session_count": 0,
394
+ "has_subagent_activity": False,
395
+ "touched_file_count": len(touched_files),
396
+ "latest_touched_file": touched_files[-1] if touched_files else "",
397
+ }
398
+ )
399
+ return latest
400
+
401
+
402
+ _CLAUDE_VALUE_FLAGS = {
403
+ "--add-dir",
404
+ "--agent",
405
+ "--agents",
406
+ "--allowedTools",
407
+ "--allowed-tools",
408
+ "--append-system-prompt",
409
+ "--debug-file",
410
+ "--disallowedTools",
411
+ "--disallowed-tools",
412
+ "--effort",
413
+ "--fallback-model",
414
+ "--file",
415
+ "--input-format",
416
+ "--json-schema",
417
+ "--max-budget-usd",
418
+ "--mcp-config",
419
+ "--model",
420
+ "-m",
421
+ "--name",
422
+ "-n",
423
+ "--output-format",
424
+ "--permission-mode",
425
+ "--plugin-dir",
426
+ "--plugin-url",
427
+ "--resume",
428
+ "-r",
429
+ "--session-id",
430
+ "--setting-sources",
431
+ "--settings",
432
+ "--system-prompt",
433
+ "--tools",
434
+ }
435
+
436
+
437
+ def _is_claude_executable(value: str) -> bool:
438
+ name = Path(value).name.lower()
439
+ return name in {"claude", "claude.exe", "claude.cmd", "claude.ps1"}
440
+
441
+
442
+ def _claude_model_name(model: str) -> str:
443
+ text = str(model or "").strip()
444
+ prefix = "agent-inspector/"
445
+ if text.startswith(prefix):
446
+ return text[len(prefix) :]
447
+ return text
448
+
449
+
450
+ _CLAUDE_ALIAS_MODEL_ENV_KEYS = (
451
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
452
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
453
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
454
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
455
+ )
456
+
457
+
458
+ def _claude_active_model(execution: dict[str, Any]) -> str:
459
+ for key in ("actual_model", "model"):
460
+ value = _claude_model_name(str(execution.get(key) or "")).strip()
461
+ if value:
462
+ return value
463
+ env = execution.get("env")
464
+ if isinstance(env, dict):
465
+ for key in ("ANTHROPIC_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"):
466
+ value = _claude_model_name(str(env.get(key) or "")).strip()
467
+ if value and value != "inherit":
468
+ return value
469
+ return ""
470
+
471
+
472
+ def _write_claude_settings_model_pin(settings_path: Path, active_model: str, env: dict[str, Any]) -> None:
473
+ settings: dict[str, Any] = {}
474
+ if settings_path.exists():
475
+ try:
476
+ loaded = json.loads(settings_path.read_text(encoding="utf-8-sig"))
477
+ if isinstance(loaded, dict):
478
+ settings = loaded
479
+ except json.JSONDecodeError:
480
+ settings = {}
481
+ settings["model"] = str(env.get("ANTHROPIC_MODEL") or active_model)
482
+ settings_env = settings.setdefault("env", {})
483
+ if not isinstance(settings_env, dict):
484
+ settings_env = {}
485
+ settings["env"] = settings_env
486
+ for key in ("ANTHROPIC_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL", *_CLAUDE_ALIAS_MODEL_ENV_KEYS):
487
+ value = env.get(key)
488
+ if value:
489
+ settings_env[key] = str(value)
490
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
491
+ settings_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
492
+
493
+
494
+ def _update_seen(record: dict[str, Any], timestamp: str | None) -> None:
495
+ if not timestamp:
496
+ return
497
+ if record.get("first_seen") is None or str(timestamp) < str(record.get("first_seen")):
498
+ record["first_seen"] = timestamp
499
+ if record.get("last_seen") is None or str(timestamp) > str(record.get("last_seen")):
500
+ record["last_seen"] = timestamp
501
+
502
+
503
+ def _apply_claude_usage(record: dict[str, Any], usage: Any) -> None:
504
+ if not isinstance(usage, dict):
505
+ return
506
+ tokens = record.setdefault("tokens", {})
507
+ mapping = {
508
+ "input_tokens": "input",
509
+ "output_tokens": "output",
510
+ "cache_read_input_tokens": "cache_read",
511
+ "cache_creation_input_tokens": "cache_write",
512
+ }
513
+ for source, target in mapping.items():
514
+ value = usage.get(source)
515
+ if isinstance(value, int):
516
+ tokens[target] = int(tokens.get(target, 0)) + value