toolbench 0.2.0__tar.gz → 0.3.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 (152) hide show
  1. toolbench-0.3.0/CHANGELOG.md +42 -0
  2. {toolbench-0.2.0 → toolbench-0.3.0}/PKG-INFO +1 -1
  3. {toolbench-0.2.0 → toolbench-0.3.0}/pyproject.toml +1 -1
  4. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_codex_runtime.py +7 -1
  5. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_metrics.py +12 -0
  6. toolbench-0.3.0/tests/test_runner_toolbase_protection.py +29 -0
  7. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/__init__.py +1 -1
  8. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/cli.py +46 -0
  9. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/metrics.py +9 -0
  10. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/runner.py +42 -2
  11. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/runtime.py +147 -8
  12. {toolbench-0.2.0 → toolbench-0.3.0}/.gitignore +0 -0
  13. {toolbench-0.2.0 → toolbench-0.3.0}/LICENSE +0 -0
  14. {toolbench-0.2.0 → toolbench-0.3.0}/README.md +0 -0
  15. {toolbench-0.2.0 → toolbench-0.3.0}/examples/calculator/requirements.txt +0 -0
  16. {toolbench-0.2.0 → toolbench-0.3.0}/examples/calculator/toolkit.yaml +0 -0
  17. {toolbench-0.2.0 → toolbench-0.3.0}/examples/calculator/tools/__init__.py +0 -0
  18. {toolbench-0.2.0 → toolbench-0.3.0}/examples/calculator/tools/basic.py +0 -0
  19. {toolbench-0.2.0 → toolbench-0.3.0}/examples/calculator/tools/scientific.py +0 -0
  20. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/benchmark.yaml +0 -0
  21. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/ground_truth/answer.json +0 -0
  22. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/harnesses/orchestral/anthropic.yaml +0 -0
  23. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/harnesses/orchestral/groq.yaml +0 -0
  24. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/harnesses/orchestral/litellm.yaml +0 -0
  25. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/harnesses/orchestral/openai.yaml +0 -0
  26. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/loadouts/additive_only.yaml +0 -0
  27. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/loadouts/all_metrics.yaml +0 -0
  28. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/loadouts/calc_toolbase.yaml +0 -0
  29. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/loadouts/core_only.yaml +0 -0
  30. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/loadouts/full_local.yaml +0 -0
  31. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/loadouts/full_mixed.yaml +0 -0
  32. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/loadouts/full_toolbase.yaml +0 -0
  33. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/loadouts/guided.yaml +0 -0
  34. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/loadouts/primitives_only.yaml +0 -0
  35. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/skills/distance_recipe.md +0 -0
  36. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/tools/chebyshev.py +0 -0
  37. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/tools/dunderkit.py +0 -0
  38. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/tools/euclid.py +0 -0
  39. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/tools/extras.py +0 -0
  40. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/tools/taxicab.py +0 -0
  41. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/derived/prompts/system.md +0 -0
  42. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/derived/prompts/user.md +0 -0
  43. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/derived/variant.yaml +0 -0
  44. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/direct/prompts/system.md +0 -0
  45. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/direct/prompts/user.md +0 -0
  46. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/direct/sandbox/template/points.json +0 -0
  47. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/direct/variant.yaml +0 -0
  48. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/polar/prompts/system.md +0 -0
  49. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/polar/prompts/user.md +0 -0
  50. {toolbench-0.2.0 → toolbench-0.3.0}/examples/geometry/variants/polar/variant.yaml +0 -0
  51. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/README.md +0 -0
  52. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/claude-code/README.md +0 -0
  53. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/claude-code/default.yaml +0 -0
  54. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/codex/README.md +0 -0
  55. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/codex/default.yaml +0 -0
  56. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/orchestral/README.md +0 -0
  57. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/orchestral/anthropic.yaml +0 -0
  58. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/orchestral/google.yaml +0 -0
  59. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/orchestral/groq.yaml +0 -0
  60. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/orchestral/litellm.yaml +0 -0
  61. {toolbench-0.2.0 → toolbench-0.3.0}/harness_templates/orchestral/openai.yaml +0 -0
  62. {toolbench-0.2.0 → toolbench-0.3.0}/mkdocs.yml +0 -0
  63. {toolbench-0.2.0 → toolbench-0.3.0}/tests/__init__.py +0 -0
  64. {toolbench-0.2.0 → toolbench-0.3.0}/tests/helpers.py +0 -0
  65. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_artifact_cleanup.py +0 -0
  66. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_artifact_policy.py +0 -0
  67. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_benchmark_extends.py +0 -0
  68. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_checks.py +0 -0
  69. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_cli_runtime_stderr_drain.py +0 -0
  70. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_content_checks.py +0 -0
  71. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_cost_extraction.py +0 -0
  72. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_crash_classifier.py +0 -0
  73. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_geometry.py +0 -0
  74. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_integrity.py +0 -0
  75. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_json_repair.py +0 -0
  76. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_judge_score.py +0 -0
  77. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_judge_select.py +0 -0
  78. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_mcp_source.py +0 -0
  79. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_numeric_checks.py +0 -0
  80. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_paired_deltas.py +0 -0
  81. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_resume.py +0 -0
  82. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_rubric_loader.py +0 -0
  83. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_runner_llm_judge.py +0 -0
  84. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_runner_rate_limit.py +0 -0
  85. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_runner_request_params.py +0 -0
  86. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_runner_retry.py +0 -0
  87. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_runner_transient.py +0 -0
  88. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_runtime_registry.py +0 -0
  89. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_runtime_version_label.py +0 -0
  90. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_schema.py +0 -0
  91. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_skills.py +0 -0
  92. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_summary_text_cost.py +0 -0
  93. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_tool_error_visibility.py +0 -0
  94. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_tool_resolver.py +0 -0
  95. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_toolbase_sandbox_scoping.py +0 -0
  96. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_toolbase_select_provenance.py +0 -0
  97. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_trial_loop.py +0 -0
  98. {toolbench-0.2.0 → toolbench-0.3.0}/tests/test_variant.py +0 -0
  99. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/__init__.py +0 -0
  100. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/artifact_policy.py +0 -0
  101. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/benchmark.py +0 -0
  102. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/budget.py +0 -0
  103. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/checks.py +0 -0
  104. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/content_checks.py +0 -0
  105. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/crash_classifier.py +0 -0
  106. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/failure_modes.py +0 -0
  107. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/harness.py +0 -0
  108. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/integrity.py +0 -0
  109. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/json_repair.py +0 -0
  110. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/judge.py +0 -0
  111. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/judge_select.py +0 -0
  112. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/litellm_pricing.py +0 -0
  113. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/llm_factory.py +0 -0
  114. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/loadout.py +0 -0
  115. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/numeric_checks.py +0 -0
  116. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/skills.py +0 -0
  117. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/store.py +0 -0
  118. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/task.py +0 -0
  119. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/tool_policy.py +0 -0
  120. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/tool_resolver.py +0 -0
  121. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/trajectory.py +0 -0
  122. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/core/variant.py +0 -0
  123. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/__init__.py +0 -0
  124. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/_output.py +0 -0
  125. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/_shared.py +0 -0
  126. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/audit_log.py +0 -0
  127. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/k_sweep.py +0 -0
  128. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/parallel_coords.py +0 -0
  129. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/per_stage_k.py +0 -0
  130. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/plot_overview.py +0 -0
  131. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/summary_text.py +0 -0
  132. {toolbench-0.2.0 → toolbench-0.3.0}/toolbench/reporting/transcript.py +0 -0
  133. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/authoring/custom-tools.md +0 -0
  134. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/authoring/define-a-benchmark.md +0 -0
  135. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/authoring/overview.md +0 -0
  136. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/authoring/rubrics-and-checks.md +0 -0
  137. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/explanation.md +0 -0
  138. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/guides/harnesses.md +0 -0
  139. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/guides/install-and-quickstart.md +0 -0
  140. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/guides/loadouts.md +0 -0
  141. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/guides/reading-results.md +0 -0
  142. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/guides/running-a-benchmark.md +0 -0
  143. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/guides/toolbase.md +0 -0
  144. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/guides/variants.md +0 -0
  145. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/index.md +0 -0
  146. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/reference/commands.md +0 -0
  147. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/reference/files-and-layout.md +0 -0
  148. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/reference/metrics.md +0 -0
  149. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/reference/schemas.md +0 -0
  150. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/reference/vocabulary.md +0 -0
  151. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/stylesheets/extra.css +0 -0
  152. {toolbench-0.2.0 → toolbench-0.3.0}/userdocs/troubleshooting.md +0 -0
@@ -0,0 +1,42 @@
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
6
+ project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ---
9
+
10
+ ## [0.3.0] — 2026-07-24
11
+
12
+ ### Added
13
+
14
+ - **MCP preflight verification.** Before any trial runs (and in `--dry-run`), a
15
+ run now verifies that every MCP-serving harness × tools-loadout can actually
16
+ reach its tools: it starts the loadout's `toolbase serve --profile …` exactly
17
+ as the CLI runtime would and completes an MCP `initialize` + `tools/list`
18
+ handshake. If the profile serves zero tools, the handshake times out, or the
19
+ resolved tools are missing, the run **aborts with exit 2 before executing a
20
+ single trial** and prints which cell failed. Previously a `tools` loadout that
21
+ could not reach its tools ran the entire arm silently tool-less and still
22
+ graded as a valid tools result. Verification is gated to the runtimes that
23
+ serve toolbase over MCP (`claude_code`, `codex`) via
24
+ `runtime_serves_toolbase_mcp`; in-process runtimes (orchestral) resolve tools
25
+ directly and are unaffected. New helpers: `verify_toolbase_mcp`,
26
+ `runtime_serves_toolbase_mcp`.
27
+
28
+ ### Fixed
29
+
30
+ - **`_toolbase_command()` now fails loudly.** When the `toolbase` executable
31
+ cannot be located (neither on `PATH` nor beside the running Python), it raises
32
+ with an actionable message instead of returning a bare `"toolbase"`. The bare
33
+ fallback silently produced a broken `.mcp.json` whose stdio server failed to
34
+ launch (`command not found` on the child's `PATH`), leaving the agent with
35
+ "No such tool available" while the trial still graded as if tools were
36
+ present — a failure mode that surfaced when an env's `toolbase` install was
37
+ in flux. Combined with the preflight above, an unresolvable toolbase is now
38
+ caught before the benchmark begins.
39
+
40
+ ## [0.2.1] — 2026-07-24
41
+
42
+ - Campaign runtime & metrics hardening.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toolbench
3
- Version: 0.2.0
3
+ Version: 0.3.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.0"
7
+ version = "0.3.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"
@@ -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
 
@@ -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
@@ -2,7 +2,7 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.3.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
@@ -598,6 +598,52 @@ def cmd_run(args: argparse.Namespace) -> int:
598
598
  release_sources(tmp)
599
599
  manifest["resolution"] = resolution_reports
600
600
  write_json(run_dir / "manifest.json", manifest)
601
+
602
+ # MCP preflight: for every MCP-serving harness x loadout that serves a
603
+ # toolbase profile, actually start `toolbase serve` and complete a
604
+ # tools/list handshake BEFORE any trial. A profile that resolves but
605
+ # serves no tools (mis-wired toolbase command, env churn) otherwise runs
606
+ # the entire "tools" arm silently tool-less and still grades it as valid.
607
+ # Hard-fail the run here instead; runs verbatim in dry-run too.
608
+ from toolbench.core.runtime import (
609
+ runtime_serves_toolbase_mcp, verify_toolbase_mcp,
610
+ _loadout_toolbase_profile)
611
+ mcp_failures: list[str] = []
612
+ for h in harnesses:
613
+ if not runtime_serves_toolbase_mcp(h.runtime_name):
614
+ continue
615
+ for lo in loadouts:
616
+ profile, proj = _loadout_toolbase_profile(lo)
617
+ if not profile:
618
+ continue
619
+ expected = [t for r in resolution_reports
620
+ if r.get("harness") == h.id and r.get("loadout") == lo.name
621
+ for s in r.get("sources", [])
622
+ if s.get("backend") == "toolbase"
623
+ for t in s.get("tools", [])]
624
+ try:
625
+ served = verify_toolbase_mcp(profile, cwd=(proj or bench_dir))
626
+ missing = [t for t in expected if t not in served]
627
+ if missing:
628
+ mcp_failures.append(
629
+ f"{h.id}/{lo.name} (profile {profile}): server served "
630
+ f"{len(served)} tools, missing {missing}")
631
+ else:
632
+ print(f" MCP preflight OK: {h.id}/{lo.name} — "
633
+ f"{len(served)} tools served ({profile})")
634
+ except Exception as e:
635
+ mcp_failures.append(
636
+ f"{h.id}/{lo.name} (profile {profile}): "
637
+ f"{type(e).__name__}: {e}")
638
+ if mcp_failures:
639
+ print("\n MCP PREFLIGHT FAILED — aborting before any trial ran:")
640
+ for f in mcp_failures:
641
+ print(f" ✗ {f}")
642
+ print(" A `tools` loadout could not reach its tools. Check that "
643
+ "`toolbase` is installed in this env and the profile serves "
644
+ "tools, then re-run.")
645
+ return 2
646
+
601
647
  if args.dry_run:
602
648
  print(" DRY-RUN: agent.run() will be skipped.")
603
649
 
@@ -73,6 +73,15 @@ SUBSCRIPTION_API_EQUIVALENT_PRICING: dict[tuple[str, str], dict[str, float | int
73
73
  "long_output_multiplier": 1.5,
74
74
  "source": "https://developers.openai.com/api/docs/models/gpt-5.5",
75
75
  },
76
+ ("openai", "gpt-5.6-sol"): {
77
+ "input": 5.00,
78
+ "cached_input": 0.50,
79
+ "output": 30.00,
80
+ "long_context_threshold": 272_000,
81
+ "long_input_multiplier": 2.0,
82
+ "long_output_multiplier": 1.5,
83
+ "source": "https://developers.openai.com/api/docs/models/gpt-5.6-sol",
84
+ },
76
85
  }
77
86
 
78
87
 
@@ -19,6 +19,7 @@ import traceback
19
19
  from dataclasses import dataclass
20
20
  from pathlib import Path
21
21
 
22
+ import yaml
22
23
  from orchestral.tools.hooks import TruncateOutputHook
23
24
 
24
25
  from .artifact_policy import DEFAULT_POLICY, ArtifactPolicy
@@ -67,6 +68,42 @@ _PROVIDER_CONTROL_KEYS = ("name", "cache_bust")
67
68
  CODE_ARG_KEYS = ("code", "script", "source", "program")
68
69
 
69
70
 
71
+ def _toolbase_protected_paths() -> list[str]:
72
+ """Paths a benchmark agent must not inspect outside the MCP interface.
73
+
74
+ Toolbase may serve editable toolkit checkouts. A CLI agent with ordinary
75
+ filesystem read access could otherwise locate and import those exact tool
76
+ implementations in a ``core_only`` arm, contaminating the control. MCP
77
+ server children are not confined by Codex/Claude's shell filesystem
78
+ profile, so denying these paths to the agent does not prevent the declared
79
+ Toolbase loadout from executing them.
80
+ """
81
+ root = Path(os.environ.get("TOOLBASE_HOME", Path.home() / ".toolbase")).resolve()
82
+ protected = [str(root)]
83
+ for meta_path in root.glob("cache/*/*/.install_meta.yaml"):
84
+ try:
85
+ meta = yaml.safe_load(meta_path.read_text()) or {}
86
+ source = meta.get("source_path") if meta.get("editable") else None
87
+ if source:
88
+ protected.append(str(Path(source).expanduser().resolve()))
89
+ except Exception:
90
+ continue
91
+ # Toolkit runtime configs can point at persistent working/data directories.
92
+ # Those may contain outputs from earlier tool-assisted runs and are equally
93
+ # inappropriate inputs to a fresh or core-only benchmark trial.
94
+ for config_path in root.glob("config/*.yaml"):
95
+ try:
96
+ config = yaml.safe_load(config_path.read_text()) or {}
97
+ base_directory = config.get("base_directory")
98
+ if base_directory:
99
+ protected.append(
100
+ str(Path(base_directory).expanduser().resolve())
101
+ )
102
+ except Exception:
103
+ continue
104
+ return list(dict.fromkeys(protected))
105
+
106
+
70
107
  def _warn(prefix: str, exc: BaseException) -> None:
71
108
  """Emit a one-line stderr breadcrumb for a non-fatal failure.
72
109
 
@@ -452,8 +489,11 @@ class TrialRunner:
452
489
  # The benchmark tree holds the ground-truth answer key
453
490
  # (soln/); the Bash sandbox (if the harness enables it)
454
491
  # deny-reads these so a trial cannot reach it.
455
- protected_paths=(benchmark_dir if isinstance(benchmark_dir, list)
456
- else [benchmark_dir]) if benchmark_dir else [],
492
+ protected_paths=(
493
+ ((benchmark_dir if isinstance(benchmark_dir, list)
494
+ else [benchmark_dir]) if benchmark_dir else [])
495
+ + _toolbase_protected_paths()
496
+ ),
457
497
  )
458
498
  # One resume loop over the SAME agent / sandbox / context.
459
499
  # After each agent.run we either:
@@ -185,6 +185,35 @@ register_runtime("orchestral", _orchestral_factory, dist="orchestral-ai")
185
185
 
186
186
  # The MCP server name `tb connect claude-code` writes into .mcp.json.
187
187
  _TOOLBASE_MCP_SERVER = "toolbase"
188
+
189
+
190
+ def _toolbase_command() -> str:
191
+ """Resolve the Toolbase executable for child MCP processes.
192
+
193
+ Invoking Toolbench with an absolute virtual-environment Python does not
194
+ activate that environment or prepend its ``bin`` directory to ``PATH``.
195
+ Prefer PATH when available, then the executable beside the running Python.
196
+
197
+ Raises ``RuntimeError`` when Toolbase cannot be located, instead of
198
+ returning a bare ``"toolbase"``. A bare command is what silently broke a
199
+ campaign: a child MCP process launched as ``toolbase serve ...`` failed to
200
+ start (``command not found`` on the child's PATH), the agent merely saw
201
+ "No such tool available", and the trial still graded as if it had tools.
202
+ Fail loudly here — the run preflight (`verify_toolbase_mcp`) turns this into
203
+ an abort before any trial runs.
204
+ """
205
+ found = shutil.which("toolbase")
206
+ if found:
207
+ return found
208
+ sibling = Path(sys.executable).resolve().with_name("toolbase")
209
+ if sibling.is_file():
210
+ return str(sibling)
211
+ raise RuntimeError(
212
+ "toolbase executable not found: neither on PATH nor beside the running "
213
+ f"Python ({Path(sys.executable).resolve().with_name('toolbase')}). "
214
+ "Install toolbase into this environment (`pip install toolbase`); a CLI "
215
+ "runtime cannot serve the loadout's MCP tools without it."
216
+ )
188
217
  # No hardcoded toolkit: the MCP profile a CLI runtime serves is derived from the
189
218
  # benchmark's loadout (its `toolbase: {profile: ...}` source) via
190
219
  # `_loadout_toolbase_profile`. None => serve no MCP server (the `core` baseline
@@ -235,6 +264,109 @@ def _loadout_toolbase_profile(loadout) -> tuple[str | None, str | None]:
235
264
  return None, None
236
265
 
237
266
 
267
+ # Runtimes that serve the loadout's toolbase profile to their agent over a
268
+ # stdio MCP server (`toolbase serve --profile ...`) rather than resolving tools
269
+ # in-process. For these the MCP connection is verified in the run preflight: a
270
+ # profile that resolves but serves zero tools (a mis-wired toolbase command,
271
+ # env churn) otherwise runs the whole "tools" arm silently tool-less. A new CLI
272
+ # runtime that serves toolbase over MCP registers its name here.
273
+ _MCP_SERVING_RUNTIMES = {"claude_code", "codex"}
274
+
275
+
276
+ def runtime_serves_toolbase_mcp(runtime_name: str) -> bool:
277
+ """True when a harness's runtime drives its agent's tools through a
278
+ `toolbase serve` MCP child (claude-code, codex), so the run preflight must
279
+ verify the server actually serves its tools. In-process runtimes
280
+ (orchestral) resolve tools directly and need no MCP check."""
281
+ return (runtime_name or "").lower() in _MCP_SERVING_RUNTIMES
282
+
283
+
284
+ def verify_toolbase_mcp(profile: str, *, cwd: str, call_timeout_s: int = 60,
285
+ timeout: float = 45.0) -> list[str]:
286
+ """Start `toolbase serve --profile <profile>` exactly as a CLI runtime does
287
+ and complete an MCP initialize + tools/list handshake, returning the served
288
+ tool names.
289
+
290
+ Raises ``RuntimeError`` if the server cannot launch, the handshake times
291
+ out, or it serves zero tools. This is the preflight guard that a `tools`
292
+ loadout actually reaches its tools *before* any trial runs (a served-but-
293
+ empty toolset previously graded as a valid tools arm)."""
294
+ import queue
295
+ import threading
296
+ import time as _time
297
+
298
+ cmd = [_toolbase_command(), "serve", "--profile", profile,
299
+ "--call-timeout", str(call_timeout_s)]
300
+ try:
301
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
302
+ stderr=subprocess.PIPE, text=True, cwd=cwd, bufsize=1)
303
+ except Exception as e:
304
+ raise RuntimeError(f"could not launch `{' '.join(cmd)}`: {e}") from e
305
+
306
+ err_chunks: list[str] = []
307
+ threading.Thread(target=_drain_stream, args=(proc.stderr, err_chunks),
308
+ daemon=True).start()
309
+ out_q: "queue.Queue" = queue.Queue()
310
+
311
+ def _read_out():
312
+ try:
313
+ for line in proc.stdout: # type: ignore[union-attr]
314
+ out_q.put(line)
315
+ except Exception:
316
+ pass
317
+ out_q.put(None)
318
+
319
+ threading.Thread(target=_read_out, daemon=True).start()
320
+
321
+ def _send(obj):
322
+ proc.stdin.write(json.dumps(obj) + "\n") # type: ignore[union-attr]
323
+ proc.stdin.flush() # type: ignore[union-attr]
324
+
325
+ def _fail(msg):
326
+ tail = "".join(err_chunks)[-400:].strip()
327
+ raise RuntimeError(f"{msg} (server exit={proc.poll()}"
328
+ + (f"; stderr: …{tail}" if tail else "") + ")")
329
+ try:
330
+ _send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
331
+ "params": {"protocolVersion": "2024-11-05", "capabilities": {},
332
+ "clientInfo": {"name": "toolbench-preflight",
333
+ "version": "0"}}})
334
+ _send({"jsonrpc": "2.0", "method": "notifications/initialized"})
335
+ _send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
336
+ deadline = _time.monotonic() + timeout
337
+ while _time.monotonic() < deadline:
338
+ try:
339
+ line = out_q.get(timeout=1.0)
340
+ except queue.Empty:
341
+ if proc.poll() is not None:
342
+ _fail(f"MCP server for profile {profile!r} exited before "
343
+ "returning tools/list")
344
+ continue
345
+ if line is None:
346
+ _fail(f"MCP server for profile {profile!r} closed its output "
347
+ "before returning tools/list")
348
+ try:
349
+ msg = json.loads(line)
350
+ except Exception:
351
+ continue
352
+ if msg.get("id") == 2:
353
+ tools = [t.get("name")
354
+ for t in (msg.get("result") or {}).get("tools", [])]
355
+ if not tools:
356
+ _fail(f"MCP server served 0 tools for profile {profile!r}")
357
+ return tools
358
+ _fail(f"MCP handshake for profile {profile!r} timed out after "
359
+ f"{timeout:.0f}s")
360
+ finally:
361
+ try:
362
+ proc.terminate(); proc.wait(timeout=5)
363
+ except Exception:
364
+ try:
365
+ proc.kill()
366
+ except Exception:
367
+ pass
368
+
369
+
238
370
  class _ClaudeCodeResponse:
239
371
  """Minimal response object matching the runner's defensive reads:
240
372
  `.text`, `.tool_calls`, and a `.context.messages` iterable."""
@@ -323,7 +455,7 @@ class ClaudeCodeAgent:
323
455
  "mcpServers": {
324
456
  _TOOLBASE_MCP_SERVER: {
325
457
  "type": "stdio",
326
- "command": "toolbase",
458
+ "command": _toolbase_command(),
327
459
  "args": args,
328
460
  }
329
461
  }
@@ -601,18 +733,25 @@ def _apply_harness_env(env: dict, overrides) -> None:
601
733
 
602
734
 
603
735
  def _claude_code_factory(*, system_prompt, sandbox_dir=None, harness=None,
604
- loadout=None, tool_hooks=None, protected_paths=None,
605
- **_):
606
- # `**_` absorbs the orchestral-shaped kwargs (llm, tools, display_hook)
607
- # this runtime doesn't use. We DO use tool_hooks: the runner's
608
- # TrajectoryHook is in there, and firing it per streamed tool call gives
609
- # us the same console.log + transcript as orchestral.
736
+ loadout=None, tool_hooks=None, llm=None,
737
+ protected_paths=None, **_):
738
+ # `**_` absorbs the orchestral-shaped kwargs (tools, display_hook) this
739
+ # runtime doesn't use. We DO use tool_hooks (the runner's TrajectoryHook,
740
+ # for console.log + transcript parity with orchestral) and llm (it carries
741
+ # the run matrix's --models value; see below).
610
742
  if not sandbox_dir:
611
743
  raise ValueError(
612
744
  "claude_code runtime requires sandbox_dir (the runner passes it)."
613
745
  )
614
746
  model, call_timeout_s, profile, project_root, traj_hook, env_overrides = \
615
747
  _cli_runtime_common(harness, loadout, tool_hooks)
748
+ # The run matrix's --models value is carried by SubscriptionLLM and MUST
749
+ # override the harness's provider.model default; otherwise every cell runs
750
+ # the harness default model regardless of its --models label (mirrors the
751
+ # codex factory).
752
+ requested_model = getattr(llm, "model", None)
753
+ if requested_model:
754
+ model = requested_model
616
755
  return ClaudeCodeAgent(
617
756
  system_prompt=system_prompt, sandbox_dir=sandbox_dir, model=model,
618
757
  profile=profile, project_root=project_root,
@@ -721,7 +860,7 @@ class CodexAgent:
721
860
  "--call-timeout", str(self.call_timeout_s)]
722
861
  return [
723
862
  "-c", f"mcp_servers.{_TOOLBASE_MCP_SERVER}.command="
724
- + json.dumps("toolbase"),
863
+ + json.dumps(_toolbase_command()),
725
864
  "-c", f"mcp_servers.{_TOOLBASE_MCP_SERVER}.args="
726
865
  + json.dumps(serve_args),
727
866
  # `codex exec` is noninteractive. Without a server-level default,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes