whetkit 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- whetkit/cli.py +434 -0
- whetkit/curation/__init__.py +17 -0
- whetkit/curation/optimizer.py +189 -0
- whetkit/curation/overlay.py +73 -0
- whetkit/curation/plan.py +109 -0
- whetkit/datasets/__init__.py +5 -0
- whetkit/datasets/tasks.py +118 -0
- whetkit/llm/__init__.py +24 -0
- whetkit/llm/anthropic_provider.py +90 -0
- whetkit/llm/base.py +88 -0
- whetkit/llm/openai_provider.py +100 -0
- whetkit/llm/registry.py +37 -0
- whetkit/mcp/__init__.py +17 -0
- whetkit/mcp/client.py +59 -0
- whetkit/mcp/introspect.py +106 -0
- whetkit/mcp/transport.py +150 -0
- whetkit/py.typed +0 -0
- whetkit/report/__init__.py +6 -0
- whetkit/report/builder.py +228 -0
- whetkit/report/html.py +420 -0
- whetkit/runner/__init__.py +5 -0
- whetkit/runner/agent.py +172 -0
- whetkit/scoring/__init__.py +18 -0
- whetkit/scoring/aggregate.py +117 -0
- whetkit/scoring/deterministic.py +99 -0
- whetkit/scoring/judge.py +170 -0
- whetkit/tracing/__init__.py +21 -0
- whetkit/tracing/records.py +76 -0
- whetkit/tracing/store.py +172 -0
- whetkit-0.1.0.dist-info/METADATA +182 -0
- whetkit-0.1.0.dist-info/RECORD +35 -0
- whetkit-0.1.0.dist-info/WHEEL +4 -0
- whetkit-0.1.0.dist-info/entry_points.txt +3 -0
- whetkit-0.1.0.dist-info/licenses/LICENSE +202 -0
whetkit/__init__.py
ADDED
whetkit/cli.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Command-line entry point for whetkit."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from whetkit.mcp import HttpMode, ServerSpec, inspect_server, resolve_server_spec
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="whetkit",
|
|
13
|
+
help="Evaluate and improve LLM agent tool selection on MCP servers.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _judge_enabled(judge: str, judge_model: str) -> bool:
|
|
19
|
+
"""--judge auto: grade with the LLM judge only when its API key is set."""
|
|
20
|
+
if judge in ("on", "off"):
|
|
21
|
+
return judge == "on"
|
|
22
|
+
from whetkit.llm import parse_model
|
|
23
|
+
|
|
24
|
+
provider_name, _ = parse_model(judge_model)
|
|
25
|
+
key_var = {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY"}.get(provider_name)
|
|
26
|
+
return bool(key_var and os.environ.get(key_var))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_task_servers(
|
|
30
|
+
tasks: list, server_override: str | None, http_mode: HttpMode
|
|
31
|
+
) -> dict[str, ServerSpec]:
|
|
32
|
+
if server_override is not None:
|
|
33
|
+
spec = resolve_server_spec(server_override, http_mode=http_mode)
|
|
34
|
+
return {task.server: spec for task in tasks}
|
|
35
|
+
return {
|
|
36
|
+
task.server: resolve_server_spec(task.server, http_mode=http_mode)
|
|
37
|
+
for task in {t.server: t for t in tasks}.values()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _write_report(report, out_dir: str) -> tuple[str, str]:
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
from whetkit.report import render_html
|
|
45
|
+
|
|
46
|
+
out = Path(out_dir)
|
|
47
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
html_path = out / "report.html"
|
|
49
|
+
json_path = out / "report.json"
|
|
50
|
+
html_path.write_text(render_html(report))
|
|
51
|
+
json_path.write_text(report.model_dump_json(indent=2))
|
|
52
|
+
return str(html_path), str(json_path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.callback()
|
|
56
|
+
def main() -> None:
|
|
57
|
+
"""Evaluate and improve LLM agent tool selection on MCP servers."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def inspect(
|
|
62
|
+
server: Annotated[
|
|
63
|
+
str,
|
|
64
|
+
typer.Option("--server", help="MCP server: URL, directory, server.json, or server.py"),
|
|
65
|
+
],
|
|
66
|
+
http_mode: Annotated[
|
|
67
|
+
HttpMode,
|
|
68
|
+
typer.Option(
|
|
69
|
+
"--http-mode",
|
|
70
|
+
help="HTTP session mode: 'stateful' (legacy 2025) or 'stateless' (2026-07-28 spec)",
|
|
71
|
+
),
|
|
72
|
+
] = HttpMode.STATEFUL,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Connect to an MCP server and print its tool inventory."""
|
|
75
|
+
spec = resolve_server_spec(server, http_mode=http_mode)
|
|
76
|
+
inventory = asyncio.run(inspect_server(spec))
|
|
77
|
+
|
|
78
|
+
for line in inventory.summary_lines():
|
|
79
|
+
typer.echo(line)
|
|
80
|
+
typer.echo()
|
|
81
|
+
|
|
82
|
+
name_w = max((len(t.name) for t in inventory.tools), default=4)
|
|
83
|
+
header = f"{'NAME':<{name_w}} {'PARAMS':>6} {'CPLX':>4} {'TOKENS':>6} DESCRIPTION"
|
|
84
|
+
typer.echo(header)
|
|
85
|
+
typer.echo("-" * len(header))
|
|
86
|
+
for tool in inventory.tools:
|
|
87
|
+
desc = " ".join(tool.description.split())
|
|
88
|
+
if len(desc) > 70:
|
|
89
|
+
desc = desc[:67] + "..."
|
|
90
|
+
typer.echo(
|
|
91
|
+
f"{tool.name:<{name_w}} {tool.param_count:>6} {tool.complexity:>4} "
|
|
92
|
+
f"{tool.description_tokens:>6} {desc}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def run(
|
|
98
|
+
tasks: Annotated[
|
|
99
|
+
str, typer.Option("--tasks", help="Task YAML file or directory of task files")
|
|
100
|
+
],
|
|
101
|
+
server: Annotated[
|
|
102
|
+
str | None,
|
|
103
|
+
typer.Option("--server", help="Override the server every task runs against"),
|
|
104
|
+
] = None,
|
|
105
|
+
model: Annotated[
|
|
106
|
+
str, typer.Option("--model", help="Agent model as provider:model_id")
|
|
107
|
+
] = "anthropic:claude-sonnet-5",
|
|
108
|
+
judge: Annotated[
|
|
109
|
+
str,
|
|
110
|
+
typer.Option(
|
|
111
|
+
"--judge",
|
|
112
|
+
help="LLM-judge grading: 'auto' (on when the judge API key is set), 'on', or 'off'",
|
|
113
|
+
),
|
|
114
|
+
] = "auto",
|
|
115
|
+
judge_model: Annotated[
|
|
116
|
+
str, typer.Option("--judge-model", help="Judge model as provider:model_id")
|
|
117
|
+
] = "anthropic:claude-sonnet-5",
|
|
118
|
+
group: Annotated[
|
|
119
|
+
str, typer.Option("--group", help="Label for this batch of runs in the trace store")
|
|
120
|
+
] = "baseline",
|
|
121
|
+
match_mode: Annotated[
|
|
122
|
+
str, typer.Option("--match-mode", help="Tool matching: 'order_tolerant' or 'exact'")
|
|
123
|
+
] = "order_tolerant",
|
|
124
|
+
max_turns: Annotated[int, typer.Option("--max-turns")] = 10,
|
|
125
|
+
store: Annotated[
|
|
126
|
+
str | None,
|
|
127
|
+
typer.Option("--store", help="Trace SQLite path (default ./.whetkit/traces.sqlite3)"),
|
|
128
|
+
] = None,
|
|
129
|
+
jsonl: Annotated[
|
|
130
|
+
str | None, typer.Option("--jsonl", help="Also write traces to this JSONL file")
|
|
131
|
+
] = None,
|
|
132
|
+
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Run eval tasks against an MCP server and print scored results."""
|
|
135
|
+
from whetkit.datasets import load_tasks
|
|
136
|
+
from whetkit.runner import RunConfig, run_task
|
|
137
|
+
from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
|
|
138
|
+
from whetkit.tracing import TraceStore, default_store_path, write_jsonl
|
|
139
|
+
|
|
140
|
+
task_list = load_tasks(tasks)
|
|
141
|
+
servers = _resolve_task_servers(task_list, server, http_mode)
|
|
142
|
+
config = RunConfig(model=model, max_turns=max_turns)
|
|
143
|
+
use_judge = _judge_enabled(judge, judge_model)
|
|
144
|
+
store_path = store or str(default_store_path())
|
|
145
|
+
|
|
146
|
+
async def _run() -> None:
|
|
147
|
+
runs = []
|
|
148
|
+
for task in task_list:
|
|
149
|
+
typer.echo(f"running {task.id} ...", err=True)
|
|
150
|
+
runs.append(await run_task(task, servers[task.server], config))
|
|
151
|
+
|
|
152
|
+
with TraceStore(store_path) as trace_store:
|
|
153
|
+
trace_store.save_runs(runs, run_group=group)
|
|
154
|
+
if jsonl:
|
|
155
|
+
write_jsonl(runs, jsonl)
|
|
156
|
+
|
|
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()
|
|
169
|
+
|
|
170
|
+
typer.echo(f"\nResults (group '{group}', model {model}):")
|
|
171
|
+
for score in summary.scores:
|
|
172
|
+
mark = "PASS" if score.hit else "MISS"
|
|
173
|
+
tools = " -> ".join(score.tool_match.called) or "(no tool calls)"
|
|
174
|
+
line = f" [{mark}] {score.task_id}: {tools}"
|
|
175
|
+
if score.tool_match.missing_slots:
|
|
176
|
+
line += f" missing={score.tool_match.missing_slots}"
|
|
177
|
+
if score.judge is not None:
|
|
178
|
+
line += f" judge={'pass' if score.judge.passed else 'FAIL'}"
|
|
179
|
+
typer.echo(line)
|
|
180
|
+
typer.echo("")
|
|
181
|
+
for line in summary.summary_lines():
|
|
182
|
+
typer.echo(line)
|
|
183
|
+
if not use_judge:
|
|
184
|
+
typer.echo(
|
|
185
|
+
"(LLM judge skipped: no API key found — set ANTHROPIC_API_KEY or pass --judge on)"
|
|
186
|
+
)
|
|
187
|
+
typer.echo(f"Traces saved to {store_path} (group '{group}')")
|
|
188
|
+
|
|
189
|
+
asyncio.run(_run())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def curate(
|
|
194
|
+
tasks: Annotated[
|
|
195
|
+
str, typer.Option("--tasks", help="Task YAML file or directory of task files")
|
|
196
|
+
],
|
|
197
|
+
server: Annotated[
|
|
198
|
+
str | None,
|
|
199
|
+
typer.Option("--server", help="Override the server every task runs against"),
|
|
200
|
+
] = None,
|
|
201
|
+
model: Annotated[
|
|
202
|
+
str, typer.Option("--model", help="Agent model as provider:model_id")
|
|
203
|
+
] = "anthropic:claude-sonnet-5",
|
|
204
|
+
optimizer_model: Annotated[
|
|
205
|
+
str, typer.Option("--optimizer-model", help="Curation model as provider:model_id")
|
|
206
|
+
] = "anthropic:claude-sonnet-5",
|
|
207
|
+
judge: Annotated[str, typer.Option("--judge", help="'auto', 'on', or 'off'")] = "auto",
|
|
208
|
+
judge_model: Annotated[str, typer.Option("--judge-model")] = "anthropic:claude-sonnet-5",
|
|
209
|
+
plan_path: Annotated[
|
|
210
|
+
str, typer.Option("--plan", help="Where to write the curation plan YAML")
|
|
211
|
+
] = ".whetkit/curation-plan.yaml",
|
|
212
|
+
report_dir: Annotated[
|
|
213
|
+
str, typer.Option("--report-dir", help="Directory for report.html + report.json")
|
|
214
|
+
] = ".whetkit",
|
|
215
|
+
match_mode: Annotated[str, typer.Option("--match-mode")] = "order_tolerant",
|
|
216
|
+
max_turns: Annotated[int, typer.Option("--max-turns")] = 10,
|
|
217
|
+
store: Annotated[str | None, typer.Option("--store")] = None,
|
|
218
|
+
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Baseline-eval the server, propose a curation overlay, re-eval through
|
|
221
|
+
it, and show the before/after hit-rate."""
|
|
222
|
+
from whetkit.curation import CuratedMCPClient, propose_plan, save_plan
|
|
223
|
+
from whetkit.curation.optimizer import OptimizerConfig
|
|
224
|
+
from whetkit.datasets import load_tasks
|
|
225
|
+
from whetkit.mcp import inspect_server
|
|
226
|
+
from whetkit.runner import RunConfig, run_task
|
|
227
|
+
from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
|
|
228
|
+
from whetkit.tracing import TraceStore, default_store_path
|
|
229
|
+
|
|
230
|
+
task_list = load_tasks(tasks)
|
|
231
|
+
servers = _resolve_task_servers(task_list, server, http_mode)
|
|
232
|
+
config = RunConfig(model=model, max_turns=max_turns)
|
|
233
|
+
use_judge = _judge_enabled(judge, judge_model)
|
|
234
|
+
judge_config = JudgeConfig(model=judge_model)
|
|
235
|
+
mode = MatchMode(match_mode)
|
|
236
|
+
store_path = store or str(default_store_path())
|
|
237
|
+
|
|
238
|
+
async def _curate() -> None:
|
|
239
|
+
cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
|
|
240
|
+
try:
|
|
241
|
+
typer.echo("== baseline eval ==", err=True)
|
|
242
|
+
baseline_runs = []
|
|
243
|
+
for task in task_list:
|
|
244
|
+
typer.echo(f"running {task.id} ...", err=True)
|
|
245
|
+
baseline_runs.append(await run_task(task, servers[task.server], config))
|
|
246
|
+
baseline = await score_runs(
|
|
247
|
+
task_list,
|
|
248
|
+
baseline_runs,
|
|
249
|
+
mode=mode,
|
|
250
|
+
judge_config=judge_config,
|
|
251
|
+
judge_cache=cache,
|
|
252
|
+
use_judge=use_judge,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
typer.echo("== proposing curation plan ==", err=True)
|
|
256
|
+
inventory = await inspect_server(next(iter(servers.values())))
|
|
257
|
+
plan, warnings = await propose_plan(
|
|
258
|
+
inventory,
|
|
259
|
+
task_list,
|
|
260
|
+
baseline_runs,
|
|
261
|
+
baseline.scores,
|
|
262
|
+
OptimizerConfig(model=optimizer_model),
|
|
263
|
+
)
|
|
264
|
+
for warning in warnings:
|
|
265
|
+
typer.echo(f"warning: {warning}", err=True)
|
|
266
|
+
save_plan(plan, plan_path)
|
|
267
|
+
typer.echo(f"curation plan written to {plan_path}", err=True)
|
|
268
|
+
|
|
269
|
+
typer.echo("== curated eval (through overlay) ==", err=True)
|
|
270
|
+
curated_runs = []
|
|
271
|
+
for task in task_list:
|
|
272
|
+
typer.echo(f"running {task.id} ...", err=True)
|
|
273
|
+
curated_runs.append(
|
|
274
|
+
await run_task(
|
|
275
|
+
task,
|
|
276
|
+
servers[task.server],
|
|
277
|
+
config,
|
|
278
|
+
client_factory=lambda spec: CuratedMCPClient(spec, plan),
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
curated = await score_runs(
|
|
282
|
+
task_list,
|
|
283
|
+
curated_runs,
|
|
284
|
+
mode=mode,
|
|
285
|
+
judge_config=judge_config,
|
|
286
|
+
judge_cache=cache,
|
|
287
|
+
use_judge=use_judge,
|
|
288
|
+
)
|
|
289
|
+
finally:
|
|
290
|
+
cache.close()
|
|
291
|
+
|
|
292
|
+
with TraceStore(store_path) as trace_store:
|
|
293
|
+
trace_store.save_runs(baseline_runs, run_group="baseline")
|
|
294
|
+
trace_store.save_runs(curated_runs, run_group="curated")
|
|
295
|
+
|
|
296
|
+
from whetkit.report import build_report
|
|
297
|
+
|
|
298
|
+
origin_names = {t.name for t in inventory.tools}
|
|
299
|
+
report = build_report(
|
|
300
|
+
task_list,
|
|
301
|
+
baseline_runs,
|
|
302
|
+
baseline,
|
|
303
|
+
curated_runs,
|
|
304
|
+
curated,
|
|
305
|
+
plan,
|
|
306
|
+
model=model,
|
|
307
|
+
server=next(iter(servers.values())).label(),
|
|
308
|
+
tools_before=inventory.tool_count,
|
|
309
|
+
tools_after=len(plan.presented_to_original(origin_names)),
|
|
310
|
+
)
|
|
311
|
+
html_path, json_path = _write_report(report, report_dir)
|
|
312
|
+
|
|
313
|
+
typer.echo("\n== before/after ==")
|
|
314
|
+
typer.echo(f"{'task':<28} {'before':<8} {'after':<8}")
|
|
315
|
+
curated_by_id = {s.task_id: s for s in curated.scores}
|
|
316
|
+
for score in baseline.scores:
|
|
317
|
+
after = curated_by_id.get(score.task_id)
|
|
318
|
+
typer.echo(
|
|
319
|
+
f"{score.task_id:<28} "
|
|
320
|
+
f"{'PASS' if score.hit else 'MISS':<8} "
|
|
321
|
+
f"{('PASS' if after.hit else 'MISS') if after else '?':<8}"
|
|
322
|
+
)
|
|
323
|
+
typer.echo("")
|
|
324
|
+
typer.echo(
|
|
325
|
+
f"Hit-rate: {baseline.hit_rate:.0%} -> {curated.hit_rate:.0%} "
|
|
326
|
+
f"Tool hit-rate: {baseline.tool_hit_rate:.0%} -> {curated.tool_hit_rate:.0%} "
|
|
327
|
+
f"Precision: {baseline.avg_precision:.0%} -> {curated.avg_precision:.0%}"
|
|
328
|
+
)
|
|
329
|
+
typer.echo(f"Traces saved to {store_path} (groups 'baseline', 'curated')")
|
|
330
|
+
typer.echo(f"Report: {html_path} (machine-readable: {json_path})")
|
|
331
|
+
|
|
332
|
+
asyncio.run(_curate())
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@app.command()
|
|
336
|
+
def report(
|
|
337
|
+
tasks: Annotated[
|
|
338
|
+
str, typer.Option("--tasks", help="Task YAML file or directory of task files")
|
|
339
|
+
],
|
|
340
|
+
plan: Annotated[
|
|
341
|
+
str, typer.Option("--plan", help="Curation plan YAML used for the 'after' runs")
|
|
342
|
+
] = ".whetkit/curation-plan.yaml",
|
|
343
|
+
before: Annotated[str, typer.Option("--before", help="Trace group for baseline")] = "baseline",
|
|
344
|
+
after: Annotated[str, typer.Option("--after", help="Trace group for curated")] = "curated",
|
|
345
|
+
judge: Annotated[str, typer.Option("--judge", help="'auto', 'on', or 'off'")] = "auto",
|
|
346
|
+
judge_model: Annotated[str, typer.Option("--judge-model")] = "anthropic:claude-sonnet-5",
|
|
347
|
+
match_mode: Annotated[str, typer.Option("--match-mode")] = "order_tolerant",
|
|
348
|
+
store: Annotated[str | None, typer.Option("--store")] = None,
|
|
349
|
+
out: Annotated[str, typer.Option("--out", help="Output directory")] = ".whetkit",
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Rebuild the before/after report from stored traces (judge verdicts
|
|
352
|
+
come from the cache when available)."""
|
|
353
|
+
from whetkit.curation import load_plan
|
|
354
|
+
from whetkit.datasets import load_tasks
|
|
355
|
+
from whetkit.report import build_report
|
|
356
|
+
from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
|
|
357
|
+
from whetkit.tracing import TraceStore, default_store_path
|
|
358
|
+
|
|
359
|
+
task_list = load_tasks(tasks)
|
|
360
|
+
curation_plan = load_plan(plan)
|
|
361
|
+
store_path = store or str(default_store_path())
|
|
362
|
+
use_judge = _judge_enabled(judge, judge_model)
|
|
363
|
+
|
|
364
|
+
with TraceStore(store_path) as trace_store:
|
|
365
|
+
before_runs = trace_store.load_runs(before)
|
|
366
|
+
after_runs = trace_store.load_runs(after)
|
|
367
|
+
if not before_runs or not after_runs:
|
|
368
|
+
raise typer.BadParameter(
|
|
369
|
+
f"trace store {store_path} has no runs for groups "
|
|
370
|
+
f"{before!r} and/or {after!r} — run 'whetkit curate' first"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
async def _report() -> None:
|
|
374
|
+
cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
|
|
375
|
+
try:
|
|
376
|
+
judge_config = JudgeConfig(model=judge_model)
|
|
377
|
+
mode = MatchMode(match_mode)
|
|
378
|
+
before_summary = await score_runs(
|
|
379
|
+
task_list,
|
|
380
|
+
before_runs,
|
|
381
|
+
mode=mode,
|
|
382
|
+
judge_config=judge_config,
|
|
383
|
+
judge_cache=cache,
|
|
384
|
+
use_judge=use_judge,
|
|
385
|
+
)
|
|
386
|
+
after_summary = await score_runs(
|
|
387
|
+
task_list,
|
|
388
|
+
after_runs,
|
|
389
|
+
mode=mode,
|
|
390
|
+
judge_config=judge_config,
|
|
391
|
+
judge_cache=cache,
|
|
392
|
+
use_judge=use_judge,
|
|
393
|
+
)
|
|
394
|
+
finally:
|
|
395
|
+
cache.close()
|
|
396
|
+
|
|
397
|
+
comparison = build_report(
|
|
398
|
+
task_list,
|
|
399
|
+
before_runs,
|
|
400
|
+
before_summary,
|
|
401
|
+
after_runs,
|
|
402
|
+
after_summary,
|
|
403
|
+
curation_plan,
|
|
404
|
+
model=before_runs[0].model if before_runs else "",
|
|
405
|
+
server=before_runs[0].server if before_runs else "",
|
|
406
|
+
)
|
|
407
|
+
html_path, json_path = _write_report(comparison, out)
|
|
408
|
+
typer.echo(
|
|
409
|
+
f"Hit-rate: {before_summary.hit_rate:.0%} -> {after_summary.hit_rate:.0%} "
|
|
410
|
+
f"({len(comparison.improved)} improved, {len(comparison.regressed)} regressed)"
|
|
411
|
+
)
|
|
412
|
+
typer.echo(f"Report: {html_path} (machine-readable: {json_path})")
|
|
413
|
+
|
|
414
|
+
asyncio.run(_report())
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@app.command()
|
|
418
|
+
def overlay(
|
|
419
|
+
server: Annotated[
|
|
420
|
+
str, typer.Option("--server", help="Origin MCP server: URL, directory, or file path")
|
|
421
|
+
],
|
|
422
|
+
plan: Annotated[str, typer.Option("--plan", help="Curation plan YAML to apply")],
|
|
423
|
+
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Serve the curated view of a server as a stdio MCP server (reversible:
|
|
426
|
+
the origin is never modified)."""
|
|
427
|
+
from whetkit.curation import load_plan, serve_overlay
|
|
428
|
+
|
|
429
|
+
origin = resolve_server_spec(server, http_mode=http_mode)
|
|
430
|
+
asyncio.run(serve_overlay(origin, load_plan(plan)))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
if __name__ == "__main__":
|
|
434
|
+
app()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Curation: analyze failing traces, propose tool-set fixes, apply them as a
|
|
2
|
+
reversible overlay in front of the origin server."""
|
|
3
|
+
|
|
4
|
+
from whetkit.curation.optimizer import OptimizerConfig, propose_plan
|
|
5
|
+
from whetkit.curation.overlay import CuratedMCPClient, serve_overlay
|
|
6
|
+
from whetkit.curation.plan import CurationPlan, ToolOverride, load_plan, save_plan
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CuratedMCPClient",
|
|
10
|
+
"CurationPlan",
|
|
11
|
+
"OptimizerConfig",
|
|
12
|
+
"ToolOverride",
|
|
13
|
+
"load_plan",
|
|
14
|
+
"propose_plan",
|
|
15
|
+
"save_plan",
|
|
16
|
+
"serve_overlay",
|
|
17
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The curation optimizer: an LLM looks at the server's tool inventory and
|
|
2
|
+
the failing eval traces, and proposes a safe overlay plan.
|
|
3
|
+
|
|
4
|
+
The model may only propose metadata changes (prune / rename / rewrite
|
|
5
|
+
descriptions; merges are expressed as hide-duplicates-keep-canonical), so a
|
|
6
|
+
bad proposal can degrade the overlay but can never break or mutate the
|
|
7
|
+
origin server. Every proposal is validated against the live tool list and
|
|
8
|
+
invalid entries are dropped with a warning.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from whetkit.curation.plan import CurationPlan, ToolOverride
|
|
17
|
+
from whetkit.datasets import TaskSpec
|
|
18
|
+
from whetkit.llm import ChatMessage, LLMProvider, get_provider, parse_model
|
|
19
|
+
from whetkit.mcp.introspect import ServerInventory
|
|
20
|
+
from whetkit.scoring import TaskScore
|
|
21
|
+
from whetkit.tracing import TaskRun
|
|
22
|
+
|
|
23
|
+
DEFAULT_OPTIMIZER_MODEL = "anthropic:claude-sonnet-5"
|
|
24
|
+
|
|
25
|
+
OPTIMIZER_SYSTEM_PROMPT = """\
|
|
26
|
+
You are an expert MCP (Model Context Protocol) tool-set curator. You are
|
|
27
|
+
given the tools an MCP server exposes and traces of an AI agent failing (and
|
|
28
|
+
succeeding) at eval tasks that use this server. Agents fail when tool names
|
|
29
|
+
are cryptic, descriptions are vague, duplicates split their attention, or
|
|
30
|
+
noise tools distract them.
|
|
31
|
+
|
|
32
|
+
Propose a curation overlay that maximizes the agent's tool-selection
|
|
33
|
+
hit-rate. You may, per tool:
|
|
34
|
+
- "rename": give it a clear, specific snake_case name
|
|
35
|
+
- "rewrite": replace the description with one precise sentence saying what it
|
|
36
|
+
does, over what data, and when to use it (mention key argument semantics)
|
|
37
|
+
- "prune": hide tools irrelevant to the eval tasks (admin/debug/noise)
|
|
38
|
+
- "merge": hide redundant duplicates and keep one canonical tool (express
|
|
39
|
+
this as prune on the duplicates, rename/rewrite on the canonical one)
|
|
40
|
+
- "keep": leave a tool untouched
|
|
41
|
+
|
|
42
|
+
Rules:
|
|
43
|
+
- Only metadata changes. Never invent tools, change schemas, or alter behavior.
|
|
44
|
+
- Renamed tools MUST keep their exact argument schema.
|
|
45
|
+
- New names must be unique, snake_case, and descriptive (verb_object style).
|
|
46
|
+
- Do not prune a tool that any task needs.
|
|
47
|
+
- Rewrite descriptions for every tool you keep whose description is vague.
|
|
48
|
+
|
|
49
|
+
Respond with ONLY a JSON object, no markdown fences:
|
|
50
|
+
{
|
|
51
|
+
"notes": "one short paragraph on your strategy",
|
|
52
|
+
"overrides": [
|
|
53
|
+
{"original_name": "...", "action": "rename|rewrite|prune|keep",
|
|
54
|
+
"new_name": "... or null", "new_description": "... or null",
|
|
55
|
+
"reason": "one sentence"}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OptimizerConfig(BaseModel):
|
|
62
|
+
model: str = DEFAULT_OPTIMIZER_MODEL
|
|
63
|
+
max_tokens: int = 4096
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _inventory_block(inventory: ServerInventory) -> str:
|
|
67
|
+
lines = []
|
|
68
|
+
for tool in inventory.tools:
|
|
69
|
+
schema = json.dumps(tool.input_schema.get("properties", {}), sort_keys=True)
|
|
70
|
+
lines.append(f"- {tool.name}: {tool.description!r} | args: {schema}")
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _trace_block(tasks: list[TaskSpec], runs: list[TaskRun], scores: list[TaskScore]) -> str:
|
|
75
|
+
tasks_by_id = {t.id: t for t in tasks}
|
|
76
|
+
runs_by_id = {r.task_id: r for r in runs}
|
|
77
|
+
lines = []
|
|
78
|
+
for score in scores:
|
|
79
|
+
task = tasks_by_id.get(score.task_id)
|
|
80
|
+
run = runs_by_id.get(score.task_id)
|
|
81
|
+
if task is None:
|
|
82
|
+
continue
|
|
83
|
+
called = " -> ".join(run.called_tool_names) if run else "(no run)"
|
|
84
|
+
outcome = "HIT" if score.hit else "MISS"
|
|
85
|
+
lines.append(f"### task {task.id} [{outcome}]")
|
|
86
|
+
lines.append(f"user prompt: {task.prompt.strip()}")
|
|
87
|
+
lines.append(f"tools called: {called or '(none)'}")
|
|
88
|
+
if score.tool_match.missing_slots:
|
|
89
|
+
lines.append(f"expected but never called (any of): {score.tool_match.missing_slots}")
|
|
90
|
+
if score.tool_match.extra_calls:
|
|
91
|
+
lines.append(f"unnecessary calls: {score.tool_match.extra_calls}")
|
|
92
|
+
if score.judge is not None and not score.judge.passed:
|
|
93
|
+
lines.append(f"judge: FAIL — {score.judge.rationale}")
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_optimizer_prompt(
|
|
98
|
+
inventory: ServerInventory,
|
|
99
|
+
tasks: list[TaskSpec],
|
|
100
|
+
runs: list[TaskRun],
|
|
101
|
+
scores: list[TaskScore],
|
|
102
|
+
) -> str:
|
|
103
|
+
return (
|
|
104
|
+
f"## Tools exposed by the server\n{_inventory_block(inventory)}\n\n"
|
|
105
|
+
f"## Eval traces\n{_trace_block(tasks, runs, scores)}\n\n"
|
|
106
|
+
"Propose the curation overlay now."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_overrides(raw: str) -> tuple[str, list[ToolOverride]] | None:
|
|
111
|
+
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
112
|
+
if not match:
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(match.group(0))
|
|
116
|
+
overrides = []
|
|
117
|
+
for entry in data.get("overrides", []):
|
|
118
|
+
action = entry.get("action", "keep")
|
|
119
|
+
if action == "keep" and not entry.get("new_name") and not entry.get("new_description"):
|
|
120
|
+
continue
|
|
121
|
+
overrides.append(
|
|
122
|
+
ToolOverride(
|
|
123
|
+
original_name=str(entry["original_name"]),
|
|
124
|
+
new_name=entry.get("new_name") or None,
|
|
125
|
+
new_description=entry.get("new_description") or None,
|
|
126
|
+
hidden=action in ("prune", "hide"),
|
|
127
|
+
reason=str(entry.get("reason", "")),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
return str(data.get("notes", "")), overrides
|
|
131
|
+
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def propose_plan(
|
|
136
|
+
inventory: ServerInventory,
|
|
137
|
+
tasks: list[TaskSpec],
|
|
138
|
+
runs: list[TaskRun],
|
|
139
|
+
scores: list[TaskScore],
|
|
140
|
+
config: OptimizerConfig | None = None,
|
|
141
|
+
provider: LLMProvider | None = None,
|
|
142
|
+
) -> tuple[CurationPlan, list[str]]:
|
|
143
|
+
"""Ask the optimizer model for a plan. Returns (plan, warnings): entries
|
|
144
|
+
that fail validation against the live tool list are dropped, never applied.
|
|
145
|
+
"""
|
|
146
|
+
config = config or OptimizerConfig()
|
|
147
|
+
provider_name, model_id = parse_model(config.model)
|
|
148
|
+
provider = provider or get_provider(provider_name)
|
|
149
|
+
|
|
150
|
+
prompt = build_optimizer_prompt(inventory, tasks, runs, scores)
|
|
151
|
+
parsed = None
|
|
152
|
+
for _attempt in range(2):
|
|
153
|
+
turn = await provider.complete(
|
|
154
|
+
model=model_id,
|
|
155
|
+
system=OPTIMIZER_SYSTEM_PROMPT,
|
|
156
|
+
messages=[ChatMessage(role="user", content=prompt)],
|
|
157
|
+
tools=[],
|
|
158
|
+
max_tokens=config.max_tokens,
|
|
159
|
+
)
|
|
160
|
+
parsed = _parse_overrides(turn.text or "")
|
|
161
|
+
if parsed is not None:
|
|
162
|
+
break
|
|
163
|
+
if parsed is None:
|
|
164
|
+
return CurationPlan(server=inventory.server), [
|
|
165
|
+
"optimizer output was not valid JSON after 2 attempts; keeping origin tool set"
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
notes, overrides = parsed
|
|
169
|
+
plan = CurationPlan(server=inventory.server, notes=notes, overrides=overrides)
|
|
170
|
+
|
|
171
|
+
origin_names = {t.name for t in inventory.tools}
|
|
172
|
+
warnings: list[str] = []
|
|
173
|
+
while problems := plan.validate_against(origin_names):
|
|
174
|
+
# Drop offending overrides one problem at a time until the plan is safe.
|
|
175
|
+
problem = problems[0]
|
|
176
|
+
name_match = re.search(r"'([^']+)'", problem)
|
|
177
|
+
offender = name_match.group(1) if name_match else None
|
|
178
|
+
before = len(plan.overrides)
|
|
179
|
+
plan.overrides = [
|
|
180
|
+
o
|
|
181
|
+
for o in plan.overrides
|
|
182
|
+
if offender not in (o.original_name, o.new_name, o.presented_name)
|
|
183
|
+
]
|
|
184
|
+
warnings.append(f"dropped unsafe override(s): {problem}")
|
|
185
|
+
if len(plan.overrides) == before: # nothing removable matched — bail out safely
|
|
186
|
+
warnings.append("could not repair plan; keeping origin tool set")
|
|
187
|
+
plan.overrides = []
|
|
188
|
+
break
|
|
189
|
+
return plan, warnings
|