whetkit 0.3.0__tar.gz → 0.5.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.3.0 → whetkit-0.5.0}/PKG-INFO +1 -1
- {whetkit-0.3.0 → whetkit-0.5.0}/pyproject.toml +1 -1
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/cli.py +271 -6
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/optimizer.py +129 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/generate.py +5 -1
- {whetkit-0.3.0 → whetkit-0.5.0}/LICENSE +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/README.md +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/__init__.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/__init__.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/export.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/overlay.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/plan.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/datasets/__init__.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/datasets/tasks.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/doctor.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/__init__.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/anthropic_provider.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/base.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/openai_provider.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/pricing.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/registry.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/mcp/__init__.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/mcp/client.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/mcp/introspect.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/mcp/transport.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/py.typed +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/report/__init__.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/report/builder.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/report/html.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/runner/__init__.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/runner/agent.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/scoring/__init__.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/scoring/aggregate.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/scoring/deterministic.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/scoring/judge.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/tracing/__init__.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/tracing/records.py +0 -0
- {whetkit-0.3.0 → whetkit-0.5.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.5.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
|
|
@@ -255,6 +255,86 @@ 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
|
+
from_traces: Annotated[
|
|
275
|
+
str | None,
|
|
276
|
+
typer.Option(
|
|
277
|
+
"--from-traces",
|
|
278
|
+
help=(
|
|
279
|
+
"Also keep every tool actually called in this trace store "
|
|
280
|
+
"(what real runs used, including tools no spec lists)"
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
] = None,
|
|
284
|
+
traces_group: Annotated[
|
|
285
|
+
str | None,
|
|
286
|
+
typer.Option("--traces-group", help="Restrict --from-traces to one run group"),
|
|
287
|
+
] = None,
|
|
288
|
+
out: Annotated[
|
|
289
|
+
str, typer.Option("--out", help="Where to write the plan YAML")
|
|
290
|
+
] = ".whetkit/curation-plan.yaml",
|
|
291
|
+
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Scaffold a view plan: keep the named tools, hide everything else.
|
|
294
|
+
The fastest way to serve a lean read-only slice of a big server."""
|
|
295
|
+
from whetkit.curation import CurationPlan, ToolOverride, save_plan
|
|
296
|
+
from whetkit.datasets import load_tasks
|
|
297
|
+
|
|
298
|
+
keep_set = {name.strip() for name in keep.split(",") if name.strip()}
|
|
299
|
+
if from_tasks:
|
|
300
|
+
for task in load_tasks(from_tasks):
|
|
301
|
+
for slot in task.expected_tool_slots:
|
|
302
|
+
keep_set.update(slot)
|
|
303
|
+
if from_traces:
|
|
304
|
+
from whetkit.tracing import TraceStore
|
|
305
|
+
|
|
306
|
+
if not Path(from_traces).is_file():
|
|
307
|
+
raise typer.BadParameter(f"no trace store at {from_traces}")
|
|
308
|
+
with TraceStore(from_traces) as trace_store:
|
|
309
|
+
for run in trace_store.load_runs(traces_group):
|
|
310
|
+
keep_set.update(run.called_tool_names)
|
|
311
|
+
if not keep_set:
|
|
312
|
+
raise typer.BadParameter(
|
|
313
|
+
"nothing to keep — pass --keep, --from-tasks, and/or --from-traces"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
spec = _resolve_server(server, http_mode)
|
|
317
|
+
inventory = asyncio.run(inspect_server(spec))
|
|
318
|
+
names = {t.name for t in inventory.tools}
|
|
319
|
+
if unknown := sorted(keep_set - names):
|
|
320
|
+
typer.echo(f"warning: not on the server, ignoring: {', '.join(unknown)}", err=True)
|
|
321
|
+
|
|
322
|
+
hidden = [
|
|
323
|
+
ToolOverride(original_name=t.name, hidden=True, reason="Not part of this view's workflows.")
|
|
324
|
+
for t in inventory.tools
|
|
325
|
+
if t.name not in keep_set
|
|
326
|
+
]
|
|
327
|
+
plan = CurationPlan(
|
|
328
|
+
server=spec.label(),
|
|
329
|
+
notes=f"View plan: keep {len(names & keep_set)} tool(s), hide the rest.",
|
|
330
|
+
overrides=hidden,
|
|
331
|
+
)
|
|
332
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
save_plan(plan, out)
|
|
334
|
+
typer.echo(f"kept {len(names & keep_set)}, hidden {len(hidden)} — wrote {out}")
|
|
335
|
+
typer.echo(f"score it: whetkit run --server {server} --tasks <tasks> --plan {out}")
|
|
336
|
+
|
|
337
|
+
|
|
258
338
|
@app.command()
|
|
259
339
|
def export(
|
|
260
340
|
plan: Annotated[
|
|
@@ -424,6 +504,17 @@ def run(
|
|
|
424
504
|
help="Write a machine-readable summary (metrics + per-task outcomes) to this path",
|
|
425
505
|
),
|
|
426
506
|
] = None,
|
|
507
|
+
concurrency: Annotated[
|
|
508
|
+
int,
|
|
509
|
+
typer.Option(
|
|
510
|
+
"--concurrency",
|
|
511
|
+
help=(
|
|
512
|
+
"Run up to N tasks of a repetition in parallel (each gets its own "
|
|
513
|
+
"server connection). Only safe when tasks are independent — writes "
|
|
514
|
+
"to shared state should stay at 1."
|
|
515
|
+
),
|
|
516
|
+
),
|
|
517
|
+
] = 1,
|
|
427
518
|
reset_cmd: Annotated[
|
|
428
519
|
str | None,
|
|
429
520
|
typer.Option(
|
|
@@ -444,6 +535,8 @@ def run(
|
|
|
444
535
|
|
|
445
536
|
if runs < 1:
|
|
446
537
|
raise typer.BadParameter("--runs must be at least 1")
|
|
538
|
+
if concurrency < 1:
|
|
539
|
+
raise typer.BadParameter("--concurrency must be at least 1")
|
|
447
540
|
|
|
448
541
|
task_list = load_tasks(tasks)
|
|
449
542
|
servers = _resolve_task_servers(task_list, server, http_mode)
|
|
@@ -467,12 +560,16 @@ def run(
|
|
|
467
560
|
name_map = curation_plan.rename_map()
|
|
468
561
|
|
|
469
562
|
async def _run_once(group_name: str, cache: JudgeCache):
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
563
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
564
|
+
|
|
565
|
+
async def _one(task):
|
|
566
|
+
async with semaphore:
|
|
567
|
+
typer.echo(f"running {task.id} ...", err=True)
|
|
568
|
+
return await run_task(
|
|
569
|
+
task, servers[task.server], config, client_factory=client_factory
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
task_runs = list(await asyncio.gather(*(_one(task) for task in task_list)))
|
|
476
573
|
|
|
477
574
|
with TraceStore(store_path) as trace_store:
|
|
478
575
|
trace_store.save_runs(task_runs, run_group=group_name)
|
|
@@ -617,6 +714,17 @@ def curate(
|
|
|
617
714
|
help="Completion-token budget per model turn (raise for reasoning models)",
|
|
618
715
|
),
|
|
619
716
|
] = 1024,
|
|
717
|
+
prune_unused: Annotated[
|
|
718
|
+
bool,
|
|
719
|
+
typer.Option(
|
|
720
|
+
"--prune-unused",
|
|
721
|
+
help=(
|
|
722
|
+
"Additionally hide every tool the eval never touched — the cost "
|
|
723
|
+
"play for big servers. Only sound when the tasks cover all the "
|
|
724
|
+
"workflows the curated view will serve."
|
|
725
|
+
),
|
|
726
|
+
),
|
|
727
|
+
] = False,
|
|
620
728
|
store: Annotated[str | None, typer.Option("--store")] = None,
|
|
621
729
|
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
622
730
|
) -> None:
|
|
@@ -624,6 +732,7 @@ def curate(
|
|
|
624
732
|
it, and show the before/after hit-rate."""
|
|
625
733
|
from whetkit.curation import CuratedMCPClient, propose_plan, save_plan
|
|
626
734
|
from whetkit.curation.optimizer import OptimizerConfig
|
|
735
|
+
from whetkit.curation.optimizer import prune_unused as apply_prune_unused
|
|
627
736
|
from whetkit.datasets import load_tasks
|
|
628
737
|
from whetkit.mcp import inspect_server
|
|
629
738
|
from whetkit.runner import RunConfig, run_task
|
|
@@ -666,6 +775,9 @@ def curate(
|
|
|
666
775
|
)
|
|
667
776
|
for warning in warnings:
|
|
668
777
|
typer.echo(f"warning: {warning}", err=True)
|
|
778
|
+
if prune_unused:
|
|
779
|
+
pruned = apply_prune_unused(plan, inventory, task_list, baseline_runs)
|
|
780
|
+
typer.echo(f"--prune-unused: hid {pruned} untouched tool(s)", err=True)
|
|
669
781
|
save_plan(plan, plan_path)
|
|
670
782
|
typer.echo(f"curation plan written to {plan_path}", err=True)
|
|
671
783
|
|
|
@@ -742,6 +854,159 @@ def curate(
|
|
|
742
854
|
asyncio.run(_curate())
|
|
743
855
|
|
|
744
856
|
|
|
857
|
+
@app.command()
|
|
858
|
+
def fix(
|
|
859
|
+
tasks: Annotated[
|
|
860
|
+
str, typer.Option("--tasks", help="Task YAML file or directory of task files")
|
|
861
|
+
],
|
|
862
|
+
server: Annotated[
|
|
863
|
+
str | None, typer.Option("--server", help="Override the server every task runs against")
|
|
864
|
+
] = None,
|
|
865
|
+
model: Annotated[
|
|
866
|
+
str, typer.Option("--model", help="Agent model as provider:model_id")
|
|
867
|
+
] = "anthropic:claude-sonnet-5",
|
|
868
|
+
optimizer_model: Annotated[
|
|
869
|
+
str, typer.Option("--optimizer-model")
|
|
870
|
+
] = "anthropic:claude-sonnet-5",
|
|
871
|
+
judge: Annotated[str, typer.Option("--judge", help="'auto', 'on', or 'off'")] = "auto",
|
|
872
|
+
judge_model: Annotated[str, typer.Option("--judge-model")] = "anthropic:claude-sonnet-5",
|
|
873
|
+
max_iterations: Annotated[
|
|
874
|
+
int, typer.Option("--max-iterations", help="Propose→eval→revise rounds (≥1)")
|
|
875
|
+
] = 3,
|
|
876
|
+
plan_path: Annotated[
|
|
877
|
+
str, typer.Option("--plan", help="Where to write the best plan YAML")
|
|
878
|
+
] = ".whetkit/curation-plan.yaml",
|
|
879
|
+
match_mode: Annotated[str, typer.Option("--match-mode")] = "order_tolerant",
|
|
880
|
+
max_turns: Annotated[int, typer.Option("--max-turns")] = 10,
|
|
881
|
+
max_tokens: Annotated[int, typer.Option("--max-tokens")] = 1024,
|
|
882
|
+
store: Annotated[str | None, typer.Option("--store")] = None,
|
|
883
|
+
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
884
|
+
) -> None:
|
|
885
|
+
"""Self-correcting curation: propose a plan, eval through it, feed the
|
|
886
|
+
regressions and remaining waste back to the optimizer, revise — up to
|
|
887
|
+
--max-iterations — and keep the best plan by measured results."""
|
|
888
|
+
from functools import partial
|
|
889
|
+
|
|
890
|
+
from whetkit.curation import CuratedMCPClient, propose_plan, save_plan
|
|
891
|
+
from whetkit.curation.optimizer import OptimizerConfig, propose_revision
|
|
892
|
+
from whetkit.datasets import load_tasks
|
|
893
|
+
from whetkit.runner import RunConfig, run_task
|
|
894
|
+
from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
|
|
895
|
+
from whetkit.tracing import TraceStore, default_store_path
|
|
896
|
+
|
|
897
|
+
if max_iterations < 1:
|
|
898
|
+
raise typer.BadParameter("--max-iterations must be at least 1")
|
|
899
|
+
task_list = load_tasks(tasks)
|
|
900
|
+
servers = _resolve_task_servers(task_list, server, http_mode)
|
|
901
|
+
config = RunConfig(model=model, max_turns=max_turns, max_tokens=max_tokens)
|
|
902
|
+
use_judge = _judge_enabled(judge, judge_model)
|
|
903
|
+
judge_config = JudgeConfig(model=judge_model)
|
|
904
|
+
mode = MatchMode(match_mode)
|
|
905
|
+
store_path = store or str(default_store_path())
|
|
906
|
+
optimizer_config = OptimizerConfig(model=optimizer_model)
|
|
907
|
+
|
|
908
|
+
from whetkit.mcp import MCPClient
|
|
909
|
+
|
|
910
|
+
async def _eval(group: str, cache, client_factory=MCPClient):
|
|
911
|
+
runs = []
|
|
912
|
+
for task in task_list:
|
|
913
|
+
typer.echo(f"running {task.id} ...", err=True)
|
|
914
|
+
runs.append(
|
|
915
|
+
await run_task(task, servers[task.server], config, client_factory=client_factory)
|
|
916
|
+
)
|
|
917
|
+
with TraceStore(store_path) as trace_store:
|
|
918
|
+
trace_store.save_runs(runs, run_group=group)
|
|
919
|
+
return runs
|
|
920
|
+
|
|
921
|
+
async def _fix() -> None:
|
|
922
|
+
from whetkit.mcp import inspect_server as _inspect
|
|
923
|
+
|
|
924
|
+
cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
|
|
925
|
+
try:
|
|
926
|
+
typer.echo("== baseline ==", err=True)
|
|
927
|
+
baseline_runs = await _eval("baseline", cache)
|
|
928
|
+
baseline = await score_runs(
|
|
929
|
+
task_list,
|
|
930
|
+
baseline_runs,
|
|
931
|
+
mode=mode,
|
|
932
|
+
judge_config=judge_config,
|
|
933
|
+
judge_cache=cache,
|
|
934
|
+
use_judge=use_judge,
|
|
935
|
+
)
|
|
936
|
+
inventory = await _inspect(next(iter(servers.values())))
|
|
937
|
+
|
|
938
|
+
typer.echo("== proposing plan (iteration 1) ==", err=True)
|
|
939
|
+
plan, warnings = await propose_plan(
|
|
940
|
+
inventory, task_list, baseline_runs, baseline.scores, optimizer_config
|
|
941
|
+
)
|
|
942
|
+
for w in warnings:
|
|
943
|
+
typer.echo(f"warning: {w}", err=True)
|
|
944
|
+
|
|
945
|
+
def metric(summary):
|
|
946
|
+
return (summary.hit_rate, -summary.avg_extra_calls, summary.avg_precision)
|
|
947
|
+
|
|
948
|
+
best_plan, best_summary = None, None
|
|
949
|
+
for iteration in range(1, max_iterations + 1):
|
|
950
|
+
typer.echo(f"== eval through plan (iteration {iteration}) ==", err=True)
|
|
951
|
+
curated_runs = await _eval(
|
|
952
|
+
f"fix-{iteration}",
|
|
953
|
+
cache,
|
|
954
|
+
client_factory=partial(CuratedMCPClient, plan=plan),
|
|
955
|
+
)
|
|
956
|
+
curated = await score_runs(
|
|
957
|
+
task_list,
|
|
958
|
+
curated_runs,
|
|
959
|
+
mode=mode,
|
|
960
|
+
judge_config=judge_config,
|
|
961
|
+
judge_cache=cache,
|
|
962
|
+
use_judge=use_judge,
|
|
963
|
+
name_map=plan.rename_map(),
|
|
964
|
+
)
|
|
965
|
+
typer.echo(
|
|
966
|
+
f"iteration {iteration}: hit {curated.hit_rate:.0%} "
|
|
967
|
+
f"(baseline {baseline.hit_rate:.0%}), "
|
|
968
|
+
f"extra calls {curated.avg_extra_calls:.1f}/task",
|
|
969
|
+
err=True,
|
|
970
|
+
)
|
|
971
|
+
if best_summary is None or metric(curated) > metric(best_summary):
|
|
972
|
+
best_plan, best_summary = plan, curated
|
|
973
|
+
|
|
974
|
+
converged = (
|
|
975
|
+
curated.hit_rate >= baseline.hit_rate and curated.avg_extra_calls <= 0.25
|
|
976
|
+
)
|
|
977
|
+
if converged or iteration == max_iterations:
|
|
978
|
+
if converged:
|
|
979
|
+
typer.echo("converged — no regressions, negligible waste", err=True)
|
|
980
|
+
break
|
|
981
|
+
|
|
982
|
+
typer.echo("== revising plan ==", err=True)
|
|
983
|
+
plan, warnings = await propose_revision(
|
|
984
|
+
plan,
|
|
985
|
+
inventory,
|
|
986
|
+
task_list,
|
|
987
|
+
baseline_runs,
|
|
988
|
+
baseline.scores,
|
|
989
|
+
curated_runs,
|
|
990
|
+
curated.scores,
|
|
991
|
+
optimizer_config,
|
|
992
|
+
)
|
|
993
|
+
for w in warnings:
|
|
994
|
+
typer.echo(f"warning: {w}", err=True)
|
|
995
|
+
finally:
|
|
996
|
+
cache.close()
|
|
997
|
+
|
|
998
|
+
save_plan(best_plan, plan_path)
|
|
999
|
+
typer.echo(f"\nbest plan (of {max_iterations} max iterations) -> {plan_path}")
|
|
1000
|
+
typer.echo(
|
|
1001
|
+
f"Hit-rate: {baseline.hit_rate:.0%} -> {best_summary.hit_rate:.0%} "
|
|
1002
|
+
f"Extra calls: {baseline.avg_extra_calls:.1f} -> "
|
|
1003
|
+
f"{best_summary.avg_extra_calls:.1f}/task"
|
|
1004
|
+
)
|
|
1005
|
+
typer.echo(f"serve it: whetkit overlay --server <origin> --plan {plan_path}")
|
|
1006
|
+
|
|
1007
|
+
asyncio.run(_fix())
|
|
1008
|
+
|
|
1009
|
+
|
|
745
1010
|
@app.command()
|
|
746
1011
|
def report(
|
|
747
1012
|
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
|
|
@@ -31,7 +31,11 @@ Rules for every task:
|
|
|
31
31
|
is either one tool name, or a list of genuinely interchangeable
|
|
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
|
-
verify from the agent's final answer alone.
|
|
34
|
+
verify from the agent's final answer alone. When the data source is live
|
|
35
|
+
or changing (news pages, network traffic, timestamps, prices), grade the
|
|
36
|
+
SHAPE of the answer ("names the current top story", "lists the requests
|
|
37
|
+
that were observed"), never a specific volatile value — a criterion that
|
|
38
|
+
is wrong an hour later is a flaky eval, not a strict one.
|
|
35
39
|
{writes_rule}
|
|
36
40
|
- Set "ordered": true only when the steps must happen in sequence.
|
|
37
41
|
- ids are short kebab-case slugs, unique across the set.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|