From d6f2218756cdb0995e766c3455aedc3febbacbf8 Mon Sep 17 00:00:00 2001 From: Alex Schapiro Date: Thu, 20 Aug 2026 19:32:56 +0000 Subject: [PATCH] Drop strict tool schemas on Claude routes --- strix/agents/factory.py | 57 ++++++++++++++++++++++----- strix/config/models.py | 15 +++++++ strix/core/runner.py | 6 +++ tests/test_agent_tool_registration.py | 16 ++++++++ tests/test_models.py | 22 +++++++++++ 5 files changed, 106 insertions(+), 10 deletions(-) diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 9d599a54..a4833539 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -2,6 +2,7 @@ from __future__ import annotations +import dataclasses import inspect import json import logging @@ -222,6 +223,17 @@ def _with_coerced_arguments(tool: FunctionTool) -> FunctionTool: return tool +def _with_strictness(tool: FunctionTool, strict_schemas: bool) -> FunctionTool: + """Drop strict JSON-schema mode when the route can't take it (see + ``supports_strict_tool_schemas``); the tool stays functionally identical. + + Returns a copy so the shared tool singletons keep their declared mode. + """ + if strict_schemas or not tool.strict_json_schema: + return tool + return dataclasses.replace(tool, strict_json_schema=False) + + def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool: invoke_tool = tool.on_invoke_tool @@ -285,24 +297,38 @@ def _bound_custom_tool(tool: CustomTool) -> CustomTool: return tool -def _configure_filesystem_tools(toolset: Any, *, chat_completions: bool) -> None: +def _configure_filesystem_tools( + toolset: Any, *, chat_completions: bool, strict_schemas: bool = True +) -> None: for name, tool in vars(toolset).items(): if chat_completions: if isinstance(tool, CustomTool): setattr(toolset, name, _custom_tool_as_function_tool(tool)) elif isinstance(tool, FunctionTool): setattr( - toolset, name, _function_tool_with_error_result(_with_coerced_arguments(tool)) + toolset, + name, + _function_tool_with_error_result( + _with_strictness(_with_coerced_arguments(tool), strict_schemas) + ), ) elif isinstance(tool, CustomTool): setattr(toolset, name, _bound_custom_tool(tool)) elif isinstance(tool, FunctionTool): - setattr(toolset, name, _with_bounded_result(_with_coerced_arguments(tool))) + setattr( + toolset, + name, + _with_bounded_result( + _with_strictness(_with_coerced_arguments(tool), strict_schemas) + ), + ) -def _make_filesystem_configurator(*, chat_completions: bool) -> Any: +def _make_filesystem_configurator(*, chat_completions: bool, strict_schemas: bool) -> Any: def configure(toolset: Any) -> None: - _configure_filesystem_tools(toolset, chat_completions=chat_completions) + _configure_filesystem_tools( + toolset, chat_completions=chat_completions, strict_schemas=strict_schemas + ) return configure @@ -406,11 +432,13 @@ def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool: return tool -def _configure_shell_tools(toolset: Any, *, chat_completions: bool) -> None: +def _configure_shell_tools( + toolset: Any, *, chat_completions: bool, strict_schemas: bool = True +) -> None: for name, tool in vars(toolset).items(): if not isinstance(tool, FunctionTool): continue - wrapped = _with_coerced_arguments(tool) + wrapped = _with_strictness(_with_coerced_arguments(tool), strict_schemas) if tool.name == "exec_command": wrapped = _wrap_exec_command(wrapped) elif tool.name == "write_stdin": @@ -420,9 +448,11 @@ def _configure_shell_tools(toolset: Any, *, chat_completions: bool) -> None: setattr(toolset, name, wrapped) -def _make_shell_configurator(*, chat_completions: bool) -> Any: +def _make_shell_configurator(*, chat_completions: bool, strict_schemas: bool) -> Any: def configure(toolset: Any) -> None: - _configure_shell_tools(toolset, chat_completions=chat_completions) + _configure_shell_tools( + toolset, chat_completions=chat_completions, strict_schemas=strict_schemas + ) return configure @@ -568,6 +598,7 @@ def build_strix_agent( is_whitebox: bool = False, interactive: bool = False, chat_completions_tools: bool = False, + strict_tool_schemas: bool = True, system_prompt_context: dict[str, Any] | None = None, extra_tools: Sequence[Tool] | None = None, instructions_override: str | None = None, @@ -577,6 +608,8 @@ def build_strix_agent( Args: chat_completions_tools: Wrap SDK custom tools as function tools when the selected backend cannot accept Responses custom tools. + strict_tool_schemas: Send function tools as strict-schema tools. Off + for routes that reject a toolset this size as strict. extra_tools: Additional tools for this scan agent only, on top of any registered via ``register_agent_tools``. instructions_override: Use this verbatim as the system prompt instead @@ -604,7 +637,7 @@ def build_strix_agent( tools = [*_BASE_TOOLS, *agent_tools, agent_finish] _ensure_unique_tool_names(tools) tools = [ - _with_bounded_result(_with_coerced_arguments(tool)) + _with_bounded_result(_with_strictness(_with_coerced_arguments(tool), strict_tool_schemas)) if isinstance(tool, FunctionTool) else tool for tool in tools @@ -630,11 +663,13 @@ def build_strix_agent( Filesystem( configure_tools=_make_filesystem_configurator( chat_completions=chat_completions_tools, + strict_schemas=strict_tool_schemas, ), ), Shell( configure_tools=_make_shell_configurator( chat_completions=chat_completions_tools, + strict_schemas=strict_tool_schemas, ), ), ], @@ -647,6 +682,7 @@ def make_child_factory( is_whitebox: bool = False, interactive: bool = False, chat_completions_tools: bool = False, + strict_tool_schemas: bool = True, system_prompt_context: dict[str, Any] | None = None, ) -> Any: """Return the runner-owned builder used by ``spawn_child_agent``. @@ -665,6 +701,7 @@ def make_child_factory( is_whitebox=is_whitebox, interactive=interactive, chat_completions_tools=chat_completions_tools, + strict_tool_schemas=strict_tool_schemas, system_prompt_context=system_prompt_context, ) diff --git a/strix/config/models.py b/strix/config/models.py index e632bb06..f6848ca4 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -749,6 +749,18 @@ def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bo return not model_supports_reasoning(model_name) +def supports_strict_tool_schemas(model_name: str) -> bool: + """Return whether the route accepts strict tool schemas for Strix's toolset. + + Claude caps a request at 20 strict tools and 16 union-typed parameters + across all strict schemas. Strix ships ~30 tools and the strict dialect + turns every optional parameter into a nullable union, so both caps are + exceeded and the request is rejected outright. + """ + name = model_name.strip().lower() + return not any(marker in name for marker in _ANTHROPIC_MODEL_MARKERS) + + def model_supports_reasoning(model_name: str) -> bool: import litellm @@ -845,6 +857,9 @@ def is_known_openai_bare_model(model_name: str) -> bool: return bool(entry and entry.get("litellm_provider") == "openai") +_ANTHROPIC_MODEL_MARKERS = ("anthropic", "claude", "sonnet", "opus", "haiku") + + def is_claude_model(model_name: str) -> bool: return "claude" in (model_name or "").strip().lower() diff --git a/strix/core/runner.py b/strix/core/runner.py index b4afdfaf..0dfe75d0 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -22,6 +22,7 @@ from strix.config import load_settings from strix.config.models import ( StrixProvider, configure_sdk_model_defaults, + supports_strict_tool_schemas, uses_chat_completions_tool_schema, ) from strix.config.settings import DEFAULT_MAX_TURNS @@ -175,6 +176,9 @@ async def run_strix_scan( ) logger.info("LLM model resolved: %s", resolved_model) chat_completions_tools = uses_chat_completions_tool_schema(resolved_model, settings) + strict_tool_schemas = supports_strict_tool_schemas(resolved_model) + if not strict_tool_schemas: + logger.info("Sending non-strict tool schemas: %s caps strict tools", resolved_model) if coordinator is None: coordinator = AgentCoordinator() @@ -306,6 +310,7 @@ async def run_strix_scan( is_whitebox=is_whitebox, interactive=interactive, chat_completions_tools=chat_completions_tools, + strict_tool_schemas=strict_tool_schemas, system_prompt_context=root_context, instructions_override=root_instructions, ) @@ -324,6 +329,7 @@ async def run_strix_scan( is_whitebox=is_whitebox, interactive=interactive, chat_completions_tools=chat_completions_tools, + strict_tool_schemas=strict_tool_schemas, system_prompt_context=scope_context, ) diff --git a/tests/test_agent_tool_registration.py b/tests/test_agent_tool_registration.py index 7d002bdc..12f88f74 100644 --- a/tests/test_agent_tool_registration.py +++ b/tests/test_agent_tool_registration.py @@ -112,3 +112,19 @@ def test_wait_for_agents_is_available_in_both_modes() -> None: for interactive in (True, False): agent = factory.build_strix_agent(is_root=True, interactive=interactive) assert "wait_for_agents" in [t.name for t in agent.tools] + + +def test_strict_tool_schemas_can_be_disabled_per_route() -> None: + """Claude routes cap strict tools; the toolset must be sendable without strict.""" + agent = factory.build_strix_agent(is_root=True, strict_tool_schemas=False) + + function_tools = [t for t in agent.tools if isinstance(t, FunctionTool)] + assert function_tools + assert not any(t.strict_json_schema for t in function_tools) + + +def test_disabling_strict_leaves_shared_tools_untouched() -> None: + factory.build_strix_agent(is_root=True, strict_tool_schemas=False) + agent = factory.build_strix_agent(is_root=True) + + assert any(t.strict_json_schema for t in agent.tools if isinstance(t, FunctionTool)) diff --git a/tests/test_models.py b/tests/test_models.py index 10b01cc5..04bb2875 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,6 +9,7 @@ from strix.config.models import ( RECOMMENDED_MODEL_NAMES, is_recommended_or_frontier_model, request_timeout_extra_args, + supports_strict_tool_schemas, ) @@ -90,3 +91,24 @@ def test_frontier_model_families_are_accepted(model_name: str) -> None: ) def test_non_frontier_models_are_rejected(model_name: str) -> None: assert not is_recommended_or_frontier_model(model_name) + + +@pytest.mark.parametrize( + "model_name", + [ + "anthropic/claude-sonnet-4-6", + "bedrock/anthropic.claude-opus-4-8-v1:0", + "vertex_ai/claude-sonnet-5", + "Sonnet-5", + ], +) +def test_claude_routes_reject_strict_tool_schemas(model_name: str) -> None: + assert not supports_strict_tool_schemas(model_name) + + +@pytest.mark.parametrize( + "model_name", + ["openai/gpt-5.4", "gpt-5.4", "gemini/gemini-3.1-pro-preview", "deepseek/deepseek-v4"], +) +def test_other_routes_keep_strict_tool_schemas(model_name: str) -> None: + assert supports_strict_tool_schemas(model_name)