deer-flow/backend/tests/test_plugin_tools.py
ShiYuan Xiong 3802db3392
feat(agent): add read-only run status introspection (phase 1) (#7297)
* feat(agent): expose read-only run status tool

* refactor(tools): name the subagent dispatch tool set

Hoist the hardcoded subagent dispatch tool-name literal into _SUBAGENT_DISPATCH_TOOL_NAMES so a future dispatch-tool rename has one place to update. No behavior change: the folded-subagent context_fill guard compares the same two names.

* test(tools): account for run status in collision assembly

* docs: keep script guidance within size budget
2026-10-23 22:06:00 +08:00

392 lines
17 KiB
Python

"""Exercise actual LangGraph tool dispatch, not just manifest serialization."""
import asyncio
import json
from dataclasses import FrozenInstanceError, dataclass, replace
from types import MappingProxyType, SimpleNamespace
import pytest
from deerflow_extension_api.plugins import ModelTool, PluginContribution
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.extensions.plugin_tools import build_plugin_tools
from deerflow.extensions.registry import ExtensionRegistry
@pytest.fixture
def installed():
calls = []
async def search(payload, context):
calls.append(context)
return {"query": payload["query"], "user": context.principal.user_id}
plugin = PluginContribution(
namespace="community.search",
title="Search",
enabled=True,
tools=(ModelTool("search", "Search selected documents", {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": False}, search),),
)
registry = ExtensionRegistry()
with registry.attributed_to("test"):
registry.plugin(plugin)
return registry.build(), plugin, calls
@pytest.mark.asyncio
async def test_real_tool_node_binds_identity_and_deployment_policy(installed):
loaded, plugin, calls = installed
(tool,) = build_plugin_tools(loaded)
assert "runtime" not in json.dumps(tool.tool_call_schema)
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode([tool]))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
agent = graph.compile()
async def invoke(args):
return (
await agent.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"id": "c", "name": tool.name, "args": args}])]},
context={"user_id": "trusted-user", "thread_id": "thread-a"},
)
)["messages"][-1]
result = await invoke({"query": "hello"})
assert json.loads(result.content) == {"query": "hello", "user": "trusted-user"}
assert calls[0].thread_id == "thread-a"
with pytest.raises(TypeError):
calls[0].settings["enabled"] = False
assert (await invoke({"query": "x", "user_id": "victim"})).status == "error"
assert len(calls) == 1
registry = ExtensionRegistry()
with registry.attributed_to("test"):
registry.plugin(replace(plugin, enabled=False))
assert build_plugin_tools(registry.build()) == []
def test_registration_rejects_invalid_schema_without_partial_install(installed):
_, plugin, _ = installed
registry = ExtensionRegistry()
with registry.attributed_to("test"):
for schema in ({"type": "string"}, {"type": "object", "$ref": "https://example.test/schema"}, {"type": "object", "properties": {"runtime": {"type": "string"}}}):
with pytest.raises(ValueError):
registry.plugin(replace(plugin, tools=(replace(plugin.tools[0], input_schema=schema),)))
assert not registry.build().plugins
def test_group_filter_and_name_collision_fail_closed(installed):
loaded, _, _ = installed
assert build_plugin_tools(loaded, groups=["web"]) == []
(tool,) = build_plugin_tools(loaded, groups=["extensions"])
with pytest.raises(ValueError, match="collision"):
build_plugin_tools(loaded, reserved_names={tool.name})
def test_built_tools_carry_host_recorded_plugin_provenance(installed):
"""The tag is written at construction, from registry-validated values."""
from deerflow.tools.tool_provenance import get_plugin_source, is_plugin_tool, resolve_tool_provenance
loaded, plugin, _ = installed
(tool,) = build_plugin_tools(loaded)
assert is_plugin_tool(tool) is True
assert get_plugin_source(tool) == {
"namespace": plugin.namespace,
"declaration": "search",
"installation": "test",
}
provenance = resolve_tool_provenance(tool)
assert provenance is not None
assert provenance.source == f"plugin:{plugin.namespace}"
assert provenance.declaration == "search"
@pytest.mark.parametrize("source", ["config", "builtin", "mcp", "acp"])
def test_assembly_keeps_ordinary_and_unaffected_plugin_tools_on_collision(installed, monkeypatch, caplog, source):
from langchain_core.tools import Tool
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.extensions.plugin_tools import plugin_tool_name
from deerflow.tools import tools as assembly
loaded, plugin, _ = installed
name = plugin_tool_name(plugin.namespace, "search")
ordinary = Tool(name=name, description="Ordinary tool", func=lambda query: "ordinary result")
healthy = replace(plugin, namespace="community.healthy")
registry = ExtensionRegistry()
with registry.attributed_to("test"):
registry.plugin(plugin)
registry.plugin(healthy)
config = SimpleNamespace(
tools=[SimpleNamespace(name=name, use="test:ordinary", group="extensions")] if source == "config" else [],
models=[],
acp_agents={"test": {}} if source == "acp" else {},
)
monkeypatch.setattr(assembly, "BUILTIN_TOOLS", [ordinary] if source == "builtin" else [])
monkeypatch.setattr(assembly, "is_mcp_task_runtime_available", lambda: False)
monkeypatch.setattr(assembly, "is_host_bash_allowed", lambda config: False)
monkeypatch.setattr(assembly, "resolve_variable", lambda *args: ordinary)
monkeypatch.setattr(ExtensionsConfig, "from_file", lambda: SimpleNamespace(get_enabled_mcp_servers=lambda: {"test": {}}))
monkeypatch.setattr("deerflow.mcp.cache.get_cached_mcp_tools", lambda: [ordinary])
monkeypatch.setattr("deerflow.tools.builtins.invoke_acp_agent_tool.build_invoke_acp_agent_tool", lambda agents: ordinary)
result = assembly.get_available_tools(app_config=config, extensions=registry.build(), include_mcp=source == "mcp", include_upload_tool=False)
# Model-bound built-ins are assembled independently of BUILTIN_TOOLS.
expected_ordinary = [name, "inspect_run_status"] if source in {"config", "builtin"} else ["inspect_run_status", name]
assert [tool.name for tool in result] == [*expected_ordinary, plugin_tool_name(healthy.namespace, "search")]
retained = next(tool for tool in result if tool.name == name)
assert retained is ordinary
assert retained.invoke("hello") == "ordinary result"
assert "Duplicate tool name" in caplog.text
# A host collision must not weaken the strict plugin-vs-plugin check.
duplicate_snapshot = replace(loaded, plugins=loaded.plugins + loaded.plugins)
with pytest.raises(ValueError, match="collision"):
assembly.get_available_tools(app_config=config, extensions=duplicate_snapshot, include_mcp=False, include_upload_tool=False)
@pytest.mark.asyncio
async def test_model_tool_receives_run_control_from_host_runtime(installed):
from unittest.mock import Mock
from deerflow_extension_api import AGENT_RUNS_CONTEXT_KEY
loaded, plugin, calls = installed
(tool,) = build_plugin_tools(loaded)
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode([tool]))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
scoped = object()
handle = SimpleNamespace(for_plugin=Mock(return_value=scoped))
await graph.compile().ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"id": "call", "name": tool.name, "args": {"query": "hello"}}])]},
context={"user_id": "alice", "thread_id": "thread", AGENT_RUNS_CONTEXT_KEY: handle},
)
handle.for_plugin.assert_called_once_with(plugin.namespace)
assert calls[0].agent_runs is scoped
def _tool_graph(plugin, declaration):
registry = ExtensionRegistry()
with registry.attributed_to("test"):
registry.plugin(replace(plugin, tools=(declaration,)))
(tool,) = build_plugin_tools(registry.build())
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode([tool]))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
return graph.compile(), tool
async def _call_tool(graph, tool, **context):
result = await graph.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"id": "artifact-call", "name": tool.name, "args": {"query": "hello"}}])]},
context={"user_id": "trusted-user", "thread_id": "thread-a", **context},
)
return result["messages"][-1]
@pytest.mark.asyncio
async def test_opt_in_mode_rejects_legacy_tuple_through_real_tool_node(installed):
# A trusted package can subclass the old declaration. This drives ToolNode
# even on a pre-feature host, exposing ignored response-format declarations.
@dataclass(frozen=True)
class ArtifactTool(ModelTool):
response_format: str = "content_and_artifact"
async def handler(payload, context):
return ("visible", {"private": "must not become content"})
_, plugin, _ = installed
old = plugin.tools[0]
graph, tool = _tool_graph(plugin, ArtifactTool(old.name, old.description, old.input_schema, handler))
message = await _call_tool(graph, tool)
assert message.status == "error"
assert message.artifact is None
assert "must not become content" not in message.content
@pytest.mark.asyncio
@pytest.mark.parametrize("artifact", [None, {"rows": [{"label": "故障", "score": 0.9}]}])
async def test_typed_result_separates_content_from_artifact_and_preserves_identity(installed, artifact):
from deerflow_extension_api import PluginToolResult
_, plugin, _ = installed
contexts = []
async def handler(payload, context):
contexts.append(context)
return PluginToolResult("One record classified.", artifact)
declaration = replace(plugin.tools[0], handler=handler, response_format="content_and_artifact")
graph, tool = _tool_graph(plugin, declaration)
message = await _call_tool(graph, tool)
assert (message.content, message.artifact, message.status) == ("One record classified.", artifact, "success")
assert (message.tool_call_id, message.name) == ("artifact-call", tool.name)
assert (contexts[0].principal.user_id, contexts[0].thread_id) == ("trusted-user", "thread-a")
assert "runtime" not in json.dumps(tool.tool_call_schema)
@pytest.mark.asyncio
async def test_artifact_is_independent_json_snapshot(installed):
from deerflow_extension_api import PluginToolResult
_, plugin, _ = installed
rows = [{"label": "original"}]
artifact = MappingProxyType({"rows": rows})
async def handler(payload, context):
return PluginToolResult("ready", artifact)
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler, response_format="content_and_artifact"))
message = await _call_tool(graph, tool)
rows[0]["label"] = "changed after return"
assert message.artifact == {"rows": [{"label": "original"}]}
json.dumps(message.artifact, allow_nan=False)
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["plain", "tuple", "bad-content", "list-artifact", "nan", "object", "int-key", "bool-key", "null-key", "float-key", "key-collision", "nested-key", "list-key", "tuple-key"])
async def test_opt_in_invalid_result_is_sanitized_without_artifact(installed, kind):
from deerflow_extension_api import PluginToolResult
_, plugin, _ = installed
values = {
"plain": {"sensitive": "secret"},
"tuple": ("visible", {"sensitive": "secret"}),
"bad-content": PluginToolResult({"sensitive": "secret"}, None),
"list-artifact": PluginToolResult("visible", ["secret"]),
"nan": PluginToolResult("visible", {"score": float("nan")}),
"object": PluginToolResult("visible", {"sensitive": object()}),
"int-key": PluginToolResult("visible", {1: "secret"}),
"bool-key": PluginToolResult("visible", {True: "secret"}),
"null-key": PluginToolResult("visible", {None: "secret"}),
"float-key": PluginToolResult("visible", {1.5: "secret"}),
"key-collision": PluginToolResult("visible", {1: "secret", "1": "other"}),
"nested-key": PluginToolResult("visible", {"record": {1: "secret", "1": "other"}}),
"list-key": PluginToolResult("visible", {"rows": [{1: "secret"}]}),
"tuple-key": PluginToolResult("visible", {"rows": ({1: "secret"},)}),
}
async def handler(payload, context):
return values[kind]
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler, response_format="content_and_artifact"))
message = await _call_tool(graph, tool)
assert message.status == "error"
assert message.artifact is None
assert message.content == "Plugin tool unavailable or input rejected."
@pytest.mark.asyncio
@pytest.mark.parametrize("over_limit", [False, True])
async def test_artifact_mode_combines_utf8_content_and_artifact_budget(installed, over_limit):
from deerflow_extension_api import PluginToolResult
_, plugin, _ = installed
artifact = {"value": "中" * 1000}
overhead = len(json.dumps({"content": "", "artifact": artifact}, ensure_ascii=False).encode())
content = "a" * (64 * 1024 - overhead + int(over_limit))
async def handler(payload, context):
return PluginToolResult(content, artifact)
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler, response_format="content_and_artifact"))
message = await _call_tool(graph, tool)
if over_limit:
assert message.status == "error"
assert message.artifact is None
assert message.content == "Plugin result exceeds 64 KiB."
else:
assert message.status == "success"
assert message.content == content
assert message.artifact == artifact
@pytest.mark.asyncio
@pytest.mark.parametrize("value", [{"text": "中文"}, ("legacy tuple", {"a": 1}), "quoted string", None, {1: "integer", "nested": {None: "null"}}])
async def test_default_mode_keeps_legacy_json_encoding(installed, value):
_, plugin, _ = installed
async def handler(payload, context):
return value
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler))
message = await _call_tool(graph, tool)
assert message.content == json.dumps(value, ensure_ascii=False, allow_nan=False)
assert message.artifact is None
@pytest.mark.asyncio
async def test_default_mode_cannot_serialize_typed_artifact_result(installed):
from deerflow_extension_api import PluginToolResult
_, plugin, _ = installed
result = PluginToolResult("visible", {"sensitive": "secret"})
# This is also what the old host's unchanged JSON path does.
with pytest.raises(TypeError):
json.dumps(result)
async def handler(payload, context):
return result
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler))
message = await _call_tool(graph, tool)
assert message.status == "error" and message.artifact is None
assert message.content == "Plugin tool unavailable or input rejected."
@pytest.mark.asyncio
async def test_artifact_mode_propagates_caller_cancellation(installed):
_, plugin, _ = installed
entered, cleaned = asyncio.Event(), asyncio.Event()
async def handler(payload, context):
entered.set()
try:
await asyncio.Event().wait()
finally:
cleaned.set()
graph, tool = _tool_graph(plugin, replace(plugin.tools[0], handler=handler, response_format="content_and_artifact"))
task = asyncio.create_task(_call_tool(graph, tool))
try:
await asyncio.wait_for(entered.wait(), 2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert cleaned.is_set()
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
def test_new_contract_keeps_old_positional_signature_and_is_frozen(installed):
from deerflow_extension_api import PluginToolResult
_, plugin, _ = installed
old = plugin.tools[0]
declaration = ModelTool(old.name, old.description, old.input_schema, old.handler, "custom-group")
assert declaration.response_format == "content" and declaration.group == "custom-group"
with pytest.raises(TypeError):
ModelTool(old.name, old.description, old.input_schema, old.handler, "custom-group", "content_and_artifact")
result = PluginToolResult("visible", None)
with pytest.raises(FrozenInstanceError):
result.content = "changed"
@pytest.mark.parametrize("mode", ["unknown", None, []])
def test_invalid_response_format_rejects_whole_plugin_before_registration(installed, mode):
_, plugin, _ = installed
registry = ExtensionRegistry()
declaration = replace(plugin.tools[0], name="other", response_format=mode)
with registry.attributed_to("test"):
with pytest.raises(ValueError, match="response format"):
registry.plugin(replace(plugin, tools=(*plugin.tools, declaration)))
assert not registry.build().plugins