whetkit 0.1.0__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.0 → whetkit-0.2.0}/PKG-INFO +38 -12
  2. {whetkit-0.1.0 → whetkit-0.2.0}/README.md +37 -11
  3. {whetkit-0.1.0 → whetkit-0.2.0}/pyproject.toml +1 -1
  4. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/cli.py +331 -28
  5. whetkit-0.2.0/src/whetkit/curation/export.py +73 -0
  6. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/curation/optimizer.py +10 -0
  7. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/curation/plan.py +10 -0
  8. whetkit-0.2.0/src/whetkit/doctor.py +225 -0
  9. whetkit-0.2.0/src/whetkit/generate.py +159 -0
  10. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/mcp/transport.py +17 -8
  11. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/scoring/__init__.py +2 -1
  12. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/scoring/aggregate.py +63 -2
  13. {whetkit-0.1.0 → whetkit-0.2.0}/LICENSE +0 -0
  14. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/__init__.py +0 -0
  15. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/curation/__init__.py +0 -0
  16. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/curation/overlay.py +0 -0
  17. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/datasets/__init__.py +0 -0
  18. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/datasets/tasks.py +0 -0
  19. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/__init__.py +0 -0
  20. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/anthropic_provider.py +0 -0
  21. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/base.py +0 -0
  22. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/openai_provider.py +0 -0
  23. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/registry.py +0 -0
  24. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/mcp/__init__.py +0 -0
  25. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/mcp/client.py +0 -0
  26. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/mcp/introspect.py +0 -0
  27. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/py.typed +0 -0
  28. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/report/__init__.py +0 -0
  29. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/report/builder.py +0 -0
  30. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/report/html.py +0 -0
  31. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/runner/__init__.py +0 -0
  32. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/runner/agent.py +0 -0
  33. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/scoring/deterministic.py +0 -0
  34. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/scoring/judge.py +0 -0
  35. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/tracing/__init__.py +0 -0
  36. {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/tracing/records.py +0 -0
  37. {whetkit-0.1.0 → 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.0
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
@@ -42,10 +42,17 @@ duplicates — via a **reversible overlay proxy** that never modifies your
42
42
  server. It re-runs the eval through the overlay and hands you a before/after
43
43
  report.
44
44
 
45
+ Unlike MCP inspectors and testing frameworks, whetkit **closes the
46
+ optimization loop**: it measures agent behavior, proposes a curated tool
47
+ surface, applies it through a reversible proxy, and re-runs the same evals to
48
+ quantify the improvement.
49
+
45
50
  ```text
46
- whetkit inspect ──► what does the agent actually see?
47
- whetkit run ──► how often does it pick the right tools? (hit-rate)
48
- whetkit curate ──► fix the tool set, prove it helped (before after)
51
+ whetkit doctor ──► ten-second lint of the tool surface (no tasks, no API key)
52
+ whetkit inspect ──► what does the agent actually see?
53
+ whetkit generate ──► draft eval tasks from the inventory (review, then run)
54
+ whetkit run ──► how often does it pick the right tools? (hit-rate)
55
+ whetkit curate ──► fix the tool set, prove it helped (before → after)
49
56
  ```
50
57
 
51
58
  ## Why tool curation matters
@@ -66,8 +73,8 @@ through an overlay, and show the hit-rate delta.
66
73
  uv tool install whetkit # or: uvx whetkit / pipx install whetkit
67
74
  ```
68
75
 
69
- (Releases are published to PyPI from tags — see [RELEASING.md](RELEASING.md).
70
- Until the first release, use the from-source quickstart below.)
76
+ (Released to PyPI from tags — see [RELEASING.md](RELEASING.md). PyPI can lag
77
+ behind main; the quickstart below runs from source.)
71
78
 
72
79
  ## Quickstart (5 minutes)
73
80
 
@@ -109,9 +116,23 @@ This writes:
109
116
  - `.whetkit/report.json` — the same data, machine-readable
110
117
  - `.whetkit/traces.sqlite3` — full reasoning-path traces of every run
111
118
 
112
- Because the sample server's tasks fail mostly on tool selection, the curated
113
- overlay typically flips several tasks from MISS to HIT that delta is the
114
- whole point.
119
+ The sample server's failures are tool-selection failures, so weaker models
120
+ flip several tasks from MISS to HIT through the overlay. Frontier models
121
+ often ace even the messy baseline on a 14-tool server — there the delta
122
+ shows up in the other columns instead: tools exposed, tokens per task, and
123
+ extra calls. On large real-world servers (dozens of tools, near-duplicates)
124
+ the hit-rate delta comes back.
125
+
126
+ Stdio servers' own logs are hidden so they can't garble the output; set
127
+ `WHETKIT_SERVER_LOGS=1` to see them when debugging a server that won't start.
128
+
129
+ The plan is yours to edit: tweak a rename the optimizer got wrong, un-hide a
130
+ tool, then re-score the curated view directly —
131
+
132
+ ```sh
133
+ uv run whetkit run --server examples/sample-server --tasks examples/tasks \
134
+ --plan .whetkit/curation-plan.yaml --group curated-v2
135
+ ```
115
136
 
116
137
  **4. Use the curated view for real** — serve it to any MCP client:
117
138
 
@@ -128,7 +149,10 @@ back to the original world.
128
149
  2026-07-28-spec servers), a directory containing `server.json` or
129
150
  `server.py`, or a `.py`/`.json` path directly.
130
151
  - Write tasks in YAML — format reference in
131
- [docs/task-format.md](docs/task-format.md).
152
+ [docs/task-format.md](docs/task-format.md) — or draft them:
153
+ `whetkit generate --server <your-server> --out tasks/generated.yaml`
154
+ writes candidate tasks from the tool inventory (validated against the
155
+ live tool list; review before trusting).
132
156
  - `--model` / `--judge-model` / `--optimizer-model` take
133
157
  `provider:model_id`, e.g. `anthropic:claude-sonnet-5` or `openai:gpt-5.2`.
134
158
  Keys come from `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`.
@@ -137,8 +161,10 @@ back to the original world.
137
161
 
138
162
  | Command | What it does |
139
163
  |---|---|
164
+ | `whetkit doctor` | Lint the tool surface: vague descriptions, cryptic names, near-duplicates, context bloat. `--json`; `--fail-on warn` for CI. |
140
165
  | `whetkit inspect` | Tool inventory: names, params, description tokens, schema complexity. |
141
- | `whetkit run` | Agentic eval loop with real tool execution; scored results + traces. |
166
+ | `whetkit generate` | Draft eval tasks from the inventory; validated, review-before-trust YAML. |
167
+ | `whetkit run` | Agentic eval loop with real tool execution; scored results + traces. `--plan` scores a curated view. |
142
168
  | `whetkit curate` | Baseline → LLM-proposed overlay plan → curated eval → before/after report. |
143
169
  | `whetkit report` | Rebuild the HTML/JSON report from stored traces. |
144
170
  | `whetkit overlay` | Serve the curated view as a stdio MCP server. |
@@ -163,7 +189,7 @@ More docs: [docs/task-format.md](docs/task-format.md) ·
163
189
 
164
190
  ```sh
165
191
  uv sync
166
- uv run pytest # 55+ tests, no API key needed (scripted fake provider)
192
+ uv run pytest # full suite, no API key needed (scripted fake provider)
167
193
  uv run ruff check .
168
194
  ```
169
195
 
@@ -11,10 +11,17 @@ duplicates — via a **reversible overlay proxy** that never modifies your
11
11
  server. It re-runs the eval through the overlay and hands you a before/after
12
12
  report.
13
13
 
14
+ Unlike MCP inspectors and testing frameworks, whetkit **closes the
15
+ optimization loop**: it measures agent behavior, proposes a curated tool
16
+ surface, applies it through a reversible proxy, and re-runs the same evals to
17
+ quantify the improvement.
18
+
14
19
  ```text
15
- whetkit inspect ──► what does the agent actually see?
16
- whetkit run ──► how often does it pick the right tools? (hit-rate)
17
- whetkit curate ──► fix the tool set, prove it helped (before after)
20
+ whetkit doctor ──► ten-second lint of the tool surface (no tasks, no API key)
21
+ whetkit inspect ──► what does the agent actually see?
22
+ whetkit generate ──► draft eval tasks from the inventory (review, then run)
23
+ whetkit run ──► how often does it pick the right tools? (hit-rate)
24
+ whetkit curate ──► fix the tool set, prove it helped (before → after)
18
25
  ```
19
26
 
20
27
  ## Why tool curation matters
@@ -35,8 +42,8 @@ through an overlay, and show the hit-rate delta.
35
42
  uv tool install whetkit # or: uvx whetkit / pipx install whetkit
36
43
  ```
37
44
 
38
- (Releases are published to PyPI from tags — see [RELEASING.md](RELEASING.md).
39
- Until the first release, use the from-source quickstart below.)
45
+ (Released to PyPI from tags — see [RELEASING.md](RELEASING.md). PyPI can lag
46
+ behind main; the quickstart below runs from source.)
40
47
 
41
48
  ## Quickstart (5 minutes)
42
49
 
@@ -78,9 +85,23 @@ This writes:
78
85
  - `.whetkit/report.json` — the same data, machine-readable
79
86
  - `.whetkit/traces.sqlite3` — full reasoning-path traces of every run
80
87
 
81
- Because the sample server's tasks fail mostly on tool selection, the curated
82
- overlay typically flips several tasks from MISS to HIT that delta is the
83
- whole point.
88
+ The sample server's failures are tool-selection failures, so weaker models
89
+ flip several tasks from MISS to HIT through the overlay. Frontier models
90
+ often ace even the messy baseline on a 14-tool server — there the delta
91
+ shows up in the other columns instead: tools exposed, tokens per task, and
92
+ extra calls. On large real-world servers (dozens of tools, near-duplicates)
93
+ the hit-rate delta comes back.
94
+
95
+ Stdio servers' own logs are hidden so they can't garble the output; set
96
+ `WHETKIT_SERVER_LOGS=1` to see them when debugging a server that won't start.
97
+
98
+ The plan is yours to edit: tweak a rename the optimizer got wrong, un-hide a
99
+ tool, then re-score the curated view directly —
100
+
101
+ ```sh
102
+ uv run whetkit run --server examples/sample-server --tasks examples/tasks \
103
+ --plan .whetkit/curation-plan.yaml --group curated-v2
104
+ ```
84
105
 
85
106
  **4. Use the curated view for real** — serve it to any MCP client:
86
107
 
@@ -97,7 +118,10 @@ back to the original world.
97
118
  2026-07-28-spec servers), a directory containing `server.json` or
98
119
  `server.py`, or a `.py`/`.json` path directly.
99
120
  - Write tasks in YAML — format reference in
100
- [docs/task-format.md](docs/task-format.md).
121
+ [docs/task-format.md](docs/task-format.md) — or draft them:
122
+ `whetkit generate --server <your-server> --out tasks/generated.yaml`
123
+ writes candidate tasks from the tool inventory (validated against the
124
+ live tool list; review before trusting).
101
125
  - `--model` / `--judge-model` / `--optimizer-model` take
102
126
  `provider:model_id`, e.g. `anthropic:claude-sonnet-5` or `openai:gpt-5.2`.
103
127
  Keys come from `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`.
@@ -106,8 +130,10 @@ back to the original world.
106
130
 
107
131
  | Command | What it does |
108
132
  |---|---|
133
+ | `whetkit doctor` | Lint the tool surface: vague descriptions, cryptic names, near-duplicates, context bloat. `--json`; `--fail-on warn` for CI. |
109
134
  | `whetkit inspect` | Tool inventory: names, params, description tokens, schema complexity. |
110
- | `whetkit run` | Agentic eval loop with real tool execution; scored results + traces. |
135
+ | `whetkit generate` | Draft eval tasks from the inventory; validated, review-before-trust YAML. |
136
+ | `whetkit run` | Agentic eval loop with real tool execution; scored results + traces. `--plan` scores a curated view. |
111
137
  | `whetkit curate` | Baseline → LLM-proposed overlay plan → curated eval → before/after report. |
112
138
  | `whetkit report` | Rebuild the HTML/JSON report from stored traces. |
113
139
  | `whetkit overlay` | Serve the curated view as a stdio MCP server. |
@@ -132,7 +158,7 @@ More docs: [docs/task-format.md](docs/task-format.md) ·
132
158
 
133
159
  ```sh
134
160
  uv sync
135
- uv run pytest # 55+ tests, no API key needed (scripted fake provider)
161
+ uv run pytest # full suite, no API key needed (scripted fake provider)
136
162
  uv run ruff check .
137
163
  ```
138
164
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "whetkit"
3
- version = "0.1.0"
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"
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import os
5
+ from pathlib import Path
5
6
  from typing import Annotated
6
7
 
7
8
  import typer
@@ -39,8 +40,6 @@ def _resolve_task_servers(
39
40
 
40
41
 
41
42
  def _write_report(report, out_dir: str) -> tuple[str, str]:
42
- from pathlib import Path
43
-
44
43
  from whetkit.report import render_html
45
44
 
46
45
  out = Path(out_dir)
@@ -52,8 +51,54 @@ def _write_report(report, out_dir: str) -> tuple[str, str]:
52
51
  return str(html_path), str(json_path)
53
52
 
54
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
+
82
+ def _version_callback(value: bool) -> None:
83
+ if value:
84
+ from importlib.metadata import version
85
+
86
+ typer.echo(f"whetkit {version('whetkit')}")
87
+ raise typer.Exit()
88
+
89
+
55
90
  @app.callback()
56
- def main() -> None:
91
+ def main(
92
+ version: Annotated[
93
+ bool,
94
+ typer.Option(
95
+ "--version",
96
+ help="Show the whetkit version and exit.",
97
+ callback=_version_callback,
98
+ is_eager=True,
99
+ ),
100
+ ] = False,
101
+ ) -> None:
57
102
  """Evaluate and improve LLM agent tool selection on MCP servers."""
58
103
 
59
104
 
@@ -93,6 +138,141 @@ def inspect(
93
138
  )
94
139
 
95
140
 
141
+ @app.command()
142
+ def doctor(
143
+ server: Annotated[
144
+ str,
145
+ typer.Option("--server", help="MCP server: URL, directory, server.json, or server.py"),
146
+ ],
147
+ json_out: Annotated[
148
+ bool, typer.Option("--json", help="Emit findings as JSON instead of text")
149
+ ] = False,
150
+ fail_on: Annotated[
151
+ str,
152
+ typer.Option(
153
+ "--fail-on",
154
+ help="Exit non-zero when findings at this severity exist: 'error', 'warn', or 'never'",
155
+ ),
156
+ ] = "never",
157
+ http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
158
+ ) -> None:
159
+ """Lint a server's tool surface: vague descriptions, cryptic names,
160
+ near-duplicates, schema and context-budget problems. No tasks or API
161
+ key needed."""
162
+ from whetkit.doctor import Severity, diagnose
163
+
164
+ if fail_on not in ("error", "warn", "never"):
165
+ raise typer.BadParameter("--fail-on must be 'error', 'warn', or 'never'")
166
+
167
+ spec = resolve_server_spec(server, http_mode=http_mode)
168
+ inventory = asyncio.run(inspect_server(spec))
169
+ findings = diagnose(inventory)
170
+
171
+ if json_out:
172
+ import json as jsonlib
173
+
174
+ typer.echo(jsonlib.dumps([f.model_dump() for f in findings], indent=2))
175
+ else:
176
+ for line in inventory.summary_lines():
177
+ typer.echo(line)
178
+ typer.echo()
179
+ if not findings:
180
+ typer.echo("No problems found — this tool surface reads clean.")
181
+ for finding in findings:
182
+ typer.echo(f"[{finding.severity.upper():<5}] {finding.check}: {finding.message}")
183
+ counts = {s: sum(f.severity == s for f in findings) for s in Severity}
184
+ if findings:
185
+ typer.echo(
186
+ f"\n{counts[Severity.ERROR]} error(s), {counts[Severity.WARN]} warning(s), "
187
+ f"{counts[Severity.INFO]} info — 'whetkit curate' can propose and prove fixes."
188
+ )
189
+
190
+ worst_hit = {
191
+ "error": any(f.severity == Severity.ERROR for f in findings),
192
+ "warn": any(f.severity in (Severity.ERROR, Severity.WARN) for f in findings),
193
+ "never": False,
194
+ }[fail_on]
195
+ if worst_hit:
196
+ raise typer.Exit(code=1)
197
+
198
+
199
+ @app.command()
200
+ def generate(
201
+ server: Annotated[
202
+ str,
203
+ typer.Option("--server", help="MCP server: URL, directory, server.json, or server.py"),
204
+ ],
205
+ out: Annotated[
206
+ str, typer.Option("--out", help="Where to write the drafted task YAML")
207
+ ] = "tasks/generated.yaml",
208
+ count: Annotated[int, typer.Option("--count", help="How many tasks to draft")] = 5,
209
+ model: Annotated[
210
+ str, typer.Option("--model", help="Generator model as provider:model_id")
211
+ ] = "anthropic:claude-sonnet-5",
212
+ http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
213
+ ) -> None:
214
+ """Draft eval tasks from the server's tool inventory (review before
215
+ trusting — the header in the output file says the same)."""
216
+ from whetkit.generate import GeneratorConfig, generate_tasks, write_tasks_yaml
217
+
218
+ spec = resolve_server_spec(server, http_mode=http_mode)
219
+ inventory = asyncio.run(inspect_server(spec))
220
+ tasks, warnings = asyncio.run(
221
+ generate_tasks(inventory, server, count=count, config=GeneratorConfig(model=model))
222
+ )
223
+ for warning in warnings:
224
+ typer.echo(f"warning: {warning}", err=True)
225
+ if not tasks:
226
+ typer.echo("no valid tasks were drafted — try again or write them by hand", err=True)
227
+ raise typer.Exit(code=1)
228
+
229
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
230
+ write_tasks_yaml(tasks, out)
231
+ typer.echo(f"drafted {len(tasks)} task(s) to {out} — review them, then:")
232
+ typer.echo(f" whetkit run --server {server} --tasks {out}")
233
+
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
+
96
276
  @app.command()
97
277
  def run(
98
278
  tasks: Annotated[
@@ -118,56 +298,109 @@ def run(
118
298
  group: Annotated[
119
299
  str, typer.Option("--group", help="Label for this batch of runs in the trace store")
120
300
  ] = "baseline",
301
+ plan: Annotated[
302
+ str | None,
303
+ typer.Option(
304
+ "--plan",
305
+ help=(
306
+ "Eval the curated view: apply this curation plan as an overlay "
307
+ "and score against origin tool names (for hand-tuned plans)"
308
+ ),
309
+ ),
310
+ ] = None,
121
311
  match_mode: Annotated[
122
312
  str, typer.Option("--match-mode", help="Tool matching: 'order_tolerant' or 'exact'")
123
313
  ] = "order_tolerant",
124
314
  max_turns: Annotated[int, typer.Option("--max-turns")] = 10,
315
+ max_tokens: Annotated[
316
+ int,
317
+ typer.Option(
318
+ "--max-tokens",
319
+ help="Completion-token budget per model turn (raise for reasoning models)",
320
+ ),
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,
125
332
  store: Annotated[
126
333
  str | None,
127
334
  typer.Option("--store", help="Trace SQLite path (default ./.whetkit/traces.sqlite3)"),
128
335
  ] = None,
129
336
  jsonl: Annotated[
130
- 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
+ ),
131
349
  ] = None,
132
350
  http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
133
351
  ) -> None:
134
352
  """Run eval tasks against an MCP server and print scored results."""
135
353
  from whetkit.datasets import load_tasks
136
354
  from whetkit.runner import RunConfig, run_task
137
- from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
355
+ from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, MultiRunSummary, score_runs
138
356
  from whetkit.tracing import TraceStore, default_store_path, write_jsonl
139
357
 
358
+ if runs < 1:
359
+ raise typer.BadParameter("--runs must be at least 1")
360
+
140
361
  task_list = load_tasks(tasks)
141
362
  servers = _resolve_task_servers(task_list, server, http_mode)
142
- config = RunConfig(model=model, max_turns=max_turns)
363
+ config = RunConfig(model=model, max_turns=max_turns, max_tokens=max_tokens)
143
364
  use_judge = _judge_enabled(judge, judge_model)
144
365
  store_path = store or str(default_store_path())
145
366
 
146
- async def _run() -> None:
147
- runs = []
367
+ from whetkit.mcp import MCPClient
368
+
369
+ client_factory = MCPClient
370
+ name_map: dict[str, str] | None = None
371
+ if plan is not None:
372
+ from functools import partial
373
+
374
+ from whetkit.curation import CuratedMCPClient, load_plan
375
+
376
+ if not Path(plan).is_file():
377
+ raise typer.BadParameter(f"no curation plan at {plan}")
378
+ curation_plan = load_plan(plan)
379
+ client_factory = partial(CuratedMCPClient, plan=curation_plan)
380
+ name_map = curation_plan.rename_map()
381
+
382
+ async def _run_once(group_name: str, cache: JudgeCache):
383
+ task_runs = []
148
384
  for task in task_list:
149
385
  typer.echo(f"running {task.id} ...", err=True)
150
- runs.append(await run_task(task, servers[task.server], config))
386
+ task_runs.append(
387
+ await run_task(task, servers[task.server], config, client_factory=client_factory)
388
+ )
151
389
 
152
390
  with TraceStore(store_path) as trace_store:
153
- trace_store.save_runs(runs, run_group=group)
154
- if jsonl:
155
- write_jsonl(runs, jsonl)
391
+ trace_store.save_runs(task_runs, run_group=group_name)
156
392
 
157
- cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
158
- try:
159
- summary = await score_runs(
160
- task_list,
161
- runs,
162
- mode=MatchMode(match_mode),
163
- judge_config=JudgeConfig(model=judge_model),
164
- judge_cache=cache,
165
- use_judge=use_judge,
166
- )
167
- finally:
168
- 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
+ )
169
402
 
170
- typer.echo(f"\nResults (group '{group}', model {model}):")
403
+ typer.echo(f"\nResults (group '{group_name}', model {model}):")
171
404
  for score in summary.scores:
172
405
  mark = "PASS" if score.hit else "MISS"
173
406
  tools = " -> ".join(score.tool_match.called) or "(no tool calls)"
@@ -180,11 +413,62 @@ def run(
180
413
  typer.echo("")
181
414
  for line in summary.summary_lines():
182
415
  typer.echo(line)
183
- if not use_judge:
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)
418
+ typer.echo(
419
+ f"Tokens in/out: {tokens_in}/{tokens_out} "
420
+ f"Total latency: {sum(r.total_latency_ms for r in task_runs) / 1000:.1f}s"
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)
444
+ if judge == "auto" and not use_judge:
184
445
  typer.echo(
185
446
  "(LLM judge skipped: no API key found — set ANTHROPIC_API_KEY or pass --judge on)"
186
447
  )
187
- 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}")
188
472
 
189
473
  asyncio.run(_run())
190
474
 
@@ -214,6 +498,13 @@ def curate(
214
498
  ] = ".whetkit",
215
499
  match_mode: Annotated[str, typer.Option("--match-mode")] = "order_tolerant",
216
500
  max_turns: Annotated[int, typer.Option("--max-turns")] = 10,
501
+ max_tokens: Annotated[
502
+ int,
503
+ typer.Option(
504
+ "--max-tokens",
505
+ help="Completion-token budget per model turn (raise for reasoning models)",
506
+ ),
507
+ ] = 1024,
217
508
  store: Annotated[str | None, typer.Option("--store")] = None,
218
509
  http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
219
510
  ) -> None:
@@ -229,7 +520,7 @@ def curate(
229
520
 
230
521
  task_list = load_tasks(tasks)
231
522
  servers = _resolve_task_servers(task_list, server, http_mode)
232
- config = RunConfig(model=model, max_turns=max_turns)
523
+ config = RunConfig(model=model, max_turns=max_turns, max_tokens=max_tokens)
233
524
  use_judge = _judge_enabled(judge, judge_model)
234
525
  judge_config = JudgeConfig(model=judge_model)
235
526
  mode = MatchMode(match_mode)
@@ -285,6 +576,7 @@ def curate(
285
576
  judge_config=judge_config,
286
577
  judge_cache=cache,
287
578
  use_judge=use_judge,
579
+ name_map=plan.rename_map(),
288
580
  )
289
581
  finally:
290
582
  cache.close()
@@ -326,6 +618,12 @@ def curate(
326
618
  f"Tool hit-rate: {baseline.tool_hit_rate:.0%} -> {curated.tool_hit_rate:.0%} "
327
619
  f"Precision: {baseline.avg_precision:.0%} -> {curated.avg_precision:.0%}"
328
620
  )
621
+ tok_before = (report.before.input_tokens + report.before.output_tokens) // len(task_list)
622
+ tok_after = (report.after.input_tokens + report.after.output_tokens) // len(task_list)
623
+ typer.echo(
624
+ f"Tools: {report.tools_before} -> {report.tools_after} "
625
+ f"Tokens/task: {tok_before} -> {tok_after}"
626
+ )
329
627
  typer.echo(f"Traces saved to {store_path} (groups 'baseline', 'curated')")
330
628
  typer.echo(f"Report: {html_path} (machine-readable: {json_path})")
331
629
 
@@ -357,6 +655,10 @@ def report(
357
655
  from whetkit.tracing import TraceStore, default_store_path
358
656
 
359
657
  task_list = load_tasks(tasks)
658
+ if not Path(plan).is_file():
659
+ raise typer.BadParameter(
660
+ f"no curation plan at {plan} — run 'whetkit curate' first, or pass --plan"
661
+ )
360
662
  curation_plan = load_plan(plan)
361
663
  store_path = store or str(default_store_path())
362
664
  use_judge = _judge_enabled(judge, judge_model)
@@ -390,6 +692,7 @@ def report(
390
692
  judge_config=judge_config,
391
693
  judge_cache=cache,
392
694
  use_judge=use_judge,
695
+ name_map=curation_plan.rename_map(),
393
696
  )
394
697
  finally:
395
698
  cache.close()