toolbench 0.2.1__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 (154) hide show
  1. toolbench-0.4.0/CHANGELOG.md +93 -0
  2. {toolbench-0.2.1 → toolbench-0.4.0}/PKG-INFO +1 -1
  3. {toolbench-0.2.1 → toolbench-0.4.0}/pyproject.toml +1 -1
  4. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_codex_runtime.py +7 -1
  5. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_crash_classifier.py +40 -0
  6. toolbench-0.4.0/tests/test_export.py +100 -0
  7. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_metrics.py +12 -0
  8. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_paired_deltas.py +40 -0
  9. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_resume.py +17 -0
  10. toolbench-0.4.0/tests/test_runner_toolbase_protection.py +29 -0
  11. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_trial_loop.py +40 -7
  12. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/__init__.py +1 -1
  13. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/cli.py +195 -19
  14. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/crash_classifier.py +41 -1
  15. toolbench-0.4.0/toolbench/core/export.py +288 -0
  16. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/failure_modes.py +31 -1
  17. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/metrics.py +9 -0
  18. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/runner.py +42 -2
  19. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/runtime.py +134 -2
  20. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/summary_text.py +54 -0
  21. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/reference/commands.md +31 -0
  22. {toolbench-0.2.1 → toolbench-0.4.0}/.gitignore +0 -0
  23. {toolbench-0.2.1 → toolbench-0.4.0}/LICENSE +0 -0
  24. {toolbench-0.2.1 → toolbench-0.4.0}/README.md +0 -0
  25. {toolbench-0.2.1 → toolbench-0.4.0}/examples/calculator/requirements.txt +0 -0
  26. {toolbench-0.2.1 → toolbench-0.4.0}/examples/calculator/toolkit.yaml +0 -0
  27. {toolbench-0.2.1 → toolbench-0.4.0}/examples/calculator/tools/__init__.py +0 -0
  28. {toolbench-0.2.1 → toolbench-0.4.0}/examples/calculator/tools/basic.py +0 -0
  29. {toolbench-0.2.1 → toolbench-0.4.0}/examples/calculator/tools/scientific.py +0 -0
  30. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/benchmark.yaml +0 -0
  31. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/ground_truth/answer.json +0 -0
  32. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/harnesses/orchestral/anthropic.yaml +0 -0
  33. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/harnesses/orchestral/groq.yaml +0 -0
  34. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/harnesses/orchestral/litellm.yaml +0 -0
  35. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/harnesses/orchestral/openai.yaml +0 -0
  36. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/loadouts/additive_only.yaml +0 -0
  37. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/loadouts/all_metrics.yaml +0 -0
  38. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/loadouts/calc_toolbase.yaml +0 -0
  39. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/loadouts/core_only.yaml +0 -0
  40. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/loadouts/full_local.yaml +0 -0
  41. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/loadouts/full_mixed.yaml +0 -0
  42. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/loadouts/full_toolbase.yaml +0 -0
  43. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/loadouts/guided.yaml +0 -0
  44. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/loadouts/primitives_only.yaml +0 -0
  45. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/skills/distance_recipe.md +0 -0
  46. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/tools/chebyshev.py +0 -0
  47. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/tools/dunderkit.py +0 -0
  48. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/tools/euclid.py +0 -0
  49. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/tools/extras.py +0 -0
  50. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/tools/taxicab.py +0 -0
  51. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/derived/prompts/system.md +0 -0
  52. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/derived/prompts/user.md +0 -0
  53. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/derived/variant.yaml +0 -0
  54. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/direct/prompts/system.md +0 -0
  55. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/direct/prompts/user.md +0 -0
  56. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/direct/sandbox/template/points.json +0 -0
  57. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/direct/variant.yaml +0 -0
  58. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/polar/prompts/system.md +0 -0
  59. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/polar/prompts/user.md +0 -0
  60. {toolbench-0.2.1 → toolbench-0.4.0}/examples/geometry/variants/polar/variant.yaml +0 -0
  61. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/README.md +0 -0
  62. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/claude-code/README.md +0 -0
  63. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/claude-code/default.yaml +0 -0
  64. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/codex/README.md +0 -0
  65. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/codex/default.yaml +0 -0
  66. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/orchestral/README.md +0 -0
  67. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/orchestral/anthropic.yaml +0 -0
  68. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/orchestral/google.yaml +0 -0
  69. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/orchestral/groq.yaml +0 -0
  70. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/orchestral/litellm.yaml +0 -0
  71. {toolbench-0.2.1 → toolbench-0.4.0}/harness_templates/orchestral/openai.yaml +0 -0
  72. {toolbench-0.2.1 → toolbench-0.4.0}/mkdocs.yml +0 -0
  73. {toolbench-0.2.1 → toolbench-0.4.0}/tests/__init__.py +0 -0
  74. {toolbench-0.2.1 → toolbench-0.4.0}/tests/helpers.py +0 -0
  75. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_artifact_cleanup.py +0 -0
  76. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_artifact_policy.py +0 -0
  77. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_benchmark_extends.py +0 -0
  78. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_checks.py +0 -0
  79. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_cli_runtime_stderr_drain.py +0 -0
  80. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_content_checks.py +0 -0
  81. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_cost_extraction.py +0 -0
  82. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_geometry.py +0 -0
  83. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_integrity.py +0 -0
  84. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_json_repair.py +0 -0
  85. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_judge_score.py +0 -0
  86. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_judge_select.py +0 -0
  87. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_mcp_source.py +0 -0
  88. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_numeric_checks.py +0 -0
  89. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_rubric_loader.py +0 -0
  90. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_runner_llm_judge.py +0 -0
  91. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_runner_rate_limit.py +0 -0
  92. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_runner_request_params.py +0 -0
  93. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_runner_retry.py +0 -0
  94. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_runner_transient.py +0 -0
  95. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_runtime_registry.py +0 -0
  96. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_runtime_version_label.py +0 -0
  97. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_schema.py +0 -0
  98. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_skills.py +0 -0
  99. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_summary_text_cost.py +0 -0
  100. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_tool_error_visibility.py +0 -0
  101. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_tool_resolver.py +0 -0
  102. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_toolbase_sandbox_scoping.py +0 -0
  103. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_toolbase_select_provenance.py +0 -0
  104. {toolbench-0.2.1 → toolbench-0.4.0}/tests/test_variant.py +0 -0
  105. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/__init__.py +0 -0
  106. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/artifact_policy.py +0 -0
  107. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/benchmark.py +0 -0
  108. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/budget.py +0 -0
  109. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/checks.py +0 -0
  110. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/content_checks.py +0 -0
  111. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/harness.py +0 -0
  112. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/integrity.py +0 -0
  113. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/json_repair.py +0 -0
  114. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/judge.py +0 -0
  115. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/judge_select.py +0 -0
  116. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/litellm_pricing.py +0 -0
  117. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/llm_factory.py +0 -0
  118. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/loadout.py +0 -0
  119. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/numeric_checks.py +0 -0
  120. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/skills.py +0 -0
  121. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/store.py +0 -0
  122. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/task.py +0 -0
  123. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/tool_policy.py +0 -0
  124. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/tool_resolver.py +0 -0
  125. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/trajectory.py +0 -0
  126. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/core/variant.py +0 -0
  127. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/__init__.py +0 -0
  128. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/_output.py +0 -0
  129. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/_shared.py +0 -0
  130. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/audit_log.py +0 -0
  131. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/k_sweep.py +0 -0
  132. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/parallel_coords.py +0 -0
  133. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/per_stage_k.py +0 -0
  134. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/plot_overview.py +0 -0
  135. {toolbench-0.2.1 → toolbench-0.4.0}/toolbench/reporting/transcript.py +0 -0
  136. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/authoring/custom-tools.md +0 -0
  137. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/authoring/define-a-benchmark.md +0 -0
  138. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/authoring/overview.md +0 -0
  139. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/authoring/rubrics-and-checks.md +0 -0
  140. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/explanation.md +0 -0
  141. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/guides/harnesses.md +0 -0
  142. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/guides/install-and-quickstart.md +0 -0
  143. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/guides/loadouts.md +0 -0
  144. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/guides/reading-results.md +0 -0
  145. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/guides/running-a-benchmark.md +0 -0
  146. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/guides/toolbase.md +0 -0
  147. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/guides/variants.md +0 -0
  148. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/index.md +0 -0
  149. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/reference/files-and-layout.md +0 -0
  150. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/reference/metrics.md +0 -0
  151. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/reference/schemas.md +0 -0
  152. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/reference/vocabulary.md +0 -0
  153. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/stylesheets/extra.css +0 -0
  154. {toolbench-0.2.1 → toolbench-0.4.0}/userdocs/troubleshooting.md +0 -0
@@ -0,0 +1,93 @@
1
+ # Changelog
2
+
3
+ All notable changes to `toolbench` will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ---
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.4.0] — 2026-07-28
12
+
13
+ ### Added
14
+
15
+ - **`toolbench export`** — turn a completed run into something you can share.
16
+ A run directory is a working artifact: gigabytes of transcripts and
17
+ intermediate data carrying absolute paths from the machine that produced it,
18
+ which is not a thing you can attach to a paper or render a results page from.
19
+ `export` writes two layers instead:
20
+
21
+ - `trials.jsonl` — one flat, denormalized, **schema-versioned** row per trial
22
+ (cell coordinates, score, pass/fail against the rubric's own threshold,
23
+ per-stage credits/weights/metrics, telemetry, provenance). Kilobytes.
24
+ Denormalized on purpose so a consumer needs no join logic and no knowledge
25
+ of toolbench's internal layout.
26
+ - `bundle/` — the graded evidence behind those rows: per-trial answer files,
27
+ audit logs, run summaries, manifest. Megabytes.
28
+
29
+ Transcripts are excluded by default (`--include-transcripts` opts in): they
30
+ dominate the size and, being binary, are copied verbatim rather than scrubbed.
31
+ Machine-specific absolute paths are rewritten to `${HOME}` / `${RUN}`
32
+ placeholders unless `--no-scrub` is passed. `--archive` also writes a
33
+ `.tar.gz`. On a representative 5-trial run this is 2.7 GB -> 13 MB (1.8 MB
34
+ compressed).
35
+
36
+ `schema_version` is the compatibility contract: additive changes bump the
37
+ minor, anything that moves or retypes an existing field bumps the major.
38
+
39
+ ## [0.3.0] — 2026-07-24
40
+
41
+ ### Added
42
+
43
+ - **MCP preflight verification.** Before any trial runs (and in `--dry-run`), a
44
+ run now verifies that every MCP-serving harness × tools-loadout can actually
45
+ reach its tools: it starts the loadout's `toolbase serve --profile …` exactly
46
+ as the CLI runtime would and completes an MCP `initialize` + `tools/list`
47
+ handshake. If the profile serves zero tools, the handshake times out, or the
48
+ resolved tools are missing, the run **aborts with exit 2 before executing a
49
+ single trial** and prints which cell failed. Previously a `tools` loadout that
50
+ could not reach its tools ran the entire arm silently tool-less and still
51
+ graded as a valid tools result. Verification is gated to the runtimes that
52
+ serve toolbase over MCP (`claude_code`, `codex`) via
53
+ `runtime_serves_toolbase_mcp`; in-process runtimes (orchestral) resolve tools
54
+ directly and are unaffected. New helpers: `verify_toolbase_mcp`,
55
+ `runtime_serves_toolbase_mcp`.
56
+
57
+ ### Fixed
58
+
59
+ - **`_toolbase_command()` now fails loudly.** When the `toolbase` executable
60
+ cannot be located (neither on `PATH` nor beside the running Python), it raises
61
+ with an actionable message instead of returning a bare `"toolbase"`. The bare
62
+ fallback silently produced a broken `.mcp.json` whose stdio server failed to
63
+ launch (`command not found` on the child's `PATH`), leaving the agent with
64
+ "No such tool available" while the trial still graded as if tools were
65
+ present — a failure mode that surfaced when an env's `toolbase` install was
66
+ in flux. Combined with the preflight above, an unresolvable toolbase is now
67
+ caught before the benchmark begins.
68
+
69
+ ## [0.2.1] — 2026-07-24
70
+
71
+ Runtime and metrics hardening surfaced by the first publication benchmarking campaign.
72
+
73
+ ### Added
74
+
75
+ - **`SESSION_LIMIT` failure mode.** A subscription coding-agent CLI (`claude-code` / `codex`) that refuses a request because the logged-in account hit its plan's session/usage quota is no longer misfiled as an `AGENT_CRASH` scored 0. It is classified as a distinct `SESSION_LIMIT` mode (detected from the CLI limit text, checked before `RATE_LIMITED` so the API's `insufficient_quota` still maps to `RATE_LIMITED`), dropped from the scored population (reach / pass@k / stage funnel / paired deltas) via `EXCLUDED_FROM_METRICS`, and re-run on `resume`. The runner aborts the remaining queue on the first one — every later trial would fail identically until the quota resets — and reports the abort reason. Excluded rows stay on disk and surface as per-cell and run-level excluded counts. ([#23](https://github.com/tonymenzo/toolbench/pull/23))
76
+ - `gpt-5.6-sol` subscription API-equivalent pricing entry for cost estimation.
77
+
78
+ ### Changed
79
+
80
+ - **Benchmark agents can no longer read Toolbase toolkit sources off disk.** A trial's `protected_paths` now also deny the agent read access to editable toolkit checkouts (`source_path` in each cached toolkit's `.install_meta.yaml`) and toolkit runtime data directories (`base_directory` in toolkit configs). Without this, an agent in a `core_only` control arm could bypass the MCP interface and import the real tool implementations straight off the filesystem, contaminating the control. MCP server children are not confined by the agent's shell filesystem profile, so declared Toolbase loadouts still execute normally.
81
+
82
+ ### Fixed
83
+
84
+ - **`claude-code` runtime ignored the `--models` sweep** — every cell ran the harness provider's default model instead of the requested one, so a model axis silently collapsed to a single model. The runtime now honors the swept model.
85
+ - **Toolbase MCP command resolution.** Child MCP processes launched a bare `toolbase` command, which failed when Toolbench was started with an absolute virtualenv Python whose `bin/` was not on `PATH`. The command is now resolved to an absolute path (`PATH` → the executable beside the running Python → bare command as a last-resort fallback so the CLI can report a normal MCP startup error).
86
+
87
+ ## [0.2.0] — 2026-06-05
88
+
89
+ - Continuous / non-gating scoring, `codex` and `claude-code` subscription runtimes, selectable LLM judge, trajectory integrity auditor, subscription cost estimation, and parallel-run hardening.
90
+
91
+ ---
92
+
93
+ Versions before 0.2.0 predate this changelog; see the git history and release commit subjects for their contents.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toolbench
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: A platform and CLI for building benchmarks for agentic tools and harnesses.
5
5
  Project-URL: Homepage, https://github.com/tonymenzo/toolbench
6
6
  Project-URL: Repository, https://github.com/tonymenzo/toolbench
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "toolbench"
7
- version = "0.2.1"
7
+ version = "0.4.0"
8
8
  description = "A platform and CLI for building benchmarks for agentic tools and harnesses."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from toolbench.core.harness import Harness
6
6
  from toolbench.core.llm_factory import SubscriptionLLM
7
- from toolbench.core.runtime import CodexAgent, _codex_factory
7
+ from toolbench.core.runtime import CodexAgent, _codex_factory, _toolbase_command
8
8
 
9
9
 
10
10
  def _agent(tmp_path: Path, **kw) -> CodexAgent:
@@ -29,6 +29,12 @@ def test_toolbase_mcp_is_auto_approved_for_noninteractive_exec(tmp_path):
29
29
  assert any("default_tools_approval_mode=\"approve\"" in x for x in args)
30
30
 
31
31
 
32
+ def test_toolbase_mcp_command_resolves_to_an_executable():
33
+ command = Path(_toolbase_command())
34
+ assert command.is_absolute()
35
+ assert command.is_file()
36
+
37
+
32
38
  def test_resume_uses_only_resume_supported_flags(tmp_path):
33
39
  a = _agent(tmp_path)
34
40
  a.thread_id = "thread-123"
@@ -9,6 +9,7 @@ sys.path.insert(0, str(REPO_ROOT))
9
9
  from toolbench.core.crash_classifier import classify_crash # noqa: E402
10
10
  from toolbench.core.failure_modes import ( # noqa: E402
11
11
  AGENT_CRASH, CONTEXT_LENGTH_EXCEEDED, MODEL_FORMAT_CRASH, RATE_LIMITED,
12
+ SESSION_LIMIT,
12
13
  )
13
14
 
14
15
 
@@ -155,6 +156,45 @@ class TestRateLimitCrash(unittest.TestCase):
155
156
  self.assertEqual(mode, MODEL_FORMAT_CRASH)
156
157
 
157
158
 
159
+ class TestSessionLimitCrash(unittest.TestCase):
160
+ """A subscription coding-agent CLI hitting the account's plan quota is a
161
+ SESSION_LIMIT — distinct from a 429 throttle and never a capability
162
+ failure. Checked before RATE_LIMITED so it can't be misfiled as one."""
163
+
164
+ def test_claude_code_session_limit_message(self):
165
+ # Verbatim shape of the claude-code CLI's is_error limit response.
166
+ exc = RuntimeError(
167
+ "claude_code runtime: claude reported is_error (success): "
168
+ "You've hit your session limit · resets 4:20am (America/Chicago)")
169
+ mode, reason = classify_crash(exc, "")
170
+ self.assertEqual(mode, SESSION_LIMIT)
171
+ self.assertIn("limit", reason.lower())
172
+
173
+ def test_usage_limit_message(self):
174
+ exc = RuntimeError("You've reached your usage limit for this plan")
175
+ mode, _ = classify_crash(exc, "")
176
+ self.assertEqual(mode, SESSION_LIMIT)
177
+
178
+ def test_session_limit_wins_over_rate_limit(self):
179
+ # A message mentioning both must classify as SESSION_LIMIT (checked
180
+ # first), not RATE_LIMITED — a quota reset is not a transient throttle.
181
+ exc = RuntimeError("session limit reached (rate limit 429)")
182
+ mode, _ = classify_crash(exc, "")
183
+ self.assertEqual(mode, SESSION_LIMIT)
184
+
185
+ def test_api_insufficient_quota_stays_rate_limited(self):
186
+ # The API's billing-quota error must NOT be swept into SESSION_LIMIT;
187
+ # it remains a RATE_LIMITED (retried/backed-off) outcome.
188
+ exc = RuntimeError("You exceeded your current quota: insufficient_quota")
189
+ mode, _ = classify_crash(exc, "")
190
+ self.assertEqual(mode, RATE_LIMITED)
191
+
192
+ def test_unrelated_crash_is_not_session_limit(self):
193
+ exc = RuntimeError("ValueError: could not parse geometry.yaml")
194
+ mode, _ = classify_crash(exc, "")
195
+ self.assertEqual(mode, AGENT_CRASH)
196
+
197
+
158
198
  class TestProviderRejectedToolCall(unittest.TestCase):
159
199
  """Groq validates tool-call arguments server-side.
160
200
 
@@ -0,0 +1,100 @@
1
+ """Export contract tests.
2
+
3
+ The export is what leaves the machine, so the two properties worth pinning are
4
+ (1) the flat schema stays stable and self-describing, and (2) scrubbing actually
5
+ removes machine-specific paths — a silent regression there publishes someone's
6
+ home directory.
7
+ """
8
+ import gzip
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from toolbench.core.export import SCHEMA_VERSION, export_run
15
+
16
+
17
+ def _make_run(tmp_path: Path, home_marker: str) -> Path:
18
+ run = tmp_path / "runs" / "2026-01-01T00-00-00_demo_stub_run"
19
+ (run / "trials" / "core_only__n000__seed1001" / "artifacts" / "results").mkdir(
20
+ parents=True)
21
+ trial_dir = run / "trials" / "core_only__n000__seed1001"
22
+
23
+ (run / "trials.jsonl").write_text(json.dumps({
24
+ "trial_id": "core_only__n000__seed1001",
25
+ "model": "stub", "harness": "h/default", "loadout": "core_only",
26
+ "variant": "v", "seed": 1001, "ok": True, "score": 0.95,
27
+ "stages": {"a": True}, "stage_credits": {"a": 1.0},
28
+ "wall_clock_s": 12.5, "cost_usd": 0.25, "output_tokens": 100,
29
+ "tool_calls": 3, "tool_errors": 0, "failure_mode": None,
30
+ }) + "\n")
31
+ (run / "manifest.json").write_text(json.dumps({
32
+ "run_id": run.name, "task": "demo", "versions": {"toolbench": "0.3.0"},
33
+ }))
34
+ (run / "summary.json").write_text(json.dumps({
35
+ "reach_weights": {"pass_threshold": 0.9},
36
+ }))
37
+ (run / "summary.txt").write_text(f"summary referencing {home_marker}/secret/path\n")
38
+ (trial_dir / "trial.json").write_text(json.dumps({
39
+ "grade": {"stage_grades": [
40
+ {"id": "a", "weight": 1.0, "credit": 1.0, "evidence": "ok",
41
+ "metrics": {"check_a": {"closeness": 0.5, "distance": 1.25,
42
+ "per_item": {"x": 1}}}},
43
+ ]}
44
+ }))
45
+ (trial_dir / "audit.txt").write_text(f"ran in {home_marker}/work/sandbox\n")
46
+ (trial_dir / "artifacts" / "results" / "answer.json").write_text('{"v": 1}')
47
+ with gzip.open(trial_dir / "transcript.jsonl.gz", "wt") as fh:
48
+ fh.write('{"big": "transcript"}\n')
49
+ return run
50
+
51
+
52
+ def test_flat_schema_and_scrub(tmp_path):
53
+ home = str(Path.home())
54
+ run = _make_run(tmp_path, home)
55
+ out = tmp_path / "export"
56
+ rep = export_run(run, out, scrub=True)
57
+
58
+ assert rep["n_trials"] == 1
59
+ rows = [json.loads(l) for l in (out / "trials.jsonl").read_text().splitlines()]
60
+ r = rows[0]
61
+
62
+ # (1) schema is self-describing and carries the cell coordinates flat
63
+ assert r["schema_version"] == SCHEMA_VERSION
64
+ for key in ("run_id", "trial_id", "harness", "loadout", "variant", "model",
65
+ "seed", "score", "pass_threshold", "passed", "stage_credits",
66
+ "stage_weights", "stage_metrics", "tokens", "provenance"):
67
+ assert key in r, f"missing {key}"
68
+
69
+ # pass/fail is resolved from the rubric threshold, not hardcoded
70
+ assert r["pass_threshold"] == 0.9
71
+ assert r["passed"] is True
72
+
73
+ # metrics are flattened out of their per-check nesting; bulk dicts dropped
74
+ assert r["stage_metrics"]["a"]["closeness"] == 0.5
75
+ assert "per_item" not in r["stage_metrics"]["a"]
76
+
77
+ # (2) no home path survives anywhere in the text output
78
+ for p in list(out.rglob("*")):
79
+ if p.is_file() and p.suffix != ".gz":
80
+ assert home not in p.read_text(), f"unscrubbed home path in {p}"
81
+
82
+ # transcripts excluded by default
83
+ assert not list((out / "bundle").rglob("transcript.jsonl.gz"))
84
+
85
+
86
+ def test_transcripts_opt_in_and_no_scrub_flag(tmp_path):
87
+ home = str(Path.home())
88
+ run = _make_run(tmp_path, home)
89
+ out = tmp_path / "export2"
90
+ export_run(run, out, scrub=False, include_transcripts=True)
91
+ assert list((out / "bundle").rglob("transcript.jsonl.gz"))
92
+ # with scrubbing off the raw path is preserved (so the flag is meaningful)
93
+ assert home in (out / "bundle" / "summary.txt").read_text()
94
+
95
+
96
+ def test_rejects_incomplete_run(tmp_path):
97
+ empty = tmp_path / "runs" / "not_a_run"
98
+ empty.mkdir(parents=True)
99
+ with pytest.raises(FileNotFoundError):
100
+ export_run(empty, tmp_path / "out")
@@ -242,6 +242,18 @@ class TestCostUsd(unittest.TestCase):
242
242
  self.assertAlmostEqual(estimate["usd"], 56.0)
243
243
  self.assertTrue(estimate["long_context_pricing_applied"])
244
244
 
245
+ def test_gpt56_sol_subscription_api_equivalent(self):
246
+ estimate = subscription_api_equivalent_cost(
247
+ "gpt-5.6-sol",
248
+ input_tokens=1_000_000,
249
+ cache_read_tokens=1_000_000,
250
+ output_tokens=1_000_000,
251
+ initial_input_tokens=272_001,
252
+ )
253
+ self.assertAlmostEqual(estimate["usd"], 56.0)
254
+ self.assertTrue(estimate["long_context_pricing_applied"])
255
+ self.assertIn("gpt-5.6-sol", estimate["source"])
256
+
245
257
  def test_unknown_subscription_model_is_not_guessed(self):
246
258
  self.assertIsNone(subscription_api_equivalent_cost("future-model"))
247
259
 
@@ -78,5 +78,45 @@ class TestPairedDeltas(unittest.TestCase):
78
78
  self.assertAlmostEqual(summary["paired_deltas"][0]["reach_delta"], 0.45)
79
79
 
80
80
 
81
+ class TestExcludedFromMetrics(unittest.TestCase):
82
+ """SESSION_LIMIT trials are dropped from the scored population (n / reach /
83
+ pass / paired deltas) but surfaced as an explicit excluded count, so a
84
+ subscription-quota termination never pollutes the metrics as a score-0."""
85
+
86
+ def _rows(self):
87
+ from toolbench.core.failure_modes import SESSION_LIMIT
88
+ rows = []
89
+ # cell (m, A): 2 genuine passes (reach 1.0) + 2 quota terminations.
90
+ for seed in (1, 2):
91
+ rows.append(_row("m", "A", seed, 1, 1))
92
+ for seed in (3, 4):
93
+ rows.append(_row("m", "A", seed, 0, 0,
94
+ failure_mode=SESSION_LIMIT, stages={}, score=0.0))
95
+ # cell (m, B): 2 genuine trials sharing seeds 1,2 with A.
96
+ for seed in (1, 2):
97
+ rows.append(_row("m", "B", seed, 1, 0))
98
+ return rows
99
+
100
+ def test_excluded_from_scored_population(self):
101
+ summary = aggregate(self._rows(), k=4, stage_order=STAGE_ORDER,
102
+ stage_weights=WEIGHTS)
103
+ self.assertEqual(summary["n_total_trials"], 4) # 2 A + 2 B scored
104
+ self.assertEqual(summary["n_excluded_trials"], 2) # 2 quota rows
105
+ cell_a = next(c for c in summary["cells"] if c["condition"] == "A")
106
+ self.assertEqual(cell_a["n"], 2)
107
+ self.assertEqual(cell_a["n_excluded"], 2)
108
+ # Reach reflects ONLY the 2 genuine passes, not diluted to 0.5 by the
109
+ # 2 excluded score-0 quota rows.
110
+ self.assertAlmostEqual(cell_a["reach_bar_k"], 1.0)
111
+
112
+ def test_excluded_not_in_paired_deltas(self):
113
+ # The A−B delta pairs only scored seeds (1, 2); A's excluded quota
114
+ # rows on seeds 3, 4 must not create phantom pairs or shift the delta.
115
+ summary = aggregate(self._rows(), k=4, stage_order=STAGE_ORDER,
116
+ stage_weights=WEIGHTS)
117
+ self.assertEqual(len(summary["paired_deltas"]), 1)
118
+ self.assertEqual(summary["paired_deltas"][0]["n_pairs"], 2)
119
+
120
+
81
121
  if __name__ == "__main__":
82
122
  unittest.main()
@@ -28,6 +28,23 @@ def test_partition_splits_resolution_errors():
28
28
  assert [r["trial_id"] for r in retryable] == ["b"]
29
29
 
30
30
 
31
+ def test_partition_retries_session_limit():
32
+ # A subscription session/usage-quota termination took no real
33
+ # measurement, so a resume must re-run it (like resolution_error) rather
34
+ # than freezing its score-0 record. A real AGENT_CRASH stays kept.
35
+ from toolbench.core.failure_modes import SESSION_LIMIT
36
+ rows = [
37
+ {"trial_id": "a", "failure_mode": "NONE"},
38
+ {"trial_id": "b", "failure_mode": SESSION_LIMIT},
39
+ {"trial_id": "c", "failure_mode": "resolution_error"},
40
+ {"trial_id": "d", "failure_mode": "AGENT_CRASH"},
41
+ {"trial_id": "e", "failure_mode": "MODEL_STOPPED_EARLY"},
42
+ ]
43
+ kept, retryable = _partition_resume_rows(rows)
44
+ assert [r["trial_id"] for r in kept] == ["a", "d", "e"]
45
+ assert sorted(r["trial_id"] for r in retryable) == ["b", "c"]
46
+
47
+
31
48
  def test_budget_precharge_counts_toward_cap():
32
49
  b = Budget(10.0)
33
50
  b.precharge(9.5)
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+
3
+ from toolbench.core.runner import _toolbase_protected_paths
4
+
5
+
6
+ def test_toolbase_and_editable_sources_are_protected(tmp_path, monkeypatch):
7
+ toolbase_home = tmp_path / ".toolbase"
8
+ slot = toolbase_home / "cache" / "physics-kit" / "editable"
9
+ slot.mkdir(parents=True)
10
+ source = tmp_path / "physics-kit-source"
11
+ source.mkdir()
12
+ runtime_data = tmp_path / "physics-kit-runtime"
13
+ runtime_data.mkdir()
14
+ (slot / ".install_meta.yaml").write_text(
15
+ "editable: true\n"
16
+ f"source_path: {source}\n"
17
+ )
18
+ monkeypatch.setenv("TOOLBASE_HOME", str(toolbase_home))
19
+ config_dir = toolbase_home / "config"
20
+ config_dir.mkdir()
21
+ (config_dir / "physics-kit.yaml").write_text(
22
+ f"base_directory: {runtime_data}\n"
23
+ )
24
+
25
+ paths = _toolbase_protected_paths()
26
+
27
+ assert str(toolbase_home.resolve()) in paths
28
+ assert str(source.resolve()) in paths
29
+ assert str(runtime_data.resolve()) in paths
@@ -49,10 +49,13 @@ class _FakeRunner:
49
49
  """Stands in for TrialRunner: records execution, optionally charges
50
50
  the budget per trial so abort behavior can be exercised."""
51
51
 
52
- def __init__(self, cost_per_trial=0.0, barrier=None):
52
+ def __init__(self, cost_per_trial=0.0, barrier=None, fail_mode=None):
53
53
  self.cost = cost_per_trial
54
54
  self.ran = []
55
55
  self.barrier = barrier
56
+ # When set, every trial returns this failure_mode with score 0 (used to
57
+ # exercise the session-limit abort path).
58
+ self.fail_mode = fail_mode
56
59
  self._lock = threading.Lock()
57
60
 
58
61
  def run_trial(self, *, model_cfg, benchmark, harness, loadout, variant,
@@ -75,6 +78,14 @@ class _FakeRunner:
75
78
  aborted = True
76
79
  from toolbench.core.task import Grade
77
80
  from toolbench.core.trajectory import Trajectory
81
+ if self.fail_mode is not None:
82
+ grade = Grade(score=0.0, stages={}, stage_grades=[],
83
+ failure_mode=self.fail_mode, judge_kind="rule")
84
+ return SimpleNamespace(
85
+ trial_id=trial_id, ok=False, score=0.0, grade=grade,
86
+ trajectory=Trajectory(), wall_clock_s=0.0, cost_usd=self.cost,
87
+ aborted_by_budget=aborted, error=None, attempts=1, nudges=0,
88
+ rate_limit_retries=0, transient_retries=0)
78
89
  grade = Grade(score=1.0, stages={"s0": True}, stage_grades=[],
79
90
  failure_mode="NONE", judge_kind="rule")
80
91
  return SimpleNamespace(
@@ -98,17 +109,19 @@ class TestRunTrialLoop(unittest.TestCase):
98
109
 
99
110
  def test_serial_runs_everything(self):
100
111
  runner = _FakeRunner()
101
- records, aborted = self._loop(runner, Budget(None))
112
+ records, aborted, reason = self._loop(runner, Budget(None))
102
113
  self.assertEqual(len(records), 6)
103
114
  self.assertFalse(aborted)
115
+ self.assertIsNone(reason)
104
116
 
105
117
  def test_budget_abort_is_balanced_across_cells(self):
106
118
  # $1/trial, $3.5 cap → abort fires on trial 4 (seed-major order:
107
119
  # A1 B1 A2 B2 ...), leaving each loadout with the SAME number of
108
120
  # completed trials instead of one loadout hogging the budget.
109
121
  runner = _FakeRunner(cost_per_trial=1.0)
110
- records, aborted = self._loop(runner, Budget(3.5))
122
+ records, aborted, reason = self._loop(runner, Budget(3.5))
111
123
  self.assertTrue(aborted)
124
+ self.assertEqual(reason, "budget")
112
125
  per_cell = {}
113
126
  for r in records:
114
127
  if not r["aborted_by_budget"]:
@@ -119,22 +132,42 @@ class TestRunTrialLoop(unittest.TestCase):
119
132
  def test_parallel_runs_everything_and_is_concurrent(self):
120
133
  barrier = threading.Barrier(2)
121
134
  runner = _FakeRunner(barrier=barrier)
122
- records, aborted = self._loop(runner, Budget(None), parallel=2,
123
- n_loadouts=2, seeds=(1, 2, 3))
135
+ records, aborted, reason = self._loop(runner, Budget(None), parallel=2,
136
+ n_loadouts=2, seeds=(1, 2, 3))
124
137
  # The barrier requires 2 trials in flight simultaneously; if the
125
138
  # loop were serial this would deadlock (and time out the wait).
126
139
  self.assertEqual(len(records), 6)
127
140
  self.assertFalse(aborted)
141
+ self.assertIsNone(reason)
128
142
  self.assertEqual(len(runner.ran), 6)
129
143
 
130
144
  def test_parallel_budget_abort_stops_launching(self):
131
145
  runner = _FakeRunner(cost_per_trial=1.0)
132
- records, aborted = self._loop(runner, Budget(2.5), parallel=2,
133
- seeds=(1, 2, 3, 4, 5))
146
+ records, aborted, reason = self._loop(runner, Budget(2.5), parallel=2,
147
+ seeds=(1, 2, 3, 4, 5))
134
148
  self.assertTrue(aborted)
149
+ self.assertEqual(reason, "budget")
135
150
  # 10 trials enumerated; the abort must prevent most of the tail.
136
151
  self.assertLess(len(runner.ran), 10)
137
152
 
153
+ def test_session_limit_aborts_and_stops_launching(self):
154
+ # A subscription session/usage-quota termination must halt the queue
155
+ # (every remaining trial would fail identically until the quota
156
+ # resets) and report the reason as "session_limit", not "budget".
157
+ # cost_per_trial slows each trial (as in the budget tests) so the
158
+ # abort demonstrably beats the queue draining; Budget(None) keeps the
159
+ # abort attributable to the session limit, not the budget.
160
+ runner = _FakeRunner(cost_per_trial=1.0, fail_mode="SESSION_LIMIT")
161
+ records, aborted, reason = self._loop(runner, Budget(None), parallel=2,
162
+ seeds=(1, 2, 3, 4, 5))
163
+ self.assertTrue(aborted)
164
+ self.assertEqual(reason, "session_limit")
165
+ # 10 trials enumerated; the abort must prevent most of the tail from
166
+ # ever launching (only the already-in-flight one(s) get recorded).
167
+ self.assertLess(len(runner.ran), 10)
168
+ self.assertTrue(all(r["failure_mode"] == "SESSION_LIMIT"
169
+ for r in records))
170
+
138
171
 
139
172
  if __name__ == "__main__":
140
173
  unittest.main()
@@ -2,7 +2,7 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
- __version__ = "0.2.1"
5
+ __version__ = "0.4.0"
6
6
 
7
7
  # Repository root (the directory that contains this `toolbench/` package in an
8
8
  # editable install). The CLI reads `REPO_ROOT/.env` for provider keys + tool