whetkit 0.1.1__tar.gz → 0.3.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.1.1 → whetkit-0.3.0}/PKG-INFO +1 -1
  2. {whetkit-0.1.1 → whetkit-0.3.0}/pyproject.toml +1 -1
  3. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/cli.py +277 -33
  4. whetkit-0.3.0/src/whetkit/curation/export.py +73 -0
  5. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/generate.py +35 -7
  6. whetkit-0.3.0/src/whetkit/llm/pricing.py +27 -0
  7. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/mcp/transport.py +27 -1
  8. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/scoring/__init__.py +2 -1
  9. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/scoring/aggregate.py +53 -0
  10. {whetkit-0.1.1 → whetkit-0.3.0}/LICENSE +0 -0
  11. {whetkit-0.1.1 → whetkit-0.3.0}/README.md +0 -0
  12. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/__init__.py +0 -0
  13. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/curation/__init__.py +0 -0
  14. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/curation/optimizer.py +0 -0
  15. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/curation/overlay.py +0 -0
  16. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/curation/plan.py +0 -0
  17. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/datasets/__init__.py +0 -0
  18. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/datasets/tasks.py +0 -0
  19. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/doctor.py +0 -0
  20. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/__init__.py +0 -0
  21. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/anthropic_provider.py +0 -0
  22. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/base.py +0 -0
  23. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/openai_provider.py +0 -0
  24. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/registry.py +0 -0
  25. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/mcp/__init__.py +0 -0
  26. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/mcp/client.py +0 -0
  27. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/mcp/introspect.py +0 -0
  28. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/py.typed +0 -0
  29. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/report/__init__.py +0 -0
  30. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/report/builder.py +0 -0
  31. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/report/html.py +0 -0
  32. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/runner/__init__.py +0 -0
  33. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/runner/agent.py +0 -0
  34. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/scoring/deterministic.py +0 -0
  35. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/scoring/judge.py +0 -0
  36. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/tracing/__init__.py +0 -0
  37. {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/tracing/records.py +0 -0
  38. {whetkit-0.1.1 → whetkit-0.3.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.1.1
3
+ Version: 0.3.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.1.1"
3
+ version = "0.3.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
 
@@ -51,6 +59,35 @@ def _write_report(report, out_dir: str) -> tuple[str, str]:
51
59
  return str(html_path), str(json_path)
52
60
 
53
61
 
62
+ def _summary_payload(group_name: str, summary, task_runs: list) -> dict:
63
+ """One run's metrics as plain data — what --summary-json emits per run."""
64
+ return {
65
+ "group": group_name,
66
+ "hit_rate": summary.hit_rate,
67
+ "tool_hit_rate": summary.tool_hit_rate,
68
+ "judge_pass_rate": summary.judge_pass_rate,
69
+ "avg_precision": summary.avg_precision,
70
+ "avg_recall": summary.avg_recall,
71
+ "avg_extra_calls": summary.avg_extra_calls,
72
+ "tokens_in": sum(r.total_usage.input_tokens for r in task_runs),
73
+ "tokens_out": sum(r.total_usage.output_tokens for r in task_runs),
74
+ "latency_ms": sum(r.total_latency_ms for r in task_runs),
75
+ "tasks": [
76
+ {
77
+ "id": score.task_id,
78
+ "hit": score.hit,
79
+ "tool_hit": score.tool_hit,
80
+ "judge_passed": score.judge.passed if score.judge else None,
81
+ "spec_gap": score.spec_gap,
82
+ "called": score.tool_match.called,
83
+ "missing": score.tool_match.missing_slots,
84
+ "extra_calls": score.tool_match.extra_calls,
85
+ }
86
+ for score in summary.scores
87
+ ],
88
+ }
89
+
90
+
54
91
  def _version_callback(value: bool) -> None:
55
92
  if value:
56
93
  from importlib.metadata import version
@@ -89,7 +126,7 @@ def inspect(
89
126
  ] = HttpMode.STATEFUL,
90
127
  ) -> None:
91
128
  """Connect to an MCP server and print its tool inventory."""
92
- spec = resolve_server_spec(server, http_mode=http_mode)
129
+ spec = _resolve_server(server, http_mode)
93
130
  inventory = asyncio.run(inspect_server(spec))
94
131
 
95
132
  for line in inventory.summary_lines():
@@ -136,7 +173,7 @@ def doctor(
136
173
  if fail_on not in ("error", "warn", "never"):
137
174
  raise typer.BadParameter("--fail-on must be 'error', 'warn', or 'never'")
138
175
 
139
- spec = resolve_server_spec(server, http_mode=http_mode)
176
+ spec = _resolve_server(server, http_mode)
140
177
  inventory = asyncio.run(inspect_server(spec))
141
178
  findings = diagnose(inventory)
142
179
 
@@ -181,16 +218,30 @@ def generate(
181
218
  model: Annotated[
182
219
  str, typer.Option("--model", help="Generator model as provider:model_id")
183
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,
184
228
  http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
185
229
  ) -> None:
186
230
  """Draft eval tasks from the server's tool inventory (review before
187
231
  trusting — the header in the output file says the same)."""
188
232
  from whetkit.generate import GeneratorConfig, generate_tasks, write_tasks_yaml
189
233
 
190
- spec = resolve_server_spec(server, http_mode=http_mode)
234
+ spec = _resolve_server(server, http_mode)
191
235
  inventory = asyncio.run(inspect_server(spec))
192
236
  tasks, warnings = asyncio.run(
193
- 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
+ )
194
245
  )
195
246
  for warning in warnings:
196
247
  typer.echo(f"warning: {warning}", err=True)
@@ -204,6 +255,101 @@ def generate(
204
255
  typer.echo(f" whetkit run --server {server} --tasks {out}")
205
256
 
206
257
 
258
+ @app.command()
259
+ def export(
260
+ plan: Annotated[
261
+ str, typer.Option("--plan", help="Curation plan YAML to export")
262
+ ] = ".whetkit/curation-plan.yaml",
263
+ to: Annotated[
264
+ str,
265
+ typer.Option(
266
+ "--to",
267
+ help=(
268
+ "'markdown' — a fix report ready for an upstream issue/PR; "
269
+ "'json' — a neutral override list for gateways and scripts"
270
+ ),
271
+ ),
272
+ ] = "markdown",
273
+ out: Annotated[str | None, typer.Option("--out", help="Write here instead of stdout")] = None,
274
+ ) -> None:
275
+ """Export a curation plan as a shareable fix report or neutral JSON."""
276
+ from whetkit.curation import load_plan
277
+ from whetkit.curation.export import plan_to_json, plan_to_markdown
278
+
279
+ if to not in ("markdown", "json"):
280
+ raise typer.BadParameter("--to must be 'markdown' or 'json'")
281
+ if not Path(plan).is_file():
282
+ raise typer.BadParameter(
283
+ f"no curation plan at {plan} — run 'whetkit curate' first, or pass --plan"
284
+ )
285
+ curation_plan = load_plan(plan)
286
+ if not curation_plan.overrides:
287
+ typer.echo("plan has no overrides — nothing to export", err=True)
288
+ raise typer.Exit(code=1)
289
+
290
+ rendered = (plan_to_markdown if to == "markdown" else plan_to_json)(curation_plan)
291
+ if out:
292
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
293
+ Path(out).write_text(rendered)
294
+ typer.echo(f"wrote {out}")
295
+ else:
296
+ typer.echo(rendered, nl=False)
297
+
298
+
299
+ @app.command()
300
+ def diff(
301
+ before: Annotated[str, typer.Argument(help="Baseline --summary-json file")],
302
+ after: Annotated[str, typer.Argument(help="Comparison --summary-json file")],
303
+ ) -> None:
304
+ """Compare two --summary-json files: headline deltas plus per-task
305
+ transitions. The before/after table without re-running anything."""
306
+ import json as jsonlib
307
+
308
+ docs = []
309
+ for path in (before, after):
310
+ if not Path(path).is_file():
311
+ raise typer.BadParameter(f"no summary file at {path}")
312
+ docs.append(jsonlib.loads(Path(path).read_text()))
313
+
314
+ def mean(doc: dict, key: str) -> float:
315
+ values = [r[key] for r in doc["runs"] if r.get(key) is not None]
316
+ return sum(values) / len(values) if values else 0.0
317
+
318
+ typer.echo(f"{'metric':<28} {'before':>10} {'after':>10}")
319
+ for label, key, fmt in (
320
+ ("Hit-rate", "hit_rate", "{:.0%}"),
321
+ ("Tool-selection hit-rate", "tool_hit_rate", "{:.0%}"),
322
+ ("Judge pass-rate", "judge_pass_rate", "{:.0%}"),
323
+ ("Precision (avg)", "avg_precision", "{:.0%}"),
324
+ ("Unnecessary calls/task", "avg_extra_calls", "{:.1f}"),
325
+ ("Tokens in (per run)", "tokens_in", "{:.0f}"),
326
+ ):
327
+ b, a = mean(docs[0], key), mean(docs[1], key)
328
+ typer.echo(f"{label:<28} {fmt.format(b):>10} {fmt.format(a):>10}")
329
+
330
+ def outcomes(doc: dict) -> dict[str, list[bool]]:
331
+ per_task: dict[str, list[bool]] = {}
332
+ for run_doc in doc["runs"]:
333
+ for task in run_doc["tasks"]:
334
+ per_task.setdefault(task["id"], []).append(bool(task["hit"]))
335
+ return per_task
336
+
337
+ before_tasks, after_tasks = outcomes(docs[0]), outcomes(docs[1])
338
+ typer.echo("")
339
+ for task_id in sorted(set(before_tasks) | set(after_tasks)):
340
+
341
+ def word(hits: list[bool] | None) -> str:
342
+ if not hits:
343
+ return "—"
344
+ if all(hits):
345
+ return "PASS"
346
+ return "MISS" if not any(hits) else f"FLAKY {sum(hits)}/{len(hits)}"
347
+
348
+ b_word, a_word = word(before_tasks.get(task_id)), word(after_tasks.get(task_id))
349
+ marker = "" if b_word == a_word else (" ↑" if a_word == "PASS" else " ↓")
350
+ typer.echo(f" {task_id:<28} {b_word:>10} -> {a_word:<10}{marker}")
351
+
352
+
207
353
  @app.command()
208
354
  def run(
209
355
  tasks: Annotated[
@@ -250,21 +396,55 @@ def run(
250
396
  help="Completion-token budget per model turn (raise for reasoning models)",
251
397
  ),
252
398
  ] = 1024,
399
+ runs: Annotated[
400
+ int,
401
+ typer.Option(
402
+ "--runs",
403
+ help=(
404
+ "Repeat the whole task set N times and report mean plus range "
405
+ "— single runs are noise. Trace groups get a -1..-N suffix."
406
+ ),
407
+ ),
408
+ ] = 1,
253
409
  store: Annotated[
254
410
  str | None,
255
411
  typer.Option("--store", help="Trace SQLite path (default ./.whetkit/traces.sqlite3)"),
256
412
  ] = None,
257
413
  jsonl: Annotated[
258
- str | None, typer.Option("--jsonl", help="Also write traces to this JSONL file")
414
+ str | None,
415
+ typer.Option(
416
+ "--jsonl",
417
+ help="Also write traces to this JSONL file (suffixed per run when --runs > 1)",
418
+ ),
419
+ ] = None,
420
+ summary_json: Annotated[
421
+ str | None,
422
+ typer.Option(
423
+ "--summary-json",
424
+ help="Write a machine-readable summary (metrics + per-task outcomes) to this path",
425
+ ),
426
+ ] = None,
427
+ reset_cmd: Annotated[
428
+ str | None,
429
+ typer.Option(
430
+ "--reset-cmd",
431
+ help=(
432
+ "Shell command run before each repetition (and the first run) to "
433
+ "reset server-side fixtures — makes --runs honest on stateful servers"
434
+ ),
435
+ ),
259
436
  ] = None,
260
437
  http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
261
438
  ) -> None:
262
439
  """Run eval tasks against an MCP server and print scored results."""
263
440
  from whetkit.datasets import load_tasks
264
441
  from whetkit.runner import RunConfig, run_task
265
- from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
442
+ from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, MultiRunSummary, score_runs
266
443
  from whetkit.tracing import TraceStore, default_store_path, write_jsonl
267
444
 
445
+ if runs < 1:
446
+ raise typer.BadParameter("--runs must be at least 1")
447
+
268
448
  task_list = load_tasks(tasks)
269
449
  servers = _resolve_task_servers(task_list, server, http_mode)
270
450
  config = RunConfig(model=model, max_turns=max_turns, max_tokens=max_tokens)
@@ -286,34 +466,28 @@ def run(
286
466
  client_factory = partial(CuratedMCPClient, plan=curation_plan)
287
467
  name_map = curation_plan.rename_map()
288
468
 
289
- async def _run() -> None:
290
- runs = []
469
+ async def _run_once(group_name: str, cache: JudgeCache):
470
+ task_runs = []
291
471
  for task in task_list:
292
472
  typer.echo(f"running {task.id} ...", err=True)
293
- runs.append(
473
+ task_runs.append(
294
474
  await run_task(task, servers[task.server], config, client_factory=client_factory)
295
475
  )
296
476
 
297
477
  with TraceStore(store_path) as trace_store:
298
- trace_store.save_runs(runs, run_group=group)
299
- if jsonl:
300
- write_jsonl(runs, jsonl)
478
+ trace_store.save_runs(task_runs, run_group=group_name)
301
479
 
302
- cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
303
- try:
304
- summary = await score_runs(
305
- task_list,
306
- runs,
307
- mode=MatchMode(match_mode),
308
- judge_config=JudgeConfig(model=judge_model),
309
- judge_cache=cache,
310
- use_judge=use_judge,
311
- name_map=name_map,
312
- )
313
- finally:
314
- cache.close()
480
+ summary = await score_runs(
481
+ task_list,
482
+ task_runs,
483
+ mode=MatchMode(match_mode),
484
+ judge_config=JudgeConfig(model=judge_model),
485
+ judge_cache=cache,
486
+ use_judge=use_judge,
487
+ name_map=name_map,
488
+ )
315
489
 
316
- typer.echo(f"\nResults (group '{group}', model {model}):")
490
+ typer.echo(f"\nResults (group '{group_name}', model {model}):")
317
491
  for score in summary.scores:
318
492
  mark = "PASS" if score.hit else "MISS"
319
493
  tools = " -> ".join(score.tool_match.called) or "(no tool calls)"
@@ -324,19 +498,89 @@ def run(
324
498
  line += f" judge={'pass' if score.judge.passed else 'FAIL'}"
325
499
  typer.echo(line)
326
500
  typer.echo("")
501
+ for score in summary.scores:
502
+ if score.spec_gap:
503
+ called = ", ".join(score.tool_match.called) or "(none)"
504
+ typer.echo(
505
+ f"⚠ possible task-spec gap: '{score.task_id}' reached a correct "
506
+ f"outcome (judge passed) via tools not listed in expected_tools "
507
+ f"— called: {called}"
508
+ )
327
509
  for line in summary.summary_lines():
328
510
  typer.echo(line)
329
- tokens_in = sum(r.total_usage.input_tokens for r in runs)
330
- tokens_out = sum(r.total_usage.output_tokens for r in runs)
511
+ tokens_in = sum(r.total_usage.input_tokens for r in task_runs)
512
+ tokens_out = sum(r.total_usage.output_tokens for r in task_runs)
513
+ from whetkit.llm import parse_model
514
+ from whetkit.llm.pricing import estimate_cost_usd
515
+
516
+ cost = estimate_cost_usd(parse_model(model)[1], tokens_in, tokens_out)
517
+ cost_note = f" ≈ ${cost:.4f} (est.)" if cost is not None else ""
331
518
  typer.echo(
332
519
  f"Tokens in/out: {tokens_in}/{tokens_out} "
333
- f"Total latency: {sum(r.total_latency_ms for r in runs) / 1000:.1f}s"
520
+ f"Total latency: {sum(r.total_latency_ms for r in task_runs) / 1000:.1f}s"
521
+ f"{cost_note}"
334
522
  )
523
+ return task_runs, summary
524
+
525
+ async def _run() -> None:
526
+ summaries = []
527
+ payloads = []
528
+ cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
529
+ try:
530
+ for run_index in range(1, runs + 1):
531
+ if reset_cmd:
532
+ import subprocess
533
+
534
+ typer.echo(f"reset: {reset_cmd}", err=True)
535
+ proc = subprocess.run(reset_cmd, shell=True)
536
+ if proc.returncode != 0:
537
+ typer.echo(
538
+ f"error: --reset-cmd failed with exit code {proc.returncode}",
539
+ err=True,
540
+ )
541
+ raise typer.Exit(code=1)
542
+ group_name = group if runs == 1 else f"{group}-{run_index}"
543
+ task_runs, summary = await _run_once(group_name, cache)
544
+ summaries.append(summary)
545
+ payloads.append(_summary_payload(group_name, summary, task_runs))
546
+ if jsonl:
547
+ write_jsonl(task_runs, jsonl if runs == 1 else f"{jsonl}.{run_index}")
548
+ finally:
549
+ cache.close()
550
+
551
+ multi = MultiRunSummary(summaries=summaries)
552
+ if runs > 1:
553
+ typer.echo(f"\n== across {runs} runs ==")
554
+ for line in multi.summary_lines():
555
+ typer.echo(line)
335
556
  if judge == "auto" and not use_judge:
336
557
  typer.echo(
337
558
  "(LLM judge skipped: no API key found — set ANTHROPIC_API_KEY or pass --judge on)"
338
559
  )
339
- typer.echo(f"Traces saved to {store_path} (group '{group}')")
560
+ suffix = f" (groups '{group}-1'..'-{runs}')" if runs > 1 else f" (group '{group}')"
561
+ typer.echo(f"Traces saved to {store_path}{suffix}")
562
+
563
+ if summary_json:
564
+ import json as jsonlib
565
+
566
+ document = {
567
+ "model": model,
568
+ "judge_model": judge_model if use_judge else None,
569
+ "match_mode": match_mode,
570
+ "plan": plan,
571
+ "runs": payloads,
572
+ }
573
+ if runs > 1:
574
+ document["aggregate"] = {
575
+ "n": multi.n,
576
+ "hit_rate_mean": sum(s.hit_rate for s in summaries) / len(summaries),
577
+ "hit_rate_min": min(s.hit_rate for s in summaries),
578
+ "hit_rate_max": max(s.hit_rate for s in summaries),
579
+ "flaky_tasks": multi.flaky_tasks(),
580
+ }
581
+ Path(summary_json).parent.mkdir(parents=True, exist_ok=True)
582
+ Path(summary_json).write_text(jsonlib.dumps(document, indent=2) + "\n")
583
+ typer.echo(f"Summary JSON: {summary_json}")
340
584
 
341
585
  asyncio.run(_run())
342
586
 
@@ -597,7 +841,7 @@ def overlay(
597
841
  the origin is never modified)."""
598
842
  from whetkit.curation import load_plan, serve_overlay
599
843
 
600
- origin = resolve_server_spec(server, http_mode=http_mode)
844
+ origin = _resolve_server(server, http_mode)
601
845
  asyncio.run(serve_overlay(origin, load_plan(plan)))
602
846
 
603
847
 
@@ -0,0 +1,73 @@
1
+ """Export a curation plan into shareable formats.
2
+
3
+ A plan is most useful when it leaves the machine it was made on: as a
4
+ Markdown fix report you can paste into an upstream issue or PR, or as
5
+ neutral JSON for gateways and scripts that apply metadata overrides
6
+ themselves.
7
+ """
8
+
9
+ import json
10
+
11
+ from whetkit.curation.plan import CurationPlan, ToolOverride
12
+
13
+
14
+ def _action(override: ToolOverride) -> str:
15
+ if override.hidden:
16
+ return "hide"
17
+ if override.new_name and override.new_description:
18
+ return "rename + rewrite"
19
+ if override.new_name:
20
+ return "rename"
21
+ return "rewrite"
22
+
23
+
24
+ def plan_to_markdown(plan: CurationPlan) -> str:
25
+ """A fix report for humans — upstream-issue / PR-description ready."""
26
+ lines = [
27
+ "## Proposed tool-surface fixes",
28
+ "",
29
+ "Metadata-only changes — no schemas or behavior are touched. "
30
+ "Measured with [whetkit](https://github.com/benlamlih/whetkit); "
31
+ "each change is reversible (it can be served as an overlay without "
32
+ "modifying the server).",
33
+ "",
34
+ ]
35
+ if plan.notes:
36
+ lines += [f"> {plan.notes.strip()}", ""]
37
+ lines += [
38
+ "| Tool | Change | Proposal | Why |",
39
+ "|---|---|---|---|",
40
+ ]
41
+ for override in plan.overrides:
42
+ if override.hidden:
43
+ proposal = "hide from the tool list"
44
+ else:
45
+ parts = []
46
+ if override.new_name:
47
+ parts.append(f"rename to `{override.new_name}`")
48
+ if override.new_description:
49
+ parts.append(f'description: "{override.new_description.strip()}"')
50
+ proposal = "; ".join(parts)
51
+ cell = lambda s: " ".join(str(s).split()).replace("|", "\\|") # noqa: E731
52
+ lines.append(
53
+ f"| `{override.original_name}` | {_action(override)} "
54
+ f"| {cell(proposal)} | {cell(override.reason) or '—'} |"
55
+ )
56
+ return "\n".join(lines) + "\n"
57
+
58
+
59
+ def plan_to_json(plan: CurationPlan) -> str:
60
+ """Neutral override list for gateways/scripts that apply their own
61
+ metadata overrides (Cloudflare portal aliases, MetaMCP namespaces, …)."""
62
+ overrides = [
63
+ {
64
+ "original_name": o.original_name,
65
+ "action": _action(o),
66
+ "new_name": o.new_name,
67
+ "new_description": o.new_description,
68
+ "hidden": o.hidden,
69
+ "reason": o.reason,
70
+ }
71
+ for o in plan.overrides
72
+ ]
73
+ return json.dumps({"notes": plan.notes, "overrides": overrides}, indent=2) + "\n"
@@ -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)
@@ -1,11 +1,12 @@
1
1
  """Scoring: deterministic tool-selection matching + LLM-as-judge grading."""
2
2
 
3
- from whetkit.scoring.aggregate import EvalSummary, TaskScore, score_runs
3
+ from whetkit.scoring.aggregate import EvalSummary, MultiRunSummary, TaskScore, score_runs
4
4
  from whetkit.scoring.deterministic import MatchMode, ToolMatchResult, score_tool_match
5
5
  from whetkit.scoring.judge import JudgeCache, JudgeConfig, JudgeVerdict, judge_run
6
6
 
7
7
  __all__ = [
8
8
  "EvalSummary",
9
+ "MultiRunSummary",
9
10
  "JudgeCache",
10
11
  "JudgeConfig",
11
12
  "JudgeVerdict",
@@ -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]
@@ -88,6 +96,51 @@ class EvalSummary(BaseModel):
88
96
  return lines
89
97
 
90
98
 
99
+ class MultiRunSummary(BaseModel):
100
+ """Aggregate over repeated executions of the same task set.
101
+
102
+ Single runs are noise: the same server, tasks, and model can flip a
103
+ task between runs. The honest headline is mean plus range."""
104
+
105
+ summaries: list[EvalSummary]
106
+
107
+ @property
108
+ def n(self) -> int:
109
+ return len(self.summaries)
110
+
111
+ def _spread(self, values: list[float]) -> str:
112
+ mean = sum(values) / len(values)
113
+ low, high = min(values), max(values)
114
+ if low == high:
115
+ return f"{mean:.0%}"
116
+ return f"{mean:.0%} [{low:.0%}–{high:.0%}]"
117
+
118
+ def summary_lines(self) -> list[str]:
119
+ lines = [
120
+ f"Runs: {self.n} × {self.summaries[0].task_count} task(s)",
121
+ f"Hit-rate: {self._spread([s.hit_rate for s in self.summaries])}",
122
+ f"Tool-selection hit-rate: {self._spread([s.tool_hit_rate for s in self.summaries])}",
123
+ f"Tool precision (avg): {self._spread([s.avg_precision for s in self.summaries])}",
124
+ ]
125
+ judged = [s.judge_pass_rate for s in self.summaries if s.judge_pass_rate is not None]
126
+ if judged:
127
+ lines.insert(2, f"Judge pass-rate: {self._spread(judged)}")
128
+ extras = [s.avg_extra_calls for s in self.summaries]
129
+ lines.append(f"Unnecessary calls (avg): {sum(extras) / len(extras):.1f}/task")
130
+ flaky = self.flaky_tasks()
131
+ if flaky:
132
+ lines.append(f"Flaky tasks (hit in some runs, missed in others): {', '.join(flaky)}")
133
+ return lines
134
+
135
+ def flaky_tasks(self) -> list[str]:
136
+ """Task ids whose hit outcome differs across runs."""
137
+ outcomes: dict[str, set[bool]] = {}
138
+ for summary in self.summaries:
139
+ for score in summary.scores:
140
+ outcomes.setdefault(score.task_id, set()).add(score.hit)
141
+ return sorted(task_id for task_id, seen in outcomes.items() if len(seen) > 1)
142
+
143
+
91
144
  async def score_runs(
92
145
  tasks: list[TaskSpec],
93
146
  runs: list[TaskRun],
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes