mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-14 12:43:45 +00:00
* fix(tools): preserve tool_search promotions across re-entrant get_available_tools Closes #2884. ``get_available_tools`` used to unconditionally call ``reset_deferred_registry()`` and rebuild a fresh ``DeferredToolRegistry`` on every invocation. That works for the first call of a request (the ContextVar starts at its default of ``None``), but any RE-ENTRANT call during the same async context — e.g. ``task_tool`` building a subagent's toolset, or a custom middleware that rebuilds tools mid-run — wiped any ``tool_search`` promotions the parent agent had already made. The ``DeferredToolFilterMiddleware`` would then re-hide those tools from the next model call, leaving the agent able to see a tool's name (via the prior ``tool_search`` result that's still in conversation history) but unable to invoke it. Fix: when the ContextVar already holds a registry, reuse it instead of rebuilding. Fresh requests still get a fresh registry because each new graph run starts in a new asyncio task with the ContextVar at ``None``. ## Verification - Unit-level reproduction (``test_get_available_tools_resets_registry_wiping_promotion``): promote a tool in the registry, call ``get_available_tools`` again, assert the promotion is preserved. Fails on main, passes on this branch. - Graph-execution reproduction (two tests): drive a real ``langchain.agents.create_agent`` graph with the real ``DeferredToolFilterMiddleware`` through two model turns, including one that issues a re-entrant ``get_available_tools`` call to simulate the task_tool subagent path. - Real-LLM end-to-end (``test_deferred_tool_promotion_real_llm.py``, opt-in via ``ONEAPI_E2E=1``): drives the same flow against a real OpenAI-compatible model (verified on GPT-5.4-mini through the one-api gateway), watches the model call the promoted ``fake_calculator`` through the deferred-filter middleware, and asserts the right arithmetic result. Passes against the fixed branch. - Companion update to ``test_tool_deduplication.py``: dropped the ``@patch("deerflow.tools.tools.reset_deferred_registry")`` decorators because the symbol is no longer imported there. - Test fixtures in the new files patch ``deerflow.tools.tools.get_app_config`` with a minimal ``model_construct``-ed ``AppConfig`` instead of calling the real loader, so they never trigger ``_apply_singleton_configs`` and never leak ``_memory_config``/``_title_config``/… mutations into the rest of the suite. Full backend suite: 3208 passed / 14 skipped / 0 failed. ruff check + format clean. * fix(tools): address Copilot review on #2885 - tools.py: rewrite the reuse-path comment to spell out (a) why we don't reconcile the registry against the current ``mcp_tools`` snapshot — the MCP cache doesn't refresh mid-graph-run, the lead agent's ``ToolNode`` is already bound to the previous tool set anyway, and ``promote()`` drops the entry so a naive re-sync misclassifies promotions as new tools — and (b) why the log uses ``max(0, …)`` to avoid negative counts when the cache shrinks between snapshots. - Replace direct ``ts_mod._registry_var.set(None)`` in test fixtures with the public ``reset_deferred_registry()`` helper so tests don't couple to module internals. - Correct the docstring path in ``test_deferred_tool_registry_promotion.py`` to match the actual monkeypatch target (``deerflow.mcp.cache.get_cached_mcp_tools``). - Rename ``test_get_available_tools_resets_registry_wiping_promotion`` to ``test_get_available_tools_preserves_promotions_across_reentrant_calls`` so the test name describes the contract being asserted, not the bug it originally reproduced. Full backend suite: 3208 passed / 14 skipped. Real-LLM e2e: 1 passed.
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""Tests for tool name deduplication in get_available_tools() (issue #1803).
|
|
|
|
Duplicate tool registrations previously passed through silently and could
|
|
produce mangled function-name schemas that caused 100% tool call failures.
|
|
``get_available_tools()`` now deduplicates by name, config-loaded tools taking
|
|
priority, and logs a warning for every skipped duplicate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from langchain_core.tools import BaseTool, StructuredTool, tool
|
|
from pydantic import BaseModel, Field
|
|
|
|
from deerflow.tools.tools import get_available_tools
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture tools
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AsyncToolArgs(BaseModel):
|
|
x: int = Field(..., description="test input")
|
|
|
|
|
|
@tool
|
|
def _tool_alpha(x: str) -> str:
|
|
"""Alpha tool."""
|
|
return x
|
|
|
|
|
|
@tool
|
|
def _tool_alpha_dup(x: str) -> str:
|
|
"""Duplicate of alpha — same name, different object."""
|
|
return x
|
|
|
|
|
|
# Rename duplicate to share the same .name as _tool_alpha
|
|
_tool_alpha_dup.name = _tool_alpha.name # type: ignore[attr-defined]
|
|
|
|
|
|
@tool
|
|
def _tool_beta(x: str) -> str:
|
|
"""Beta tool."""
|
|
return x
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Deduplication behaviour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_minimal_config(tools):
|
|
"""Return an AppConfig-like mock with the given tools list."""
|
|
config = MagicMock()
|
|
config.tools = tools
|
|
config.models = []
|
|
config.tool_search.enabled = False
|
|
config.skill_evolution.enabled = False
|
|
config.sandbox = MagicMock()
|
|
config.acp_agents = {}
|
|
return config
|
|
|
|
|
|
@patch("deerflow.tools.tools.get_app_config")
|
|
@patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True)
|
|
def test_config_loaded_async_only_tool_gets_sync_wrapper(mock_bash, mock_cfg):
|
|
"""Config-loaded async-only tools can still be invoked by sync clients."""
|
|
|
|
async def async_tool_impl(x: int) -> str:
|
|
return f"result: {x}"
|
|
|
|
async_tool = StructuredTool(
|
|
name="async_tool",
|
|
description="Async-only test tool.",
|
|
args_schema=AsyncToolArgs,
|
|
func=None,
|
|
coroutine=async_tool_impl,
|
|
)
|
|
tool_cfg = MagicMock()
|
|
tool_cfg.name = "async_tool"
|
|
tool_cfg.group = "test"
|
|
tool_cfg.use = "tests.fake:async_tool"
|
|
mock_cfg.return_value = _make_minimal_config([tool_cfg])
|
|
|
|
with (
|
|
patch("deerflow.tools.tools.resolve_variable", return_value=async_tool),
|
|
patch("deerflow.tools.tools.BUILTIN_TOOLS", []),
|
|
):
|
|
result = get_available_tools(include_mcp=False, app_config=mock_cfg.return_value)
|
|
|
|
assert async_tool in result
|
|
assert async_tool.func is not None
|
|
assert async_tool.invoke({"x": 42}) == "result: 42"
|
|
|
|
|
|
@patch("deerflow.tools.tools.get_app_config")
|
|
@patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True)
|
|
def test_no_duplicates_returned(mock_bash, mock_cfg):
|
|
"""get_available_tools() never returns two tools with the same name."""
|
|
mock_cfg.return_value = _make_minimal_config([])
|
|
|
|
# Patch the builtin tools so we control exactly what comes back.
|
|
with patch("deerflow.tools.tools.BUILTIN_TOOLS", [_tool_alpha, _tool_alpha_dup, _tool_beta]):
|
|
result = get_available_tools(include_mcp=False)
|
|
|
|
names = [t.name for t in result]
|
|
assert len(names) == len(set(names)), f"Duplicate names detected: {names}"
|
|
|
|
|
|
@patch("deerflow.tools.tools.get_app_config")
|
|
@patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True)
|
|
def test_first_occurrence_wins(mock_bash, mock_cfg):
|
|
"""When duplicates exist, the first occurrence is kept."""
|
|
mock_cfg.return_value = _make_minimal_config([])
|
|
|
|
sentinel_alpha = MagicMock(spec=BaseTool, name="_sentinel")
|
|
sentinel_alpha.name = _tool_alpha.name # same name
|
|
sentinel_alpha_dup = MagicMock(spec=BaseTool, name="_sentinel_dup")
|
|
sentinel_alpha_dup.name = _tool_alpha.name # same name — should be dropped
|
|
|
|
with patch("deerflow.tools.tools.BUILTIN_TOOLS", [sentinel_alpha, sentinel_alpha_dup, _tool_beta]):
|
|
result = get_available_tools(include_mcp=False)
|
|
|
|
returned_alpha = next(t for t in result if t.name == _tool_alpha.name)
|
|
assert returned_alpha is sentinel_alpha
|
|
|
|
|
|
@patch("deerflow.tools.tools.get_app_config")
|
|
@patch("deerflow.tools.tools.is_host_bash_allowed", return_value=True)
|
|
def test_duplicate_triggers_warning(mock_bash, mock_cfg, caplog):
|
|
"""A warning is logged for every skipped duplicate."""
|
|
import logging
|
|
|
|
mock_cfg.return_value = _make_minimal_config([])
|
|
|
|
with patch("deerflow.tools.tools.BUILTIN_TOOLS", [_tool_alpha, _tool_alpha_dup]):
|
|
with caplog.at_level(logging.WARNING, logger="deerflow.tools.tools"):
|
|
get_available_tools(include_mcp=False)
|
|
|
|
assert any("Duplicate tool name" in r.message for r in caplog.records), "Expected a duplicate-tool warning in log output"
|