@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.
- 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,412 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .base import DEFAULT_AGENT_PROMPT, _command_set_named_arg
|
|
11
|
+
from .opencode import OpenCodeRunner
|
|
12
|
+
|
|
13
|
+
CODEX_EXECUTABLE_ENV_KEYS = (
|
|
14
|
+
"OEVAL_CODEX_EXECUTABLE",
|
|
15
|
+
"CODEX_EXECUTABLE",
|
|
16
|
+
"CODEX_CLI",
|
|
17
|
+
"CODEX_EXE",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CodexCliRunner(OpenCodeRunner):
|
|
22
|
+
"""Codex CLI agent runner adapter."""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def name(self) -> str:
|
|
26
|
+
return "codex"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def binary(self) -> str:
|
|
30
|
+
return "codex"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def config_key(self) -> str:
|
|
34
|
+
return "codex"
|
|
35
|
+
|
|
36
|
+
# -- command building
|
|
37
|
+
|
|
38
|
+
def default_command(self, config: dict[str, Any]) -> list[str]:
|
|
39
|
+
executable = self.executable(config)
|
|
40
|
+
return [
|
|
41
|
+
executable,
|
|
42
|
+
"exec",
|
|
43
|
+
"--json",
|
|
44
|
+
"--skip-git-repo-check",
|
|
45
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
46
|
+
"--cd",
|
|
47
|
+
"{workspace}",
|
|
48
|
+
DEFAULT_AGENT_PROMPT,
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
def executable(self, config: dict[str, Any]) -> str:
|
|
52
|
+
for value in (
|
|
53
|
+
(config.get("agent") or {}).get("executable") if isinstance(config.get("agent"), dict) else None,
|
|
54
|
+
(config.get("codex") or {}).get("executable") if isinstance(config.get("codex"), dict) else None,
|
|
55
|
+
(config.get("execution") or {}).get("codex_executable") if isinstance(config.get("execution"), dict) else None,
|
|
56
|
+
((config.get("execution") or {}).get("codex") or {}).get("executable")
|
|
57
|
+
if isinstance(config.get("execution"), dict) and isinstance((config.get("execution") or {}).get("codex"), dict)
|
|
58
|
+
else None,
|
|
59
|
+
):
|
|
60
|
+
text = str(value or "").strip()
|
|
61
|
+
if text:
|
|
62
|
+
return text
|
|
63
|
+
return discover_codex_executable() or "codex"
|
|
64
|
+
|
|
65
|
+
def format_flags(self) -> list[str]:
|
|
66
|
+
return ["--json"]
|
|
67
|
+
|
|
68
|
+
def command_with_variant(self, command: list[str], variant: str) -> list[str]:
|
|
69
|
+
return command
|
|
70
|
+
|
|
71
|
+
def variant_cli_flag(self) -> str:
|
|
72
|
+
return ""
|
|
73
|
+
|
|
74
|
+
def variant_from_command(self, command: list[str]) -> str:
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
def default_worker_tools(self, config: dict[str, Any] | None = None) -> list[str]:
|
|
78
|
+
return [self.executable(config or {})]
|
|
79
|
+
|
|
80
|
+
def command_with_model(self, command: list[str], model: str) -> list[str]:
|
|
81
|
+
model = _codex_cli_model_name(model)
|
|
82
|
+
for item in command:
|
|
83
|
+
text = str(item)
|
|
84
|
+
if text == "--model" or text.startswith("--model="):
|
|
85
|
+
return _command_set_named_arg(command, "--model", model)
|
|
86
|
+
if len(command) >= 3 and str(command[1]).lower() == "exec":
|
|
87
|
+
value_flags = {
|
|
88
|
+
"--ask-for-approval",
|
|
89
|
+
"--cd",
|
|
90
|
+
"--config",
|
|
91
|
+
"--model",
|
|
92
|
+
"--sandbox",
|
|
93
|
+
"-C",
|
|
94
|
+
"-c",
|
|
95
|
+
"-m",
|
|
96
|
+
}
|
|
97
|
+
previous = str(command[-2]) if len(command) >= 2 else ""
|
|
98
|
+
last = str(command[-1])
|
|
99
|
+
if last and not last.startswith("-") and previous not in value_flags:
|
|
100
|
+
return [*command[:-1], "--model", model, command[-1]]
|
|
101
|
+
return [*command, "--model", model]
|
|
102
|
+
|
|
103
|
+
# -- log directory
|
|
104
|
+
|
|
105
|
+
def log_dir_name(self) -> str:
|
|
106
|
+
return "codex"
|
|
107
|
+
|
|
108
|
+
# -- asset installation
|
|
109
|
+
|
|
110
|
+
def default_asset_installs(self) -> list[dict[str, Any]]:
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
"source": "work/work/skills/*",
|
|
114
|
+
"target": ".codex/skills",
|
|
115
|
+
"kind": "skills",
|
|
116
|
+
"entries": "directories",
|
|
117
|
+
"required": False,
|
|
118
|
+
"clean": True,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"source": "work/work/skills/versions.json",
|
|
122
|
+
"target": ".codex/skills",
|
|
123
|
+
"kind": "skill-manifest",
|
|
124
|
+
"entries": "files",
|
|
125
|
+
"required": False,
|
|
126
|
+
"clean": False,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"source": "work/skills/*",
|
|
130
|
+
"target": ".codex/skills",
|
|
131
|
+
"kind": "skills-flat-fallback",
|
|
132
|
+
"entries": "directories",
|
|
133
|
+
"required": False,
|
|
134
|
+
"clean": False,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"source": "work/skills/versions.json",
|
|
138
|
+
"target": ".codex/skills",
|
|
139
|
+
"kind": "skill-manifest-flat-fallback",
|
|
140
|
+
"entries": "files",
|
|
141
|
+
"required": False,
|
|
142
|
+
"clean": False,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"source": "work/work/skills/*.md",
|
|
146
|
+
"target": ".codex/agents",
|
|
147
|
+
"kind": "agents",
|
|
148
|
+
"required": False,
|
|
149
|
+
"clean": True,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"source": "work/skills/*.md",
|
|
153
|
+
"target": ".codex/agents",
|
|
154
|
+
"kind": "agents-flat-fallback",
|
|
155
|
+
"required": False,
|
|
156
|
+
"clean": False,
|
|
157
|
+
},
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
# -- runtime profile seeding
|
|
161
|
+
|
|
162
|
+
def runtime_profile_candidates(
|
|
163
|
+
self, base_env: dict[str, str], runtime: dict[str, str]
|
|
164
|
+
) -> list[tuple[Path | None, Path, str]]:
|
|
165
|
+
from ..cli import existing_path
|
|
166
|
+
|
|
167
|
+
home = Path(runtime["home_dir"])
|
|
168
|
+
appdata = Path(runtime.get("appdata_dir", home / "AppData" / "Roaming"))
|
|
169
|
+
localappdata = Path(runtime.get("localappdata_dir", home / "AppData" / "Local"))
|
|
170
|
+
config_dir = Path(runtime["config_dir"])
|
|
171
|
+
data_dir = Path(runtime["data_dir"])
|
|
172
|
+
|
|
173
|
+
return [
|
|
174
|
+
(existing_path(base_env.get("CODEX_HOME")), home / ".codex", "CODEX_HOME"),
|
|
175
|
+
(existing_path(base_env.get("USERPROFILE"), ".codex"), home / ".codex", "USERPROFILE/.codex"),
|
|
176
|
+
(existing_path(base_env.get("HOME"), ".codex"), home / ".codex", "HOME/.codex"),
|
|
177
|
+
(existing_path(base_env.get("USERPROFILE"), ".config", "codex"), config_dir / "codex", "USERPROFILE/.config/codex"),
|
|
178
|
+
(existing_path(base_env.get("USERPROFILE"), ".local", "share", "codex"), data_dir / "codex", "USERPROFILE/.local/share/codex"),
|
|
179
|
+
(existing_path(base_env.get("USERPROFILE"), ".cache", "codex"), Path(runtime["cache_dir"]) / "codex", "USERPROFILE/.cache/codex"),
|
|
180
|
+
(existing_path(base_env.get("APPDATA"), "codex"), appdata / "codex", "APPDATA/codex"),
|
|
181
|
+
(existing_path(base_env.get("LOCALAPPDATA"), "codex"), localappdata / "codex", "LOCALAPPDATA/codex"),
|
|
182
|
+
(existing_path(base_env.get("XDG_CONFIG_HOME"), "codex"), config_dir / "codex", "XDG_CONFIG_HOME/codex"),
|
|
183
|
+
(existing_path(base_env.get("XDG_DATA_HOME"), "codex"), data_dir / "codex", "XDG_DATA_HOME/codex"),
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
# -- provider config
|
|
187
|
+
|
|
188
|
+
def write_agent_config(self, manifest: dict[str, Any]) -> str | None:
|
|
189
|
+
runtime = manifest.get("runtime") or {}
|
|
190
|
+
home = runtime.get("home_dir")
|
|
191
|
+
if not home:
|
|
192
|
+
return None
|
|
193
|
+
config_dir = Path(home) / ".codex"
|
|
194
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
config_path = config_dir / "config.toml"
|
|
196
|
+
|
|
197
|
+
from ..cli import execution_agent_provider # local to avoid circular import
|
|
198
|
+
|
|
199
|
+
execution = manifest.get("execution", {})
|
|
200
|
+
provider = execution_agent_provider(execution, self)
|
|
201
|
+
if not isinstance(provider, dict):
|
|
202
|
+
return str(config_path)
|
|
203
|
+
provider_id = str(provider.get("id", "agent-inspector")).strip() or "agent-inspector"
|
|
204
|
+
if provider_id != "agent-inspector":
|
|
205
|
+
return str(config_path)
|
|
206
|
+
|
|
207
|
+
base_url = str(provider.get("baseURL", "http://127.0.0.1:25947/proxy")).strip()
|
|
208
|
+
if not base_url:
|
|
209
|
+
base_url = "http://127.0.0.1:25947/proxy"
|
|
210
|
+
base_url = _codex_provider_base_url(base_url)
|
|
211
|
+
provider_name = str(provider.get("name", "Agent Inspector")).strip() or "Agent Inspector"
|
|
212
|
+
api_key = str(provider.get("apiKey", "") or "")
|
|
213
|
+
wire_api = _codex_provider_wire_api(provider)
|
|
214
|
+
|
|
215
|
+
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
216
|
+
preserved = _remove_codex_inspector_config(existing)
|
|
217
|
+
top_level_block = [
|
|
218
|
+
"# Managed by eval-harness for Agent Inspector runs.",
|
|
219
|
+
f'model_provider = "{_toml_escape(provider_id)}"',
|
|
220
|
+
]
|
|
221
|
+
provider_block = [
|
|
222
|
+
"",
|
|
223
|
+
"# Managed by eval-harness for Agent Inspector runs.",
|
|
224
|
+
f'[model_providers."{_toml_escape(provider_id)}"]',
|
|
225
|
+
f'name = "{_toml_escape(provider_name)}"',
|
|
226
|
+
f'base_url = "{_toml_escape(base_url)}"',
|
|
227
|
+
f'wire_api = "{_toml_escape(wire_api)}"',
|
|
228
|
+
]
|
|
229
|
+
if api_key:
|
|
230
|
+
provider_block.append(f'experimental_bearer_token = "{_toml_escape(api_key)}"')
|
|
231
|
+
text = _insert_codex_top_level_config(preserved, top_level_block)
|
|
232
|
+
text = text.rstrip() + "\n" + "\n".join(provider_block) + "\n"
|
|
233
|
+
config_path.write_text(text, encoding="utf-8")
|
|
234
|
+
return str(config_path)
|
|
235
|
+
|
|
236
|
+
# -- presentation
|
|
237
|
+
|
|
238
|
+
def extract_sessions_from_text(
|
|
239
|
+
self, text: str, *, inspector_base_url: str = "http://127.0.0.1:25947"
|
|
240
|
+
) -> dict[str, Any]:
|
|
241
|
+
summary = super().extract_sessions_from_text(text, inspector_base_url=inspector_base_url)
|
|
242
|
+
thread_ids: list[str] = []
|
|
243
|
+
for raw_line in text.splitlines():
|
|
244
|
+
line = 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
|
+
thread_id = str(payload.get("thread_id") or "").strip()
|
|
254
|
+
if thread_id and thread_id not in thread_ids:
|
|
255
|
+
thread_ids.append(thread_id)
|
|
256
|
+
summary["codex_thread_ids"] = thread_ids
|
|
257
|
+
summary["primary_codex_thread_id"] = thread_ids[0] if thread_ids else None
|
|
258
|
+
return summary
|
|
259
|
+
|
|
260
|
+
def render_sessions_md(self, summary: dict[str, Any]) -> str:
|
|
261
|
+
text = super().render_sessions_md(summary)
|
|
262
|
+
text = text.replace("# Codex Sessions", "# Codex CLI Sessions", 1)
|
|
263
|
+
lines = [text.rstrip()]
|
|
264
|
+
thread_ids = summary.get("codex_thread_ids") or []
|
|
265
|
+
if thread_ids:
|
|
266
|
+
lines.extend(["", "## Codex Threads", ""])
|
|
267
|
+
for thread_id in thread_ids:
|
|
268
|
+
lines.append(f"- `{thread_id}`")
|
|
269
|
+
return "\n".join(lines) + "\n"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _codex_cli_model_name(model: str) -> str:
|
|
273
|
+
text = str(model or "").strip()
|
|
274
|
+
prefix = "agent-inspector/"
|
|
275
|
+
if text.startswith(prefix):
|
|
276
|
+
text = text[len(prefix) :]
|
|
277
|
+
return _codex_inspector_api_model_name(text)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def discover_codex_executable(env: dict[str, str] | None = None) -> str:
|
|
281
|
+
"""Find Codex CLI without relying on an interactive shell PATH."""
|
|
282
|
+
env_map = os.environ if env is None else env
|
|
283
|
+
for key in CODEX_EXECUTABLE_ENV_KEYS:
|
|
284
|
+
found = _existing_executable(env_map.get(key))
|
|
285
|
+
if found:
|
|
286
|
+
return found
|
|
287
|
+
|
|
288
|
+
if os.name == "nt":
|
|
289
|
+
candidates = _windows_codex_install_candidates(env_map)
|
|
290
|
+
if candidates:
|
|
291
|
+
return str(candidates[0])
|
|
292
|
+
|
|
293
|
+
for name in ("codex", "codex.exe"):
|
|
294
|
+
found = shutil.which(name)
|
|
295
|
+
if found:
|
|
296
|
+
return found
|
|
297
|
+
return ""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _existing_executable(value: Any) -> str:
|
|
301
|
+
text = str(value or "").strip().strip('"')
|
|
302
|
+
if not text:
|
|
303
|
+
return ""
|
|
304
|
+
path = Path(text)
|
|
305
|
+
if path.is_file():
|
|
306
|
+
return str(path)
|
|
307
|
+
found = shutil.which(text)
|
|
308
|
+
return found or ""
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _windows_codex_install_candidates(env: dict[str, str]) -> list[Path]:
|
|
312
|
+
roots: list[Path] = []
|
|
313
|
+
for raw in (
|
|
314
|
+
env.get("LOCALAPPDATA"),
|
|
315
|
+
str(Path(env["USERPROFILE"]) / "AppData" / "Local") if env.get("USERPROFILE") else "",
|
|
316
|
+
str(Path.home() / "AppData" / "Local"),
|
|
317
|
+
):
|
|
318
|
+
text = str(raw or "").strip()
|
|
319
|
+
if not text:
|
|
320
|
+
continue
|
|
321
|
+
root = Path(text) / "OpenAI" / "Codex" / "bin"
|
|
322
|
+
if root not in roots:
|
|
323
|
+
roots.append(root)
|
|
324
|
+
|
|
325
|
+
candidates: list[Path] = []
|
|
326
|
+
for root in roots:
|
|
327
|
+
if not root.exists():
|
|
328
|
+
continue
|
|
329
|
+
candidates.extend(root.glob("codex.exe"))
|
|
330
|
+
candidates.extend(root.glob("*/codex.exe"))
|
|
331
|
+
|
|
332
|
+
unique: dict[str, Path] = {}
|
|
333
|
+
for path in candidates:
|
|
334
|
+
if path.is_file():
|
|
335
|
+
unique[str(path).lower()] = path
|
|
336
|
+
|
|
337
|
+
def sort_key(path: Path) -> tuple[float, str]:
|
|
338
|
+
try:
|
|
339
|
+
mtime = path.stat().st_mtime
|
|
340
|
+
except OSError:
|
|
341
|
+
mtime = 0.0
|
|
342
|
+
return (mtime, str(path))
|
|
343
|
+
|
|
344
|
+
return sorted(unique.values(), key=sort_key, reverse=True)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _codex_inspector_api_model_name(model: str) -> str:
|
|
348
|
+
text = str(model or "").strip()
|
|
349
|
+
if re.match(r"(?i)^minimax\s+m", text):
|
|
350
|
+
return re.sub(r"\s+", "-", text)
|
|
351
|
+
return text
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _remove_codex_inspector_config(text: str) -> str:
|
|
355
|
+
lines = text.splitlines()
|
|
356
|
+
result: list[str] = []
|
|
357
|
+
skip = False
|
|
358
|
+
for line in lines:
|
|
359
|
+
stripped = line.strip()
|
|
360
|
+
if stripped == "# Managed by eval-harness for Agent Inspector runs.":
|
|
361
|
+
continue
|
|
362
|
+
if stripped == 'model_provider = "agent-inspector"':
|
|
363
|
+
continue
|
|
364
|
+
if stripped == '[model_providers."agent-inspector"]' or stripped == "[model_providers.agent-inspector]":
|
|
365
|
+
skip = True
|
|
366
|
+
continue
|
|
367
|
+
if skip and stripped.startswith("[") and stripped.endswith("]"):
|
|
368
|
+
skip = False
|
|
369
|
+
if not skip:
|
|
370
|
+
result.append(line)
|
|
371
|
+
return "\n".join(result)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _insert_codex_top_level_config(text: str, block: list[str]) -> str:
|
|
375
|
+
lines = text.rstrip().splitlines() if text.strip() else []
|
|
376
|
+
first_table = len(lines)
|
|
377
|
+
for index, line in enumerate(lines):
|
|
378
|
+
stripped = line.strip()
|
|
379
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
380
|
+
first_table = index
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
before = lines[:first_table]
|
|
384
|
+
after = lines[first_table:]
|
|
385
|
+
result: list[str] = []
|
|
386
|
+
result.extend(before)
|
|
387
|
+
if result and result[-1].strip():
|
|
388
|
+
result.append("")
|
|
389
|
+
result.extend(block)
|
|
390
|
+
if after:
|
|
391
|
+
result.append("")
|
|
392
|
+
result.extend(after)
|
|
393
|
+
return "\n".join(result)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _codex_provider_wire_api(provider: dict[str, Any]) -> str:
|
|
397
|
+
for key in ("codexWireApi", "codex_wire_api", "wireApi", "wire_api"):
|
|
398
|
+
value = str(provider.get(key, "") or "").strip()
|
|
399
|
+
if value:
|
|
400
|
+
return value
|
|
401
|
+
return "responses"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _codex_provider_base_url(base_url: str) -> str:
|
|
405
|
+
value = base_url.rstrip("/")
|
|
406
|
+
if value.endswith("/v1"):
|
|
407
|
+
return value
|
|
408
|
+
return f"{value}/v1"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _toml_escape(value: str) -> str:
|
|
412
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|