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.
Files changed (38) hide show
  1. {whetkit-0.3.0 → whetkit-0.5.0}/PKG-INFO +1 -1
  2. {whetkit-0.3.0 → whetkit-0.5.0}/pyproject.toml +1 -1
  3. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/cli.py +271 -6
  4. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/optimizer.py +129 -0
  5. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/generate.py +5 -1
  6. {whetkit-0.3.0 → whetkit-0.5.0}/LICENSE +0 -0
  7. {whetkit-0.3.0 → whetkit-0.5.0}/README.md +0 -0
  8. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/__init__.py +0 -0
  9. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/__init__.py +0 -0
  10. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/export.py +0 -0
  11. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/overlay.py +0 -0
  12. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/curation/plan.py +0 -0
  13. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/datasets/__init__.py +0 -0
  14. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/datasets/tasks.py +0 -0
  15. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/doctor.py +0 -0
  16. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/__init__.py +0 -0
  17. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/anthropic_provider.py +0 -0
  18. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/base.py +0 -0
  19. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/openai_provider.py +0 -0
  20. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/pricing.py +0 -0
  21. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/llm/registry.py +0 -0
  22. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/mcp/__init__.py +0 -0
  23. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/mcp/client.py +0 -0
  24. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/mcp/introspect.py +0 -0
  25. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/mcp/transport.py +0 -0
  26. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/py.typed +0 -0
  27. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/report/__init__.py +0 -0
  28. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/report/builder.py +0 -0
  29. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/report/html.py +0 -0
  30. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/runner/__init__.py +0 -0
  31. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/runner/agent.py +0 -0
  32. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/scoring/__init__.py +0 -0
  33. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/scoring/aggregate.py +0 -0
  34. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/scoring/deterministic.py +0 -0
  35. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/scoring/judge.py +0 -0
  36. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/tracing/__init__.py +0 -0
  37. {whetkit-0.3.0 → whetkit-0.5.0}/src/whetkit/tracing/records.py +0 -0
  38. {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.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "whetkit"
3
- version = "0.3.0"
3
+ version = "0.5.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,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
- task_runs = []
471
- for task in task_list:
472
- typer.echo(f"running {task.id} ...", err=True)
473
- task_runs.append(
474
- await run_task(task, servers[task.server], config, client_factory=client_factory)
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