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
19 changes: 16 additions & 3 deletions agentflow/core/graph/compiled_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,17 +512,30 @@ 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:
GraphError: If the specified node is not a ToolNode.

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")
"""
Expand Down
10 changes: 7 additions & 3 deletions agentflow/core/graph/tool_node/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions agentflow/prebuilt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# Agents
from .agent import (
AudioAgent,
BaseReranker,
CohereReranker,
CrossEncoderReranker,
Expand Down Expand Up @@ -44,6 +45,7 @@

__all__ = [
# Agents
"AudioAgent",
"BaseReranker",
"CohereReranker",
"CrossEncoderReranker",
Expand Down
4 changes: 1 addition & 3 deletions agentflow/qa/evaluation/simulators/user_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions tests/graph/test_tool_node.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the tool_node module."""

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand Down Expand Up @@ -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"]
Loading