From 51cd0d474f80812e3edb2d2e6472d528f9d7ec41 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Thu, 29 Oct 2026 13:24:06 -0700 Subject: [PATCH 1/2] fix(AIC-3716): build OpenAI native graph node tools with FunctionTool _build_node_tools called agents.tool, which is a submodule in openai-agents, so every native-graph node with a tool raised "TypeError: module object is not callable". Build agents.FunctionTool directly (the class handler.py uses), passing the config schema as given (strict_json_schema=False, matching the TypeScript graph). The tool tests now use the real agents package, and a new test runs a node with a tool through to_openai_agents with only the model faked. Bound openai-agents below 0.25, its next breaking release. Co-Authored-By: Claude Opus 5.5 --- packages/openai-agents/pyproject.toml | 2 +- .../native_graph.py | 25 ++- .../openai-agents/tests/test_native_graph.py | 180 +++++++++++------- uv.lock | 2 +- 4 files changed, 132 insertions(+), 77 deletions(-) diff --git a/packages/openai-agents/pyproject.toml b/packages/openai-agents/pyproject.toml index 8d621a91..9b75a035 100644 --- a/packages/openai-agents/pyproject.toml +++ b/packages/openai-agents/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "opentelemetry-api>=1.25", "pydantic>=2.13", "openai>=2.44", - "openai-agents>=0.17", + "openai-agents>=0.17,<0.25", ] description = "OpenAI agent handler for LaunchDarkly AI SDK" readme = "README.md" diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py index d0ecb6f2..92af5ccb 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/native_graph.py @@ -6,6 +6,7 @@ from __future__ import annotations import inspect +import json import logging import re import time @@ -70,18 +71,23 @@ def _build_node_tools( import importlib agents_mod = importlib.import_module("agents") - tool_fn = agents_mod.tool + # Not `agents.tool`, which is a submodule. Same class as handler._build_agent_tools. + FunctionTool = agents_mod.FunctionTool if not node.config.get("tools"): return [] result = [] for name, tool_cfg in node.config["tools"].items(): - - async def _execute(args: Any, _name: str = name) -> str: + # on_invoke_tool receives (ToolContext, json_args_str); decode and dispatch. + async def _execute(_ctx: Any, args_str: str, _name: str = name) -> str: handler = tool_handlers.get(_name) if not handler or isinstance(handler, NativeTool): return "" + try: + args = json.loads(args_str) if args_str else {} + except (json.JSONDecodeError, ValueError): + args = {} # Handlers may be sync or async. Same rule as handler._build_agent_tools # and tracking.wrap_tool_handlers — awaiting a plain return raises. result = handler(args) @@ -89,11 +95,16 @@ async def _execute(args: Any, _name: str = name) -> str: result = await result return tool_result_string(result) - t = tool_fn( + t = FunctionTool( name=name, - description=tool_cfg.get("description", ""), - params_json_schema=tool_cfg.get("parameters") or {}, - )(_execute) + description=tool_cfg.get("description", "") or "", + params_json_schema=tool_cfg.get("parameters") + or {"type": "object", "properties": {}}, + on_invoke_tool=_execute, + # Send the config's schema as given, like the TypeScript graph's `strict: false`. + # Strict mode would make every property required and reject additionalProperties. + strict_json_schema=False, + ) result.append(t) return result diff --git a/packages/openai-agents/tests/test_native_graph.py b/packages/openai-agents/tests/test_native_graph.py index f789a3da..e7ed0382 100644 --- a/packages/openai-agents/tests/test_native_graph.py +++ b/packages/openai-agents/tests/test_native_graph.py @@ -138,7 +138,6 @@ def _make_agent(**kw: Any) -> MagicMock: mock.Agent = MagicMock(side_effect=_make_agent) mock.Runner.run = AsyncMock(return_value=run_result) mock.handoff = MagicMock(side_effect=lambda agent: agent) - mock.tool = MagicMock(side_effect=lambda **kw: lambda fn: fn) # RunHooks base class class _FakeRunHooks: @@ -526,7 +525,6 @@ async def test_error_path_emits_invocation_failure_and_rethrows(self) -> None: agents_mock.Agent = MagicMock(return_value=MagicMock(name="root")) agents_mock.Runner.run = AsyncMock(side_effect=RuntimeError("provider error")) agents_mock.handoff = MagicMock() - agents_mock.tool = MagicMock(side_effect=lambda **kw: lambda fn: fn) class _FakeRunHooks: async def on_agent_end(self, *a: Any) -> None: @@ -605,7 +603,6 @@ async def test_span_end_called_on_error(self) -> None: agents_mock.Agent = MagicMock(return_value=MagicMock(name="root")) agents_mock.Runner.run = AsyncMock(side_effect=RuntimeError("fail")) agents_mock.handoff = MagicMock() - agents_mock.tool = MagicMock(side_effect=lambda **kw: lambda fn: fn) class _FakeRunHooks: async def on_agent_end(self, *a: Any) -> None: @@ -1124,22 +1121,19 @@ async def test_no_never_forwarded_key_reaches_any_agent_or_the_run(self) -> None class TestBuildNodeToolsSyncHandlers: - """``_build_node_tools`` must accept sync handlers (native path skips wrap_tool_handlers).""" + """``_build_node_tools`` must accept sync handlers (native path skips wrap_tool_handlers). - @pytest.mark.asyncio - async def test_sync_handler_returns_string(self) -> None: - captured: list[Any] = [] - - agents_mock = MagicMock() - agents_mock.tool = MagicMock( - side_effect=lambda **kw: lambda fn: (captured.append(fn), fn)[1] - ) + Built with the real ``agents`` package: a mocked module accepted ``agents.tool(...)``, which is + a submodule in openai-agents, so the crash on every node with a tool went unseen (AIC-3716). + """ - node = _to_graph_node( + @staticmethod + def _node(tool_name: str) -> GraphNode: + return _to_graph_node( { "key": "root", "config": { - "tools": {"weather": {"description": "d", "parameters": {}}}, + "tools": {tool_name: {"description": "d", "parameters": {}}}, }, "meta": {}, "edges": [], @@ -1147,81 +1141,131 @@ async def test_sync_handler_returns_string(self) -> None: } ) - with patch( - "importlib.import_module", - side_effect=lambda n: agents_mock if n == "agents" else __import__(n), - ): - _build_node_tools(node, {"weather": lambda a: "sunny"}) - - assert captured, "tool was not registered" - assert await captured[0]({"city": "Paris"}) == "sunny" + async def test_sync_handler_returns_string(self) -> None: + from agents import FunctionTool - @pytest.mark.asyncio - async def test_async_handler_is_awaited(self) -> None: - captured: list[Any] = [] + [t] = _build_node_tools(self._node("weather"), {"weather": lambda a: "sunny"}) - agents_mock = MagicMock() - agents_mock.tool = MagicMock( - side_effect=lambda **kw: lambda fn: (captured.append(fn), fn)[1] - ) + assert isinstance(t, FunctionTool) + assert t.name == "weather" + assert await t.on_invoke_tool(MagicMock(), '{"city": "Paris"}') == "sunny" + async def test_async_handler_is_awaited(self) -> None: async def async_handler(args: Any) -> str: return f"async:{args.get('q')}" - node = _to_graph_node( - { - "key": "root", - "config": { - "tools": {"lookup": {"description": "d", "parameters": {}}}, - }, - "meta": {}, - "edges": [], - "is_terminal": True, - } - ) - - with patch( - "importlib.import_module", - side_effect=lambda n: agents_mock if n == "agents" else __import__(n), - ): - _build_node_tools(node, {"lookup": async_handler}) + [t] = _build_node_tools(self._node("lookup"), {"lookup": async_handler}) - assert await captured[0]({"q": "hi"}) == "async:hi" + assert await t.on_invoke_tool(MagicMock(), '{"q": "hi"}') == "async:hi" - @pytest.mark.asyncio async def test_sync_handler_returning_awaitable_is_awaited(self) -> None: - captured: list[Any] = [] - - agents_mock = MagicMock() - agents_mock.tool = MagicMock( - side_effect=lambda **kw: lambda fn: (captured.append(fn), fn)[1] - ) - async def _inner() -> str: return "done" def sync_wrapper(_args: Any) -> Any: return _inner() - node = _to_graph_node( - { + [t] = _build_node_tools(self._node("my-tool"), {"my-tool": sync_wrapper}) + + assert await t.on_invoke_tool(MagicMock(), "") == "done" + + +class TestNativeGraphRunsANodeWithAToolOnTheRealSdk: + """The real ``agents`` package runs the graph; only the model is fake (AIC-3716).""" + + async def test_tool_call_reaches_the_handler_and_the_run_completes(self) -> None: + from agents import FunctionTool, ModelResponse, Usage + from agents.models.interface import Model + from agents.models.multi_provider import MultiProvider + from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputText, + ) + + # Not strict-mode shaped (`limit` optional, no additionalProperties), so a rewrite shows. + schema = { + "type": "object", + "properties": {"q": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["q"], + } + seen_tools: list[list[Any]] = [] + + class _FakeModel(Model): + """Turn 1 calls the tool; turn 2 answers with the tool's result.""" + + async def get_response(self, *args: Any, **kwargs: Any) -> Any: + seen_tools.append(list(kwargs["tools"])) + items = kwargs["input"] + outputs = [ + i["output"] + for i in (items if isinstance(items, list) else []) + if isinstance(i, dict) and i.get("type") == "function_call_output" + ] + if outputs: + output: list[Any] = [ + ResponseOutputMessage( + id="m1", + type="message", + role="assistant", + status="completed", + content=[ + ResponseOutputText( + type="output_text", + text=f"answer: {outputs[-1]}", + annotations=[], + ) + ], + ) + ] + else: + output = [ + ResponseFunctionToolCall( + id="fc1", + call_id="call1", + type="function_call", + name="lookup", + arguments='{"q": "hi"}', + ) + ] + return ModelResponse(output=output, usage=Usage(), response_id=None) + + def stream_response(self, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError + + calls: list[Any] = [] + + def lookup(args: Any) -> str: + calls.append(args) + return f"found {args['q']}" + + nodes = { + "root": { "key": "root", "config": { - "tools": {"my-tool": {"description": "d", "parameters": {}}}, + "model": {"name": "gpt-4o"}, + "instructions": "help", + "tools": { + "lookup": {"description": "Look up", "parameters": schema} + }, }, - "meta": {}, + "meta": {"variationKey": "v1", "version": 1}, "edges": [], "is_terminal": True, } - ) - - with patch( - "importlib.import_module", - side_effect=lambda n: agents_mock if n == "agents" else __import__(n), - ): - _build_node_tools(node, {"my-tool": sync_wrapper}) - - assert await captured[0]({}) == "done" + } + with patch.object(MultiProvider, "get_model", return_value=_FakeModel()): + result = await to_openai_agents( + _make_def_promise(_make_graph_def(nodes=nodes)), + {"tool_handlers": {"lookup": lookup}}, + ).invoke("q") + + assert calls == [{"q": "hi"}] + assert result["response"] == "answer: found hi" + [tool] = seen_tools[0] + assert isinstance(tool, FunctionTool) + assert (tool.name, tool.description) == ("lookup", "Look up") + assert tool.params_json_schema == schema class TestNativeGraphForwardsExactlyTheCrossSdkList: diff --git a/uv.lock b/uv.lock index 6fcf5658..a2608e64 100644 --- a/uv.lock +++ b/uv.lock @@ -1490,7 +1490,7 @@ dependencies = [ requires-dist = [ { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "openai", specifier = ">=2.44" }, - { name = "openai-agents", specifier = ">=0.17" }, + { name = "openai-agents", specifier = ">=0.17,<0.25" }, { name = "opentelemetry-api", specifier = ">=1.25" }, { name = "pydantic", specifier = ">=2.13" }, ] From 1969debf9d4ba9a9483097110c512e506fb6184b Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Thu, 29 Oct 2026 13:38:15 -0700 Subject: [PATCH 2/2] test(AIC-3716): keep the real-SDK graph test from exporting traces Co-Authored-By: Claude Opus 5.5 --- packages/openai-agents/tests/test_native_graph.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/openai-agents/tests/test_native_graph.py b/packages/openai-agents/tests/test_native_graph.py index e7ed0382..db22f888 100644 --- a/packages/openai-agents/tests/test_native_graph.py +++ b/packages/openai-agents/tests/test_native_graph.py @@ -1177,6 +1177,7 @@ async def test_tool_call_reaches_the_handler_and_the_run_completes(self) -> None from agents import FunctionTool, ModelResponse, Usage from agents.models.interface import Model from agents.models.multi_provider import MultiProvider + from agents.tracing import get_trace_provider from openai.types.responses import ( ResponseFunctionToolCall, ResponseOutputMessage, @@ -1254,7 +1255,13 @@ def lookup(args: Any) -> str: "is_terminal": True, } } - with patch.object(MultiProvider, "get_model", return_value=_FakeModel()): + # Never export this run's trace to OpenAI, whatever OPENAI_API_KEY is set. The + # exporter sends in the background, so only stopping the trace itself is enough. + # `set_tracing_disabled` has no undo; patching its flag restores the exact prior state. + with ( + patch.object(get_trace_provider(), "_manual_disabled", True), + patch.object(MultiProvider, "get_model", return_value=_FakeModel()), + ): result = await to_openai_agents( _make_def_promise(_make_graph_def(nodes=nodes)), {"tool_handlers": {"lookup": lookup}},