whetkit 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {whetkit-0.3.0 → whetkit-0.4.0}/PKG-INFO +1 -1
  2. {whetkit-0.3.0 → whetkit-0.4.0}/pyproject.toml +1 -1
  3. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/cli.py +224 -0
  4. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/curation/optimizer.py +129 -0
  5. {whetkit-0.3.0 → whetkit-0.4.0}/LICENSE +0 -0
  6. {whetkit-0.3.0 → whetkit-0.4.0}/README.md +0 -0
  7. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/__init__.py +0 -0
  8. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/curation/__init__.py +0 -0
  9. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/curation/export.py +0 -0
  10. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/curation/overlay.py +0 -0
  11. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/curation/plan.py +0 -0
  12. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/datasets/__init__.py +0 -0
  13. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/datasets/tasks.py +0 -0
  14. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/doctor.py +0 -0
  15. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/generate.py +0 -0
  16. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/llm/__init__.py +0 -0
  17. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/llm/anthropic_provider.py +0 -0
  18. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/llm/base.py +0 -0
  19. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/llm/openai_provider.py +0 -0
  20. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/llm/pricing.py +0 -0
  21. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/llm/registry.py +0 -0
  22. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/mcp/__init__.py +0 -0
  23. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/mcp/client.py +0 -0
  24. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/mcp/introspect.py +0 -0
  25. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/mcp/transport.py +0 -0
  26. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/py.typed +0 -0
  27. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/report/__init__.py +0 -0
  28. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/report/builder.py +0 -0
  29. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/report/html.py +0 -0
  30. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/runner/__init__.py +0 -0
  31. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/runner/agent.py +0 -0
  32. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/scoring/__init__.py +0 -0
  33. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/scoring/aggregate.py +0 -0
  34. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/scoring/deterministic.py +0 -0
  35. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/scoring/judge.py +0 -0
  36. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/tracing/__init__.py +0 -0
  37. {whetkit-0.3.0 → whetkit-0.4.0}/src/whetkit/tracing/records.py +0 -0
  38. {whetkit-0.3.0 → whetkit-0.4.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.0
3
+ Version: 0.4.0
4
4
  Summary: Measure and improve how well LLM agents select and use the tools exposed by an MCP server
5
5
  Keywords: mcp,model-context-protocol,llm,agents,evals,tool-use,tool-selection,benchmark
6
6
  Author: Mohammed Benlamlih
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "whetkit"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Measure and improve how well LLM agents select and use the tools exposed by an MCP server"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -255,6 +255,62 @@ def generate(
255
255
  typer.echo(f" whetkit run --server {server} --tasks {out}")
256
256
 
257
257
 
258
+ @app.command(name="plan-init")
259
+ def plan_init(
260
+ server: Annotated[
261
+ str,
262
+ typer.Option("--server", help="MCP server: URL, directory, server.json, or server.py"),
263
+ ],
264
+ keep: Annotated[
265
+ str, typer.Option("--keep", help="Comma-separated tool names to keep visible")
266
+ ] = "",
267
+ from_tasks: Annotated[
268
+ str | None,
269
+ typer.Option(
270
+ "--from-tasks",
271
+ help="Also keep every tool referenced by these tasks' expected_tools",
272
+ ),
273
+ ] = None,
274
+ out: Annotated[
275
+ str, typer.Option("--out", help="Where to write the plan YAML")
276
+ ] = ".whetkit/curation-plan.yaml",
277
+ http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
278
+ ) -> None:
279
+ """Scaffold a view plan: keep the named tools, hide everything else.
280
+ The fastest way to serve a lean read-only slice of a big server."""
281
+ from whetkit.curation import CurationPlan, ToolOverride, save_plan
282
+ from whetkit.datasets import load_tasks
283
+
284
+ keep_set = {name.strip() for name in keep.split(",") if name.strip()}
285
+ if from_tasks:
286
+ for task in load_tasks(from_tasks):
287
+ for slot in task.expected_tool_slots:
288
+ keep_set.update(slot)
289
+ if not keep_set:
290
+ raise typer.BadParameter("nothing to keep — pass --keep and/or --from-tasks")
291
+
292
+ spec = _resolve_server(server, http_mode)
293
+ inventory = asyncio.run(inspect_server(spec))
294
+ names = {t.name for t in inventory.tools}
295
+ if unknown := sorted(keep_set - names):
296
+ typer.echo(f"warning: not on the server, ignoring: {', '.join(unknown)}", err=True)
297
+
298
+ hidden = [
299
+ ToolOverride(original_name=t.name, hidden=True, reason="Not part of this view's workflows.")
300
+ for t in inventory.tools
301
+ if t.name not in keep_set
302
+ ]
303
+ plan = CurationPlan(
304
+ server=spec.label(),
305
+ notes=f"View plan: keep {len(names & keep_set)} tool(s), hide the rest.",
306
+ overrides=hidden,
307
+ )
308
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
309
+ save_plan(plan, out)
310
+ typer.echo(f"kept {len(names & keep_set)}, hidden {len(hidden)} — wrote {out}")
311
+ typer.echo(f"score it: whetkit run --server {server} --tasks <tasks> --plan {out}")
312
+
313
+
258
314
  @app.command()
259
315
  def export(
260
316
  plan: Annotated[
@@ -617,6 +673,17 @@ def curate(
617
673
  help="Completion-token budget per model turn (raise for reasoning models)",
618
674
  ),
619
675
  ] = 1024,
676
+ prune_unused: Annotated[
677
+ bool,
678
+ typer.Option(
679
+ "--prune-unused",
680
+ help=(
681
+ "Additionally hide every tool the eval never touched — the cost "
682
+ "play for big servers. Only sound when the tasks cover all the "
683
+ "workflows the curated view will serve."
684
+ ),
685
+ ),
686
+ ] = False,
620
687
  store: Annotated[str | None, typer.Option("--store")] = None,
621
688
  http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
622
689
  ) -> None:
@@ -624,6 +691,7 @@ def curate(
624
691
  it, and show the before/after hit-rate."""
625
692
  from whetkit.curation import CuratedMCPClient, propose_plan, save_plan
626
693
  from whetkit.curation.optimizer import OptimizerConfig
694
+ from whetkit.curation.optimizer import prune_unused as apply_prune_unused
627
695
  from whetkit.datasets import load_tasks
628
696
  from whetkit.mcp import inspect_server
629
697
  from whetkit.runner import RunConfig, run_task
@@ -666,6 +734,9 @@ def curate(
666
734
  )
667
735
  for warning in warnings:
668
736
  typer.echo(f"warning: {warning}", err=True)
737
+ if prune_unused:
738
+ pruned = apply_prune_unused(plan, inventory, task_list, baseline_runs)
739
+ typer.echo(f"--prune-unused: hid {pruned} untouched tool(s)", err=True)
669
740
  save_plan(plan, plan_path)
670
741
  typer.echo(f"curation plan written to {plan_path}", err=True)
671
742
 
@@ -742,6 +813,159 @@ def curate(
742
813
  asyncio.run(_curate())
743
814
 
744
815
 
816
+ @app.command()
817
+ def fix(
818
+ tasks: Annotated[
819
+ str, typer.Option("--tasks", help="Task YAML file or directory of task files")
820
+ ],
821
+ server: Annotated[
822
+ str | None, typer.Option("--server", help="Override the server every task runs against")
823
+ ] = None,
824
+ model: Annotated[
825
+ str, typer.Option("--model", help="Agent model as provider:model_id")
826
+ ] = "anthropic:claude-sonnet-5",
827
+ optimizer_model: Annotated[
828
+ str, typer.Option("--optimizer-model")
829
+ ] = "anthropic:claude-sonnet-5",
830
+ judge: Annotated[str, typer.Option("--judge", help="'auto', 'on', or 'off'")] = "auto",
831
+ judge_model: Annotated[str, typer.Option("--judge-model")] = "anthropic:claude-sonnet-5",
832
+ max_iterations: Annotated[
833
+ int, typer.Option("--max-iterations", help="Propose→eval→revise rounds (≥1)")
834
+ ] = 3,
835
+ plan_path: Annotated[
836
+ str, typer.Option("--plan", help="Where to write the best plan YAML")
837
+ ] = ".whetkit/curation-plan.yaml",
838
+ match_mode: Annotated[str, typer.Option("--match-mode")] = "order_tolerant",
839
+ max_turns: Annotated[int, typer.Option("--max-turns")] = 10,
840
+ max_tokens: Annotated[int, typer.Option("--max-tokens")] = 1024,
841
+ store: Annotated[str | None, typer.Option("--store")] = None,
842
+ http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
843
+ ) -> None:
844
+ """Self-correcting curation: propose a plan, eval through it, feed the
845
+ regressions and remaining waste back to the optimizer, revise — up to
846
+ --max-iterations — and keep the best plan by measured results."""
847
+ from functools import partial
848
+
849
+ from whetkit.curation import CuratedMCPClient, propose_plan, save_plan
850
+ from whetkit.curation.optimizer import OptimizerConfig, propose_revision
851
+ from whetkit.datasets import load_tasks
852
+ from whetkit.runner import RunConfig, run_task
853
+ from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
854
+ from whetkit.tracing import TraceStore, default_store_path
855
+
856
+ if max_iterations < 1:
857
+ raise typer.BadParameter("--max-iterations must be at least 1")
858
+ task_list = load_tasks(tasks)
859
+ servers = _resolve_task_servers(task_list, server, http_mode)
860
+ config = RunConfig(model=model, max_turns=max_turns, max_tokens=max_tokens)
861
+ use_judge = _judge_enabled(judge, judge_model)
862
+ judge_config = JudgeConfig(model=judge_model)
863
+ mode = MatchMode(match_mode)
864
+ store_path = store or str(default_store_path())
865
+ optimizer_config = OptimizerConfig(model=optimizer_model)
866
+
867
+ from whetkit.mcp import MCPClient
868
+
869
+ async def _eval(group: str, cache, client_factory=MCPClient):
870
+ runs = []
871
+ for task in task_list:
872
+ typer.echo(f"running {task.id} ...", err=True)
873
+ runs.append(
874
+ await run_task(task, servers[task.server], config, client_factory=client_factory)
875
+ )
876
+ with TraceStore(store_path) as trace_store:
877
+ trace_store.save_runs(runs, run_group=group)
878
+ return runs
879
+
880
+ async def _fix() -> None:
881
+ from whetkit.mcp import inspect_server as _inspect
882
+
883
+ cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
884
+ try:
885
+ typer.echo("== baseline ==", err=True)
886
+ baseline_runs = await _eval("baseline", cache)
887
+ baseline = await score_runs(
888
+ task_list,
889
+ baseline_runs,
890
+ mode=mode,
891
+ judge_config=judge_config,
892
+ judge_cache=cache,
893
+ use_judge=use_judge,
894
+ )
895
+ inventory = await _inspect(next(iter(servers.values())))
896
+
897
+ typer.echo("== proposing plan (iteration 1) ==", err=True)
898
+ plan, warnings = await propose_plan(
899
+ inventory, task_list, baseline_runs, baseline.scores, optimizer_config
900
+ )
901
+ for w in warnings:
902
+ typer.echo(f"warning: {w}", err=True)
903
+
904
+ def metric(summary):
905
+ return (summary.hit_rate, -summary.avg_extra_calls, summary.avg_precision)
906
+
907
+ best_plan, best_summary = None, None
908
+ for iteration in range(1, max_iterations + 1):
909
+ typer.echo(f"== eval through plan (iteration {iteration}) ==", err=True)
910
+ curated_runs = await _eval(
911
+ f"fix-{iteration}",
912
+ cache,
913
+ client_factory=partial(CuratedMCPClient, plan=plan),
914
+ )
915
+ curated = await score_runs(
916
+ task_list,
917
+ curated_runs,
918
+ mode=mode,
919
+ judge_config=judge_config,
920
+ judge_cache=cache,
921
+ use_judge=use_judge,
922
+ name_map=plan.rename_map(),
923
+ )
924
+ typer.echo(
925
+ f"iteration {iteration}: hit {curated.hit_rate:.0%} "
926
+ f"(baseline {baseline.hit_rate:.0%}), "
927
+ f"extra calls {curated.avg_extra_calls:.1f}/task",
928
+ err=True,
929
+ )
930
+ if best_summary is None or metric(curated) > metric(best_summary):
931
+ best_plan, best_summary = plan, curated
932
+
933
+ converged = (
934
+ curated.hit_rate >= baseline.hit_rate and curated.avg_extra_calls <= 0.25
935
+ )
936
+ if converged or iteration == max_iterations:
937
+ if converged:
938
+ typer.echo("converged — no regressions, negligible waste", err=True)
939
+ break
940
+
941
+ typer.echo("== revising plan ==", err=True)
942
+ plan, warnings = await propose_revision(
943
+ plan,
944
+ inventory,
945
+ task_list,
946
+ baseline_runs,
947
+ baseline.scores,
948
+ curated_runs,
949
+ curated.scores,
950
+ optimizer_config,
951
+ )
952
+ for w in warnings:
953
+ typer.echo(f"warning: {w}", err=True)
954
+ finally:
955
+ cache.close()
956
+
957
+ save_plan(best_plan, plan_path)
958
+ typer.echo(f"\nbest plan (of {max_iterations} max iterations) -> {plan_path}")
959
+ typer.echo(
960
+ f"Hit-rate: {baseline.hit_rate:.0%} -> {best_summary.hit_rate:.0%} "
961
+ f"Extra calls: {baseline.avg_extra_calls:.1f} -> "
962
+ f"{best_summary.avg_extra_calls:.1f}/task"
963
+ )
964
+ typer.echo(f"serve it: whetkit overlay --server <origin> --plan {plan_path}")
965
+
966
+ asyncio.run(_fix())
967
+
968
+
745
969
  @app.command()
746
970
  def report(
747
971
  tasks: Annotated[
@@ -197,3 +197,132 @@ async def propose_plan(
197
197
  plan.overrides = []
198
198
  break
199
199
  return plan, warnings
200
+
201
+
202
+ def prune_unused(
203
+ plan: CurationPlan,
204
+ inventory: ServerInventory,
205
+ tasks: list[TaskSpec],
206
+ runs: list[TaskRun],
207
+ ) -> int:
208
+ """Hide every tool the eval never touched: not called in any baseline
209
+ run, not accepted by any task's expected_tools, and not already
210
+ overridden. The cost play for big servers — only sound when the task
211
+ set really covers the workflows the curated view will serve, which is
212
+ why it's opt-in. Returns how many tools were hidden."""
213
+ used = {name for run in runs for name in run.called_tool_names}
214
+ for task in tasks:
215
+ for slot in task.expected_tool_slots:
216
+ used.update(slot)
217
+ overridden = {o.original_name for o in plan.overrides}
218
+ added = 0
219
+ for tool in inventory.tools:
220
+ if tool.name in used or tool.name in overridden:
221
+ continue
222
+ plan.overrides.append(
223
+ ToolOverride(
224
+ original_name=tool.name,
225
+ hidden=True,
226
+ reason="Unused by the eval workflows (pruned by --prune-unused).",
227
+ )
228
+ )
229
+ added += 1
230
+ return added
231
+
232
+
233
+ REVISION_INSTRUCTIONS = """\
234
+ You previously proposed the curation plan below, and it was re-evaluated.
235
+ Revise it: KEEP the changes that worked, FIX or DROP the ones implicated in
236
+ regressions or remaining waste. A rename the agent still misused needs a
237
+ better name or a sharper description; a prune that broke a task must be
238
+ un-hidden. Respond with the SAME JSON shape as before — the FULL revised
239
+ plan, not a delta.
240
+ """
241
+
242
+
243
+ def build_revision_prompt(
244
+ plan: CurationPlan,
245
+ inventory: ServerInventory,
246
+ tasks: list[TaskSpec],
247
+ baseline_runs: list[TaskRun],
248
+ baseline_scores: list[TaskScore],
249
+ curated_runs: list[TaskRun],
250
+ curated_scores: list[TaskScore],
251
+ ) -> str:
252
+ import yaml
253
+
254
+ baseline_by_id = {s.task_id: s for s in baseline_scores}
255
+ regressed = [
256
+ s.task_id
257
+ for s in curated_scores
258
+ if not s.hit and (b := baseline_by_id.get(s.task_id)) is not None and b.hit
259
+ ]
260
+ regression_line = (
261
+ f"REGRESSIONS your plan caused (fix these first): {', '.join(regressed)}"
262
+ if regressed
263
+ else "No regressions — focus on remaining waste (unnecessary calls)."
264
+ )
265
+ plan_yaml = yaml.safe_dump(plan.model_dump(exclude_defaults=True), sort_keys=False)
266
+ return (
267
+ f"## Tools exposed by the server\n{_inventory_block(inventory)}\n\n"
268
+ f"## Baseline eval (no plan)\n{_trace_block(tasks, baseline_runs, baseline_scores)}\n\n"
269
+ f"## Your previous plan\n```yaml\n{plan_yaml}```\n\n"
270
+ f"## Eval THROUGH your plan\n{_trace_block(tasks, curated_runs, curated_scores)}\n\n"
271
+ f"{regression_line}\n\nRevise the plan now."
272
+ )
273
+
274
+
275
+ async def propose_revision(
276
+ plan: CurationPlan,
277
+ inventory: ServerInventory,
278
+ tasks: list[TaskSpec],
279
+ baseline_runs: list[TaskRun],
280
+ baseline_scores: list[TaskScore],
281
+ curated_runs: list[TaskRun],
282
+ curated_scores: list[TaskScore],
283
+ config: OptimizerConfig | None = None,
284
+ provider: LLMProvider | None = None,
285
+ ) -> tuple[CurationPlan, list[str]]:
286
+ """One revision round: same parsing/validation as propose_plan, but the
287
+ model sees its previous plan and what happened through it."""
288
+ config = config or OptimizerConfig()
289
+ provider_name, model_id = parse_model(config.model)
290
+ provider = provider or get_provider(provider_name)
291
+
292
+ prompt = build_revision_prompt(
293
+ plan, inventory, tasks, baseline_runs, baseline_scores, curated_runs, curated_scores
294
+ )
295
+ parsed = None
296
+ for _attempt in range(2):
297
+ turn = await provider.complete(
298
+ model=model_id,
299
+ system=OPTIMIZER_SYSTEM_PROMPT + "\n" + REVISION_INSTRUCTIONS,
300
+ messages=[ChatMessage(role="user", content=prompt)],
301
+ tools=[],
302
+ max_tokens=config.max_tokens,
303
+ )
304
+ parsed = _parse_overrides(turn.text or "")
305
+ if parsed is not None:
306
+ break
307
+ if parsed is None:
308
+ return plan, ["revision output was not valid JSON after 2 attempts; keeping previous plan"]
309
+
310
+ notes, overrides = parsed
311
+ revised = CurationPlan(server=inventory.server, notes=notes, overrides=overrides)
312
+ origin_names = {t.name for t in inventory.tools}
313
+ warnings: list[str] = []
314
+ while problems := revised.validate_against(origin_names):
315
+ problem = problems[0]
316
+ name_match = re.search(r"'([^']+)'", problem)
317
+ offender = name_match.group(1) if name_match else None
318
+ before = len(revised.overrides)
319
+ revised.overrides = [
320
+ o
321
+ for o in revised.overrides
322
+ if offender not in (o.original_name, o.new_name, o.presented_name)
323
+ ]
324
+ warnings.append(f"dropped unsafe override(s): {problem}")
325
+ if len(revised.overrides) == before:
326
+ warnings.append("could not repair revised plan; keeping previous plan")
327
+ return plan, warnings
328
+ return revised, warnings
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes