diff --git a/agentflow/core/graph/compiled_graph.py b/agentflow/core/graph/compiled_graph.py index d2d4886..9ac39b4 100644 --- a/agentflow/core/graph/compiled_graph.py +++ b/agentflow/core/graph/compiled_graph.py @@ -512,8 +512,15 @@ def attach_remote_tools( ): """Attach remote tools to a ToolNode in the graph. + Remote tools are executed by the client, not the server. The graph only + advertises their schemas to the model and emits a ``RemoteToolCallBlock`` + when one is called. + Args: - tools: List of tool configurations to attach. + tools: List of tool schemas in OpenAI function-calling format. Each + entry must be ``{"type": "function", "function": {...}}`` with a + ``name`` inside ``function`` -- the name is read from there to + route calls back to the client. node_name: Name of the ToolNode to attach tools to. Raises: @@ -521,8 +528,14 @@ def attach_remote_tools( Example: >>> tool_configs = [ - ... {"name": "search", "type": "SearchTool", "config": {...}}, - ... {"name": "calculator", "type": "CalculatorTool", "config": {...}}, + ... { + ... "type": "function", + ... "function": { + ... "name": "read_clipboard", + ... "description": "Read the current clipboard text.", + ... "parameters": {"type": "object", "properties": {}, "required": []}, + ... }, + ... } ... ] >>> graph.attach_remote_tools(tool_configs, "tool_node") """ diff --git a/agentflow/core/graph/tool_node/base.py b/agentflow/core/graph/tool_node/base.py index 9d38864..3a02b9f 100644 --- a/agentflow/core/graph/tool_node/base.py +++ b/agentflow/core/graph/tool_node/base.py @@ -250,9 +250,9 @@ def all_tools_sync( ) -> list[dict]: """Synchronously get all available tools from all configured providers. - This is a synchronous wrapper around the async all_tools() method. - It uses asyncio.run() to handle async operations from MCP, Composio, - and LangChain adapters. + This is a synchronous wrapper around the async all_tools() method. It + returns the same set of tools -- local, MCP and remote -- and uses + asyncio.run() to handle the async MCP listing. Returns: List of tool definitions in OpenAI function calling format. @@ -268,6 +268,10 @@ def all_tools_sync( if result: tools.extend(result) + # Must mirror ``_all_tools_async``: dropping remote tools here makes + # client-side tools silently invisible to the model. + tools.extend(self.remote_tools) + return tools async def invoke( diff --git a/agentflow/prebuilt/__init__.py b/agentflow/prebuilt/__init__.py index d09feb3..8ca30f3 100644 --- a/agentflow/prebuilt/__init__.py +++ b/agentflow/prebuilt/__init__.py @@ -12,6 +12,7 @@ # Agents from .agent import ( + AudioAgent, BaseReranker, CohereReranker, CrossEncoderReranker, @@ -44,6 +45,7 @@ __all__ = [ # Agents + "AudioAgent", "BaseReranker", "CohereReranker", "CrossEncoderReranker", diff --git a/agentflow/qa/evaluation/simulators/user_simulator.py b/agentflow/qa/evaluation/simulators/user_simulator.py index 7024513..8a62482 100644 --- a/agentflow/qa/evaluation/simulators/user_simulator.py +++ b/agentflow/qa/evaluation/simulators/user_simulator.py @@ -228,9 +228,7 @@ async def run( # Each simulation gets its own thread_id so checkpointer state # doesn't bleed between concurrent or sequential runs. run_config = dict(config or {}) - run_config.setdefault("configurable", {}).setdefault( - "thread_id", f"{scenario.scenario_id}-{uuid.uuid4().hex[:8]}" - ) + run_config.setdefault("thread_id", f"{scenario.scenario_id}-{uuid.uuid4().hex[:8]}") try: # Start with the initial prompt diff --git a/tests/graph/test_tool_node.py b/tests/graph/test_tool_node.py index 763e247..6ce10f5 100644 --- a/tests/graph/test_tool_node.py +++ b/tests/graph/test_tool_node.py @@ -1,5 +1,6 @@ """Tests for the tool_node module.""" +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -673,3 +674,80 @@ def sample_func() -> str: assert call_args[0][0] == "mcp_tool" # name assert call_args[0][1] == {"param": "value"} # input_data assert "meta" not in call_args[1] # no meta kwarg + + +class TestToolNodeRemoteToolParity: + """Regression tests for issue #148. + + ``all_tools_sync`` used to rebuild the tool list from local + MCP tools only and + never appended ``remote_tools``, so remote (client-side) tools silently vanished + for any caller that went through the sync accessor. + """ + + @staticmethod + def _remote_tool(name: str) -> dict: + return { + "type": "function", + "function": { + "name": name, + "description": f"Remote tool {name}.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + } + + def test_all_tools_sync_includes_remote_tools(self): + """Remote tools must be advertised by the sync accessor.""" + + def local_func() -> str: + """Local function.""" + return "local" + + tool_node = ToolNode([local_func]) + tool_node.set_remote_tool([self._remote_tool("write_file")]) + + names = [t["function"]["name"] for t in tool_node.all_tools_sync()] + assert "local_func" in names + assert "write_file" in names + + def test_sync_and_async_tool_sets_match(self): + """The sync and async builders must not drift apart. + + Deliberately a sync test: ``all_tools_sync`` uses ``asyncio.run`` internally + and cannot be called from inside a running event loop. + """ + + def local_func() -> str: + """Local function.""" + return "local" + + mcp_tool = { + "type": "function", + "function": { + "name": "mcp_tool", + "description": "MCP tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + + tool_node = ToolNode([local_func], client=MagicMock()) + tool_node.set_remote_tool([self._remote_tool("write_file")]) + + with patch.object(tool_node, "_get_mcp_tool", new_callable=AsyncMock) as mock_mcp: + mock_mcp.return_value = [mcp_tool] + + async_names = {t["function"]["name"] for t in asyncio.run(tool_node.all_tools())} + sync_names = {t["function"]["name"] for t in tool_node.all_tools_sync()} + + assert sync_names == async_names + assert sync_names == {"local_func", "mcp_tool", "write_file"} + + def test_all_tools_sync_without_remote_tools_is_unchanged(self): + """No remote tools registered means nothing extra is appended.""" + + def local_func() -> str: + """Local function.""" + return "local" + + tool_node = ToolNode([local_func]) + + assert [t["function"]["name"] for t in tool_node.all_tools_sync()] == ["local_func"]