哈基米 dbe11dc798
fix(mcp): keep ToolRuntime injection for sync-wrapped MCP tools (#5164)
* 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.
2026-09-04 19:34:15 +08:00

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