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.
Files changed (38) hide show
  1. {whetkit-0.2.0 → whetkit-0.4.0}/PKG-INFO +1 -1
  2. {whetkit-0.2.0 → whetkit-0.4.0}/pyproject.toml +1 -1
  3. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/cli.py +343 -7
  4. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/optimizer.py +129 -0
  5. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/generate.py +35 -7
  6. whetkit-0.4.0/src/whetkit/llm/pricing.py +27 -0
  7. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/mcp/transport.py +27 -1
  8. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/scoring/aggregate.py +8 -0
  9. {whetkit-0.2.0 → whetkit-0.4.0}/LICENSE +0 -0
  10. {whetkit-0.2.0 → whetkit-0.4.0}/README.md +0 -0
  11. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/__init__.py +0 -0
  12. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/__init__.py +0 -0
  13. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/export.py +0 -0
  14. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/overlay.py +0 -0
  15. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/curation/plan.py +0 -0
  16. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/datasets/__init__.py +0 -0
  17. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/datasets/tasks.py +0 -0
  18. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/doctor.py +0 -0
  19. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/__init__.py +0 -0
  20. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/anthropic_provider.py +0 -0
  21. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/base.py +0 -0
  22. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/openai_provider.py +0 -0
  23. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/llm/registry.py +0 -0
  24. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/mcp/__init__.py +0 -0
  25. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/mcp/client.py +0 -0
  26. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/mcp/introspect.py +0 -0
  27. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/py.typed +0 -0
  28. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/report/__init__.py +0 -0
  29. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/report/builder.py +0 -0
  30. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/report/html.py +0 -0
  31. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/runner/__init__.py +0 -0
  32. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/runner/agent.py +0 -0
  33. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/scoring/__init__.py +0 -0
  34. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/scoring/deterministic.py +0 -0
  35. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/scoring/judge.py +0 -0
  36. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/tracing/__init__.py +0 -0
  37. {whetkit-0.2.0 → whetkit-0.4.0}/src/whetkit/tracing/records.py +0 -0
  38. {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.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "whetkit"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Measure and improve how well LLM agents select and use the tools exposed by an MCP server"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -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 = resolve_server_spec(server_override, http_mode=http_mode)
42
+ spec = _resolve_server(server_override, http_mode)
35
43
  return {task.server: spec for task in tasks}
36
44
  return {
37
- task.server: resolve_server_spec(task.server, http_mode=http_mode)
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 = resolve_server_spec(server, http_mode=http_mode)
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 = resolve_server_spec(server, http_mode=http_mode)
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 = resolve_server_spec(server, http_mode=http_mode)
234
+ spec = _resolve_server(server, http_mode)
219
235
  inventory = asyncio.run(inspect_server(spec))
220
236
  tasks, warnings = asyncio.run(
221
- generate_tasks(inventory, server, count=count, config=GeneratorConfig(model=model))
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 = resolve_server_spec(server, http_mode=http_mode)
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
- GENERATOR_SYSTEM_PROMPT = """\
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
- - Prefer read-only tasks. Include a write task only when the tool set is
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
- def build_generator_prompt(inventory: ServerInventory, count: int) -> str:
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=GENERATOR_SYSTEM_PROMPT,
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