diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 0c1efdcd..e6c12b79 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -240,10 +240,12 @@ def _partition_tools( user_config_tools: dict[str, Any] = {} for ld_name, stub in tool_handlers.items(): + if not config_tools or ld_name not in config_tools: + continue native = getattr(stub, NATIVE_TOOL_KEY, None) if isinstance(native, NativeTool): native_tool_map[native.tool_name] = stub - elif config_tools and ld_name in config_tools: + else: user_config_tools[ld_name] = config_tools[ld_name] return native_tool_map, user_config_tools, list(native_tool_map.keys()) diff --git a/packages/claude-agents/tests/test_handler.py b/packages/claude-agents/tests/test_handler.py index 723dc0fd..0cd01ffb 100644 --- a/packages/claude-agents/tests/test_handler.py +++ b/packages/claude-agents/tests/test_handler.py @@ -406,6 +406,20 @@ def test_native_tool_goes_to_native_bucket(self) -> None: assert native_names == ["WebSearch"] assert user_tools == {} + def test_native_tool_absent_from_the_variation_is_not_enabled(self) -> None: + from launchdarkly_ai_claude_agents.builtins import ClaudeWebSearch + from launchdarkly_ai_server import NATIVE_TOOL_KEY + + stub = lambda: None # noqa: E731 + setattr(stub, NATIVE_TOOL_KEY, ClaudeWebSearch) + for config_tools in (None, {}, {"other": {"name": "other"}}): + native_map, user_tools, native_names = _partition_tools( + config_tools, {"webSearch": stub} + ) + assert native_map == {} + assert user_tools == {} + assert native_names == [] + def test_native_tool_aliases_map_ld_key_to_provider_name(self) -> None: from launchdarkly_ai_claude_agents.builtins import ClaudeWebSearch from launchdarkly_ai_server import NATIVE_TOOL_KEY @@ -2496,15 +2510,46 @@ async def test_sends_exactly_the_native_tool_provider_names( read, bash = MagicMock(), MagicMock() setattr(read, NATIVE_TOOL_KEY, ClaudeRead) setattr(bash, NATIVE_TOOL_KEY, ClaudeBash) + handlers = {"read": read, "bash": bash} + listed = { + **BASE_CONFIG, + "tools": { + "read": {"name": "read", "type": "function", "parameters": {}}, + "bash": {"name": "bash", "type": "function", "parameters": {}}, + }, + } opts = await _query_options( monkeypatch, create_claude_agents_handler(), path, - BASE_CONFIG, - {"read": read, "bash": bash}, + listed, + handlers, ) assert sorted(opts.tools) == ["Bash", "Read"] + for config in ( + BASE_CONFIG, + {**BASE_CONFIG, "tools": {}}, + { + **BASE_CONFIG, + "tools": { + "search": { + "name": "search", + "type": "function", + "parameters": {}, + } + }, + }, + ): + omitted = await _query_options( + monkeypatch, + create_claude_agents_handler(), + path, + config, + handlers, + ) + assert omitted.tools == [] + class TestAbandonedToolSpansAreNotErrors: """An abandoned stream leaves an open tool span UNSET, not ERROR. diff --git a/packages/claude-agents/tests/test_native_graph.py b/packages/claude-agents/tests/test_native_graph.py index 21fa8936..3fb51a53 100644 --- a/packages/claude-agents/tests/test_native_graph.py +++ b/packages/claude-agents/tests/test_native_graph.py @@ -496,6 +496,7 @@ class TestToClaudeAgentsHostIsolation: @staticmethod async def _root_options( tool_handlers: dict[str, Any] | None = None, + graph_def: GraphDefinition | None = None, ) -> dict[str, Any]: mock_sdk = _make_sdk_mock("done") captured: list[dict[str, Any]] = [] @@ -509,7 +510,7 @@ async def _root_options( ), ): await to_claude_agents( - _make_def_promise(_make_graph_def()), + _make_def_promise(graph_def or _make_graph_def()), opts={"tool_handlers": tool_handlers or {}}, ).invoke("hi") assert len(captured) == 1 @@ -581,8 +582,35 @@ async def test_sends_an_empty_tool_list_with_no_native_tools(self) -> None: assert opts["tools"] == [] @pytest.mark.asyncio - async def test_sends_the_native_tool_names(self) -> None: + async def test_does_not_enable_a_native_tool_the_variation_omits(self) -> None: opts = await self._root_options({"run-bash": NativeTool("Bash")}) + assert opts["tools"] == [] + + async def test_sends_the_native_tool_names(self) -> None: + graph_def = _make_graph_def( + nodes={ + "root": { + "key": "root", + "config": { + "model": {"name": "claude-3"}, + "instructions": "be helpful", + "tools": { + "run-bash": { + "name": "run-bash", + "type": "function", + "parameters": {}, + } + }, + }, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + } + } + ) + opts = await self._root_options( + {"run-bash": NativeTool("Bash")}, graph_def=graph_def + ) assert opts["tools"] == ["Bash"] @pytest.mark.asyncio diff --git a/packages/claude-agents/tests/test_skills.py b/packages/claude-agents/tests/test_skills.py index 5fdb836d..43edf6f4 100644 --- a/packages/claude-agents/tests/test_skills.py +++ b/packages/claude-agents/tests/test_skills.py @@ -593,9 +593,22 @@ async def test_skill_joins_the_builtin_set_once_last( assert fake.options.tools == ["Skill"] assert "Skill" not in fake.options.allowed_tools + unlisted = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", unlisted) + await _run(handler, path, _config(("a", 1)), {"read": _read_stub()}) + assert unlisted.options.tools == ["Skill"] + with_read = _FakeQuery() monkeypatch.setattr(handler_mod, "query", with_read) - await _run(handler, path, _config(("a", 1)), {"read": _read_stub()}) + await _run( + handler, + path, + _config( + ("a", 1), + tools={"read": {"name": "read", "type": "function", "parameters": {}}}, + ), + {"read": _read_stub()}, + ) assert with_read.options.tools == ["Read", "Skill"] assert "Skill" not in with_read.options.allowed_tools