mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-12 15:09:47 +00:00
* feat(mcp): add durable task runtime foundation * fix(chart): sync embedded config version * fix(mcp): isolate task polls during shutdown * feat(mcp): track consecutive poll errors on mcp_tasks poll_attempt_count grows on every claim (successful polls included), so it cannot drive a failure backoff without misjudging normal long tasks. Add consecutive_poll_error_count: incremented when a claim is released after a poll error, reset to zero by any applied snapshot. The backoff/terminal policy that consumes it lands with the first concrete driver. * fix(mcp): harden durable task lifecycle * feat(mcp): add ordinary durable task driver * test(mcp): address durable task review feedback * fix(mcp): preserve submit tool descriptions * fix(mcp): bound remote task calls * fix(mcp): bound persisted task payloads * fix(mcp): preserve task tool error details * fix(mcp): enforce durable task boundaries * test(mcp): cover task config snapshot lifecycle --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
204 lines
6.3 KiB
Python
204 lines
6.3 KiB
Python
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from langchain_core.tools import StructuredTool
|
|
from pydantic import BaseModel
|
|
|
|
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig
|
|
from deerflow.mcp.tasks.runtime import (
|
|
McpTaskConfigurationError,
|
|
set_mcp_task_config_snapshot,
|
|
set_mcp_task_submitter,
|
|
)
|
|
from deerflow.mcp.tools import _configure_task_tools_for_server, get_mcp_tools
|
|
|
|
|
|
class _SubmitArgs(BaseModel):
|
|
topic: str
|
|
|
|
|
|
def _tool(name: str, *, description: str | None = None) -> StructuredTool:
|
|
async def call(topic: str):
|
|
return topic
|
|
|
|
return StructuredTool(
|
|
name=name,
|
|
description=description if description is not None else name,
|
|
args_schema=_SubmitArgs,
|
|
coroutine=call,
|
|
)
|
|
|
|
|
|
def _server_config() -> McpServerConfig:
|
|
return McpServerConfig.model_validate(
|
|
{
|
|
"task_toolsets": [
|
|
{
|
|
"name": "report-generation",
|
|
"submit_tool": "submit_report",
|
|
"status_tool": "get_report_status",
|
|
"cancel_tool": "cancel_report",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
class FakeSubmitter:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
async def submit(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
return {
|
|
"id": "mcp-task-local-1",
|
|
"status": "submitted",
|
|
"remote_task_id": "must-not-leak",
|
|
"driver_data": {"must": "not leak"},
|
|
}
|
|
|
|
|
|
def test_unconfigured_server_tools_are_returned_unchanged() -> None:
|
|
tools = [_tool("reports_search")]
|
|
|
|
configured = _configure_task_tools_for_server(
|
|
tools,
|
|
server_name="reports",
|
|
server_config=McpServerConfig(),
|
|
tool_name_prefix=True,
|
|
)
|
|
|
|
assert configured == tools
|
|
assert configured[0] is tools[0]
|
|
|
|
|
|
def test_configured_status_and_cancel_tools_are_hidden_from_the_agent() -> None:
|
|
tools = [
|
|
_tool("reports_submit_report"),
|
|
_tool("reports_get_report_status"),
|
|
_tool("reports_cancel_report"),
|
|
_tool("reports_search"),
|
|
]
|
|
|
|
configured = _configure_task_tools_for_server(
|
|
tools,
|
|
server_name="reports",
|
|
server_config=_server_config(),
|
|
tool_name_prefix=True,
|
|
)
|
|
|
|
assert [tool.name for tool in configured] == ["reports_submit_report", "reports_search"]
|
|
|
|
|
|
def test_submit_wrapper_preserves_server_description_and_appends_background_contract() -> None:
|
|
tools = [
|
|
_tool(
|
|
"submit_report",
|
|
description="Generate a quarterly financial report for the requested topic.",
|
|
),
|
|
_tool("get_report_status"),
|
|
_tool("cancel_report"),
|
|
]
|
|
|
|
configured = _configure_task_tools_for_server(
|
|
tools,
|
|
server_name="reports",
|
|
server_config=_server_config(),
|
|
tool_name_prefix=False,
|
|
)
|
|
|
|
assert configured[0].description == (
|
|
"Generate a quarterly financial report for the requested topic.\n\nSubmitted as durable background task 'report-generation'; returns a DeerFlow task ID immediately and status polling is handled automatically."
|
|
)
|
|
|
|
|
|
def test_configured_task_toolsets_fail_when_a_raw_tool_is_missing() -> None:
|
|
with pytest.raises(McpTaskConfigurationError, match="cancel_report"):
|
|
_configure_task_tools_for_server(
|
|
[_tool("reports_submit_report"), _tool("reports_get_report_status")],
|
|
server_name="reports",
|
|
server_config=_server_config(),
|
|
tool_name_prefix=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_wrapper_persists_before_returning_only_the_local_handle() -> None:
|
|
submitter = FakeSubmitter()
|
|
set_mcp_task_submitter(submitter)
|
|
try:
|
|
configured = _configure_task_tools_for_server(
|
|
[
|
|
_tool("submit_report"),
|
|
_tool("get_report_status"),
|
|
_tool("cancel_report"),
|
|
],
|
|
server_name="reports",
|
|
server_config=_server_config(),
|
|
tool_name_prefix=False,
|
|
)
|
|
submit_tool = configured[0]
|
|
runtime = SimpleNamespace(
|
|
context={"thread_id": "thread-1", "run_id": "run-1"},
|
|
config={},
|
|
tool_call_id="call-1",
|
|
)
|
|
|
|
result = await submit_tool.coroutine(runtime=runtime, topic="MCP")
|
|
|
|
assert result == {
|
|
"task_id": "mcp-task-local-1",
|
|
"task_name": "report-generation",
|
|
"status": "submitted",
|
|
"message": "Task is running in the background.",
|
|
}
|
|
call = submitter.calls[0]
|
|
request = call["request"]
|
|
assert call["driver_name"] == "ordinary-tools"
|
|
assert request.user_id == "test-user-autouse"
|
|
assert request.thread_id == "thread-1"
|
|
assert request.run_id == "run-1"
|
|
assert request.tool_call_id == "call-1"
|
|
assert request.arguments == {"topic": "MCP"}
|
|
assert request.driver_data == {
|
|
"submit_tool": "submit_report",
|
|
"status_tool": "get_report_status",
|
|
"cancel_tool": "cancel_report",
|
|
}
|
|
finally:
|
|
set_mcp_task_submitter(None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_wrapper_fails_clearly_without_gateway_task_runtime() -> None:
|
|
set_mcp_task_submitter(None)
|
|
configured = _configure_task_tools_for_server(
|
|
[_tool("submit_report"), _tool("get_report_status"), _tool("cancel_report")],
|
|
server_name="reports",
|
|
server_config=_server_config(),
|
|
tool_name_prefix=False,
|
|
)
|
|
|
|
with pytest.raises(McpTaskConfigurationError, match="not initialized"):
|
|
await configured[0].coroutine(topic="MCP")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_reload_rejects_task_server_runtime_config_drift() -> None:
|
|
startup = ExtensionsConfig(mcpServers={"reports": _server_config()})
|
|
current = ExtensionsConfig(mcpServers={"reports": _server_config()})
|
|
current.mcp_servers["reports"].env["TOKEN"] = "rotated"
|
|
set_mcp_task_config_snapshot(startup)
|
|
try:
|
|
with (
|
|
patch(
|
|
"deerflow.mcp.tools.ExtensionsConfig.from_file",
|
|
return_value=current,
|
|
),
|
|
pytest.raises(McpTaskConfigurationError, match="reports.*restart"),
|
|
):
|
|
await get_mcp_tools()
|
|
finally:
|
|
set_mcp_task_config_snapshot(None)
|