mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
* fix(mcp): keep ToolRuntime injection for sync-wrapped MCP tools make_sync_tool_wrapper attached an annotation-less wrapper to tool.func, which made LangGraph's ToolNode stop detecting the coroutine's "runtime" parameter (_get_all_injected_args falls back to func first and its type hints are empty). Every MCP tool in a sync agent caller then ran with runtime=None: resolve_runtime_user_id fell through to the default user, and the background-submit wrapper lost run_id/tool_call_id on the TaskSubmitRequest, so completion notifications launched under the default lead agent instead of the thread's agent. Wrap the generator and both sync_wrapper variants with functools.wraps so get_type_hints still sees the original annotations. Adds a regression test that drives a func-patched pooled MCP tool through a real ToolNode and asserts the ToolRuntime is injected with the thread's user context. It fails on main (runtime=None) and passes with the fix. * docs(mcp): record sync-wrapper annotation contract; extend regression coverage Address review feedback on #5164: - Expand the Notes block in make_sync_tool_wrapper to state the functools.wraps contract (copies __name__/__qualname__/__doc__/__annotations__/__dict__ and sets __wrapped__) and why that is what keeps get_type_hints resolving string annotations from callers like mcp/tools.py and skill_manage_tool.py. Drop the no-op wraps on the inner run_coroutine so the wrap surface stays minimal. - Rename the regression test to test_func_patched_mcp_tool_keeps_toolnode_runtime_injection. - Add test_sync_wrapped_builtin_tools_still_resolve_runtime to pin that the built-in tools (which carry runtime as a pydantic schema field) keep resolving runtime after their func is wrapped by make_sync_tool_wrapper, so a future wrapper refactor cannot silently regress per-user resolution for them.
109 lines
4.2 KiB
Python
109 lines
4.2 KiB
Python
"""Utilities for invoking async tools from synchronous agent paths."""
|
|
|
|
import asyncio
|
|
import atexit
|
|
import concurrent.futures
|
|
import contextvars
|
|
import functools
|
|
import logging
|
|
from collections.abc import Callable
|
|
from typing import Any, get_type_hints
|
|
|
|
from langchain_core.runnables import RunnableConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Shared thread pool for sync tool invocation in async environments.
|
|
_SYNC_TOOL_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="tool-sync")
|
|
|
|
atexit.register(lambda: _SYNC_TOOL_EXECUTOR.shutdown(wait=False))
|
|
|
|
|
|
def _get_runnable_config_param(func: Callable[..., Any]) -> str | None:
|
|
"""Return the coroutine parameter that expects LangChain RunnableConfig."""
|
|
if isinstance(func, functools.partial):
|
|
func = func.func
|
|
|
|
try:
|
|
type_hints = get_type_hints(func)
|
|
except Exception:
|
|
return None
|
|
|
|
for name, type_ in type_hints.items():
|
|
if type_ is RunnableConfig:
|
|
return name
|
|
return None
|
|
|
|
|
|
def make_sync_tool_wrapper(coro: Callable[..., Any], tool_name: str) -> Callable[..., Any]:
|
|
"""Build a synchronous wrapper for an asynchronous tool coroutine.
|
|
|
|
Args:
|
|
coro: Async callable backing a LangChain tool.
|
|
tool_name: Tool name used in error logs.
|
|
|
|
Returns:
|
|
A sync callable suitable for ``BaseTool.func``.
|
|
|
|
Notes:
|
|
If ``coro`` declares a ``RunnableConfig`` parameter, this wrapper
|
|
exposes ``config: RunnableConfig`` so LangChain can inject runtime
|
|
config and then forwards it to the coroutine's detected config
|
|
parameter. This covers DeerFlow's current config-sensitive tools, such
|
|
as ``invoke_acp_agent``.
|
|
|
|
This wrapper intentionally does not synthesize a dynamic function
|
|
signature. A future async tool with a normal user-facing argument named
|
|
``config`` and a separate ``RunnableConfig`` parameter named something
|
|
else, such as ``run_config``, may collide with LangChain's injected
|
|
``config`` argument. Rename that user-facing field or extend this
|
|
helper before using that signature.
|
|
|
|
The returned wrapper is built with ``functools.wraps(coro)``, so it
|
|
copies ``__name__``, ``__qualname__``, ``__doc__``, ``__annotations__``
|
|
and ``__dict__`` and sets ``__wrapped__``. This matters for LangGraph:
|
|
``ToolNode._get_all_injected_args`` reads ``get_type_hints(tool.func)``,
|
|
and ``get_type_hints`` follows ``__wrapped__`` back to the coroutine's
|
|
own ``__globals__``. That is why injected parameters such as
|
|
``runtime: Runtime`` are still detected here even when the caller was
|
|
compiled with ``from __future__ import annotations`` (mcp/tools.py,
|
|
skill_manage_tool.py) and the annotation is a string. Do not replace
|
|
``wraps`` with a bare wrapper or a hand-copied signature: the string
|
|
annotation would then be evaluated against the wrapper's module globals,
|
|
or the ``runtime`` parameter would disappear entirely and the tool would
|
|
run with ``runtime=None``.
|
|
"""
|
|
config_param = _get_runnable_config_param(coro)
|
|
|
|
def run_coroutine(*args: Any, **kwargs: Any) -> Any:
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
loop = None
|
|
|
|
try:
|
|
if loop is not None and loop.is_running():
|
|
context = contextvars.copy_context()
|
|
future = _SYNC_TOOL_EXECUTOR.submit(context.run, lambda: asyncio.run(coro(*args, **kwargs)))
|
|
return future.result()
|
|
return asyncio.run(coro(*args, **kwargs))
|
|
except Exception as e:
|
|
logger.error("Error invoking tool %r via sync wrapper: %s", tool_name, e, exc_info=True)
|
|
raise
|
|
|
|
if config_param:
|
|
|
|
@functools.wraps(coro)
|
|
def sync_wrapper(*args: Any, config: RunnableConfig = None, **kwargs: Any) -> Any:
|
|
if config is not None or config_param not in kwargs:
|
|
kwargs[config_param] = config
|
|
return run_coroutine(*args, **kwargs)
|
|
|
|
return sync_wrapper
|
|
|
|
@functools.wraps(coro)
|
|
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
return run_coroutine(*args, **kwargs)
|
|
|
|
return sync_wrapper
|