Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/openai-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import inspect
import json
import logging
import re
import time
Expand Down Expand Up @@ -70,30 +71,40 @@ 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)
if inspect.isawaitable(result):
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

Expand Down
185 changes: 118 additions & 67 deletions packages/openai-agents/tests/test_native_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1124,104 +1121,158 @@ 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": [],
"is_terminal": True,
}
)

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 agents.tracing import get_trace_provider
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),
}
# 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()),
):
_build_node_tools(node, {"my-tool": sync_wrapper})

assert await captured[0]({}) == "done"
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:
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading