mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* 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
392 lines
17 KiB
Python
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
|