whetkit 0.2.0__tar.gz → 0.4.0__tar.gz
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.
- {whetkit-0.2.0 → whetkit-0.4.0}/PKG-INFO +1 -1
- {whetkit-0.2.0 → whetkit-0.4.0}/pyproject.toml +1 -1
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/cli.py +343 -7
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/optimizer.py +129 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/generate.py +35 -7
- whetkit-0.4.0/src/whetkit/llm/pricing.py +27 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/mcp/transport.py +27 -1
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/scoring/aggregate.py +8 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/LICENSE +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/README.md +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/export.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/overlay.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/plan.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/datasets/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/datasets/tasks.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/doctor.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/anthropic_provider.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/base.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/openai_provider.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/registry.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/mcp/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/mcp/client.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/mcp/introspect.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/py.typed +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/report/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/report/builder.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/report/html.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/runner/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/runner/agent.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/scoring/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/scoring/deterministic.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/scoring/judge.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/tracing/__init__.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/tracing/records.py +0 -0
- {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/tracing/store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: whetkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Measure and improve how well LLM agents select and use the tools exposed by an MCP server
|
|
5
5
|
Keywords: mcp,model-context-protocol,llm,agents,evals,tool-use,tool-selection,benchmark
|
|
6
6
|
Author: Mohammed Benlamlih
|
|
@@ -16,6 +16,14 @@ app = typer.Typer(
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
def _resolve_server(value: str, http_mode: HttpMode) -> ServerSpec:
|
|
20
|
+
"""resolve_server_spec with a CLI-friendly error instead of a traceback."""
|
|
21
|
+
try:
|
|
22
|
+
return resolve_server_spec(value, http_mode=http_mode)
|
|
23
|
+
except ValueError as exc:
|
|
24
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
25
|
+
|
|
26
|
+
|
|
19
27
|
def _judge_enabled(judge: str, judge_model: str) -> bool:
|
|
20
28
|
"""--judge auto: grade with the LLM judge only when its API key is set."""
|
|
21
29
|
if judge in ("on", "off"):
|
|
@@ -31,10 +39,10 @@ def _resolve_task_servers(
|
|
|
31
39
|
tasks: list, server_override: str | None, http_mode: HttpMode
|
|
32
40
|
) -> dict[str, ServerSpec]:
|
|
33
41
|
if server_override is not None:
|
|
34
|
-
spec =
|
|
42
|
+
spec = _resolve_server(server_override, http_mode)
|
|
35
43
|
return {task.server: spec for task in tasks}
|
|
36
44
|
return {
|
|
37
|
-
task.server:
|
|
45
|
+
task.server: _resolve_server(task.server, http_mode)
|
|
38
46
|
for task in {t.server: t for t in tasks}.values()
|
|
39
47
|
}
|
|
40
48
|
|
|
@@ -70,6 +78,7 @@ def _summary_payload(group_name: str, summary, task_runs: list) -> dict:
|
|
|
70
78
|
"hit": score.hit,
|
|
71
79
|
"tool_hit": score.tool_hit,
|
|
72
80
|
"judge_passed": score.judge.passed if score.judge else None,
|
|
81
|
+
"spec_gap": score.spec_gap,
|
|
73
82
|
"called": score.tool_match.called,
|
|
74
83
|
"missing": score.tool_match.missing_slots,
|
|
75
84
|
"extra_calls": score.tool_match.extra_calls,
|
|
@@ -117,7 +126,7 @@ def inspect(
|
|
|
117
126
|
] = HttpMode.STATEFUL,
|
|
118
127
|
) -> None:
|
|
119
128
|
"""Connect to an MCP server and print its tool inventory."""
|
|
120
|
-
spec =
|
|
129
|
+
spec = _resolve_server(server, http_mode)
|
|
121
130
|
inventory = asyncio.run(inspect_server(spec))
|
|
122
131
|
|
|
123
132
|
for line in inventory.summary_lines():
|
|
@@ -164,7 +173,7 @@ def doctor(
|
|
|
164
173
|
if fail_on not in ("error", "warn", "never"):
|
|
165
174
|
raise typer.BadParameter("--fail-on must be 'error', 'warn', or 'never'")
|
|
166
175
|
|
|
167
|
-
spec =
|
|
176
|
+
spec = _resolve_server(server, http_mode)
|
|
168
177
|
inventory = asyncio.run(inspect_server(spec))
|
|
169
178
|
findings = diagnose(inventory)
|
|
170
179
|
|
|
@@ -209,16 +218,30 @@ def generate(
|
|
|
209
218
|
model: Annotated[
|
|
210
219
|
str, typer.Option("--model", help="Generator model as provider:model_id")
|
|
211
220
|
] = "anthropic:claude-sonnet-5",
|
|
221
|
+
allow_writes: Annotated[
|
|
222
|
+
bool,
|
|
223
|
+
typer.Option(
|
|
224
|
+
"--allow-writes",
|
|
225
|
+
help="Let drafts include non-destructive write tasks (default: read-only only)",
|
|
226
|
+
),
|
|
227
|
+
] = False,
|
|
212
228
|
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
213
229
|
) -> None:
|
|
214
230
|
"""Draft eval tasks from the server's tool inventory (review before
|
|
215
231
|
trusting — the header in the output file says the same)."""
|
|
216
232
|
from whetkit.generate import GeneratorConfig, generate_tasks, write_tasks_yaml
|
|
217
233
|
|
|
218
|
-
spec =
|
|
234
|
+
spec = _resolve_server(server, http_mode)
|
|
219
235
|
inventory = asyncio.run(inspect_server(spec))
|
|
220
236
|
tasks, warnings = asyncio.run(
|
|
221
|
-
generate_tasks(
|
|
237
|
+
generate_tasks(
|
|
238
|
+
inventory,
|
|
239
|
+
server,
|
|
240
|
+
count=count,
|
|
241
|
+
config=GeneratorConfig(model=model),
|
|
242
|
+
server_context=spec.label(),
|
|
243
|
+
allow_writes=allow_writes,
|
|
244
|
+
)
|
|
222
245
|
)
|
|
223
246
|
for warning in warnings:
|
|
224
247
|
typer.echo(f"warning: {warning}", err=True)
|
|
@@ -232,6 +255,62 @@ def generate(
|
|
|
232
255
|
typer.echo(f" whetkit run --server {server} --tasks {out}")
|
|
233
256
|
|
|
234
257
|
|
|
258
|
+
@app.command(name="plan-init")
|
|
259
|
+
def plan_init(
|
|
260
|
+
server: Annotated[
|
|
261
|
+
str,
|
|
262
|
+
typer.Option("--server", help="MCP server: URL, directory, server.json, or server.py"),
|
|
263
|
+
],
|
|
264
|
+
keep: Annotated[
|
|
265
|
+
str, typer.Option("--keep", help="Comma-separated tool names to keep visible")
|
|
266
|
+
] = "",
|
|
267
|
+
from_tasks: Annotated[
|
|
268
|
+
str | None,
|
|
269
|
+
typer.Option(
|
|
270
|
+
"--from-tasks",
|
|
271
|
+
help="Also keep every tool referenced by these tasks' expected_tools",
|
|
272
|
+
),
|
|
273
|
+
] = None,
|
|
274
|
+
out: Annotated[
|
|
275
|
+
str, typer.Option("--out", help="Where to write the plan YAML")
|
|
276
|
+
] = ".whetkit/curation-plan.yaml",
|
|
277
|
+
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Scaffold a view plan: keep the named tools, hide everything else.
|
|
280
|
+
The fastest way to serve a lean read-only slice of a big server."""
|
|
281
|
+
from whetkit.curation import CurationPlan, ToolOverride, save_plan
|
|
282
|
+
from whetkit.datasets import load_tasks
|
|
283
|
+
|
|
284
|
+
keep_set = {name.strip() for name in keep.split(",") if name.strip()}
|
|
285
|
+
if from_tasks:
|
|
286
|
+
for task in load_tasks(from_tasks):
|
|
287
|
+
for slot in task.expected_tool_slots:
|
|
288
|
+
keep_set.update(slot)
|
|
289
|
+
if not keep_set:
|
|
290
|
+
raise typer.BadParameter("nothing to keep — pass --keep and/or --from-tasks")
|
|
291
|
+
|
|
292
|
+
spec = _resolve_server(server, http_mode)
|
|
293
|
+
inventory = asyncio.run(inspect_server(spec))
|
|
294
|
+
names = {t.name for t in inventory.tools}
|
|
295
|
+
if unknown := sorted(keep_set - names):
|
|
296
|
+
typer.echo(f"warning: not on the server, ignoring: {', '.join(unknown)}", err=True)
|
|
297
|
+
|
|
298
|
+
hidden = [
|
|
299
|
+
ToolOverride(original_name=t.name, hidden=True, reason="Not part of this view's workflows.")
|
|
300
|
+
for t in inventory.tools
|
|
301
|
+
if t.name not in keep_set
|
|
302
|
+
]
|
|
303
|
+
plan = CurationPlan(
|
|
304
|
+
server=spec.label(),
|
|
305
|
+
notes=f"View plan: keep {len(names & keep_set)} tool(s), hide the rest.",
|
|
306
|
+
overrides=hidden,
|
|
307
|
+
)
|
|
308
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
save_plan(plan, out)
|
|
310
|
+
typer.echo(f"kept {len(names & keep_set)}, hidden {len(hidden)} — wrote {out}")
|
|
311
|
+
typer.echo(f"score it: whetkit run --server {server} --tasks <tasks> --plan {out}")
|
|
312
|
+
|
|
313
|
+
|
|
235
314
|
@app.command()
|
|
236
315
|
def export(
|
|
237
316
|
plan: Annotated[
|
|
@@ -273,6 +352,60 @@ def export(
|
|
|
273
352
|
typer.echo(rendered, nl=False)
|
|
274
353
|
|
|
275
354
|
|
|
355
|
+
@app.command()
|
|
356
|
+
def diff(
|
|
357
|
+
before: Annotated[str, typer.Argument(help="Baseline --summary-json file")],
|
|
358
|
+
after: Annotated[str, typer.Argument(help="Comparison --summary-json file")],
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Compare two --summary-json files: headline deltas plus per-task
|
|
361
|
+
transitions. The before/after table without re-running anything."""
|
|
362
|
+
import json as jsonlib
|
|
363
|
+
|
|
364
|
+
docs = []
|
|
365
|
+
for path in (before, after):
|
|
366
|
+
if not Path(path).is_file():
|
|
367
|
+
raise typer.BadParameter(f"no summary file at {path}")
|
|
368
|
+
docs.append(jsonlib.loads(Path(path).read_text()))
|
|
369
|
+
|
|
370
|
+
def mean(doc: dict, key: str) -> float:
|
|
371
|
+
values = [r[key] for r in doc["runs"] if r.get(key) is not None]
|
|
372
|
+
return sum(values) / len(values) if values else 0.0
|
|
373
|
+
|
|
374
|
+
typer.echo(f"{'metric':<28} {'before':>10} {'after':>10}")
|
|
375
|
+
for label, key, fmt in (
|
|
376
|
+
("Hit-rate", "hit_rate", "{:.0%}"),
|
|
377
|
+
("Tool-selection hit-rate", "tool_hit_rate", "{:.0%}"),
|
|
378
|
+
("Judge pass-rate", "judge_pass_rate", "{:.0%}"),
|
|
379
|
+
("Precision (avg)", "avg_precision", "{:.0%}"),
|
|
380
|
+
("Unnecessary calls/task", "avg_extra_calls", "{:.1f}"),
|
|
381
|
+
("Tokens in (per run)", "tokens_in", "{:.0f}"),
|
|
382
|
+
):
|
|
383
|
+
b, a = mean(docs[0], key), mean(docs[1], key)
|
|
384
|
+
typer.echo(f"{label:<28} {fmt.format(b):>10} {fmt.format(a):>10}")
|
|
385
|
+
|
|
386
|
+
def outcomes(doc: dict) -> dict[str, list[bool]]:
|
|
387
|
+
per_task: dict[str, list[bool]] = {}
|
|
388
|
+
for run_doc in doc["runs"]:
|
|
389
|
+
for task in run_doc["tasks"]:
|
|
390
|
+
per_task.setdefault(task["id"], []).append(bool(task["hit"]))
|
|
391
|
+
return per_task
|
|
392
|
+
|
|
393
|
+
before_tasks, after_tasks = outcomes(docs[0]), outcomes(docs[1])
|
|
394
|
+
typer.echo("")
|
|
395
|
+
for task_id in sorted(set(before_tasks) | set(after_tasks)):
|
|
396
|
+
|
|
397
|
+
def word(hits: list[bool] | None) -> str:
|
|
398
|
+
if not hits:
|
|
399
|
+
return "—"
|
|
400
|
+
if all(hits):
|
|
401
|
+
return "PASS"
|
|
402
|
+
return "MISS" if not any(hits) else f"FLAKY {sum(hits)}/{len(hits)}"
|
|
403
|
+
|
|
404
|
+
b_word, a_word = word(before_tasks.get(task_id)), word(after_tasks.get(task_id))
|
|
405
|
+
marker = "" if b_word == a_word else (" ↑" if a_word == "PASS" else " ↓")
|
|
406
|
+
typer.echo(f" {task_id:<28} {b_word:>10} -> {a_word:<10}{marker}")
|
|
407
|
+
|
|
408
|
+
|
|
276
409
|
@app.command()
|
|
277
410
|
def run(
|
|
278
411
|
tasks: Annotated[
|
|
@@ -347,6 +480,16 @@ def run(
|
|
|
347
480
|
help="Write a machine-readable summary (metrics + per-task outcomes) to this path",
|
|
348
481
|
),
|
|
349
482
|
] = None,
|
|
483
|
+
reset_cmd: Annotated[
|
|
484
|
+
str | None,
|
|
485
|
+
typer.Option(
|
|
486
|
+
"--reset-cmd",
|
|
487
|
+
help=(
|
|
488
|
+
"Shell command run before each repetition (and the first run) to "
|
|
489
|
+
"reset server-side fixtures — makes --runs honest on stateful servers"
|
|
490
|
+
),
|
|
491
|
+
),
|
|
492
|
+
] = None,
|
|
350
493
|
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
351
494
|
) -> None:
|
|
352
495
|
"""Run eval tasks against an MCP server and print scored results."""
|
|
@@ -411,13 +554,27 @@ def run(
|
|
|
411
554
|
line += f" judge={'pass' if score.judge.passed else 'FAIL'}"
|
|
412
555
|
typer.echo(line)
|
|
413
556
|
typer.echo("")
|
|
557
|
+
for score in summary.scores:
|
|
558
|
+
if score.spec_gap:
|
|
559
|
+
called = ", ".join(score.tool_match.called) or "(none)"
|
|
560
|
+
typer.echo(
|
|
561
|
+
f"⚠ possible task-spec gap: '{score.task_id}' reached a correct "
|
|
562
|
+
f"outcome (judge passed) via tools not listed in expected_tools "
|
|
563
|
+
f"— called: {called}"
|
|
564
|
+
)
|
|
414
565
|
for line in summary.summary_lines():
|
|
415
566
|
typer.echo(line)
|
|
416
567
|
tokens_in = sum(r.total_usage.input_tokens for r in task_runs)
|
|
417
568
|
tokens_out = sum(r.total_usage.output_tokens for r in task_runs)
|
|
569
|
+
from whetkit.llm import parse_model
|
|
570
|
+
from whetkit.llm.pricing import estimate_cost_usd
|
|
571
|
+
|
|
572
|
+
cost = estimate_cost_usd(parse_model(model)[1], tokens_in, tokens_out)
|
|
573
|
+
cost_note = f" ≈ ${cost:.4f} (est.)" if cost is not None else ""
|
|
418
574
|
typer.echo(
|
|
419
575
|
f"Tokens in/out: {tokens_in}/{tokens_out} "
|
|
420
576
|
f"Total latency: {sum(r.total_latency_ms for r in task_runs) / 1000:.1f}s"
|
|
577
|
+
f"{cost_note}"
|
|
421
578
|
)
|
|
422
579
|
return task_runs, summary
|
|
423
580
|
|
|
@@ -427,6 +584,17 @@ def run(
|
|
|
427
584
|
cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
|
|
428
585
|
try:
|
|
429
586
|
for run_index in range(1, runs + 1):
|
|
587
|
+
if reset_cmd:
|
|
588
|
+
import subprocess
|
|
589
|
+
|
|
590
|
+
typer.echo(f"reset: {reset_cmd}", err=True)
|
|
591
|
+
proc = subprocess.run(reset_cmd, shell=True)
|
|
592
|
+
if proc.returncode != 0:
|
|
593
|
+
typer.echo(
|
|
594
|
+
f"error: --reset-cmd failed with exit code {proc.returncode}",
|
|
595
|
+
err=True,
|
|
596
|
+
)
|
|
597
|
+
raise typer.Exit(code=1)
|
|
430
598
|
group_name = group if runs == 1 else f"{group}-{run_index}"
|
|
431
599
|
task_runs, summary = await _run_once(group_name, cache)
|
|
432
600
|
summaries.append(summary)
|
|
@@ -505,6 +673,17 @@ def curate(
|
|
|
505
673
|
help="Completion-token budget per model turn (raise for reasoning models)",
|
|
506
674
|
),
|
|
507
675
|
] = 1024,
|
|
676
|
+
prune_unused: Annotated[
|
|
677
|
+
bool,
|
|
678
|
+
typer.Option(
|
|
679
|
+
"--prune-unused",
|
|
680
|
+
help=(
|
|
681
|
+
"Additionally hide every tool the eval never touched — the cost "
|
|
682
|
+
"play for big servers. Only sound when the tasks cover all the "
|
|
683
|
+
"workflows the curated view will serve."
|
|
684
|
+
),
|
|
685
|
+
),
|
|
686
|
+
] = False,
|
|
508
687
|
store: Annotated[str | None, typer.Option("--store")] = None,
|
|
509
688
|
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
510
689
|
) -> None:
|
|
@@ -512,6 +691,7 @@ def curate(
|
|
|
512
691
|
it, and show the before/after hit-rate."""
|
|
513
692
|
from whetkit.curation import CuratedMCPClient, propose_plan, save_plan
|
|
514
693
|
from whetkit.curation.optimizer import OptimizerConfig
|
|
694
|
+
from whetkit.curation.optimizer import prune_unused as apply_prune_unused
|
|
515
695
|
from whetkit.datasets import load_tasks
|
|
516
696
|
from whetkit.mcp import inspect_server
|
|
517
697
|
from whetkit.runner import RunConfig, run_task
|
|
@@ -554,6 +734,9 @@ def curate(
|
|
|
554
734
|
)
|
|
555
735
|
for warning in warnings:
|
|
556
736
|
typer.echo(f"warning: {warning}", err=True)
|
|
737
|
+
if prune_unused:
|
|
738
|
+
pruned = apply_prune_unused(plan, inventory, task_list, baseline_runs)
|
|
739
|
+
typer.echo(f"--prune-unused: hid {pruned} untouched tool(s)", err=True)
|
|
557
740
|
save_plan(plan, plan_path)
|
|
558
741
|
typer.echo(f"curation plan written to {plan_path}", err=True)
|
|
559
742
|
|
|
@@ -630,6 +813,159 @@ def curate(
|
|
|
630
813
|
asyncio.run(_curate())
|
|
631
814
|
|
|
632
815
|
|
|
816
|
+
@app.command()
|
|
817
|
+
def fix(
|
|
818
|
+
tasks: Annotated[
|
|
819
|
+
str, typer.Option("--tasks", help="Task YAML file or directory of task files")
|
|
820
|
+
],
|
|
821
|
+
server: Annotated[
|
|
822
|
+
str | None, typer.Option("--server", help="Override the server every task runs against")
|
|
823
|
+
] = None,
|
|
824
|
+
model: Annotated[
|
|
825
|
+
str, typer.Option("--model", help="Agent model as provider:model_id")
|
|
826
|
+
] = "anthropic:claude-sonnet-5",
|
|
827
|
+
optimizer_model: Annotated[
|
|
828
|
+
str, typer.Option("--optimizer-model")
|
|
829
|
+
] = "anthropic:claude-sonnet-5",
|
|
830
|
+
judge: Annotated[str, typer.Option("--judge", help="'auto', 'on', or 'off'")] = "auto",
|
|
831
|
+
judge_model: Annotated[str, typer.Option("--judge-model")] = "anthropic:claude-sonnet-5",
|
|
832
|
+
max_iterations: Annotated[
|
|
833
|
+
int, typer.Option("--max-iterations", help="Propose→eval→revise rounds (≥1)")
|
|
834
|
+
] = 3,
|
|
835
|
+
plan_path: Annotated[
|
|
836
|
+
str, typer.Option("--plan", help="Where to write the best plan YAML")
|
|
837
|
+
] = ".whetkit/curation-plan.yaml",
|
|
838
|
+
match_mode: Annotated[str, typer.Option("--match-mode")] = "order_tolerant",
|
|
839
|
+
max_turns: Annotated[int, typer.Option("--max-turns")] = 10,
|
|
840
|
+
max_tokens: Annotated[int, typer.Option("--max-tokens")] = 1024,
|
|
841
|
+
store: Annotated[str | None, typer.Option("--store")] = None,
|
|
842
|
+
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
843
|
+
) -> None:
|
|
844
|
+
"""Self-correcting curation: propose a plan, eval through it, feed the
|
|
845
|
+
regressions and remaining waste back to the optimizer, revise — up to
|
|
846
|
+
--max-iterations — and keep the best plan by measured results."""
|
|
847
|
+
from functools import partial
|
|
848
|
+
|
|
849
|
+
from whetkit.curation import CuratedMCPClient, propose_plan, save_plan
|
|
850
|
+
from whetkit.curation.optimizer import OptimizerConfig, propose_revision
|
|
851
|
+
from whetkit.datasets import load_tasks
|
|
852
|
+
from whetkit.runner import RunConfig, run_task
|
|
853
|
+
from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
|
|
854
|
+
from whetkit.tracing import TraceStore, default_store_path
|
|
855
|
+
|
|
856
|
+
if max_iterations < 1:
|
|
857
|
+
raise typer.BadParameter("--max-iterations must be at least 1")
|
|
858
|
+
task_list = load_tasks(tasks)
|
|
859
|
+
servers = _resolve_task_servers(task_list, server, http_mode)
|
|
860
|
+
config = RunConfig(model=model, max_turns=max_turns, max_tokens=max_tokens)
|
|
861
|
+
use_judge = _judge_enabled(judge, judge_model)
|
|
862
|
+
judge_config = JudgeConfig(model=judge_model)
|
|
863
|
+
mode = MatchMode(match_mode)
|
|
864
|
+
store_path = store or str(default_store_path())
|
|
865
|
+
optimizer_config = OptimizerConfig(model=optimizer_model)
|
|
866
|
+
|
|
867
|
+
from whetkit.mcp import MCPClient
|
|
868
|
+
|
|
869
|
+
async def _eval(group: str, cache, client_factory=MCPClient):
|
|
870
|
+
runs = []
|
|
871
|
+
for task in task_list:
|
|
872
|
+
typer.echo(f"running {task.id} ...", err=True)
|
|
873
|
+
runs.append(
|
|
874
|
+
await run_task(task, servers[task.server], config, client_factory=client_factory)
|
|
875
|
+
)
|
|
876
|
+
with TraceStore(store_path) as trace_store:
|
|
877
|
+
trace_store.save_runs(runs, run_group=group)
|
|
878
|
+
return runs
|
|
879
|
+
|
|
880
|
+
async def _fix() -> None:
|
|
881
|
+
from whetkit.mcp import inspect_server as _inspect
|
|
882
|
+
|
|
883
|
+
cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
|
|
884
|
+
try:
|
|
885
|
+
typer.echo("== baseline ==", err=True)
|
|
886
|
+
baseline_runs = await _eval("baseline", cache)
|
|
887
|
+
baseline = await score_runs(
|
|
888
|
+
task_list,
|
|
889
|
+
baseline_runs,
|
|
890
|
+
mode=mode,
|
|
891
|
+
judge_config=judge_config,
|
|
892
|
+
judge_cache=cache,
|
|
893
|
+
use_judge=use_judge,
|
|
894
|
+
)
|
|
895
|
+
inventory = await _inspect(next(iter(servers.values())))
|
|
896
|
+
|
|
897
|
+
typer.echo("== proposing plan (iteration 1) ==", err=True)
|
|
898
|
+
plan, warnings = await propose_plan(
|
|
899
|
+
inventory, task_list, baseline_runs, baseline.scores, optimizer_config
|
|
900
|
+
)
|
|
901
|
+
for w in warnings:
|
|
902
|
+
typer.echo(f"warning: {w}", err=True)
|
|
903
|
+
|
|
904
|
+
def metric(summary):
|
|
905
|
+
return (summary.hit_rate, -summary.avg_extra_calls, summary.avg_precision)
|
|
906
|
+
|
|
907
|
+
best_plan, best_summary = None, None
|
|
908
|
+
for iteration in range(1, max_iterations + 1):
|
|
909
|
+
typer.echo(f"== eval through plan (iteration {iteration}) ==", err=True)
|
|
910
|
+
curated_runs = await _eval(
|
|
911
|
+
f"fix-{iteration}",
|
|
912
|
+
cache,
|
|
913
|
+
client_factory=partial(CuratedMCPClient, plan=plan),
|
|
914
|
+
)
|
|
915
|
+
curated = await score_runs(
|
|
916
|
+
task_list,
|
|
917
|
+
curated_runs,
|
|
918
|
+
mode=mode,
|
|
919
|
+
judge_config=judge_config,
|
|
920
|
+
judge_cache=cache,
|
|
921
|
+
use_judge=use_judge,
|
|
922
|
+
name_map=plan.rename_map(),
|
|
923
|
+
)
|
|
924
|
+
typer.echo(
|
|
925
|
+
f"iteration {iteration}: hit {curated.hit_rate:.0%} "
|
|
926
|
+
f"(baseline {baseline.hit_rate:.0%}), "
|
|
927
|
+
f"extra calls {curated.avg_extra_calls:.1f}/task",
|
|
928
|
+
err=True,
|
|
929
|
+
)
|
|
930
|
+
if best_summary is None or metric(curated) > metric(best_summary):
|
|
931
|
+
best_plan, best_summary = plan, curated
|
|
932
|
+
|
|
933
|
+
converged = (
|
|
934
|
+
curated.hit_rate >= baseline.hit_rate and curated.avg_extra_calls <= 0.25
|
|
935
|
+
)
|
|
936
|
+
if converged or iteration == max_iterations:
|
|
937
|
+
if converged:
|
|
938
|
+
typer.echo("converged — no regressions, negligible waste", err=True)
|
|
939
|
+
break
|
|
940
|
+
|
|
941
|
+
typer.echo("== revising plan ==", err=True)
|
|
942
|
+
plan, warnings = await propose_revision(
|
|
943
|
+
plan,
|
|
944
|
+
inventory,
|
|
945
|
+
task_list,
|
|
946
|
+
baseline_runs,
|
|
947
|
+
baseline.scores,
|
|
948
|
+
curated_runs,
|
|
949
|
+
curated.scores,
|
|
950
|
+
optimizer_config,
|
|
951
|
+
)
|
|
952
|
+
for w in warnings:
|
|
953
|
+
typer.echo(f"warning: {w}", err=True)
|
|
954
|
+
finally:
|
|
955
|
+
cache.close()
|
|
956
|
+
|
|
957
|
+
save_plan(best_plan, plan_path)
|
|
958
|
+
typer.echo(f"\nbest plan (of {max_iterations} max iterations) -> {plan_path}")
|
|
959
|
+
typer.echo(
|
|
960
|
+
f"Hit-rate: {baseline.hit_rate:.0%} -> {best_summary.hit_rate:.0%} "
|
|
961
|
+
f"Extra calls: {baseline.avg_extra_calls:.1f} -> "
|
|
962
|
+
f"{best_summary.avg_extra_calls:.1f}/task"
|
|
963
|
+
)
|
|
964
|
+
typer.echo(f"serve it: whetkit overlay --server <origin> --plan {plan_path}")
|
|
965
|
+
|
|
966
|
+
asyncio.run(_fix())
|
|
967
|
+
|
|
968
|
+
|
|
633
969
|
@app.command()
|
|
634
970
|
def report(
|
|
635
971
|
tasks: Annotated[
|
|
@@ -729,7 +1065,7 @@ def overlay(
|
|
|
729
1065
|
the origin is never modified)."""
|
|
730
1066
|
from whetkit.curation import load_plan, serve_overlay
|
|
731
1067
|
|
|
732
|
-
origin =
|
|
1068
|
+
origin = _resolve_server(server, http_mode)
|
|
733
1069
|
asyncio.run(serve_overlay(origin, load_plan(plan)))
|
|
734
1070
|
|
|
735
1071
|
|
|
@@ -197,3 +197,132 @@ async def propose_plan(
|
|
|
197
197
|
plan.overrides = []
|
|
198
198
|
break
|
|
199
199
|
return plan, warnings
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def prune_unused(
|
|
203
|
+
plan: CurationPlan,
|
|
204
|
+
inventory: ServerInventory,
|
|
205
|
+
tasks: list[TaskSpec],
|
|
206
|
+
runs: list[TaskRun],
|
|
207
|
+
) -> int:
|
|
208
|
+
"""Hide every tool the eval never touched: not called in any baseline
|
|
209
|
+
run, not accepted by any task's expected_tools, and not already
|
|
210
|
+
overridden. The cost play for big servers — only sound when the task
|
|
211
|
+
set really covers the workflows the curated view will serve, which is
|
|
212
|
+
why it's opt-in. Returns how many tools were hidden."""
|
|
213
|
+
used = {name for run in runs for name in run.called_tool_names}
|
|
214
|
+
for task in tasks:
|
|
215
|
+
for slot in task.expected_tool_slots:
|
|
216
|
+
used.update(slot)
|
|
217
|
+
overridden = {o.original_name for o in plan.overrides}
|
|
218
|
+
added = 0
|
|
219
|
+
for tool in inventory.tools:
|
|
220
|
+
if tool.name in used or tool.name in overridden:
|
|
221
|
+
continue
|
|
222
|
+
plan.overrides.append(
|
|
223
|
+
ToolOverride(
|
|
224
|
+
original_name=tool.name,
|
|
225
|
+
hidden=True,
|
|
226
|
+
reason="Unused by the eval workflows (pruned by --prune-unused).",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
added += 1
|
|
230
|
+
return added
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
REVISION_INSTRUCTIONS = """\
|
|
234
|
+
You previously proposed the curation plan below, and it was re-evaluated.
|
|
235
|
+
Revise it: KEEP the changes that worked, FIX or DROP the ones implicated in
|
|
236
|
+
regressions or remaining waste. A rename the agent still misused needs a
|
|
237
|
+
better name or a sharper description; a prune that broke a task must be
|
|
238
|
+
un-hidden. Respond with the SAME JSON shape as before — the FULL revised
|
|
239
|
+
plan, not a delta.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def build_revision_prompt(
|
|
244
|
+
plan: CurationPlan,
|
|
245
|
+
inventory: ServerInventory,
|
|
246
|
+
tasks: list[TaskSpec],
|
|
247
|
+
baseline_runs: list[TaskRun],
|
|
248
|
+
baseline_scores: list[TaskScore],
|
|
249
|
+
curated_runs: list[TaskRun],
|
|
250
|
+
curated_scores: list[TaskScore],
|
|
251
|
+
) -> str:
|
|
252
|
+
import yaml
|
|
253
|
+
|
|
254
|
+
baseline_by_id = {s.task_id: s for s in baseline_scores}
|
|
255
|
+
regressed = [
|
|
256
|
+
s.task_id
|
|
257
|
+
for s in curated_scores
|
|
258
|
+
if not s.hit and (b := baseline_by_id.get(s.task_id)) is not None and b.hit
|
|
259
|
+
]
|
|
260
|
+
regression_line = (
|
|
261
|
+
f"REGRESSIONS your plan caused (fix these first): {', '.join(regressed)}"
|
|
262
|
+
if regressed
|
|
263
|
+
else "No regressions — focus on remaining waste (unnecessary calls)."
|
|
264
|
+
)
|
|
265
|
+
plan_yaml = yaml.safe_dump(plan.model_dump(exclude_defaults=True), sort_keys=False)
|
|
266
|
+
return (
|
|
267
|
+
f"## Tools exposed by the server\n{_inventory_block(inventory)}\n\n"
|
|
268
|
+
f"## Baseline eval (no plan)\n{_trace_block(tasks, baseline_runs, baseline_scores)}\n\n"
|
|
269
|
+
f"## Your previous plan\n```yaml\n{plan_yaml}```\n\n"
|
|
270
|
+
f"## Eval THROUGH your plan\n{_trace_block(tasks, curated_runs, curated_scores)}\n\n"
|
|
271
|
+
f"{regression_line}\n\nRevise the plan now."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def propose_revision(
|
|
276
|
+
plan: CurationPlan,
|
|
277
|
+
inventory: ServerInventory,
|
|
278
|
+
tasks: list[TaskSpec],
|
|
279
|
+
baseline_runs: list[TaskRun],
|
|
280
|
+
baseline_scores: list[TaskScore],
|
|
281
|
+
curated_runs: list[TaskRun],
|
|
282
|
+
curated_scores: list[TaskScore],
|
|
283
|
+
config: OptimizerConfig | None = None,
|
|
284
|
+
provider: LLMProvider | None = None,
|
|
285
|
+
) -> tuple[CurationPlan, list[str]]:
|
|
286
|
+
"""One revision round: same parsing/validation as propose_plan, but the
|
|
287
|
+
model sees its previous plan and what happened through it."""
|
|
288
|
+
config = config or OptimizerConfig()
|
|
289
|
+
provider_name, model_id = parse_model(config.model)
|
|
290
|
+
provider = provider or get_provider(provider_name)
|
|
291
|
+
|
|
292
|
+
prompt = build_revision_prompt(
|
|
293
|
+
plan, inventory, tasks, baseline_runs, baseline_scores, curated_runs, curated_scores
|
|
294
|
+
)
|
|
295
|
+
parsed = None
|
|
296
|
+
for _attempt in range(2):
|
|
297
|
+
turn = await provider.complete(
|
|
298
|
+
model=model_id,
|
|
299
|
+
system=OPTIMIZER_SYSTEM_PROMPT + "\n" + REVISION_INSTRUCTIONS,
|
|
300
|
+
messages=[ChatMessage(role="user", content=prompt)],
|
|
301
|
+
tools=[],
|
|
302
|
+
max_tokens=config.max_tokens,
|
|
303
|
+
)
|
|
304
|
+
parsed = _parse_overrides(turn.text or "")
|
|
305
|
+
if parsed is not None:
|
|
306
|
+
break
|
|
307
|
+
if parsed is None:
|
|
308
|
+
return plan, ["revision output was not valid JSON after 2 attempts; keeping previous plan"]
|
|
309
|
+
|
|
310
|
+
notes, overrides = parsed
|
|
311
|
+
revised = CurationPlan(server=inventory.server, notes=notes, overrides=overrides)
|
|
312
|
+
origin_names = {t.name for t in inventory.tools}
|
|
313
|
+
warnings: list[str] = []
|
|
314
|
+
while problems := revised.validate_against(origin_names):
|
|
315
|
+
problem = problems[0]
|
|
316
|
+
name_match = re.search(r"'([^']+)'", problem)
|
|
317
|
+
offender = name_match.group(1) if name_match else None
|
|
318
|
+
before = len(revised.overrides)
|
|
319
|
+
revised.overrides = [
|
|
320
|
+
o
|
|
321
|
+
for o in revised.overrides
|
|
322
|
+
if offender not in (o.original_name, o.new_name, o.presented_name)
|
|
323
|
+
]
|
|
324
|
+
warnings.append(f"dropped unsafe override(s): {problem}")
|
|
325
|
+
if len(revised.overrides) == before:
|
|
326
|
+
warnings.append("could not repair revised plan; keeping previous plan")
|
|
327
|
+
return plan, warnings
|
|
328
|
+
return revised, warnings
|
|
@@ -18,7 +18,7 @@ from whetkit.mcp.introspect import ServerInventory
|
|
|
18
18
|
|
|
19
19
|
DEFAULT_GENERATOR_MODEL = "anthropic:claude-sonnet-5"
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
GENERATOR_SYSTEM_PROMPT_TEMPLATE = """\
|
|
22
22
|
You write eval tasks for an AI-agent benchmark. You are given the tools an
|
|
23
23
|
MCP server exposes. Draft tasks that measure whether an agent picks the
|
|
24
24
|
right tools for realistic user requests.
|
|
@@ -32,9 +32,7 @@ Rules for every task:
|
|
|
32
32
|
alternatives for that step. Use ONLY tool names from the given list.
|
|
33
33
|
- success_criteria is one or two concrete, checkable sentences a grader can
|
|
34
34
|
verify from the agent's final answer alone.
|
|
35
|
-
|
|
36
|
-
clearly built for writes, and never anything destructive or irreversible
|
|
37
|
-
(no deletes, resets, or purges).
|
|
35
|
+
{writes_rule}
|
|
38
36
|
- Set "ordered": true only when the steps must happen in sequence.
|
|
39
37
|
- ids are short kebab-case slugs, unique across the set.
|
|
40
38
|
|
|
@@ -59,9 +57,37 @@ def _inventory_block(inventory: ServerInventory) -> str:
|
|
|
59
57
|
return "\n".join(lines)
|
|
60
58
|
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
READ_ONLY_RULE = (
|
|
61
|
+
"- Draft ONLY read-only tasks: nothing that creates, modifies, deletes,\n"
|
|
62
|
+
" or sends anything. If the tool set is write-heavy, still restrict\n"
|
|
63
|
+
" yourself to its read/query tools."
|
|
64
|
+
)
|
|
65
|
+
WRITES_ALLOWED_RULE = (
|
|
66
|
+
"- Prefer read-only tasks. Write tasks are allowed when the tool set is\n"
|
|
67
|
+
" clearly built for writes, but never anything destructive or\n"
|
|
68
|
+
" irreversible (no deletes, resets, or purges)."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def generator_system_prompt(allow_writes: bool) -> str:
|
|
73
|
+
rule = WRITES_ALLOWED_RULE if allow_writes else READ_ONLY_RULE
|
|
74
|
+
# plain replace, not str.format — the template contains JSON braces
|
|
75
|
+
return GENERATOR_SYSTEM_PROMPT_TEMPLATE.replace("{writes_rule}", rule)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_generator_prompt(inventory: ServerInventory, count: int, server_context: str = "") -> str:
|
|
79
|
+
context_block = ""
|
|
80
|
+
if server_context:
|
|
81
|
+
context_block = (
|
|
82
|
+
"## Execution context\n"
|
|
83
|
+
f"These tasks will execute against: {server_context}\n"
|
|
84
|
+
"Write prompts whose arguments fit THAT context — the real paths,\n"
|
|
85
|
+
"repositories, or resources it implies. Never invent placeholder\n"
|
|
86
|
+
"examples like /home/user/project or acme-corp/website.\n\n"
|
|
87
|
+
)
|
|
63
88
|
return (
|
|
64
89
|
f"## Tools exposed by the server\n{_inventory_block(inventory)}\n\n"
|
|
90
|
+
f"{context_block}"
|
|
65
91
|
f"Draft exactly {count} eval tasks now."
|
|
66
92
|
)
|
|
67
93
|
|
|
@@ -102,6 +128,8 @@ async def generate_tasks(
|
|
|
102
128
|
count: int = 5,
|
|
103
129
|
config: GeneratorConfig | None = None,
|
|
104
130
|
provider: LLMProvider | None = None,
|
|
131
|
+
server_context: str = "",
|
|
132
|
+
allow_writes: bool = False,
|
|
105
133
|
) -> tuple[list[TaskSpec], list[str]]:
|
|
106
134
|
"""Draft ``count`` tasks for ``server``. Returns (tasks, warnings);
|
|
107
135
|
drafts that fail validation are dropped, never written."""
|
|
@@ -109,12 +137,12 @@ async def generate_tasks(
|
|
|
109
137
|
provider_name, model_id = parse_model(config.model)
|
|
110
138
|
provider = provider or get_provider(provider_name)
|
|
111
139
|
|
|
112
|
-
prompt = build_generator_prompt(inventory, count)
|
|
140
|
+
prompt = build_generator_prompt(inventory, count, server_context=server_context)
|
|
113
141
|
drafts = None
|
|
114
142
|
for _attempt in range(2):
|
|
115
143
|
turn = await provider.complete(
|
|
116
144
|
model=model_id,
|
|
117
|
-
system=
|
|
145
|
+
system=generator_system_prompt(allow_writes),
|
|
118
146
|
messages=[ChatMessage(role="user", content=prompt)],
|
|
119
147
|
tools=[],
|
|
120
148
|
max_tokens=config.max_tokens,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Rough USD cost estimates for known models.
|
|
2
|
+
|
|
3
|
+
Prices are per million tokens (input, output), hand-checked 2026-07-08
|
|
4
|
+
against the providers' public pricing pages. They rot — treat every
|
|
5
|
+
number this module produces as an estimate, which is how the CLI labels
|
|
6
|
+
it. Unknown models simply get no estimate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
PRICES_PER_MTOK: dict[str, tuple[float, float]] = {
|
|
10
|
+
"claude-sonnet-5": (3.00, 15.00),
|
|
11
|
+
"claude-haiku-4-5": (1.00, 5.00),
|
|
12
|
+
"gpt-4.1": (2.00, 8.00),
|
|
13
|
+
"gpt-4.1-mini": (0.40, 1.60),
|
|
14
|
+
"gpt-4.1-nano": (0.10, 0.40),
|
|
15
|
+
"gpt-5-mini": (0.25, 2.00),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def estimate_cost_usd(model_id: str, tokens_in: int, tokens_out: int) -> float | None:
|
|
20
|
+
"""Best-effort estimate; None when the model isn't in the table.
|
|
21
|
+
Dated model ids (claude-haiku-4-5-20251001) match their base entry."""
|
|
22
|
+
# longest key first, so gpt-4.1-mini never matches the gpt-4.1 entry
|
|
23
|
+
for known in sorted(PRICES_PER_MTOK, key=len, reverse=True):
|
|
24
|
+
price_in, price_out = PRICES_PER_MTOK[known]
|
|
25
|
+
if model_id == known or model_id.startswith(f"{known}-"):
|
|
26
|
+
return (tokens_in * price_in + tokens_out * price_out) / 1_000_000
|
|
27
|
+
return None
|
|
@@ -17,6 +17,7 @@ Three connection modes are supported, because real-world servers are mixed:
|
|
|
17
17
|
|
|
18
18
|
import json
|
|
19
19
|
import os
|
|
20
|
+
import re
|
|
20
21
|
import shutil
|
|
21
22
|
import sys
|
|
22
23
|
from collections.abc import AsyncIterator
|
|
@@ -105,8 +106,33 @@ def resolve_server_spec(value: str, http_mode: HttpMode = HttpMode.STATEFUL) ->
|
|
|
105
106
|
raise ValueError(f"{value}: not a URL, directory, .json spec, or .py server")
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
_ENV_REF_RE = re.compile(r"\$\{(\w+)\}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _expand_env(value):
|
|
113
|
+
"""Substitute ``${VAR}`` references from the environment in every string
|
|
114
|
+
of a spec document — so credentials (HTTP headers, child env) never have
|
|
115
|
+
to be written into server.json itself."""
|
|
116
|
+
if isinstance(value, str):
|
|
117
|
+
|
|
118
|
+
def sub(match: re.Match) -> str:
|
|
119
|
+
name = match.group(1)
|
|
120
|
+
if (resolved := os.environ.get(name)) is None:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"server spec references ${{{name}}} but it is not set in the environment"
|
|
123
|
+
)
|
|
124
|
+
return resolved
|
|
125
|
+
|
|
126
|
+
return _ENV_REF_RE.sub(sub, value)
|
|
127
|
+
if isinstance(value, dict):
|
|
128
|
+
return {k: _expand_env(v) for k, v in value.items()}
|
|
129
|
+
if isinstance(value, list):
|
|
130
|
+
return [_expand_env(v) for v in value]
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
108
134
|
def _spec_from_json(path: Path) -> ServerSpec:
|
|
109
|
-
data = json.loads(path.read_text())
|
|
135
|
+
data = _expand_env(json.loads(path.read_text()))
|
|
110
136
|
spec = spec_from_dict(data)
|
|
111
137
|
if isinstance(spec, StdioSpec):
|
|
112
138
|
spec.command = _python_command(spec.command)
|
|
@@ -27,6 +27,14 @@ class TaskScore(BaseModel):
|
|
|
27
27
|
return False
|
|
28
28
|
return self.tool_hit
|
|
29
29
|
|
|
30
|
+
@property
|
|
31
|
+
def spec_gap(self) -> bool:
|
|
32
|
+
"""Judge passed but the tool match failed: the agent reached a
|
|
33
|
+
correct outcome via tools the task never listed. Nine times out of
|
|
34
|
+
ten that means expected_tools is incomplete, not that the agent
|
|
35
|
+
failed."""
|
|
36
|
+
return self.judge is not None and self.judge.passed and not self.tool_hit
|
|
37
|
+
|
|
30
38
|
|
|
31
39
|
class EvalSummary(BaseModel):
|
|
32
40
|
scores: list[TaskScore]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|