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 ADDED
@@ -0,0 +1,3 @@
1
+ """whetkit: measure and improve LLM agent tool selection on MCP servers."""
2
+
3
+ __version__ = "0.1.0"
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