whetkit 0.1.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {whetkit-0.1.1 → whetkit-0.3.0}/PKG-INFO +1 -1
- {whetkit-0.1.1 → whetkit-0.3.0}/pyproject.toml +1 -1
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/cli.py +277 -33
- whetkit-0.3.0/src/whetkit/curation/export.py +73 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/generate.py +35 -7
- whetkit-0.3.0/src/whetkit/llm/pricing.py +27 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/mcp/transport.py +27 -1
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/scoring/__init__.py +2 -1
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/scoring/aggregate.py +53 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/LICENSE +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/README.md +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/curation/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/curation/optimizer.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/curation/overlay.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/curation/plan.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/datasets/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/datasets/tasks.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/doctor.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/anthropic_provider.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/base.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/openai_provider.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/llm/registry.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/mcp/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/mcp/client.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/mcp/introspect.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/py.typed +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/report/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/report/builder.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/report/html.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/runner/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/runner/agent.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/scoring/deterministic.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/scoring/judge.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/tracing/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/tracing/records.py +0 -0
- {whetkit-0.1.1 → whetkit-0.3.0}/src/whetkit/tracing/store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: whetkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Measure and improve how well LLM agents select and use the tools exposed by an MCP server
|
|
5
5
|
Keywords: mcp,model-context-protocol,llm,agents,evals,tool-use,tool-selection,benchmark
|
|
6
6
|
Author: Mohammed Benlamlih
|
|
@@ -16,6 +16,14 @@ app = typer.Typer(
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
def _resolve_server(value: str, http_mode: HttpMode) -> ServerSpec:
|
|
20
|
+
"""resolve_server_spec with a CLI-friendly error instead of a traceback."""
|
|
21
|
+
try:
|
|
22
|
+
return resolve_server_spec(value, http_mode=http_mode)
|
|
23
|
+
except ValueError as exc:
|
|
24
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
25
|
+
|
|
26
|
+
|
|
19
27
|
def _judge_enabled(judge: str, judge_model: str) -> bool:
|
|
20
28
|
"""--judge auto: grade with the LLM judge only when its API key is set."""
|
|
21
29
|
if judge in ("on", "off"):
|
|
@@ -31,10 +39,10 @@ def _resolve_task_servers(
|
|
|
31
39
|
tasks: list, server_override: str | None, http_mode: HttpMode
|
|
32
40
|
) -> dict[str, ServerSpec]:
|
|
33
41
|
if server_override is not None:
|
|
34
|
-
spec =
|
|
42
|
+
spec = _resolve_server(server_override, http_mode)
|
|
35
43
|
return {task.server: spec for task in tasks}
|
|
36
44
|
return {
|
|
37
|
-
task.server:
|
|
45
|
+
task.server: _resolve_server(task.server, http_mode)
|
|
38
46
|
for task in {t.server: t for t in tasks}.values()
|
|
39
47
|
}
|
|
40
48
|
|
|
@@ -51,6 +59,35 @@ def _write_report(report, out_dir: str) -> tuple[str, str]:
|
|
|
51
59
|
return str(html_path), str(json_path)
|
|
52
60
|
|
|
53
61
|
|
|
62
|
+
def _summary_payload(group_name: str, summary, task_runs: list) -> dict:
|
|
63
|
+
"""One run's metrics as plain data — what --summary-json emits per run."""
|
|
64
|
+
return {
|
|
65
|
+
"group": group_name,
|
|
66
|
+
"hit_rate": summary.hit_rate,
|
|
67
|
+
"tool_hit_rate": summary.tool_hit_rate,
|
|
68
|
+
"judge_pass_rate": summary.judge_pass_rate,
|
|
69
|
+
"avg_precision": summary.avg_precision,
|
|
70
|
+
"avg_recall": summary.avg_recall,
|
|
71
|
+
"avg_extra_calls": summary.avg_extra_calls,
|
|
72
|
+
"tokens_in": sum(r.total_usage.input_tokens for r in task_runs),
|
|
73
|
+
"tokens_out": sum(r.total_usage.output_tokens for r in task_runs),
|
|
74
|
+
"latency_ms": sum(r.total_latency_ms for r in task_runs),
|
|
75
|
+
"tasks": [
|
|
76
|
+
{
|
|
77
|
+
"id": score.task_id,
|
|
78
|
+
"hit": score.hit,
|
|
79
|
+
"tool_hit": score.tool_hit,
|
|
80
|
+
"judge_passed": score.judge.passed if score.judge else None,
|
|
81
|
+
"spec_gap": score.spec_gap,
|
|
82
|
+
"called": score.tool_match.called,
|
|
83
|
+
"missing": score.tool_match.missing_slots,
|
|
84
|
+
"extra_calls": score.tool_match.extra_calls,
|
|
85
|
+
}
|
|
86
|
+
for score in summary.scores
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
54
91
|
def _version_callback(value: bool) -> None:
|
|
55
92
|
if value:
|
|
56
93
|
from importlib.metadata import version
|
|
@@ -89,7 +126,7 @@ def inspect(
|
|
|
89
126
|
] = HttpMode.STATEFUL,
|
|
90
127
|
) -> None:
|
|
91
128
|
"""Connect to an MCP server and print its tool inventory."""
|
|
92
|
-
spec =
|
|
129
|
+
spec = _resolve_server(server, http_mode)
|
|
93
130
|
inventory = asyncio.run(inspect_server(spec))
|
|
94
131
|
|
|
95
132
|
for line in inventory.summary_lines():
|
|
@@ -136,7 +173,7 @@ def doctor(
|
|
|
136
173
|
if fail_on not in ("error", "warn", "never"):
|
|
137
174
|
raise typer.BadParameter("--fail-on must be 'error', 'warn', or 'never'")
|
|
138
175
|
|
|
139
|
-
spec =
|
|
176
|
+
spec = _resolve_server(server, http_mode)
|
|
140
177
|
inventory = asyncio.run(inspect_server(spec))
|
|
141
178
|
findings = diagnose(inventory)
|
|
142
179
|
|
|
@@ -181,16 +218,30 @@ def generate(
|
|
|
181
218
|
model: Annotated[
|
|
182
219
|
str, typer.Option("--model", help="Generator model as provider:model_id")
|
|
183
220
|
] = "anthropic:claude-sonnet-5",
|
|
221
|
+
allow_writes: Annotated[
|
|
222
|
+
bool,
|
|
223
|
+
typer.Option(
|
|
224
|
+
"--allow-writes",
|
|
225
|
+
help="Let drafts include non-destructive write tasks (default: read-only only)",
|
|
226
|
+
),
|
|
227
|
+
] = False,
|
|
184
228
|
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
185
229
|
) -> None:
|
|
186
230
|
"""Draft eval tasks from the server's tool inventory (review before
|
|
187
231
|
trusting — the header in the output file says the same)."""
|
|
188
232
|
from whetkit.generate import GeneratorConfig, generate_tasks, write_tasks_yaml
|
|
189
233
|
|
|
190
|
-
spec =
|
|
234
|
+
spec = _resolve_server(server, http_mode)
|
|
191
235
|
inventory = asyncio.run(inspect_server(spec))
|
|
192
236
|
tasks, warnings = asyncio.run(
|
|
193
|
-
generate_tasks(
|
|
237
|
+
generate_tasks(
|
|
238
|
+
inventory,
|
|
239
|
+
server,
|
|
240
|
+
count=count,
|
|
241
|
+
config=GeneratorConfig(model=model),
|
|
242
|
+
server_context=spec.label(),
|
|
243
|
+
allow_writes=allow_writes,
|
|
244
|
+
)
|
|
194
245
|
)
|
|
195
246
|
for warning in warnings:
|
|
196
247
|
typer.echo(f"warning: {warning}", err=True)
|
|
@@ -204,6 +255,101 @@ def generate(
|
|
|
204
255
|
typer.echo(f" whetkit run --server {server} --tasks {out}")
|
|
205
256
|
|
|
206
257
|
|
|
258
|
+
@app.command()
|
|
259
|
+
def export(
|
|
260
|
+
plan: Annotated[
|
|
261
|
+
str, typer.Option("--plan", help="Curation plan YAML to export")
|
|
262
|
+
] = ".whetkit/curation-plan.yaml",
|
|
263
|
+
to: Annotated[
|
|
264
|
+
str,
|
|
265
|
+
typer.Option(
|
|
266
|
+
"--to",
|
|
267
|
+
help=(
|
|
268
|
+
"'markdown' — a fix report ready for an upstream issue/PR; "
|
|
269
|
+
"'json' — a neutral override list for gateways and scripts"
|
|
270
|
+
),
|
|
271
|
+
),
|
|
272
|
+
] = "markdown",
|
|
273
|
+
out: Annotated[str | None, typer.Option("--out", help="Write here instead of stdout")] = None,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Export a curation plan as a shareable fix report or neutral JSON."""
|
|
276
|
+
from whetkit.curation import load_plan
|
|
277
|
+
from whetkit.curation.export import plan_to_json, plan_to_markdown
|
|
278
|
+
|
|
279
|
+
if to not in ("markdown", "json"):
|
|
280
|
+
raise typer.BadParameter("--to must be 'markdown' or 'json'")
|
|
281
|
+
if not Path(plan).is_file():
|
|
282
|
+
raise typer.BadParameter(
|
|
283
|
+
f"no curation plan at {plan} — run 'whetkit curate' first, or pass --plan"
|
|
284
|
+
)
|
|
285
|
+
curation_plan = load_plan(plan)
|
|
286
|
+
if not curation_plan.overrides:
|
|
287
|
+
typer.echo("plan has no overrides — nothing to export", err=True)
|
|
288
|
+
raise typer.Exit(code=1)
|
|
289
|
+
|
|
290
|
+
rendered = (plan_to_markdown if to == "markdown" else plan_to_json)(curation_plan)
|
|
291
|
+
if out:
|
|
292
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
Path(out).write_text(rendered)
|
|
294
|
+
typer.echo(f"wrote {out}")
|
|
295
|
+
else:
|
|
296
|
+
typer.echo(rendered, nl=False)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.command()
|
|
300
|
+
def diff(
|
|
301
|
+
before: Annotated[str, typer.Argument(help="Baseline --summary-json file")],
|
|
302
|
+
after: Annotated[str, typer.Argument(help="Comparison --summary-json file")],
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Compare two --summary-json files: headline deltas plus per-task
|
|
305
|
+
transitions. The before/after table without re-running anything."""
|
|
306
|
+
import json as jsonlib
|
|
307
|
+
|
|
308
|
+
docs = []
|
|
309
|
+
for path in (before, after):
|
|
310
|
+
if not Path(path).is_file():
|
|
311
|
+
raise typer.BadParameter(f"no summary file at {path}")
|
|
312
|
+
docs.append(jsonlib.loads(Path(path).read_text()))
|
|
313
|
+
|
|
314
|
+
def mean(doc: dict, key: str) -> float:
|
|
315
|
+
values = [r[key] for r in doc["runs"] if r.get(key) is not None]
|
|
316
|
+
return sum(values) / len(values) if values else 0.0
|
|
317
|
+
|
|
318
|
+
typer.echo(f"{'metric':<28} {'before':>10} {'after':>10}")
|
|
319
|
+
for label, key, fmt in (
|
|
320
|
+
("Hit-rate", "hit_rate", "{:.0%}"),
|
|
321
|
+
("Tool-selection hit-rate", "tool_hit_rate", "{:.0%}"),
|
|
322
|
+
("Judge pass-rate", "judge_pass_rate", "{:.0%}"),
|
|
323
|
+
("Precision (avg)", "avg_precision", "{:.0%}"),
|
|
324
|
+
("Unnecessary calls/task", "avg_extra_calls", "{:.1f}"),
|
|
325
|
+
("Tokens in (per run)", "tokens_in", "{:.0f}"),
|
|
326
|
+
):
|
|
327
|
+
b, a = mean(docs[0], key), mean(docs[1], key)
|
|
328
|
+
typer.echo(f"{label:<28} {fmt.format(b):>10} {fmt.format(a):>10}")
|
|
329
|
+
|
|
330
|
+
def outcomes(doc: dict) -> dict[str, list[bool]]:
|
|
331
|
+
per_task: dict[str, list[bool]] = {}
|
|
332
|
+
for run_doc in doc["runs"]:
|
|
333
|
+
for task in run_doc["tasks"]:
|
|
334
|
+
per_task.setdefault(task["id"], []).append(bool(task["hit"]))
|
|
335
|
+
return per_task
|
|
336
|
+
|
|
337
|
+
before_tasks, after_tasks = outcomes(docs[0]), outcomes(docs[1])
|
|
338
|
+
typer.echo("")
|
|
339
|
+
for task_id in sorted(set(before_tasks) | set(after_tasks)):
|
|
340
|
+
|
|
341
|
+
def word(hits: list[bool] | None) -> str:
|
|
342
|
+
if not hits:
|
|
343
|
+
return "—"
|
|
344
|
+
if all(hits):
|
|
345
|
+
return "PASS"
|
|
346
|
+
return "MISS" if not any(hits) else f"FLAKY {sum(hits)}/{len(hits)}"
|
|
347
|
+
|
|
348
|
+
b_word, a_word = word(before_tasks.get(task_id)), word(after_tasks.get(task_id))
|
|
349
|
+
marker = "" if b_word == a_word else (" ↑" if a_word == "PASS" else " ↓")
|
|
350
|
+
typer.echo(f" {task_id:<28} {b_word:>10} -> {a_word:<10}{marker}")
|
|
351
|
+
|
|
352
|
+
|
|
207
353
|
@app.command()
|
|
208
354
|
def run(
|
|
209
355
|
tasks: Annotated[
|
|
@@ -250,21 +396,55 @@ def run(
|
|
|
250
396
|
help="Completion-token budget per model turn (raise for reasoning models)",
|
|
251
397
|
),
|
|
252
398
|
] = 1024,
|
|
399
|
+
runs: Annotated[
|
|
400
|
+
int,
|
|
401
|
+
typer.Option(
|
|
402
|
+
"--runs",
|
|
403
|
+
help=(
|
|
404
|
+
"Repeat the whole task set N times and report mean plus range "
|
|
405
|
+
"— single runs are noise. Trace groups get a -1..-N suffix."
|
|
406
|
+
),
|
|
407
|
+
),
|
|
408
|
+
] = 1,
|
|
253
409
|
store: Annotated[
|
|
254
410
|
str | None,
|
|
255
411
|
typer.Option("--store", help="Trace SQLite path (default ./.whetkit/traces.sqlite3)"),
|
|
256
412
|
] = None,
|
|
257
413
|
jsonl: Annotated[
|
|
258
|
-
str | None,
|
|
414
|
+
str | None,
|
|
415
|
+
typer.Option(
|
|
416
|
+
"--jsonl",
|
|
417
|
+
help="Also write traces to this JSONL file (suffixed per run when --runs > 1)",
|
|
418
|
+
),
|
|
419
|
+
] = None,
|
|
420
|
+
summary_json: Annotated[
|
|
421
|
+
str | None,
|
|
422
|
+
typer.Option(
|
|
423
|
+
"--summary-json",
|
|
424
|
+
help="Write a machine-readable summary (metrics + per-task outcomes) to this path",
|
|
425
|
+
),
|
|
426
|
+
] = None,
|
|
427
|
+
reset_cmd: Annotated[
|
|
428
|
+
str | None,
|
|
429
|
+
typer.Option(
|
|
430
|
+
"--reset-cmd",
|
|
431
|
+
help=(
|
|
432
|
+
"Shell command run before each repetition (and the first run) to "
|
|
433
|
+
"reset server-side fixtures — makes --runs honest on stateful servers"
|
|
434
|
+
),
|
|
435
|
+
),
|
|
259
436
|
] = None,
|
|
260
437
|
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
261
438
|
) -> None:
|
|
262
439
|
"""Run eval tasks against an MCP server and print scored results."""
|
|
263
440
|
from whetkit.datasets import load_tasks
|
|
264
441
|
from whetkit.runner import RunConfig, run_task
|
|
265
|
-
from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
|
|
442
|
+
from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, MultiRunSummary, score_runs
|
|
266
443
|
from whetkit.tracing import TraceStore, default_store_path, write_jsonl
|
|
267
444
|
|
|
445
|
+
if runs < 1:
|
|
446
|
+
raise typer.BadParameter("--runs must be at least 1")
|
|
447
|
+
|
|
268
448
|
task_list = load_tasks(tasks)
|
|
269
449
|
servers = _resolve_task_servers(task_list, server, http_mode)
|
|
270
450
|
config = RunConfig(model=model, max_turns=max_turns, max_tokens=max_tokens)
|
|
@@ -286,34 +466,28 @@ def run(
|
|
|
286
466
|
client_factory = partial(CuratedMCPClient, plan=curation_plan)
|
|
287
467
|
name_map = curation_plan.rename_map()
|
|
288
468
|
|
|
289
|
-
async def
|
|
290
|
-
|
|
469
|
+
async def _run_once(group_name: str, cache: JudgeCache):
|
|
470
|
+
task_runs = []
|
|
291
471
|
for task in task_list:
|
|
292
472
|
typer.echo(f"running {task.id} ...", err=True)
|
|
293
|
-
|
|
473
|
+
task_runs.append(
|
|
294
474
|
await run_task(task, servers[task.server], config, client_factory=client_factory)
|
|
295
475
|
)
|
|
296
476
|
|
|
297
477
|
with TraceStore(store_path) as trace_store:
|
|
298
|
-
trace_store.save_runs(
|
|
299
|
-
if jsonl:
|
|
300
|
-
write_jsonl(runs, jsonl)
|
|
478
|
+
trace_store.save_runs(task_runs, run_group=group_name)
|
|
301
479
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
name_map=name_map,
|
|
312
|
-
)
|
|
313
|
-
finally:
|
|
314
|
-
cache.close()
|
|
480
|
+
summary = await score_runs(
|
|
481
|
+
task_list,
|
|
482
|
+
task_runs,
|
|
483
|
+
mode=MatchMode(match_mode),
|
|
484
|
+
judge_config=JudgeConfig(model=judge_model),
|
|
485
|
+
judge_cache=cache,
|
|
486
|
+
use_judge=use_judge,
|
|
487
|
+
name_map=name_map,
|
|
488
|
+
)
|
|
315
489
|
|
|
316
|
-
typer.echo(f"\nResults (group '{
|
|
490
|
+
typer.echo(f"\nResults (group '{group_name}', model {model}):")
|
|
317
491
|
for score in summary.scores:
|
|
318
492
|
mark = "PASS" if score.hit else "MISS"
|
|
319
493
|
tools = " -> ".join(score.tool_match.called) or "(no tool calls)"
|
|
@@ -324,19 +498,89 @@ def run(
|
|
|
324
498
|
line += f" judge={'pass' if score.judge.passed else 'FAIL'}"
|
|
325
499
|
typer.echo(line)
|
|
326
500
|
typer.echo("")
|
|
501
|
+
for score in summary.scores:
|
|
502
|
+
if score.spec_gap:
|
|
503
|
+
called = ", ".join(score.tool_match.called) or "(none)"
|
|
504
|
+
typer.echo(
|
|
505
|
+
f"⚠ possible task-spec gap: '{score.task_id}' reached a correct "
|
|
506
|
+
f"outcome (judge passed) via tools not listed in expected_tools "
|
|
507
|
+
f"— called: {called}"
|
|
508
|
+
)
|
|
327
509
|
for line in summary.summary_lines():
|
|
328
510
|
typer.echo(line)
|
|
329
|
-
tokens_in = sum(r.total_usage.input_tokens for r in
|
|
330
|
-
tokens_out = sum(r.total_usage.output_tokens for r in
|
|
511
|
+
tokens_in = sum(r.total_usage.input_tokens for r in task_runs)
|
|
512
|
+
tokens_out = sum(r.total_usage.output_tokens for r in task_runs)
|
|
513
|
+
from whetkit.llm import parse_model
|
|
514
|
+
from whetkit.llm.pricing import estimate_cost_usd
|
|
515
|
+
|
|
516
|
+
cost = estimate_cost_usd(parse_model(model)[1], tokens_in, tokens_out)
|
|
517
|
+
cost_note = f" ≈ ${cost:.4f} (est.)" if cost is not None else ""
|
|
331
518
|
typer.echo(
|
|
332
519
|
f"Tokens in/out: {tokens_in}/{tokens_out} "
|
|
333
|
-
f"Total latency: {sum(r.total_latency_ms for r in
|
|
520
|
+
f"Total latency: {sum(r.total_latency_ms for r in task_runs) / 1000:.1f}s"
|
|
521
|
+
f"{cost_note}"
|
|
334
522
|
)
|
|
523
|
+
return task_runs, summary
|
|
524
|
+
|
|
525
|
+
async def _run() -> None:
|
|
526
|
+
summaries = []
|
|
527
|
+
payloads = []
|
|
528
|
+
cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
|
|
529
|
+
try:
|
|
530
|
+
for run_index in range(1, runs + 1):
|
|
531
|
+
if reset_cmd:
|
|
532
|
+
import subprocess
|
|
533
|
+
|
|
534
|
+
typer.echo(f"reset: {reset_cmd}", err=True)
|
|
535
|
+
proc = subprocess.run(reset_cmd, shell=True)
|
|
536
|
+
if proc.returncode != 0:
|
|
537
|
+
typer.echo(
|
|
538
|
+
f"error: --reset-cmd failed with exit code {proc.returncode}",
|
|
539
|
+
err=True,
|
|
540
|
+
)
|
|
541
|
+
raise typer.Exit(code=1)
|
|
542
|
+
group_name = group if runs == 1 else f"{group}-{run_index}"
|
|
543
|
+
task_runs, summary = await _run_once(group_name, cache)
|
|
544
|
+
summaries.append(summary)
|
|
545
|
+
payloads.append(_summary_payload(group_name, summary, task_runs))
|
|
546
|
+
if jsonl:
|
|
547
|
+
write_jsonl(task_runs, jsonl if runs == 1 else f"{jsonl}.{run_index}")
|
|
548
|
+
finally:
|
|
549
|
+
cache.close()
|
|
550
|
+
|
|
551
|
+
multi = MultiRunSummary(summaries=summaries)
|
|
552
|
+
if runs > 1:
|
|
553
|
+
typer.echo(f"\n== across {runs} runs ==")
|
|
554
|
+
for line in multi.summary_lines():
|
|
555
|
+
typer.echo(line)
|
|
335
556
|
if judge == "auto" and not use_judge:
|
|
336
557
|
typer.echo(
|
|
337
558
|
"(LLM judge skipped: no API key found — set ANTHROPIC_API_KEY or pass --judge on)"
|
|
338
559
|
)
|
|
339
|
-
|
|
560
|
+
suffix = f" (groups '{group}-1'..'-{runs}')" if runs > 1 else f" (group '{group}')"
|
|
561
|
+
typer.echo(f"Traces saved to {store_path}{suffix}")
|
|
562
|
+
|
|
563
|
+
if summary_json:
|
|
564
|
+
import json as jsonlib
|
|
565
|
+
|
|
566
|
+
document = {
|
|
567
|
+
"model": model,
|
|
568
|
+
"judge_model": judge_model if use_judge else None,
|
|
569
|
+
"match_mode": match_mode,
|
|
570
|
+
"plan": plan,
|
|
571
|
+
"runs": payloads,
|
|
572
|
+
}
|
|
573
|
+
if runs > 1:
|
|
574
|
+
document["aggregate"] = {
|
|
575
|
+
"n": multi.n,
|
|
576
|
+
"hit_rate_mean": sum(s.hit_rate for s in summaries) / len(summaries),
|
|
577
|
+
"hit_rate_min": min(s.hit_rate for s in summaries),
|
|
578
|
+
"hit_rate_max": max(s.hit_rate for s in summaries),
|
|
579
|
+
"flaky_tasks": multi.flaky_tasks(),
|
|
580
|
+
}
|
|
581
|
+
Path(summary_json).parent.mkdir(parents=True, exist_ok=True)
|
|
582
|
+
Path(summary_json).write_text(jsonlib.dumps(document, indent=2) + "\n")
|
|
583
|
+
typer.echo(f"Summary JSON: {summary_json}")
|
|
340
584
|
|
|
341
585
|
asyncio.run(_run())
|
|
342
586
|
|
|
@@ -597,7 +841,7 @@ def overlay(
|
|
|
597
841
|
the origin is never modified)."""
|
|
598
842
|
from whetkit.curation import load_plan, serve_overlay
|
|
599
843
|
|
|
600
|
-
origin =
|
|
844
|
+
origin = _resolve_server(server, http_mode)
|
|
601
845
|
asyncio.run(serve_overlay(origin, load_plan(plan)))
|
|
602
846
|
|
|
603
847
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Export a curation plan into shareable formats.
|
|
2
|
+
|
|
3
|
+
A plan is most useful when it leaves the machine it was made on: as a
|
|
4
|
+
Markdown fix report you can paste into an upstream issue or PR, or as
|
|
5
|
+
neutral JSON for gateways and scripts that apply metadata overrides
|
|
6
|
+
themselves.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from whetkit.curation.plan import CurationPlan, ToolOverride
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _action(override: ToolOverride) -> str:
|
|
15
|
+
if override.hidden:
|
|
16
|
+
return "hide"
|
|
17
|
+
if override.new_name and override.new_description:
|
|
18
|
+
return "rename + rewrite"
|
|
19
|
+
if override.new_name:
|
|
20
|
+
return "rename"
|
|
21
|
+
return "rewrite"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def plan_to_markdown(plan: CurationPlan) -> str:
|
|
25
|
+
"""A fix report for humans — upstream-issue / PR-description ready."""
|
|
26
|
+
lines = [
|
|
27
|
+
"## Proposed tool-surface fixes",
|
|
28
|
+
"",
|
|
29
|
+
"Metadata-only changes — no schemas or behavior are touched. "
|
|
30
|
+
"Measured with [whetkit](https://github.com/benlamlih/whetkit); "
|
|
31
|
+
"each change is reversible (it can be served as an overlay without "
|
|
32
|
+
"modifying the server).",
|
|
33
|
+
"",
|
|
34
|
+
]
|
|
35
|
+
if plan.notes:
|
|
36
|
+
lines += [f"> {plan.notes.strip()}", ""]
|
|
37
|
+
lines += [
|
|
38
|
+
"| Tool | Change | Proposal | Why |",
|
|
39
|
+
"|---|---|---|---|",
|
|
40
|
+
]
|
|
41
|
+
for override in plan.overrides:
|
|
42
|
+
if override.hidden:
|
|
43
|
+
proposal = "hide from the tool list"
|
|
44
|
+
else:
|
|
45
|
+
parts = []
|
|
46
|
+
if override.new_name:
|
|
47
|
+
parts.append(f"rename to `{override.new_name}`")
|
|
48
|
+
if override.new_description:
|
|
49
|
+
parts.append(f'description: "{override.new_description.strip()}"')
|
|
50
|
+
proposal = "; ".join(parts)
|
|
51
|
+
cell = lambda s: " ".join(str(s).split()).replace("|", "\\|") # noqa: E731
|
|
52
|
+
lines.append(
|
|
53
|
+
f"| `{override.original_name}` | {_action(override)} "
|
|
54
|
+
f"| {cell(proposal)} | {cell(override.reason) or '—'} |"
|
|
55
|
+
)
|
|
56
|
+
return "\n".join(lines) + "\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def plan_to_json(plan: CurationPlan) -> str:
|
|
60
|
+
"""Neutral override list for gateways/scripts that apply their own
|
|
61
|
+
metadata overrides (Cloudflare portal aliases, MetaMCP namespaces, …)."""
|
|
62
|
+
overrides = [
|
|
63
|
+
{
|
|
64
|
+
"original_name": o.original_name,
|
|
65
|
+
"action": _action(o),
|
|
66
|
+
"new_name": o.new_name,
|
|
67
|
+
"new_description": o.new_description,
|
|
68
|
+
"hidden": o.hidden,
|
|
69
|
+
"reason": o.reason,
|
|
70
|
+
}
|
|
71
|
+
for o in plan.overrides
|
|
72
|
+
]
|
|
73
|
+
return json.dumps({"notes": plan.notes, "overrides": overrides}, indent=2) + "\n"
|
|
@@ -18,7 +18,7 @@ from whetkit.mcp.introspect import ServerInventory
|
|
|
18
18
|
|
|
19
19
|
DEFAULT_GENERATOR_MODEL = "anthropic:claude-sonnet-5"
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
GENERATOR_SYSTEM_PROMPT_TEMPLATE = """\
|
|
22
22
|
You write eval tasks for an AI-agent benchmark. You are given the tools an
|
|
23
23
|
MCP server exposes. Draft tasks that measure whether an agent picks the
|
|
24
24
|
right tools for realistic user requests.
|
|
@@ -32,9 +32,7 @@ Rules for every task:
|
|
|
32
32
|
alternatives for that step. Use ONLY tool names from the given list.
|
|
33
33
|
- success_criteria is one or two concrete, checkable sentences a grader can
|
|
34
34
|
verify from the agent's final answer alone.
|
|
35
|
-
|
|
36
|
-
clearly built for writes, and never anything destructive or irreversible
|
|
37
|
-
(no deletes, resets, or purges).
|
|
35
|
+
{writes_rule}
|
|
38
36
|
- Set "ordered": true only when the steps must happen in sequence.
|
|
39
37
|
- ids are short kebab-case slugs, unique across the set.
|
|
40
38
|
|
|
@@ -59,9 +57,37 @@ def _inventory_block(inventory: ServerInventory) -> str:
|
|
|
59
57
|
return "\n".join(lines)
|
|
60
58
|
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
READ_ONLY_RULE = (
|
|
61
|
+
"- Draft ONLY read-only tasks: nothing that creates, modifies, deletes,\n"
|
|
62
|
+
" or sends anything. If the tool set is write-heavy, still restrict\n"
|
|
63
|
+
" yourself to its read/query tools."
|
|
64
|
+
)
|
|
65
|
+
WRITES_ALLOWED_RULE = (
|
|
66
|
+
"- Prefer read-only tasks. Write tasks are allowed when the tool set is\n"
|
|
67
|
+
" clearly built for writes, but never anything destructive or\n"
|
|
68
|
+
" irreversible (no deletes, resets, or purges)."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def generator_system_prompt(allow_writes: bool) -> str:
|
|
73
|
+
rule = WRITES_ALLOWED_RULE if allow_writes else READ_ONLY_RULE
|
|
74
|
+
# plain replace, not str.format — the template contains JSON braces
|
|
75
|
+
return GENERATOR_SYSTEM_PROMPT_TEMPLATE.replace("{writes_rule}", rule)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_generator_prompt(inventory: ServerInventory, count: int, server_context: str = "") -> str:
|
|
79
|
+
context_block = ""
|
|
80
|
+
if server_context:
|
|
81
|
+
context_block = (
|
|
82
|
+
"## Execution context\n"
|
|
83
|
+
f"These tasks will execute against: {server_context}\n"
|
|
84
|
+
"Write prompts whose arguments fit THAT context — the real paths,\n"
|
|
85
|
+
"repositories, or resources it implies. Never invent placeholder\n"
|
|
86
|
+
"examples like /home/user/project or acme-corp/website.\n\n"
|
|
87
|
+
)
|
|
63
88
|
return (
|
|
64
89
|
f"## Tools exposed by the server\n{_inventory_block(inventory)}\n\n"
|
|
90
|
+
f"{context_block}"
|
|
65
91
|
f"Draft exactly {count} eval tasks now."
|
|
66
92
|
)
|
|
67
93
|
|
|
@@ -102,6 +128,8 @@ async def generate_tasks(
|
|
|
102
128
|
count: int = 5,
|
|
103
129
|
config: GeneratorConfig | None = None,
|
|
104
130
|
provider: LLMProvider | None = None,
|
|
131
|
+
server_context: str = "",
|
|
132
|
+
allow_writes: bool = False,
|
|
105
133
|
) -> tuple[list[TaskSpec], list[str]]:
|
|
106
134
|
"""Draft ``count`` tasks for ``server``. Returns (tasks, warnings);
|
|
107
135
|
drafts that fail validation are dropped, never written."""
|
|
@@ -109,12 +137,12 @@ async def generate_tasks(
|
|
|
109
137
|
provider_name, model_id = parse_model(config.model)
|
|
110
138
|
provider = provider or get_provider(provider_name)
|
|
111
139
|
|
|
112
|
-
prompt = build_generator_prompt(inventory, count)
|
|
140
|
+
prompt = build_generator_prompt(inventory, count, server_context=server_context)
|
|
113
141
|
drafts = None
|
|
114
142
|
for _attempt in range(2):
|
|
115
143
|
turn = await provider.complete(
|
|
116
144
|
model=model_id,
|
|
117
|
-
system=
|
|
145
|
+
system=generator_system_prompt(allow_writes),
|
|
118
146
|
messages=[ChatMessage(role="user", content=prompt)],
|
|
119
147
|
tools=[],
|
|
120
148
|
max_tokens=config.max_tokens,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Rough USD cost estimates for known models.
|
|
2
|
+
|
|
3
|
+
Prices are per million tokens (input, output), hand-checked 2026-07-08
|
|
4
|
+
against the providers' public pricing pages. They rot — treat every
|
|
5
|
+
number this module produces as an estimate, which is how the CLI labels
|
|
6
|
+
it. Unknown models simply get no estimate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
PRICES_PER_MTOK: dict[str, tuple[float, float]] = {
|
|
10
|
+
"claude-sonnet-5": (3.00, 15.00),
|
|
11
|
+
"claude-haiku-4-5": (1.00, 5.00),
|
|
12
|
+
"gpt-4.1": (2.00, 8.00),
|
|
13
|
+
"gpt-4.1-mini": (0.40, 1.60),
|
|
14
|
+
"gpt-4.1-nano": (0.10, 0.40),
|
|
15
|
+
"gpt-5-mini": (0.25, 2.00),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def estimate_cost_usd(model_id: str, tokens_in: int, tokens_out: int) -> float | None:
|
|
20
|
+
"""Best-effort estimate; None when the model isn't in the table.
|
|
21
|
+
Dated model ids (claude-haiku-4-5-20251001) match their base entry."""
|
|
22
|
+
# longest key first, so gpt-4.1-mini never matches the gpt-4.1 entry
|
|
23
|
+
for known in sorted(PRICES_PER_MTOK, key=len, reverse=True):
|
|
24
|
+
price_in, price_out = PRICES_PER_MTOK[known]
|
|
25
|
+
if model_id == known or model_id.startswith(f"{known}-"):
|
|
26
|
+
return (tokens_in * price_in + tokens_out * price_out) / 1_000_000
|
|
27
|
+
return None
|
|
@@ -17,6 +17,7 @@ Three connection modes are supported, because real-world servers are mixed:
|
|
|
17
17
|
|
|
18
18
|
import json
|
|
19
19
|
import os
|
|
20
|
+
import re
|
|
20
21
|
import shutil
|
|
21
22
|
import sys
|
|
22
23
|
from collections.abc import AsyncIterator
|
|
@@ -105,8 +106,33 @@ def resolve_server_spec(value: str, http_mode: HttpMode = HttpMode.STATEFUL) ->
|
|
|
105
106
|
raise ValueError(f"{value}: not a URL, directory, .json spec, or .py server")
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
_ENV_REF_RE = re.compile(r"\$\{(\w+)\}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _expand_env(value):
|
|
113
|
+
"""Substitute ``${VAR}`` references from the environment in every string
|
|
114
|
+
of a spec document — so credentials (HTTP headers, child env) never have
|
|
115
|
+
to be written into server.json itself."""
|
|
116
|
+
if isinstance(value, str):
|
|
117
|
+
|
|
118
|
+
def sub(match: re.Match) -> str:
|
|
119
|
+
name = match.group(1)
|
|
120
|
+
if (resolved := os.environ.get(name)) is None:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"server spec references ${{{name}}} but it is not set in the environment"
|
|
123
|
+
)
|
|
124
|
+
return resolved
|
|
125
|
+
|
|
126
|
+
return _ENV_REF_RE.sub(sub, value)
|
|
127
|
+
if isinstance(value, dict):
|
|
128
|
+
return {k: _expand_env(v) for k, v in value.items()}
|
|
129
|
+
if isinstance(value, list):
|
|
130
|
+
return [_expand_env(v) for v in value]
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
108
134
|
def _spec_from_json(path: Path) -> ServerSpec:
|
|
109
|
-
data = json.loads(path.read_text())
|
|
135
|
+
data = _expand_env(json.loads(path.read_text()))
|
|
110
136
|
spec = spec_from_dict(data)
|
|
111
137
|
if isinstance(spec, StdioSpec):
|
|
112
138
|
spec.command = _python_command(spec.command)
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""Scoring: deterministic tool-selection matching + LLM-as-judge grading."""
|
|
2
2
|
|
|
3
|
-
from whetkit.scoring.aggregate import EvalSummary, TaskScore, score_runs
|
|
3
|
+
from whetkit.scoring.aggregate import EvalSummary, MultiRunSummary, TaskScore, score_runs
|
|
4
4
|
from whetkit.scoring.deterministic import MatchMode, ToolMatchResult, score_tool_match
|
|
5
5
|
from whetkit.scoring.judge import JudgeCache, JudgeConfig, JudgeVerdict, judge_run
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"EvalSummary",
|
|
9
|
+
"MultiRunSummary",
|
|
9
10
|
"JudgeCache",
|
|
10
11
|
"JudgeConfig",
|
|
11
12
|
"JudgeVerdict",
|
|
@@ -27,6 +27,14 @@ class TaskScore(BaseModel):
|
|
|
27
27
|
return False
|
|
28
28
|
return self.tool_hit
|
|
29
29
|
|
|
30
|
+
@property
|
|
31
|
+
def spec_gap(self) -> bool:
|
|
32
|
+
"""Judge passed but the tool match failed: the agent reached a
|
|
33
|
+
correct outcome via tools the task never listed. Nine times out of
|
|
34
|
+
ten that means expected_tools is incomplete, not that the agent
|
|
35
|
+
failed."""
|
|
36
|
+
return self.judge is not None and self.judge.passed and not self.tool_hit
|
|
37
|
+
|
|
30
38
|
|
|
31
39
|
class EvalSummary(BaseModel):
|
|
32
40
|
scores: list[TaskScore]
|
|
@@ -88,6 +96,51 @@ class EvalSummary(BaseModel):
|
|
|
88
96
|
return lines
|
|
89
97
|
|
|
90
98
|
|
|
99
|
+
class MultiRunSummary(BaseModel):
|
|
100
|
+
"""Aggregate over repeated executions of the same task set.
|
|
101
|
+
|
|
102
|
+
Single runs are noise: the same server, tasks, and model can flip a
|
|
103
|
+
task between runs. The honest headline is mean plus range."""
|
|
104
|
+
|
|
105
|
+
summaries: list[EvalSummary]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def n(self) -> int:
|
|
109
|
+
return len(self.summaries)
|
|
110
|
+
|
|
111
|
+
def _spread(self, values: list[float]) -> str:
|
|
112
|
+
mean = sum(values) / len(values)
|
|
113
|
+
low, high = min(values), max(values)
|
|
114
|
+
if low == high:
|
|
115
|
+
return f"{mean:.0%}"
|
|
116
|
+
return f"{mean:.0%} [{low:.0%}–{high:.0%}]"
|
|
117
|
+
|
|
118
|
+
def summary_lines(self) -> list[str]:
|
|
119
|
+
lines = [
|
|
120
|
+
f"Runs: {self.n} × {self.summaries[0].task_count} task(s)",
|
|
121
|
+
f"Hit-rate: {self._spread([s.hit_rate for s in self.summaries])}",
|
|
122
|
+
f"Tool-selection hit-rate: {self._spread([s.tool_hit_rate for s in self.summaries])}",
|
|
123
|
+
f"Tool precision (avg): {self._spread([s.avg_precision for s in self.summaries])}",
|
|
124
|
+
]
|
|
125
|
+
judged = [s.judge_pass_rate for s in self.summaries if s.judge_pass_rate is not None]
|
|
126
|
+
if judged:
|
|
127
|
+
lines.insert(2, f"Judge pass-rate: {self._spread(judged)}")
|
|
128
|
+
extras = [s.avg_extra_calls for s in self.summaries]
|
|
129
|
+
lines.append(f"Unnecessary calls (avg): {sum(extras) / len(extras):.1f}/task")
|
|
130
|
+
flaky = self.flaky_tasks()
|
|
131
|
+
if flaky:
|
|
132
|
+
lines.append(f"Flaky tasks (hit in some runs, missed in others): {', '.join(flaky)}")
|
|
133
|
+
return lines
|
|
134
|
+
|
|
135
|
+
def flaky_tasks(self) -> list[str]:
|
|
136
|
+
"""Task ids whose hit outcome differs across runs."""
|
|
137
|
+
outcomes: dict[str, set[bool]] = {}
|
|
138
|
+
for summary in self.summaries:
|
|
139
|
+
for score in summary.scores:
|
|
140
|
+
outcomes.setdefault(score.task_id, set()).add(score.hit)
|
|
141
|
+
return sorted(task_id for task_id, seen in outcomes.items() if len(seen) > 1)
|
|
142
|
+
|
|
143
|
+
|
|
91
144
|
async def score_runs(
|
|
92
145
|
tasks: list[TaskSpec],
|
|
93
146
|
runs: list[TaskRun],
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|