whetkit 0.1.1__tar.gz → 0.2.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 (37) hide show
  1. {whetkit-0.1.1 → whetkit-0.2.0}/PKG-INFO +1 -1
  2. {whetkit-0.1.1 → whetkit-0.2.0}/pyproject.toml +1 -1
  3. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/cli.py +158 -26
  4. whetkit-0.2.0/src/whetkit/curation/export.py +73 -0
  5. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/scoring/__init__.py +2 -1
  6. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/scoring/aggregate.py +45 -0
  7. {whetkit-0.1.1 → whetkit-0.2.0}/LICENSE +0 -0
  8. {whetkit-0.1.1 → whetkit-0.2.0}/README.md +0 -0
  9. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/__init__.py +0 -0
  10. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/curation/__init__.py +0 -0
  11. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/curation/optimizer.py +0 -0
  12. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/curation/overlay.py +0 -0
  13. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/curation/plan.py +0 -0
  14. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/datasets/__init__.py +0 -0
  15. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/datasets/tasks.py +0 -0
  16. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/doctor.py +0 -0
  17. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/generate.py +0 -0
  18. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/__init__.py +0 -0
  19. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/anthropic_provider.py +0 -0
  20. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/base.py +0 -0
  21. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/openai_provider.py +0 -0
  22. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/registry.py +0 -0
  23. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/mcp/__init__.py +0 -0
  24. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/mcp/client.py +0 -0
  25. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/mcp/introspect.py +0 -0
  26. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/mcp/transport.py +0 -0
  27. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/py.typed +0 -0
  28. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/report/__init__.py +0 -0
  29. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/report/builder.py +0 -0
  30. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/report/html.py +0 -0
  31. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/runner/__init__.py +0 -0
  32. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/runner/agent.py +0 -0
  33. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/scoring/deterministic.py +0 -0
  34. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/scoring/judge.py +0 -0
  35. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/tracing/__init__.py +0 -0
  36. {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/tracing/records.py +0 -0
  37. {whetkit-0.1.1 → whetkit-0.2.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.2.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.2.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"
@@ -51,6 +51,34 @@ def _write_report(report, out_dir: str) -> tuple[str, str]:
51
51
  return str(html_path), str(json_path)
52
52
 
53
53
 
54
+ def _summary_payload(group_name: str, summary, task_runs: list) -> dict:
55
+ """One run's metrics as plain data — what --summary-json emits per run."""
56
+ return {
57
+ "group": group_name,
58
+ "hit_rate": summary.hit_rate,
59
+ "tool_hit_rate": summary.tool_hit_rate,
60
+ "judge_pass_rate": summary.judge_pass_rate,
61
+ "avg_precision": summary.avg_precision,
62
+ "avg_recall": summary.avg_recall,
63
+ "avg_extra_calls": summary.avg_extra_calls,
64
+ "tokens_in": sum(r.total_usage.input_tokens for r in task_runs),
65
+ "tokens_out": sum(r.total_usage.output_tokens for r in task_runs),
66
+ "latency_ms": sum(r.total_latency_ms for r in task_runs),
67
+ "tasks": [
68
+ {
69
+ "id": score.task_id,
70
+ "hit": score.hit,
71
+ "tool_hit": score.tool_hit,
72
+ "judge_passed": score.judge.passed if score.judge else None,
73
+ "called": score.tool_match.called,
74
+ "missing": score.tool_match.missing_slots,
75
+ "extra_calls": score.tool_match.extra_calls,
76
+ }
77
+ for score in summary.scores
78
+ ],
79
+ }
80
+
81
+
54
82
  def _version_callback(value: bool) -> None:
55
83
  if value:
56
84
  from importlib.metadata import version
@@ -204,6 +232,47 @@ def generate(
204
232
  typer.echo(f" whetkit run --server {server} --tasks {out}")
205
233
 
206
234
 
235
+ @app.command()
236
+ def export(
237
+ plan: Annotated[
238
+ str, typer.Option("--plan", help="Curation plan YAML to export")
239
+ ] = ".whetkit/curation-plan.yaml",
240
+ to: Annotated[
241
+ str,
242
+ typer.Option(
243
+ "--to",
244
+ help=(
245
+ "'markdown' — a fix report ready for an upstream issue/PR; "
246
+ "'json' — a neutral override list for gateways and scripts"
247
+ ),
248
+ ),
249
+ ] = "markdown",
250
+ out: Annotated[str | None, typer.Option("--out", help="Write here instead of stdout")] = None,
251
+ ) -> None:
252
+ """Export a curation plan as a shareable fix report or neutral JSON."""
253
+ from whetkit.curation import load_plan
254
+ from whetkit.curation.export import plan_to_json, plan_to_markdown
255
+
256
+ if to not in ("markdown", "json"):
257
+ raise typer.BadParameter("--to must be 'markdown' or 'json'")
258
+ if not Path(plan).is_file():
259
+ raise typer.BadParameter(
260
+ f"no curation plan at {plan} — run 'whetkit curate' first, or pass --plan"
261
+ )
262
+ curation_plan = load_plan(plan)
263
+ if not curation_plan.overrides:
264
+ typer.echo("plan has no overrides — nothing to export", err=True)
265
+ raise typer.Exit(code=1)
266
+
267
+ rendered = (plan_to_markdown if to == "markdown" else plan_to_json)(curation_plan)
268
+ if out:
269
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
270
+ Path(out).write_text(rendered)
271
+ typer.echo(f"wrote {out}")
272
+ else:
273
+ typer.echo(rendered, nl=False)
274
+
275
+
207
276
  @app.command()
208
277
  def run(
209
278
  tasks: Annotated[
@@ -250,21 +319,45 @@ def run(
250
319
  help="Completion-token budget per model turn (raise for reasoning models)",
251
320
  ),
252
321
  ] = 1024,
322
+ runs: Annotated[
323
+ int,
324
+ typer.Option(
325
+ "--runs",
326
+ help=(
327
+ "Repeat the whole task set N times and report mean plus range "
328
+ "— single runs are noise. Trace groups get a -1..-N suffix."
329
+ ),
330
+ ),
331
+ ] = 1,
253
332
  store: Annotated[
254
333
  str | None,
255
334
  typer.Option("--store", help="Trace SQLite path (default ./.whetkit/traces.sqlite3)"),
256
335
  ] = None,
257
336
  jsonl: Annotated[
258
- str | None, typer.Option("--jsonl", help="Also write traces to this JSONL file")
337
+ str | None,
338
+ typer.Option(
339
+ "--jsonl",
340
+ help="Also write traces to this JSONL file (suffixed per run when --runs > 1)",
341
+ ),
342
+ ] = None,
343
+ summary_json: Annotated[
344
+ str | None,
345
+ typer.Option(
346
+ "--summary-json",
347
+ help="Write a machine-readable summary (metrics + per-task outcomes) to this path",
348
+ ),
259
349
  ] = None,
260
350
  http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
261
351
  ) -> None:
262
352
  """Run eval tasks against an MCP server and print scored results."""
263
353
  from whetkit.datasets import load_tasks
264
354
  from whetkit.runner import RunConfig, run_task
265
- from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
355
+ from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, MultiRunSummary, score_runs
266
356
  from whetkit.tracing import TraceStore, default_store_path, write_jsonl
267
357
 
358
+ if runs < 1:
359
+ raise typer.BadParameter("--runs must be at least 1")
360
+
268
361
  task_list = load_tasks(tasks)
269
362
  servers = _resolve_task_servers(task_list, server, http_mode)
270
363
  config = RunConfig(model=model, max_turns=max_turns, max_tokens=max_tokens)
@@ -286,34 +379,28 @@ def run(
286
379
  client_factory = partial(CuratedMCPClient, plan=curation_plan)
287
380
  name_map = curation_plan.rename_map()
288
381
 
289
- async def _run() -> None:
290
- runs = []
382
+ async def _run_once(group_name: str, cache: JudgeCache):
383
+ task_runs = []
291
384
  for task in task_list:
292
385
  typer.echo(f"running {task.id} ...", err=True)
293
- runs.append(
386
+ task_runs.append(
294
387
  await run_task(task, servers[task.server], config, client_factory=client_factory)
295
388
  )
296
389
 
297
390
  with TraceStore(store_path) as trace_store:
298
- trace_store.save_runs(runs, run_group=group)
299
- if jsonl:
300
- write_jsonl(runs, jsonl)
391
+ trace_store.save_runs(task_runs, run_group=group_name)
301
392
 
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()
393
+ summary = await score_runs(
394
+ task_list,
395
+ task_runs,
396
+ mode=MatchMode(match_mode),
397
+ judge_config=JudgeConfig(model=judge_model),
398
+ judge_cache=cache,
399
+ use_judge=use_judge,
400
+ name_map=name_map,
401
+ )
315
402
 
316
- typer.echo(f"\nResults (group '{group}', model {model}):")
403
+ typer.echo(f"\nResults (group '{group_name}', model {model}):")
317
404
  for score in summary.scores:
318
405
  mark = "PASS" if score.hit else "MISS"
319
406
  tools = " -> ".join(score.tool_match.called) or "(no tool calls)"
@@ -326,17 +413,62 @@ def run(
326
413
  typer.echo("")
327
414
  for line in summary.summary_lines():
328
415
  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)
416
+ tokens_in = sum(r.total_usage.input_tokens for r in task_runs)
417
+ tokens_out = sum(r.total_usage.output_tokens for r in task_runs)
331
418
  typer.echo(
332
419
  f"Tokens in/out: {tokens_in}/{tokens_out} "
333
- f"Total latency: {sum(r.total_latency_ms for r in runs) / 1000:.1f}s"
420
+ f"Total latency: {sum(r.total_latency_ms for r in task_runs) / 1000:.1f}s"
334
421
  )
422
+ return task_runs, summary
423
+
424
+ async def _run() -> None:
425
+ summaries = []
426
+ payloads = []
427
+ cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
428
+ try:
429
+ for run_index in range(1, runs + 1):
430
+ group_name = group if runs == 1 else f"{group}-{run_index}"
431
+ task_runs, summary = await _run_once(group_name, cache)
432
+ summaries.append(summary)
433
+ payloads.append(_summary_payload(group_name, summary, task_runs))
434
+ if jsonl:
435
+ write_jsonl(task_runs, jsonl if runs == 1 else f"{jsonl}.{run_index}")
436
+ finally:
437
+ cache.close()
438
+
439
+ multi = MultiRunSummary(summaries=summaries)
440
+ if runs > 1:
441
+ typer.echo(f"\n== across {runs} runs ==")
442
+ for line in multi.summary_lines():
443
+ typer.echo(line)
335
444
  if judge == "auto" and not use_judge:
336
445
  typer.echo(
337
446
  "(LLM judge skipped: no API key found — set ANTHROPIC_API_KEY or pass --judge on)"
338
447
  )
339
- typer.echo(f"Traces saved to {store_path} (group '{group}')")
448
+ suffix = f" (groups '{group}-1'..'-{runs}')" if runs > 1 else f" (group '{group}')"
449
+ typer.echo(f"Traces saved to {store_path}{suffix}")
450
+
451
+ if summary_json:
452
+ import json as jsonlib
453
+
454
+ document = {
455
+ "model": model,
456
+ "judge_model": judge_model if use_judge else None,
457
+ "match_mode": match_mode,
458
+ "plan": plan,
459
+ "runs": payloads,
460
+ }
461
+ if runs > 1:
462
+ document["aggregate"] = {
463
+ "n": multi.n,
464
+ "hit_rate_mean": sum(s.hit_rate for s in summaries) / len(summaries),
465
+ "hit_rate_min": min(s.hit_rate for s in summaries),
466
+ "hit_rate_max": max(s.hit_rate for s in summaries),
467
+ "flaky_tasks": multi.flaky_tasks(),
468
+ }
469
+ Path(summary_json).parent.mkdir(parents=True, exist_ok=True)
470
+ Path(summary_json).write_text(jsonlib.dumps(document, indent=2) + "\n")
471
+ typer.echo(f"Summary JSON: {summary_json}")
340
472
 
341
473
  asyncio.run(_run())
342
474
 
@@ -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"
@@ -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",
@@ -88,6 +88,51 @@ class EvalSummary(BaseModel):
88
88
  return lines
89
89
 
90
90
 
91
+ class MultiRunSummary(BaseModel):
92
+ """Aggregate over repeated executions of the same task set.
93
+
94
+ Single runs are noise: the same server, tasks, and model can flip a
95
+ task between runs. The honest headline is mean plus range."""
96
+
97
+ summaries: list[EvalSummary]
98
+
99
+ @property
100
+ def n(self) -> int:
101
+ return len(self.summaries)
102
+
103
+ def _spread(self, values: list[float]) -> str:
104
+ mean = sum(values) / len(values)
105
+ low, high = min(values), max(values)
106
+ if low == high:
107
+ return f"{mean:.0%}"
108
+ return f"{mean:.0%} [{low:.0%}–{high:.0%}]"
109
+
110
+ def summary_lines(self) -> list[str]:
111
+ lines = [
112
+ f"Runs: {self.n} × {self.summaries[0].task_count} task(s)",
113
+ f"Hit-rate: {self._spread([s.hit_rate for s in self.summaries])}",
114
+ f"Tool-selection hit-rate: {self._spread([s.tool_hit_rate for s in self.summaries])}",
115
+ f"Tool precision (avg): {self._spread([s.avg_precision for s in self.summaries])}",
116
+ ]
117
+ judged = [s.judge_pass_rate for s in self.summaries if s.judge_pass_rate is not None]
118
+ if judged:
119
+ lines.insert(2, f"Judge pass-rate: {self._spread(judged)}")
120
+ extras = [s.avg_extra_calls for s in self.summaries]
121
+ lines.append(f"Unnecessary calls (avg): {sum(extras) / len(extras):.1f}/task")
122
+ flaky = self.flaky_tasks()
123
+ if flaky:
124
+ lines.append(f"Flaky tasks (hit in some runs, missed in others): {', '.join(flaky)}")
125
+ return lines
126
+
127
+ def flaky_tasks(self) -> list[str]:
128
+ """Task ids whose hit outcome differs across runs."""
129
+ outcomes: dict[str, set[bool]] = {}
130
+ for summary in self.summaries:
131
+ for score in summary.scores:
132
+ outcomes.setdefault(score.task_id, set()).add(score.hit)
133
+ return sorted(task_id for task_id, seen in outcomes.items() if len(seen) > 1)
134
+
135
+
91
136
  async def score_runs(
92
137
  tasks: list[TaskSpec],
93
138
  runs: list[TaskRun],
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes