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.
- {whetkit-0.1.0 → whetkit-0.2.0}/PKG-INFO +38 -12
- {whetkit-0.1.0 → whetkit-0.2.0}/README.md +37 -11
- {whetkit-0.1.0 → whetkit-0.2.0}/pyproject.toml +1 -1
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/cli.py +331 -28
- whetkit-0.2.0/src/whetkit/curation/export.py +73 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/curation/optimizer.py +10 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/curation/plan.py +10 -0
- whetkit-0.2.0/src/whetkit/doctor.py +225 -0
- whetkit-0.2.0/src/whetkit/generate.py +159 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/mcp/transport.py +17 -8
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/scoring/__init__.py +2 -1
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/scoring/aggregate.py +63 -2
- {whetkit-0.1.0 → whetkit-0.2.0}/LICENSE +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/__init__.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/curation/__init__.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/curation/overlay.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/datasets/__init__.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/datasets/tasks.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/__init__.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/anthropic_provider.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/base.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/openai_provider.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/llm/registry.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/mcp/__init__.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/mcp/client.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/mcp/introspect.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/py.typed +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/report/__init__.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/report/builder.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/report/html.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/runner/__init__.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/runner/agent.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/scoring/deterministic.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/scoring/judge.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/tracing/__init__.py +0 -0
- {whetkit-0.1.0 → whetkit-0.2.0}/src/whetkit/tracing/records.py +0 -0
- {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.
|
|
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
|
|
47
|
-
whetkit
|
|
48
|
-
whetkit
|
|
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
|
-
(
|
|
70
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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 #
|
|
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
|
|
16
|
-
whetkit
|
|
17
|
-
whetkit
|
|
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
|
-
(
|
|
39
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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 #
|
|
161
|
+
uv run pytest # full suite, no API key needed (scripted fake provider)
|
|
136
162
|
uv run ruff check .
|
|
137
163
|
```
|
|
138
164
|
|
|
@@ -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(
|
|
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,
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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(
|
|
154
|
-
if jsonl:
|
|
155
|
-
write_jsonl(runs, jsonl)
|
|
391
|
+
trace_store.save_runs(task_runs, run_group=group_name)
|
|
156
392
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 '{
|
|
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
|
-
|
|
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
|
-
|
|
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()
|