From 96847b9f6a75daca8a9b8a0bd801c8230fa36375 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Mon, 30 Mar 2026 11:23:35 -0500 Subject: [PATCH 1/7] fix(langchain): wrap tools with StructuredTool before bind_tools Raw Python callables passed to bind_tools() lack the schema metadata required to generate a valid OpenAI tools[].function spec, causing a 400 "Missing required parameter: 'tools[0].function'" error. Wrapping each callable with StructuredTool.from_function(name=config_key) also ensures the model response carries the config key as the tool name, fixing a separate bug where function.__name__ was being tracked instead of the LaunchDarkly config key. Co-Authored-By: Claude Sonnet 4.6 --- .../langgraph_agent_graph_runner.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py index c0c0b5c..0a905d9 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py @@ -53,6 +53,7 @@ async def run(self, input: Any) -> AgentGraphResult: start_ns = time.perf_counter_ns() try: from langchain_core.messages import AnyMessage, HumanMessage + from langchain_core.tools import StructuredTool from langgraph.graph import END, START, StateGraph from typing_extensions import TypedDict @@ -74,11 +75,18 @@ def handle_traversal(node: AgentGraphNode, ctx: dict) -> None: if node_config.model: lc_model = create_langchain_model(node_config) tool_defs = node_config.model.get_parameter('tools') or [] - tool_fns = [ - tools_ref[t.get('name', '')] - for t in tool_defs - if t.get('name', '') in tools_ref - ] + tool_fns = [] + for t in tool_defs: + config_key = t.get('name', '') + if config_key not in tools_ref: + continue + tool_fns.append( + StructuredTool.from_function( + func=tools_ref[config_key], + name=config_key, + description=t.get('description', ''), + ) + ) model = lc_model.bind_tools(tool_fns) if tool_fns else lc_model def invoke(state: WorkflowState) -> WorkflowState: From daa5d7b1d88bc80ec474e0fd41c690ae06fd809c Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Mon, 30 Mar 2026 11:51:39 -0500 Subject: [PATCH 2/7] fix(openai): remove _to_openai_name and pass configured model to Agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _to_openai_name converted hyphens to underscores based on an assumed restriction that doesn't exist — the OpenAI API allows hyphens in tool and function names (^[a-zA-Z0-9_-]{1,64}$). Removing it simplifies the tool name lookup and agent naming. Also adds model=model.name to the Agent constructor so each node runs with its configured model rather than the SDK default. Co-Authored-By: Claude Sonnet 4.6 --- .../ldai_openai/openai_agent_graph_runner.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py index df2acf6..cdbebd6 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py @@ -10,11 +10,6 @@ from ldai.tracker import TokenUsage -def _to_openai_name(name: str) -> str: - """Convert a hyphenated tool/node name to an underscore-separated OpenAI function name.""" - return name.replace('-', '_') - - def _build_native_tool_map() -> dict: try: from agents import ( @@ -281,21 +276,19 @@ def build_node(node: AgentGraphNode, ctx: dict) -> Any: # --- tools --- agent_tools: List[Tool] = [] for tool_def in tool_defs: - tool_name_raw = tool_def.get('name', '') - tool_name = _to_openai_name(tool_name_raw) + tool_name = tool_def.get('name', '') # Check native OpenAI tools first, then fall back to ToolRegistry if tool_name in _NATIVE_OPENAI_TOOLS: agent_tools.append(_NATIVE_OPENAI_TOOLS[tool_name](tool_def)) continue - tool_fn = self._tools.get(tool_name) or self._tools.get(tool_name_raw) + tool_fn = self._tools.get(tool_name) if not tool_fn: continue def _make_tool( name: str, - raw_name: str, fn: Any, description: str, params_schema: dict, @@ -306,7 +299,7 @@ def wrapped(tool_ctx: ToolContext, tool_args: str) -> Any: args = json.loads(tool_args) except Exception: args = {} - path.append(raw_name) + path.append(name) if config_tracker is not None: config_tracker.track_tool_call( name, @@ -324,7 +317,6 @@ def wrapped(tool_ctx: ToolContext, tool_args: str) -> Any: agent_tools.append( _make_tool( tool_name, - tool_name_raw, tool_fn, tool_def.get('description', ''), tool_def.get('parameters', {}), @@ -332,7 +324,8 @@ def wrapped(tool_ctx: ToolContext, tool_args: str) -> Any: ) return Agent( - name=_to_openai_name(node_config.key), + name=node_config.key, + model=model.name, instructions=f'{RECOMMENDED_PROMPT_PREFIX} {node_config.instructions or ""}', handoffs=list(agent_handoffs), tools=list(agent_tools), From 26f16654c05ce5dba098bbb43906cd985e5d58e8 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Mon, 30 Mar 2026 14:06:50 -0500 Subject: [PATCH 3/7] fix(openai): track native tool calls by parsing RunResult.new_items Previously, tool tracking relied on wrapping custom FunctionTools, which meant native hosted tools (WebSearchTool, FileSearchTool, etc.) were never tracked since they run server-side with no local callback. Instead, parse result.new_items after the run completes. Each ToolCallItem carries the originating agent (node attribution) and raw tool call data, covering both custom and native tools without requiring local execution. Also adds openai-agents as an optional dependency and dev dependency. Co-Authored-By: Claude Sonnet 4.6 --- .../server-ai-openai/pyproject.toml | 4 ++ .../ldai_openai/openai_agent_graph_runner.py | 24 ++++++---- .../src/ldai_openai/openai_helper.py | 46 ++++++++++++++++++- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/ai-providers/server-ai-openai/pyproject.toml b/packages/ai-providers/server-ai-openai/pyproject.toml index eec8642..0f7f78c 100644 --- a/packages/ai-providers/server-ai-openai/pyproject.toml +++ b/packages/ai-providers/server-ai-openai/pyproject.toml @@ -23,6 +23,9 @@ dependencies = [ "openai>=1.0.0", ] +[project.optional-dependencies] +agents = ["openai-agents>=0.0.1"] + [project.urls] Homepage = "https://docs.launchdarkly.com/sdk/ai/python" Repository = "https://github.com/launchdarkly/python-server-sdk-ai" @@ -35,6 +38,7 @@ dev = [ "mypy==1.18.2", "pycodestyle>=2.11.0", "isort>=5.12.0", + "openai-agents>=0.0.1", ] [build-system] diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py index cdbebd6..68cabce 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py @@ -9,6 +9,8 @@ from ldai.providers.types import LDAIMetrics from ldai.tracker import TokenUsage +from ldai_openai.openai_helper import get_tool_calls_from_run_items + def _build_native_tool_map() -> dict: try: @@ -85,6 +87,7 @@ async def run(self, input: Any) -> AgentGraphResult: root_agent = self._build_agents(path, state) result = await Runner.run(root_agent, str(input)) self._flush_final_segment(state, tracker, result) + self._track_tool_calls(result, tracker) duration = (time.perf_counter_ns() - start_ns) // 1_000_000 @@ -163,6 +166,17 @@ def _flush_final_segment( config_tracker.track_duration(int(duration_ms), graph_key=gk) config_tracker.track_success(graph_key=gk) + def _track_tool_calls(self, result: Any, tracker: Any) -> None: + """Track all tool calls from the run result, attributed to the node that called them.""" + gk = tracker.graph_key if tracker is not None else None + for agent_name, tool_name in get_tool_calls_from_run_items(result.new_items): + node = self._graph.get_node(agent_name) + if node is None: + continue + config_tracker = node.get_config().tracker + if config_tracker is not None: + config_tracker.track_tool_call(tool_name, graph_key=gk) + def _handle_handoff( self, run_ctx: Any, @@ -231,12 +245,10 @@ def _build_agents(self, path: List[str], state: _RunState) -> Any: Agent, FunctionTool, Handoff, - RunContextWrapper, Tool, handoff, ) from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX - from agents.tool_context import ToolContext except ImportError as exc: raise ImportError( "openai-agents is required for OpenAIAgentGraphRunner. " @@ -293,18 +305,12 @@ def _make_tool( description: str, params_schema: dict, ) -> FunctionTool: - def wrapped(tool_ctx: ToolContext, tool_args: str) -> Any: + def wrapped(tool_ctx: Any, tool_args: str) -> Any: import json try: args = json.loads(tool_args) except Exception: args = {} - path.append(name) - if config_tracker is not None: - config_tracker.track_tool_call( - name, - graph_key=tracker.graph_key if tracker is not None else None, - ) return fn(**args) return FunctionTool( diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py index 3cc41e4..89d1ca4 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py @@ -1,4 +1,4 @@ -from typing import Any, Iterable, List, Optional, cast +from typing import Any, Iterable, List, Optional, Tuple, cast from ldai import LDMessage from ldai.providers.types import LDAIMetrics @@ -44,3 +44,47 @@ def get_ai_metrics_from_response(response: Any) -> LDAIMetrics: :return: LDAIMetrics with success status and token usage """ return LDAIMetrics(success=True, usage=get_ai_usage_from_response(response)) + + +# Native tool raw_item type names don't always match the LD config key convention. +_NATIVE_TOOL_TYPE_TO_CONFIG_KEY = { + 'web_search': 'web_search_tool', + 'file_search': 'file_search_tool', +} + + +def get_tool_calls_from_run_items(new_items: List[Any]) -> List[Tuple[str, str]]: + """ + Extract (agent_name, tool_name) pairs from RunResult.new_items. + + Covers both custom FunctionTools (tracked by their config key) and native + hosted tools (web search, file search, code interpreter, image generation). + + :param new_items: The new_items list from a RunResult + :return: List of (agent_name, tool_name) tuples + """ + try: + from agents.items import ToolCallItem + from openai.types.responses import ResponseFunctionToolCall + except ImportError: + return [] + + result = [] + for item in new_items: + if not isinstance(item, ToolCallItem): + continue + agent_name = getattr(item.agent, 'name', None) + if not agent_name: + continue + raw = item.raw_item + if isinstance(raw, ResponseFunctionToolCall): + # Custom FunctionTools are registered as 'tool_{config_key}' + tool_name = raw.name.removeprefix('tool_') + else: + raw_type = getattr(raw, 'type', None) or (raw.get('type') if isinstance(raw, dict) else None) + if not raw_type: + continue + tool_name = _NATIVE_TOOL_TYPE_TO_CONFIG_KEY.get(raw_type, raw_type) + if tool_name: + result.append((agent_name, tool_name)) + return result From 7800e522c865dfbf0273d25fecbda613c3705731 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Mon, 30 Mar 2026 14:07:30 -0500 Subject: [PATCH 4/7] chore(langchain): add langgraph as optional and dev dependency langgraph is required to use LangGraphAgentGraphRunner but was not listed as a dependency. Adding it as an optional extra (graph) and dev dependency, consistent with how openai-agents is handled in the openai provider. Co-Authored-By: Claude Sonnet 4.6 --- packages/ai-providers/server-ai-langchain/pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ai-providers/server-ai-langchain/pyproject.toml b/packages/ai-providers/server-ai-langchain/pyproject.toml index a8fe2ef..f2469ac 100644 --- a/packages/ai-providers/server-ai-langchain/pyproject.toml +++ b/packages/ai-providers/server-ai-langchain/pyproject.toml @@ -24,6 +24,9 @@ dependencies = [ "langchain>=1.0.0", ] +[project.optional-dependencies] +graph = ["langgraph>=0.1.0"] + [project.urls] Homepage = "https://docs.launchdarkly.com/sdk/ai/python" Repository = "https://github.com/launchdarkly/python-server-sdk-ai" @@ -36,6 +39,7 @@ dev = [ "mypy==1.18.2", "pycodestyle>=2.11.0", "isort>=5.12.0", + "langgraph>=0.1.0", ] [build-system] From 957747db02d33f54bb4898d274b7a82e19f581dd Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 31 Mar 2026 11:40:56 -0500 Subject: [PATCH 5/7] fix: resolve lint and test failures - Remove FileSearchTool, CodeInterpreterTool, ImageGenerationTool from the native tool map; they require mandatory constructor args not available from the LD config and were already broken before. - Move StructuredTool import inside the tools loop so it is only attempted when tools are present, fixing a test that mocks langchain_core without langchain_core.tools. Co-Authored-By: Claude Sonnet 4.6 --- .../src/ldai_langchain/langgraph_agent_graph_runner.py | 2 +- .../src/ldai_openai/openai_agent_graph_runner.py | 10 +--------- .../server-ai-openai/src/ldai_openai/openai_helper.py | 1 - 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py index 0a905d9..6f75295 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py @@ -53,7 +53,6 @@ async def run(self, input: Any) -> AgentGraphResult: start_ns = time.perf_counter_ns() try: from langchain_core.messages import AnyMessage, HumanMessage - from langchain_core.tools import StructuredTool from langgraph.graph import END, START, StateGraph from typing_extensions import TypedDict @@ -80,6 +79,7 @@ def handle_traversal(node: AgentGraphNode, ctx: dict) -> None: config_key = t.get('name', '') if config_key not in tools_ref: continue + from langchain_core.tools import StructuredTool tool_fns.append( StructuredTool.from_function( func=tools_ref[config_key], diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py index 68cabce..4bda667 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py @@ -14,17 +14,9 @@ def _build_native_tool_map() -> dict: try: - from agents import ( - CodeInterpreterTool, - FileSearchTool, - ImageGenerationTool, - WebSearchTool, - ) + from agents import WebSearchTool return { 'web_search_tool': lambda _: WebSearchTool(), - 'file_search_tool': lambda _: FileSearchTool(), - 'code_interpreter': lambda _: CodeInterpreterTool(), - 'image_generation': lambda _: ImageGenerationTool(), } except ImportError: return {} diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py index 89d1ca4..f63076b 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py @@ -49,7 +49,6 @@ def get_ai_metrics_from_response(response: Any) -> LDAIMetrics: # Native tool raw_item type names don't always match the LD config key convention. _NATIVE_TOOL_TYPE_TO_CONFIG_KEY = { 'web_search': 'web_search_tool', - 'file_search': 'file_search_tool', } From fb647ae481f5148e17f3aa1e0c7a86562bcfeafa Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 31 Mar 2026 13:01:21 -0500 Subject: [PATCH 6/7] additional cleanup --- .../ldai_langchain/langchain_agent_runner.py | 8 +- .../src/ldai_langchain/langchain_helper.py | 14 ++ .../langgraph_agent_graph_runner.py | 23 +- .../ldai_openai/openai_agent_graph_runner.py | 213 +++++++++--------- .../src/ldai_openai/openai_helper.py | 20 +- .../src/ldai_openai/openai_runner_factory.py | 22 +- packages/sdk/server-ai/src/ldai/client.py | 11 +- .../src/ldai/providers/runner_factory.py | 10 +- 8 files changed, 172 insertions(+), 149 deletions(-) diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_agent_runner.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_agent_runner.py index b0a1c85..bb63771 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_agent_runner.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_agent_runner.py @@ -6,7 +6,7 @@ from ldai.providers import AgentResult, AgentRunner from ldai.providers.types import LDAIMetrics -from ldai_langchain.langchain_helper import sum_token_usage_from_messages +from ldai_langchain.langchain_helper import extract_last_message_content, sum_token_usage_from_messages class LangChainAgentRunner(AgentRunner): @@ -37,11 +37,7 @@ async def run(self, input: Any) -> AgentResult: "messages": [{"role": "user", "content": str(input)}] }) messages = result.get("messages", []) - output = "" - if messages: - last = messages[-1] - if hasattr(last, 'content') and isinstance(last.content, str): - output = last.content + output = extract_last_message_content(messages) return AgentResult( output=output, raw=result, diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py index 677d243..a42cf12 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py @@ -234,6 +234,20 @@ def get_tool_calls_from_response(response: Any) -> List[str]: return names +def extract_last_message_content(messages: List[Any]) -> str: + """ + Extract the string content of the last message in a list. + + :param messages: List of LangChain message objects + :return: String content of the last message, or empty string if none + """ + if messages: + last = messages[-1] + if hasattr(last, 'content'): + return str(last.content) + return '' + + def sum_token_usage_from_messages(messages: List[Any]) -> Optional[TokenUsage]: """ Sum token usage across LangChain messages using get_ai_usage_from_response per message. diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py index 6f75295..746fdd1 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py @@ -10,7 +10,9 @@ from ldai.providers.types import LDAIMetrics from ldai_langchain.langchain_helper import ( + build_structured_tools, create_langchain_model, + extract_last_message_content, get_ai_metrics_from_response, get_ai_usage_from_response, get_tool_calls_from_response, @@ -73,20 +75,7 @@ def handle_traversal(node: AgentGraphNode, ctx: dict) -> None: model = None if node_config.model: lc_model = create_langchain_model(node_config) - tool_defs = node_config.model.get_parameter('tools') or [] - tool_fns = [] - for t in tool_defs: - config_key = t.get('name', '') - if config_key not in tools_ref: - continue - from langchain_core.tools import StructuredTool - tool_fns.append( - StructuredTool.from_function( - func=tools_ref[config_key], - name=config_key, - description=t.get('description', ''), - ) - ) + tool_fns = build_structured_tools(node_config, tools_ref) model = lc_model.bind_tools(tool_fns) if tool_fns else lc_model def invoke(state: WorkflowState) -> WorkflowState: @@ -132,12 +121,8 @@ def invoke(state: WorkflowState) -> WorkflowState: ) duration = (time.perf_counter_ns() - start_ns) // 1_000_000 - output = '' messages = result.get('messages', []) - if messages: - last = messages[-1] - if hasattr(last, 'content'): - output = str(last.content) + output = extract_last_message_content(messages) if tracker: tracker.track_path(exec_path) diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py index 1996791..fbddd2d 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py @@ -1,5 +1,6 @@ """OpenAI agent graph runner for LaunchDarkly AI SDK.""" +import json import time from typing import Any, List, Optional @@ -11,6 +12,7 @@ from ldai_openai.openai_helper import ( NATIVE_OPENAI_TOOLS, + extract_usage_from_request_entry, get_ai_usage_from_response, get_tool_calls_from_run_items, ) @@ -40,7 +42,7 @@ def __init__(self, graph: AgentGraphDefinition, tools: ToolRegistry): Initialize the runner. :param graph: The AgentGraphDefinition to execute - :param tools: Registry mapping OpenAI-formatted tool names to callables + :param tools: Registry mapping tool names to callables """ self._graph = graph self._tools = tools @@ -105,105 +107,6 @@ async def run(self, input: Any) -> AgentGraphResult: metrics=LDAIMetrics(success=False), ) - def _flush_final_segment( - self, - state: _RunState, - tracker: Any, - result: Any, - ) -> None: - """Record duration/tokens for the last active agent (no handoff after it).""" - if not state.last_node_key: - return - node = self._graph.get_node(state.last_node_key) - if node is None: - return - config_tracker = node.get_config().tracker - if config_tracker is None: - return - - now_ns = time.perf_counter_ns() - duration_ms = (now_ns - state.last_handoff_ns) // 1_000_000 - - usage: Optional[TokenUsage] = None - try: - usage_entry = result.context_wrapper.usage.request_usage_entries[-1] - usage = TokenUsage( - total=usage_entry.total_tokens, - input=usage_entry.input_tokens, - output=usage_entry.output_tokens, - ) - except Exception: - pass - - gk = tracker.graph_key if tracker is not None else None - if usage is not None: - config_tracker.track_tokens(usage, graph_key=gk) - config_tracker.track_duration(int(duration_ms), graph_key=gk) - config_tracker.track_success(graph_key=gk) - - def _track_tool_calls(self, result: Any, tracker: Any) -> None: - """Track all tool calls from the run result, attributed to the node that called them.""" - gk = tracker.graph_key if tracker is not None else None - for agent_name, tool_name in get_tool_calls_from_run_items(result.new_items): - node = self._graph.get_node(agent_name) - if node is None: - continue - config_tracker = node.get_config().tracker - if config_tracker is not None: - config_tracker.track_tool_call(tool_name, graph_key=gk) - - def _handle_handoff( - self, - run_ctx: Any, - src: str, - tgt: str, - path: List[str], - tracker: Any, - config_tracker: Any, - state: _RunState, - ) -> None: - path.append(tgt) - state.last_node_key = tgt - if tracker: - tracker.track_handoff_success(src, tgt) - - usage: Optional[TokenUsage] = None - now_ns = time.perf_counter_ns() - duration_ms = (now_ns - state.last_handoff_ns) // 1_000_000 - state.last_handoff_ns = now_ns - try: - usage_entry = run_ctx.usage.request_usage_entries[-1] - usage = TokenUsage( - total=usage_entry.total_tokens, - input=usage_entry.input_tokens, - output=usage_entry.output_tokens, - ) - except Exception: - pass - - gk = tracker.graph_key if tracker is not None else None - if config_tracker is not None: - if usage is not None: - config_tracker.track_tokens(usage, graph_key=gk) - if duration_ms is not None: - config_tracker.track_duration(int(duration_ms), graph_key=gk) - config_tracker.track_success(graph_key=gk) - - def _make_on_handoff( - self, - src: str, - tgt: str, - path: List[str], - tracker: Any, - config_tracker: Any, - state: _RunState, - ): - def on_handoff(run_ctx: Any) -> None: - self._handle_handoff( - run_ctx, src, tgt, path, tracker, config_tracker, state - ) - return on_handoff - def _build_agents(self, path: List[str], state: _RunState) -> Any: """ Build the agent tree from the graph definition via reverse_traverse. @@ -280,16 +183,22 @@ def _make_tool( description: str, params_schema: dict, ) -> FunctionTool: - def wrapped(tool_ctx: Any, tool_args: str) -> Any: - import json + async def wrapped(tool_ctx: Any, tool_args: str) -> str: try: - args = json.loads(tool_args) + args = json.loads(tool_args) if tool_args else {} except Exception: args = {} - return fn(**args) + try: + res = fn(**args) + if hasattr(res, "__await__"): + res = await res + return str(res) + except Exception as e: + log.warning(f"Tool '{name}' execution failed: {e}") + return f"Tool execution failed: {e}" return FunctionTool( - name=f'tool_{name}', + name=name, description=description, params_json_schema=params_schema, on_invoke_tool=wrapped, @@ -313,3 +222,97 @@ def wrapped(tool_ctx: Any, tool_args: str) -> Any: ) return self._graph.reverse_traverse(fn=build_node) + + def _make_on_handoff( + self, + src: str, + tgt: str, + path: List[str], + tracker: Any, + config_tracker: Any, + state: _RunState, + ): + def on_handoff(run_ctx: Any) -> None: + self._handle_handoff( + run_ctx, src, tgt, path, tracker, config_tracker, state + ) + return on_handoff + + def _handle_handoff( + self, + run_ctx: Any, + src: str, + tgt: str, + path: List[str], + tracker: Any, + config_tracker: Any, + state: _RunState, + ) -> None: + path.append(tgt) + state.last_node_key = tgt + if tracker: + tracker.track_handoff_success(src, tgt) + + now_ns = time.perf_counter_ns() + duration_ms = (now_ns - state.last_handoff_ns) // 1_000_000 + state.last_handoff_ns = now_ns + + usage: Optional[TokenUsage] = None + try: + usage = extract_usage_from_request_entry( + run_ctx.usage.request_usage_entries[-1] + ) + except Exception: + pass + + gk = tracker.graph_key if tracker is not None else None + if config_tracker is not None: + if usage is not None: + config_tracker.track_tokens(usage, graph_key=gk) + if duration_ms is not None: + config_tracker.track_duration(int(duration_ms), graph_key=gk) + config_tracker.track_success(graph_key=gk) + + def _flush_final_segment( + self, + state: _RunState, + tracker: Any, + result: Any, + ) -> None: + """Record duration/tokens for the last active agent (no handoff after it).""" + if not state.last_node_key: + return + node = self._graph.get_node(state.last_node_key) + if node is None: + return + config_tracker = node.get_config().tracker + if config_tracker is None: + return + + now_ns = time.perf_counter_ns() + duration_ms = (now_ns - state.last_handoff_ns) // 1_000_000 + + usage: Optional[TokenUsage] = None + try: + usage = extract_usage_from_request_entry( + result.context_wrapper.usage.request_usage_entries[-1] + ) + except Exception: + pass + + gk = tracker.graph_key if tracker is not None else None + if usage is not None: + config_tracker.track_tokens(usage, graph_key=gk) + config_tracker.track_duration(int(duration_ms), graph_key=gk) + config_tracker.track_success(graph_key=gk) + + def _track_tool_calls(self, result: Any, tracker: Any) -> None: + """Track all tool calls from the run result, attributed to the node that called them.""" + gk = tracker.graph_key if tracker is not None else None + for agent_name, tool_name in get_tool_calls_from_run_items(result.new_items): + node = self._graph.get_node(agent_name) + if node is None: + continue + config_tracker = node.get_config().tracker + if config_tracker is not None: + config_tracker.track_tool_call(tool_name, graph_key=gk) diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py index 8923b36..6e4ade1 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_helper.py @@ -64,6 +64,23 @@ def get_ai_usage_from_response(response: Any) -> Optional[TokenUsage]: return None +def extract_usage_from_request_entry(entry: Any) -> Optional[TokenUsage]: + """ + Extract token usage from a single request_usage_entry in an openai-agents RunResult. + + :param entry: A request_usage_entry from context_wrapper.usage.request_usage_entries + :return: TokenUsage or None if extraction fails + """ + try: + return TokenUsage( + total=entry.total_tokens, + input=entry.input_tokens, + output=entry.output_tokens, + ) + except Exception: + return None + + def get_ai_metrics_from_response(response: Any) -> LDAIMetrics: """ Extract LaunchDarkly AI metrics from an OpenAI response. @@ -105,8 +122,7 @@ def get_tool_calls_from_run_items(new_items: List[Any]) -> List[Tuple[str, str]] continue raw = item.raw_item if isinstance(raw, ResponseFunctionToolCall): - # Custom FunctionTools are registered as 'tool_{config_key}' - tool_name = raw.name.removeprefix('tool_') + tool_name = raw.name else: raw_type = getattr(raw, 'type', None) or (raw.get('type') if isinstance(raw, dict) else None) if not raw_type: diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_runner_factory.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_runner_factory.py index 644d12f..2816655 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_runner_factory.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_runner_factory.py @@ -24,6 +24,17 @@ def __init__(self, client: Optional[AsyncOpenAI] = None): api_key=os.environ.get('OPENAI_API_KEY'), ) + def _extract_model_config(self, config: AIConfigKind) -> tuple: + """ + Extract model name and parameters from an AI config. + + :param config: The LaunchDarkly AI configuration + :return: Tuple of (model_name, parameters) + """ + config_dict = config.to_dict() + model_dict = config_dict.get('model') or {} + return model_dict.get('name', ''), model_dict.get('parameters') or {} + def create_model(self, config: AIConfigKind) -> OpenAIModelRunner: """ Create a configured OpenAIModelRunner for the given AI config. @@ -33,10 +44,7 @@ def create_model(self, config: AIConfigKind) -> OpenAIModelRunner: :param config: The LaunchDarkly AI configuration :return: OpenAIModelRunner ready to invoke the model """ - config_dict = config.to_dict() - model_dict = config_dict.get('model') or {} - model_name = model_dict.get('name', '') - parameters = model_dict.get('parameters') or {} + model_name, parameters = self._extract_model_config(config) return OpenAIModelRunner(self._client, model_name, parameters) def create_agent_graph(self, graph_def: Any, tools: ToolRegistry) -> Any: @@ -60,10 +68,8 @@ def create_agent(self, config: Any, tools: Optional[ToolRegistry] = None) -> 'Op """ from ldai_openai.openai_agent_runner import OpenAIAgentRunner - config_dict = config.to_dict() - model_dict = config_dict.get('model') or {} - model_name = model_dict.get('name', '') - parameters = dict(model_dict.get('parameters') or {}) + model_name, base_parameters = self._extract_model_config(config) + parameters = dict(base_parameters) tool_definitions = parameters.pop('tools', []) or [] instructions = (config.instructions or '') if hasattr(config, 'instructions') else '' diff --git a/packages/sdk/server-ai/src/ldai/client.py b/packages/sdk/server-ai/src/ldai/client.py index d77974e..cdc14c4 100644 --- a/packages/sdk/server-ai/src/ldai/client.py +++ b/packages/sdk/server-ai/src/ldai/client.py @@ -32,14 +32,15 @@ from ldai.tracker import AIGraphTracker, LDAIConfigTracker _TRACK_SDK_INFO = '$ld:ai:sdk:info' -_TRACK_USAGE_COMPLETION_CONFIG = '$ld:ai:usage:completion-config' -_TRACK_USAGE_CREATE_MODEL = '$ld:ai:usage:create-model' -_TRACK_USAGE_CREATE_AGENT = '$ld:ai:usage:create-agent' -_TRACK_USAGE_JUDGE_CONFIG = '$ld:ai:usage:judge-config' -_TRACK_USAGE_CREATE_JUDGE = '$ld:ai:usage:create-judge' + _TRACK_USAGE_AGENT_CONFIG = '$ld:ai:usage:agent-config' _TRACK_USAGE_AGENT_CONFIGS = '$ld:ai:usage:agent-configs' +_TRACK_USAGE_COMPLETION_CONFIG = '$ld:ai:usage:completion-config' +_TRACK_USAGE_CREATE_AGENT = '$ld:ai:usage:create-agent' _TRACK_USAGE_CREATE_AGENT_GRAPH = '$ld:ai:usage:create-agent-graph' +_TRACK_USAGE_CREATE_JUDGE = '$ld:ai:usage:create-judge' +_TRACK_USAGE_CREATE_MODEL = '$ld:ai:usage:create-model' +_TRACK_USAGE_JUDGE_CONFIG = '$ld:ai:usage:judge-config' _INIT_TRACK_CONTEXT = Context.builder('ld-internal-tracking').kind('ld_ai').anonymous(True).build() diff --git a/packages/sdk/server-ai/src/ldai/providers/runner_factory.py b/packages/sdk/server-ai/src/ldai/providers/runner_factory.py index 190704e..589896b 100644 --- a/packages/sdk/server-ai/src/ldai/providers/runner_factory.py +++ b/packages/sdk/server-ai/src/ldai/providers/runner_factory.py @@ -4,6 +4,8 @@ from ldai import log from ldai.models import AIConfigKind from ldai.providers.ai_provider import AIProvider +from ldai.providers.agent_graph_runner import AgentGraphRunner +from ldai.providers.agent_runner import AgentRunner from ldai.providers.model_runner import ModelRunner T = TypeVar('T') @@ -133,14 +135,14 @@ def create_agent( config: Any, tools: Any, default_ai_provider: Optional[str] = None, - ) -> Optional[Any]: + ) -> Optional[AgentRunner]: """ Create an agent executor for the given AI agent config and tool registry. :param config: LaunchDarkly AI agent config :param tools: Tool registry mapping tool names to callables :param default_ai_provider: Optional provider override - :return: AgentExecutor instance, or None + :return: AgentRunner instance, or None """ provider_name = config.provider.name.lower() if config.provider else None providers = RunnerFactory._get_providers_to_try(default_ai_provider, provider_name) @@ -151,14 +153,14 @@ def create_agent_graph( graph_def: Any, tools: Any, default_ai_provider: Optional[str] = None, - ) -> Optional[Any]: + ) -> Optional[AgentGraphRunner]: """ Create an agent graph executor for the given graph definition and tool registry. :param graph_def: AgentGraphDefinition instance :param tools: Tool registry mapping tool names to callables :param default_ai_provider: Optional provider override - :return: AgentGraphExecutor instance, or None + :return: AgentGraphRunner instance, or None """ provider_name = None if graph_def.root() and graph_def.root().get_config() and graph_def.root().get_config().provider: From c5a494ba20b241d27dfebc90e4ba64bece1f1afb Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 31 Mar 2026 16:42:54 -0500 Subject: [PATCH 7/7] simplify langchain tools --- .../src/ldai_langchain/langchain_helper.py | 117 ++++-------------- .../langchain_runner_factory.py | 4 +- .../langgraph_agent_graph_runner.py | 4 +- .../tests/test_langchain_provider.py | 4 +- .../src/ldai_openai/openai_agent_runner.py | 74 +++++------ 5 files changed, 64 insertions(+), 139 deletions(-) diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py index a42cf12..b1b18c8 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_helper.py @@ -1,7 +1,7 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, List, Optional from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from ldai import LDMessage, log from ldai.models import AIConfigKind from ldai.providers import ToolRegistry @@ -51,18 +51,12 @@ def convert_messages_to_langchain( return result -def create_langchain_model(ai_config: AIConfigKind, tool_registry: Optional[ToolRegistry] = None) -> BaseChatModel: +def create_langchain_model(ai_config: AIConfigKind) -> BaseChatModel: """ Create a LangChain BaseChatModel from a LaunchDarkly AI configuration. - If the config includes tool definitions and a tool_registry is provided, tools found - in the registry are bound to the model. Tools not found in the registry are skipped - with a warning. Built-in provider tools (e.g. code_interpreter) are not supported - via LangChain's bind_tools abstraction and are skipped with a warning. - :param ai_config: The LaunchDarkly AI configuration - :param tool_registry: Optional registry mapping tool names to callable implementations - :return: A configured LangChain BaseChatModel, with tools bound if applicable + :return: A configured LangChain BaseChatModel """ from langchain.chat_models import init_chat_model @@ -73,7 +67,7 @@ def create_langchain_model(ai_config: AIConfigKind, tool_registry: Optional[Tool model_name = model_dict.get('name', '') provider = provider_dict.get('name', '') parameters = dict(model_dict.get('parameters') or {}) - tool_definitions = parameters.pop('tools', []) or [] + parameters.pop('tools', None) mapped_provider = map_provider(provider) # Bedrock requires the foundation provider (e.g. Bedrock:Anthropic) passed in @@ -81,104 +75,43 @@ def create_langchain_model(ai_config: AIConfigKind, tool_registry: Optional[Tool if mapped_provider == 'bedrock_converse' and 'provider' not in parameters: parameters['provider'] = provider.removeprefix('bedrock:') - model = init_chat_model( + return init_chat_model( model_name, model_provider=mapped_provider, **parameters, ) - if tool_definitions and tool_registry is not None: - bindable = _resolve_tools_for_langchain(tool_definitions, tool_registry) - if bindable: - model = model.bind_tools(bindable) - - return model - -def _iter_valid_tools( - tool_definitions: List[Dict[str, Any]], - tool_registry: ToolRegistry, -) -> List[tuple]: - """ - Filter LD tool definitions against a registry, returning (name, td) pairs for each - valid function tool that has a callable implementation. Built-in provider tools and - tools missing from the registry are skipped with a warning. +def build_tools(ai_config: AIConfigKind, tool_registry: ToolRegistry) -> List[Any]: """ - valid = [] - for td in tool_definitions: - if not isinstance(td, dict): - continue - - tool_type = td.get('type') - if tool_type and tool_type != 'function': - log.warning( - f"Built-in tool '{tool_type}' is not reliably supported via LangChain and will be skipped. " - "Use a provider-specific runner to use built-in provider tools." - ) - continue - - name = td.get('name') - if not name: - continue - - if name not in tool_registry: - log.warning(f"Tool '{name}' is defined in the AI config but was not found in the tool registry; skipping.") - continue + Return callables from the registry for each tool defined in the AI config. - valid.append((name, td)) - - return valid - - -def _resolve_tools_for_langchain( - tool_definitions: List[Dict[str, Any]], - tool_registry: ToolRegistry, -) -> List[Dict[str, Any]]: - """ - Match LD tool definitions against a registry, returning function-calling tool dicts - for tools that have a callable implementation. Built-in provider tools and tools - missing from the registry are skipped with a warning. - """ - return [ - { - 'type': 'function', - 'function': { - 'name': name, - 'description': td.get('description', ''), - 'parameters': td.get('parameters', {'type': 'object', 'properties': {}}), - }, - } - for name, td in _iter_valid_tools(tool_definitions, tool_registry) - ] - - -def build_structured_tools(ai_config: AIConfigKind, tool_registry: ToolRegistry) -> List[Any]: - """ - Build a list of LangChain StructuredTool instances from LD tool definitions and a registry. - - Tools found in the registry are wrapped as StructuredTool with the name and description - from the LD config. Built-in provider tools and tools missing from the registry are - skipped with a warning. + Tools not found in the registry are skipped with a warning. The returned + callables can be passed directly to bind_tools or langchain.agents.create_agent. + Functions should have type-annotated parameters so LangChain can infer the schema. :param ai_config: The LaunchDarkly AI configuration :param tool_registry: Registry mapping tool names to callable implementations - :return: List of StructuredTool instances ready to pass to langchain.agents.create_agent + :return: List of callables ready to pass to bind_tools or create_agent """ - from langchain_core.tools import StructuredTool - config_dict = ai_config.to_dict() model_dict = config_dict.get('model') or {} parameters = dict(model_dict.get('parameters') or {}) tool_definitions = parameters.pop('tools', []) or [] - return [ - StructuredTool.from_function( - func=tool_registry[name], - name=name, - description=td.get('description', ''), - ) - for name, td in _iter_valid_tools(tool_definitions, tool_registry) - ] + tools = [] + for td in tool_definitions: + if not isinstance(td, dict): + continue + name = td.get('name') + if not name: + continue + fn = tool_registry.get(name) + if fn is None: + log.warning(f"Tool '{name}' is defined in the AI config but was not found in the tool registry; skipping.") + continue + tools.append(fn) + return tools def get_ai_usage_from_response(response: Any) -> Optional[TokenUsage]: diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py index 29ab68b..6bea7ed 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langchain_runner_factory.py @@ -5,7 +5,7 @@ from ldai_langchain.langchain_agent_runner import LangChainAgentRunner from ldai_langchain.langchain_helper import ( - build_structured_tools, + build_tools, create_langchain_model, ) from ldai_langchain.langchain_model_runner import LangChainModelRunner @@ -48,7 +48,7 @@ def create_agent(self, config: Any, tools: Optional[ToolRegistry] = None) -> Lan from langchain.agents import create_agent as lc_create_agent instructions = (config.instructions or '') if hasattr(config, 'instructions') else '' llm = create_langchain_model(config) - lc_tools = build_structured_tools(config, tools or {}) + lc_tools = build_tools(config, tools or {}) agent = lc_create_agent( llm, diff --git a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py index 746fdd1..b86ad6e 100644 --- a/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py +++ b/packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py @@ -10,7 +10,7 @@ from ldai.providers.types import LDAIMetrics from ldai_langchain.langchain_helper import ( - build_structured_tools, + build_tools, create_langchain_model, extract_last_message_content, get_ai_metrics_from_response, @@ -75,7 +75,7 @@ def handle_traversal(node: AgentGraphNode, ctx: dict) -> None: model = None if node_config.model: lc_model = create_langchain_model(node_config) - tool_fns = build_structured_tools(node_config, tools_ref) + tool_fns = build_tools(node_config, tools_ref) model = lc_model.bind_tools(tool_fns) if tool_fns else lc_model def invoke(state: WorkflowState) -> WorkflowState: diff --git a/packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py b/packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py index 52cca65..bad5640 100644 --- a/packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py +++ b/packages/ai-providers/server-ai-langchain/tests/test_langchain_provider.py @@ -422,7 +422,7 @@ def test_creates_agent_runner_with_instructions_and_tool_definitions(self): mock_agent = MagicMock() with patch('ldai_langchain.langchain_runner_factory.create_langchain_model') as mock_create, \ - patch('ldai_langchain.langchain_runner_factory.build_structured_tools') as mock_tools, \ + patch('ldai_langchain.langchain_runner_factory.build_tools') as mock_tools, \ patch('langchain.agents.create_agent', return_value=mock_agent): mock_create.return_value = MagicMock() mock_tools.return_value = [MagicMock()] @@ -447,7 +447,7 @@ def test_creates_agent_runner_with_no_tools(self): mock_agent = MagicMock() with patch('ldai_langchain.langchain_runner_factory.create_langchain_model') as mock_create, \ - patch('ldai_langchain.langchain_runner_factory.build_structured_tools', return_value=[]), \ + patch('ldai_langchain.langchain_runner_factory.build_tools', return_value=[]), \ patch('langchain.agents.create_agent', return_value=mock_agent): mock_create.return_value = MagicMock() diff --git a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py index 199bef4..7bd0889 100644 --- a/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py +++ b/packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_runner.py @@ -93,53 +93,45 @@ def _build_agent_tools(self) -> List[Any]: if not isinstance(td, dict): continue name = td.get("name", "") - - # Native OpenAI tools run on OpenAI's infrastructure — no local fn required. - if name and name in NATIVE_OPENAI_TOOLS: - tools.append(NATIVE_OPENAI_TOOLS[name](td)) - continue - - tool_type = td.get("type") - if tool_type and tool_type != "function": - log.warning( - f"Built-in tool '{tool_type}' is not supported and will be skipped. " - "Use the OpenAIAgentGraphRunner for built-in provider tools." - ) - continue - if not name: continue tool_fn = self._tools.get(name) - if not tool_fn: - log.warning( - f"Tool '{name}' is defined in the AI config but was not found in " - "the tool registry; skipping." - ) + if tool_fn: + def _make_invoker(fn: Any, tool_name: str) -> Any: + async def on_invoke_tool(tool_ctx: ToolContext, args_json: str) -> str: + try: + args = json.loads(args_json) if args_json else {} + except Exception: + args = {} + try: + res = fn(**args) + if hasattr(res, "__await__"): + res = await res + return str(res) + except Exception as e: + log.warning(f"Tool '{tool_name}' execution failed: {e}") + return f"Tool execution failed: {e}" + return on_invoke_tool + + tools.append(FunctionTool( + name=name, + description=td.get("description", ""), + params_json_schema=td.get("parameters", {}), + on_invoke_tool=_make_invoker(tool_fn, name), + )) continue - def _make_invoker(fn: Any, tool_name: str) -> Any: - async def on_invoke_tool(tool_ctx: ToolContext, args_json: str) -> str: - try: - args = json.loads(args_json) if args_json else {} - except Exception: - args = {} - try: - res = fn(**args) - if hasattr(res, "__await__"): - res = await res - return str(res) - except Exception as e: - log.warning(f"Tool '{tool_name}' execution failed: {e}") - return f"Tool execution failed: {e}" - return on_invoke_tool - - tools.append(FunctionTool( - name=name, - description=td.get("description", ""), - params_json_schema=td.get("parameters", {}), - on_invoke_tool=_make_invoker(tool_fn, name), - )) + # No callable in registry — try native OpenAI tool (exact name match required). + native = NATIVE_OPENAI_TOOLS.get(name) + if native: + tools.append(native(td)) + continue + + log.warning( + f"Tool '{name}' is defined in the AI config but was not found in " + "the tool registry and is not a known native tool; skipping." + ) return tools def _build_model_settings(self) -> Any: