diff --git a/eval/harbor/clawcodex_agent.py b/eval/harbor/clawcodex_agent.py index 7ff61ae0..9db0b24a 100644 --- a/eval/harbor/clawcodex_agent.py +++ b/eval/harbor/clawcodex_agent.py @@ -379,6 +379,13 @@ def _build_env(self) -> dict[str, str]: # post-run so the host still gets them. env["CLAWCODEX_CONFIG_DIR"] = _CONTAINER_CONFIG_DIR env["NO_COLOR"] = "1" + # The CLI safely defaults experimental Anthropic betas off for + # external API-key users. Subscription mode uses Claude's first-party + # endpoint, where ToolSearch/tool_reference is supported; explicitly + # override the default so deferred schemas actually leave the initial + # prompt. ``setdefault`` in cli.py preserves this value. + if self._subscription: + env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] = "false" return env async def _inject_subscription_credentials( diff --git a/src/entrypoints/headless.py b/src/entrypoints/headless.py index 7f301ead..6408c5fe 100644 --- a/src/entrypoints/headless.py +++ b/src/entrypoints/headless.py @@ -405,10 +405,19 @@ def _with_goal_continuations( # headless filter application at main.tsx:1871-1879. from src.coordinator.mode import coordinator_main_loop_registry - tools = [ - tool.name + initial_registry_tools = [ + tool for tool in coordinator_main_loop_registry(tool_registry).list_tools() + if tool.is_enabled() ] + from src.tool_system.tool_search import filter_tools_for_request + + initial_registry_tools = filter_tools_for_request( + initial_registry_tools, + getattr(provider, "model", "") or "", + messages=[], + ) + tools = [tool.name for tool in initial_registry_tools] writer.write( SystemEvent( subtype="init", diff --git a/src/providers/anthropic_provider.py b/src/providers/anthropic_provider.py index 51093fe0..fdbc02f3 100644 --- a/src/providers/anthropic_provider.py +++ b/src/providers/anthropic_provider.py @@ -319,6 +319,19 @@ def _prepare_subscription_request( name = block.get("name") if isinstance(name, str) and not name.startswith("mcp_"): block["name"] = "mcp_" + name + if isinstance(block, dict) and block.get("type") == "tool_result": + result_content = block.get("content") + if not isinstance(result_content, list): + continue + for item in result_content: + if not ( + isinstance(item, dict) + and item.get("type") == "tool_reference" + ): + continue + name = item.get("tool_name") + if isinstance(name, str) and not name.startswith("mcp_"): + item["tool_name"] = "mcp_" + name if prepared_tools: for tool in prepared_tools: name = tool.get("name") @@ -497,6 +510,29 @@ def _merge_request_id(self, kwargs: dict[str, Any]) -> str | None: kwargs["extra_headers"] = merged return merged["x-client-request-id"] + @staticmethod + def _merge_beta_headers(kwargs: dict[str, Any]) -> None: + """Translate query-layer beta names to the SDK's wire header. + + ``messages.create/stream`` do not accept a ``betas=`` keyword; that + convenience exists only on ``client.beta.messages``. The regular + client supports the same API contract through ``anthropic-beta``. + """ + raw = kwargs.pop("betas", None) + if not raw: + return + values = [str(value).strip() for value in raw if str(value).strip()] + if not values: + return + merged = dict(kwargs.get("extra_headers") or {}) + existing = str(merged.get("anthropic-beta", "")).strip() + combined = [part.strip() for part in existing.split(",") if part.strip()] + for value in values: + if value not in combined: + combined.append(value) + merged["anthropic-beta"] = ",".join(combined) + kwargs["extra_headers"] = merged + def chat( self, messages: list[MessageInput], @@ -529,6 +565,7 @@ def chat( extra_kwargs: dict[str, Any] = {} if tools: extra_kwargs["tools"] = tools + self._merge_beta_headers(kwargs) self._merge_request_id(kwargs) # Explicit per-request timeout (TS API_TIMEOUT_MS, openaiShim.ts: @@ -583,6 +620,7 @@ def chat_stream( extra_kwargs: dict[str, Any] = {} if tools: extra_kwargs["tools"] = tools + self._merge_beta_headers(kwargs) with client.messages.stream( model=model, @@ -667,6 +705,7 @@ def chat_stream_response( extra_kwargs: dict[str, Any] = {} if tools: extra_kwargs["tools"] = tools + self._merge_beta_headers(kwargs) request_id = self._merge_request_id(kwargs) from src.utils.stream_watchdog import ( diff --git a/src/query/query.py b/src/query/query.py index 12a24be9..bd37808e 100644 --- a/src/query/query.py +++ b/src/query/query.py @@ -857,10 +857,36 @@ async def _call_model_sync( block_types.append(str(type(b).__name__)) logger.warning("[DIAG] msg[%d] role=%s blocks=%s", i, role, block_types) _t0 = time.monotonic() - from ..tool_system.tool_search import filter_tools_for_request + from ..tool_system.tool_search import ( + TOOL_SEARCH_BETA_HEADER_1P, + filter_tools_for_request, + is_deferred_tool, + ) provider_model = getattr(provider, "model", None) or "" request_tools = filter_tools_for_request(tools, provider_model, api_messages) + deferred_tool_names = sorted( + tool.name + for tool in tools + if is_deferred_tool(tool) + and ( + not callable(getattr(tool, "is_enabled", None)) + or tool.is_enabled() + ) + and tool not in request_tools + ) + if deferred_tool_names: + api_messages = [ + { + "role": "user", + "content": ( + "\n" + + "\n".join(deferred_tool_names) + + "\n" + ), + }, + *api_messages, + ] tool_schemas = [] for tool in request_tools: # Filter out internal/hidden tools (is_enabled=False) so they @@ -911,6 +937,12 @@ async def _call_model_sync( from ..providers.minimax_provider import MinimaxProvider is_anthropic = isinstance(provider, (AnthropicProvider, MinimaxProvider)) + if deferred_tool_names and isinstance(provider, AnthropicProvider): + has_custom_endpoint = getattr(provider, "has_custom_endpoint", None) + if not callable(has_custom_endpoint) or not has_custom_endpoint(): + call_kwargs.setdefault("betas", []).append( + TOOL_SEARCH_BETA_HEADER_1P + ) advisor_instructions_active = advisor_mode != ADVISOR_MODE_INACTIVE if is_anthropic: # Forward whatever shape the engine produced — str or list[dict]. diff --git a/src/tool_system/defaults.py b/src/tool_system/defaults.py index 89a40022..04d58674 100644 --- a/src/tool_system/defaults.py +++ b/src/tool_system/defaults.py @@ -1,11 +1,56 @@ from __future__ import annotations +from dataclasses import replace from typing import Any, Callable from .registry import ToolRegistry from .tools import ALL_STATIC_TOOLS, make_agent_tool, make_tool_search_tool +# Keep the first request close to Claude Code's proven default surface. Tools +# outside this set remain registered and dispatchable, but are discovered +# through ToolSearch before their full schemas are added to subsequent calls. +# +# Claude Code 2.1.215 advertised the equivalent 25-tool set in the matched +# pypi-server benchmark. ReportFindings has no clawcodex equivalent, leaving +# 24 names here. +ESSENTIAL_INITIAL_TOOL_NAMES = frozenset({ + "Agent", + "Bash", + "CronCreate", + "CronDelete", + "CronList", + "Edit", + "EnterWorktree", + "ExitWorktree", + "NotebookEdit", + "Read", + "ScheduleWakeup", + "SendMessage", + "Skill", + "TaskCreate", + "TaskGet", + "TaskList", + "TaskOutput", + "TaskStop", + "TaskUpdate", + "ToolSearch", + "WebFetch", + "WebSearch", + "Workflow", + "Write", +}) + + +def _apply_initial_loading_policy(tool): + """Return a registry-local tool carrying the default loading policy.""" + if tool.name in ESSENTIAL_INITIAL_TOOL_NAMES or tool.always_load: + return tool + if tool.should_defer: + return tool + return replace(tool, should_defer=True) + + def build_default_registry( *, include_user_tools: bool = True, @@ -14,15 +59,15 @@ def build_default_registry( ) -> ToolRegistry: registry = ToolRegistry() for tool in ALL_STATIC_TOOLS: - registry.register(tool) - registry.register( + registry.register(_apply_initial_loading_policy(tool)) + registry.register(_apply_initial_loading_policy( make_agent_tool( registry, provider=provider, get_available_mcp_servers=get_available_mcp_servers, ) - ) - registry.register(make_tool_search_tool(registry)) + )) + registry.register(_apply_initial_loading_policy(make_tool_search_tool(registry))) # Dynamic workflows. Registered unconditionally (like the Agent tool, which # also needs the registry + provider); the tool's ``is_enabled`` is the @@ -30,6 +75,8 @@ def build_default_registry( # toggle of ``disable_workflows`` takes effect without rebuilding the registry. from .tools.workflow import make_workflow_tool - registry.register(make_workflow_tool(registry, provider=provider)) + registry.register(_apply_initial_loading_policy( + make_workflow_tool(registry, provider=provider), + )) return registry diff --git a/src/tool_system/tool_search.py b/src/tool_system/tool_search.py index 65540b1e..adabe8ee 100644 --- a/src/tool_system/tool_search.py +++ b/src/tool_system/tool_search.py @@ -22,6 +22,7 @@ # --------------------------------------------------------------------------- TOOL_SEARCH_TOOL_NAME = "ToolSearch" +TOOL_SEARCH_BETA_HEADER_1P = "advanced-tool-use-2025-11-20" DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE = 10 # 10% of context window CHARS_PER_TOKEN = 2.5 @@ -165,6 +166,10 @@ def is_deferred_tool(tool: Tool) -> bool: A tool is deferred if it's an MCP tool or has should_defer=True. Mirrors TS isDeferredTool from toolSearch.ts. """ + if getattr(tool, "always_load", False): + return False + if getattr(tool, "name", "") == TOOL_SEARCH_TOOL_NAME: + return False if getattr(tool, "is_mcp", False): return True if getattr(tool, "should_defer", False): @@ -343,6 +348,9 @@ def filter_tools_for_request( if not model_supports_tool_reference(model): return tools + if not is_tool_search_tool_available(tools): + return tools + discovered = extract_discovered_tool_names(messages or []) result: Tools = [] diff --git a/src/tool_system/tools/tool_search.py b/src/tool_system/tools/tool_search.py index 6d306f6e..7df6e002 100644 --- a/src/tool_system/tools/tool_search.py +++ b/src/tool_system/tools/tool_search.py @@ -9,7 +9,35 @@ from ..registry import ToolRegistry +def _map_result_to_api(output: Any, tool_use_id: str) -> dict[str, Any]: + """Expose matches as tool_reference blocks so request filtering can load them.""" + matches = output.get("matches", []) if isinstance(output, dict) else [] + if not matches: + return { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": "No matching deferred tools found", + } + references = [ + {"type": "tool_reference", "tool_name": name} + for name in matches + if isinstance(name, str) and name + ] + return { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": references, + } + + def make_tool_search_tool(registry: ToolRegistry) -> Tool: + def _deferred_count() -> int: + return sum( + 1 + for tool in registry.list_tools() + if tool.should_defer or tool.is_mcp + ) + def _tool_search_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResult: query = tool_input.get("query") if not isinstance(query, str) or not query.strip(): @@ -29,7 +57,7 @@ def _tool_search_call(tool_input: dict[str, Any], context: ToolContext) -> ToolR output={ "matches": matches, "query": query, - "total_deferred_tools": 0, + "total_deferred_tools": _deferred_count(), }, ) @@ -44,7 +72,11 @@ def _tool_search_call(tool_input: dict[str, Any], context: ToolContext) -> ToolR matches = [name for _, name in scored[:max_results]] return ToolResult( name="ToolSearch", - output={"matches": matches, "query": query, "total_deferred_tools": 0}, + output={ + "matches": matches, + "query": query, + "total_deferred_tools": _deferred_count(), + }, ) return build_tool( @@ -59,8 +91,17 @@ def _tool_search_call(tool_input: dict[str, Any], context: ToolContext) -> ToolR "required": ["query"], }, call=_tool_search_call, - prompt="Search for available tools by name or keywords.", - description="Search for available tools by name or keywords.", + map_result_to_api=_map_result_to_api, + prompt=( + "Fetch full schema definitions for deferred tools. Deferred tools " + "are announced by name in . Use " + "'select:ToolName' for an exact tool or capability keywords to " + "search. A matched tool becomes callable on the next turn." + ), + description=( + "Fetch full schema definitions for deferred tools by exact name " + "or capability keywords." + ), strict=True, max_result_size_chars=100_000, is_read_only=lambda _input: True, diff --git a/tests/test_advisor_request_wiring.py b/tests/test_advisor_request_wiring.py index 61c5140f..04e52936 100644 --- a/tests/test_advisor_request_wiring.py +++ b/tests/test_advisor_request_wiring.py @@ -206,12 +206,25 @@ def prompt(self) -> str: }): _run(provider, [UserMessage(content="x")], tools=[ _FakeTool("Read", False), + _FakeTool("ToolSearch", False), _FakeTool("EnterPlanMode", True), ]) names = [tool["name"] for tool in cap.call_kwargs["tools"]] self.assertIn("Read", names) self.assertNotIn("EnterPlanMode", names) + self.assertIn( + "advanced-tool-use-2025-11-20", + cap.call_kwargs["betas"], + ) + self.assertEqual( + cap.api_messages[0]["content"], + ( + "\n" + "EnterPlanMode\n" + "" + ), + ) def test_signed_thinking_is_kept_in_assistant_history(self) -> None: cap = _Capture() diff --git a/tests/test_anthropic_subscription.py b/tests/test_anthropic_subscription.py index 30d76cd8..c8f5243d 100644 --- a/tests/test_anthropic_subscription.py +++ b/tests/test_anthropic_subscription.py @@ -112,6 +112,32 @@ def test_provider_uses_bearer_oauth_and_adapts_tools(monkeypatch) -> None: assert result.usage["billing_mode"] == "subscription" +def test_subscription_adapts_deferred_tool_references(monkeypatch) -> None: + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + with patch.object(auth, "get_valid_credentials", return_value=_credentials()), \ + patch("src.providers.anthropic_provider.anthropic.Anthropic"): + provider = AnthropicProvider(api_key="") + + messages = [{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "search-1", + "content": [{"type": "tool_reference", "tool_name": "Grep"}], + }], + }] + prepared, tools, _ = provider._prepare_subscription_request( + messages, + [{"name": "Grep", "description": "search", "input_schema": {}}], + None, + ) + + assert prepared[0]["content"][0]["content"][0]["tool_name"] == "mcp_Grep" + assert tools and tools[0]["name"] == "mcp_Grep" + # Caller-owned history remains canonical for discovery on future turns. + assert messages[0]["content"][0]["content"][0]["tool_name"] == "Grep" + + def test_identity_rewrite_preserves_paths_env_vars_and_domains(monkeypatch) -> None: """The OAuth identity disguise must not corrupt machine-readable tokens. diff --git a/tests/test_providers.py b/tests/test_providers.py index 4aad06c1..0a0d9f8a 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -72,6 +72,29 @@ def test_custom_model(self): provider = AnthropicProvider(api_key="test_key", model="claude-3-opus-20240229") self.assertEqual(provider.model, "claude-3-opus-20240229") + def test_beta_names_are_translated_to_anthropic_header(self): + kwargs = { + "betas": [ + "advanced-tool-use-2025-11-20", + "prompt-caching-scope-2026-01-05", + ], + "extra_headers": { + "anthropic-beta": "advanced-tool-use-2025-11-20", + "x-test": "kept", + }, + } + + AnthropicProvider._merge_beta_headers(kwargs) + + self.assertNotIn("betas", kwargs) + self.assertEqual(kwargs["extra_headers"], { + "anthropic-beta": ( + "advanced-tool-use-2025-11-20," + "prompt-caching-scope-2026-01-05" + ), + "x-test": "kept", + }) + def test_build_response_preserves_signed_thinking_blocks(self): provider = AnthropicProvider(api_key="test_key") response = SimpleNamespace( diff --git a/tests/test_tool_search.py b/tests/test_tool_search.py index 11043a8b..eeb13ada 100644 --- a/tests/test_tool_search.py +++ b/tests/test_tool_search.py @@ -4,6 +4,7 @@ import os import unittest +from pathlib import Path from unittest.mock import patch from src.tool_system.tool_search import ( @@ -20,6 +21,12 @@ model_supports_tool_reference, ) from src.tool_system.build_tool import build_tool +from src.tool_system.context import ToolContext +from src.tool_system.defaults import ( + ESSENTIAL_INITIAL_TOOL_NAMES, + build_default_registry, +) +from src.tool_system.protocol import ToolCall def _make_tool(name: str, *, should_defer: bool = False, is_mcp: bool = False): @@ -237,6 +244,68 @@ def test_discovered_tools_kept(self): names = [t.name for t in result] self.assertIn("mcp_tool", names) + def test_missing_tool_search_keeps_deferred_tools_reachable(self): + with patch.dict(os.environ, { + "ENABLE_TOOL_SEARCH": "true", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "", + }): + tools = [ + _make_tool("Read"), + _make_tool("deferred", should_defer=True), + ] + self.assertEqual(filter_tools_for_request( + tools, "claude-opus-4-8", messages=[], + ), tools) + + +class TestDefaultInitialToolSet(unittest.TestCase): + def test_initial_request_matches_essential_policy(self): + registry = build_default_registry(provider=None) + tools = [tool for tool in registry.list_tools() if tool.is_enabled()] + + with patch.dict(os.environ, { + "ENABLE_TOOL_SEARCH": "true", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "", + }): + initial = filter_tools_for_request( + tools, "claude-opus-4-8", messages=[], + ) + + self.assertEqual( + {tool.name for tool in initial}, + ESSENTIAL_INITIAL_TOOL_NAMES & {tool.name for tool in tools}, + ) + + def test_tool_search_result_loads_deferred_schema_next_request(self): + registry = build_default_registry(provider=None) + result = registry.dispatch( + ToolCall( + name="ToolSearch", + input={"query": "select:Grep"}, + tool_use_id="search-1", + ), + ToolContext(workspace_root=Path.cwd()), + ) + search_tool = registry.get("ToolSearch") + assert search_tool is not None + block = search_tool.map_result_to_api(result.output, "search-1") + + self.assertEqual(block["content"], [ + {"type": "tool_reference", "tool_name": "Grep"}, + ]) + + messages = [{"type": "user", "content": [block]}] + tools = [tool for tool in registry.list_tools() if tool.is_enabled()] + with patch.dict(os.environ, { + "ENABLE_TOOL_SEARCH": "true", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "", + }): + loaded = filter_tools_for_request( + tools, "claude-opus-4-8", messages, + ) + + self.assertIn("Grep", {tool.name for tool in loaded}) + if __name__ == "__main__": unittest.main()