deer-flow/backend/tests/test_knowledge_retrieval_tools.py
Xuehao Xu 29ba806fc6
feat(knowledge): add independent Wiki and entity relation retrieval tools (#7171)
* feat(knowledge): add independent Wiki and entity relation retrieval tools

* fix(knowledge): address review diagnostics and CI catalog coverage

* fix(knowledge): deduplicate graph evidence and update catalog e2e

* fix(knowledge): clarify bounded evidence and pin browser CI runners

* Update e2e-tests to run on ubuntu-latest

* Update workflow to use ubuntu-latest

* test(ci): stop pinning maintainer-controlled browser runner images

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-22 16:54:58 +08:00

144 lines
8.9 KiB
Python

"""Synthetic providers exercise the three configurable retrieval operations."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from deerflow.knowledge import KnowledgeCapabilities, RetrievalResult
from deerflow.knowledge.providers import search_provider, validate_provider
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["entries_search", "relations_search"])
async def test_optional_operation_is_contextual_and_does_not_fallback(operation):
method = AsyncMock(return_value=RetrievalResult())
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", operation}), search=AsyncMock(), **{operation: method})
validate_provider(provider)
result = await search_provider(provider, "q", scope={"version": 1, "mode": "all"}, user_id="alice", agent_name="researcher", operation=operation)
assert result.hits == []
assert method.await_args.kwargs["context"].user_id == "alice"
assert method.await_args.kwargs["context"].agent_name == "researcher"
provider.search.assert_not_called()
with pytest.raises(ValueError, match="dataset_filter"):
await search_provider(provider, "q", scope={"version": 1, "mode": "selected", "dataset_ids": ["kb"]}, user_id="alice", agent_name="researcher", operation=operation)
assert method.await_count == 1
@pytest.mark.asyncio
async def test_optional_operation_has_its_own_filter_capabilities():
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", "dataset_filter", "document_filter", "entries_search", "entries_dataset_filter"}), entries_search=AsyncMock(return_value=RetrievalResult()))
await search_provider(provider, "q", scope={"version": 1, "mode": "selected", "dataset_ids": ["kb"]}, user_id="alice", agent_name=None, operation="entries_search")
with pytest.raises(ValueError, match="document_filter"):
await search_provider(provider, "q", scope={"version": 1, "mode": "selected", "dataset_ids": ["kb"], "document_filters": [{"dataset_id": "kb", "document_ids": ["doc"]}]}, user_id="alice", agent_name=None, operation="entries_search")
assert provider.entries_search.await_count == 1
def test_registration_checks_optional_methods():
with pytest.raises(ValueError, match="entries_search"):
validate_provider(SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", "entries_search"}), search=AsyncMock()))
@pytest.mark.asyncio
@pytest.mark.parametrize("mask", range(8))
async def test_all_tool_combinations_use_config_and_shared_evidence(mask):
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.tool_config import ToolConfig
from deerflow.extensions import EXTENSION_SNAPSHOT_CONTEXT_KEY, ExtensionRegistry
from deerflow.knowledge import KnowledgeSource, RetrievalHit, sources_from_messages, tools
from deerflow.knowledge.providers import KNOWLEDGE_TOOL_OPERATIONS, get_knowledge_capabilities
from deerflow.knowledge.sources import forward_sources, prune_sources
from deerflow.runtime.serialization import serialize_channel_values_for_api
names = [name for i, name in enumerate(KNOWLEDGE_TOOL_OPERATIONS) if mask & (1 << i)]
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", "sources", "entries_search", "relations_search"}))
for operation in KNOWLEDGE_TOOL_OPERATIONS.values():
source = KnowledgeSource(provider="synthetic", title=operation, excerpt=f"Returned {operation} evidence")
setattr(provider, operation, AsyncMock(return_value=RetrievalResult(hits=[RetrievalHit(content=source.excerpt, source=source)])))
registry = ExtensionRegistry()
with registry.attributed_to("synthetic:install"):
registry.knowledge_provider("synthetic", provider)
loaded = registry.build()
config = AppConfig(sandbox=SandboxConfig(use="example.remote:Sandbox"), tools=[ToolConfig(name=name, group="knowledge", use=f"deerflow.knowledge.tools:{name}_tool", provider="synthetic") for name in names])
assert get_knowledge_capabilities(config, extensions=loaded).supports("search") == bool(names)
if not names:
return
builder = StateGraph(MessagesState, context_schema=dict)
builder.add_node("tools", ToolNode([getattr(tools, name + "_tool") for name in names]))
builder.add_edge(START, "tools")
builder.add_edge("tools", END)
result = await builder.compile().ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"id": name, "name": name, "args": {"query": "q"}} for name in names])]},
context={"app_config": config, "user_id": "alice", "is_subagent": True, "agent_id": "researcher", "__knowledge_scope_execution": {"version": 1, "mode": "all"}, EXTENSION_SNAPSHOT_CONTEXT_KEY: loaded},
)
sources = sources_from_messages(result["messages"])
assert len(sources) == len(names)
persisted = serialize_channel_values_for_api(result)
assert sources_from_messages(persisted["messages"]) == sources
report = "\n".join(message.content for message in result["messages"][1:])
forwarded, artifact = forward_sources(report, [s.model_dump(mode="json") for s in sources])
assert sources_from_messages([{"type": "tool", "content": forwarded, "artifact": artifact}]) == sources
assert prune_sources("removed", artifact)[1]["knowledge_sources"] == []
for name in names:
method = getattr(provider, KNOWLEDGE_TOOL_OPERATIONS[name])
if name != "knowledge_search":
assert method.await_args.kwargs["context"].agent_name == "researcher"
@pytest.mark.parametrize("name", ["knowledge_search", "knowledge_entries_search", "entity_relations_search"])
def test_disabled_scope_blocks_each_tool(name):
from deerflow.agents.middlewares.knowledge_scope_middleware import KnowledgeScopeMiddleware
request = SimpleNamespace(tool_call={"name": name, "id": "call"}, runtime=SimpleNamespace(context={"__knowledge_scope_execution": {"version": 1, "mode": "disabled"}}))
message = KnowledgeScopeMiddleware._disabled_tool_message(request)
assert message.name == name and message.status == "error"
def test_combined_scope_is_intersection_and_rejects_mixed_namespaces():
from deerflow.extensions import ExtensionRegistry
from deerflow.knowledge.providers import get_knowledge_capabilities
registry = ExtensionRegistry()
capabilities = KnowledgeCapabilities(operations={"search", "catalog", "documents", "dataset_filter", "document_filter", "entries_search", "entries_dataset_filter"})
provider = SimpleNamespace(capabilities=capabilities, search=AsyncMock(), search_scoped=AsyncMock(), entries_search=AsyncMock(), catalog=AsyncMock(), documents=AsyncMock())
with registry.attributed_to("test:install"):
registry.knowledge_provider("one", provider)
registry.knowledge_provider("two", provider)
entries = {name: SimpleNamespace(use=f"deerflow.knowledge.tools:{name}_tool", model_extra={"provider": "one"}) for name in ["knowledge_search", "knowledge_entries_search"]}
config = SimpleNamespace(get_tool_config=entries.get)
caps = get_knowledge_capabilities(config, extensions=registry.build())
assert caps.supports("dataset_filter") and not caps.supports("document_filter")
entries["knowledge_entries_search"].model_extra["provider"] = "two"
caps = get_knowledge_capabilities(config, extensions=registry.build())
assert caps.supports("search") and not caps.supports("dataset_filter")
@pytest.mark.parametrize("registered", [True, False])
def test_incompatible_tool_reports_pair_without_silently_emptying_capabilities(caplog, registered):
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.tool_config import ToolConfig
from deerflow.extensions import ExtensionRegistry
from deerflow.knowledge.providers import get_knowledge_capabilities
registry = ExtensionRegistry()
if registered:
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search"}), search=AsyncMock())
with registry.attributed_to("test:install"):
registry.knowledge_provider("documents-only", provider)
names = ["knowledge_search", "knowledge_entries_search"]
config = AppConfig(
sandbox=SandboxConfig(use="example:Sandbox"),
tools=[ToolConfig(name=name, group="knowledge", use=f"deerflow.knowledge.tools:{name}_tool", provider="documents-only") for name in names],
)
with pytest.raises(ValueError, match="Invalid knowledge tool configuration"):
get_knowledge_capabilities(config, extensions=registry.build())
assert "documents-only" in caplog.text
assert ("knowledge_entries_search" if registered else "knowledge_search") in caplog.text
assert ("entries_search" if registered else "not registered") in caplog.text