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>
This commit is contained in:
Xuehao Xu 2026-10-22 16:54:58 +08:00 committed by GitHub
parent f5907405a0
commit 29ba806fc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1566 additions and 51 deletions

View File

@ -1500,6 +1500,18 @@ Parsing remains strict and invalid skills stay unavailable to agents.
#### Read-only knowledge sources
Three retrieval tools can be configured independently in `tools[]`: document
`knowledge_search`, published `knowledge_entries_search` through the
[WeKnora plugin](examples/deerflow-extension-weknora/README.md), and
`entity_relations_search` through the optional
[LightRAG plugin](examples/deerflow-extension-lightrag/README.md). Remove a tool
entry to disable it. They share scope admission, source validation, budgets and
citation rendering. Wiki queries that return no pages retry up to four individual
keywords within the same authorized base. Unsupported filters fail closed;
multiple providers with different resource namespaces support only all/disabled scope. The new adapters
have offline coverage and [local live validation](examples/deerflow-extension-lightrag/VALIDATION.md)
with bge-m3 and GLM-5.3; see that record for coverage limits.
Configure `knowledge_search` with either
`deerflow.community.ragflow.tools:knowledge_search_tool` or
`deerflow.community.lightrag.tools:knowledge_search_tool` in `config.yaml`.

View File

@ -165,7 +165,8 @@ Read the nearest scoped `AGENTS.md` for subsystem ownership.
## Architecture
Read-only knowledge: [contract](docs/KNOWLEDGE.md).
Read-only knowledge: [contract](docs/KNOWLEDGE.md) covers independent retrieval
tools, scope intersection and extension-owned providers.
### Harness / App Split

View File

@ -14,8 +14,9 @@ all six read-only operations against the v0.8.3 API.
An operator-installed extension calls
`registry.knowledge_provider(name, provider)` during `install()`. The method
returns `False` on older hosts and is supported by extension-api **0.2.7**.
The provider implements the existing `RetrievalProvider.search(query)` and
advertises `KnowledgeCapabilities`. Registration validates each advertised
The provider advertises `KnowledgeCapabilities` and implements its retrieval
operations: the existing `RetrievalProvider.search(query)`, or one of the
optional entry/relation methods described below. Registration validates each advertised
operation, rejects duplicate/reserved names, and participates in source
attribution and positional install rollback. Connection settings, response
mapping and credentials belong to that extension's `plugins[].config`.
@ -24,7 +25,7 @@ The contract package remains dependency-free; providers use the published
| Operation | Optional interface | Behavior |
| --- | --- | --- |
| `search` | `RetrievalProvider.search` | Required; returns `RetrievalResult` |
| `search` | `RetrievalProvider.search` | Document retrieval; returns `RetrievalResult` |
| `sources` | Existing result/source models | Advertises attributable evidence |
| `catalog` | `RetrievalCatalogProvider.catalog` | Paginated accessible datasets |
| `documents` | `RetrievalDocumentsProvider.documents` | Paginated documents in an accessible dataset |
@ -279,3 +280,66 @@ actual registry, Gateway catalog, ToolNode, shared evidence and replay path.
Live validation needs an indexed instance and is not implied by offline passes.
The extension's [validation record](../../examples/deerflow-extension-weknora/VALIDATION.md)
documents the local v0.8.3 / Ollama bge-m3 run.
## New Phase 1: three independently configurable retrieval tools
This is the follow-up plan in #6269 comments 6281785410 and 6282389387, distinct
from the completed original three phases. Existing `tools`, `tool_groups`,
`tools[].provider` and the registry remain the only configuration mechanism;
there is no `retrieval_channels` or per-tool `enabled` switch.
| Tool | Optional provider method | Filter capability names |
| --- | --- | --- |
| `knowledge_search` | existing `search` / `search_scoped` | `dataset_filter`, `document_filter` |
| `knowledge_entries_search` | `entries_search(query, *, context)` | `entries_dataset_filter`, `entries_document_filter` |
| `entity_relations_search` | `relations_search(query, *, context)` | `relations_dataset_filter`, `relations_document_filter` |
Tool paths are `deerflow.knowledge.tools:<tool_name>_tool`. All methods return
`RetrievalResult`; optional methods always receive `KnowledgeRequestContext`,
including all-scope requests. Registration checks advertised async methods.
An entry-only or relation-only provider needs no document `search` method.
Add/remove any combination of the three tool entries, including all absent.
Tool group and normal authorization admission remain unchanged.
Core contains the generic dispatch and operation-level capability projection;
provider API calls and mapping stay in packaged extensions. The catalog and
admission expose the **intersection** of configured operations' scope support.
Different registered providers cannot share selected IDs: combinations across
providers support all/disabled only. This conservative rule also applies during
runtime revalidation, resume and regeneration; it never drops filters or retries
unrestricted. Single-operation deployments do not need document search enabled.
The disabled-scope middleware hides and blocks all three tools.
The WeKnora adapter advertises entries plus base filtering, using published Wiki
pages on bases reporting `capabilities.wiki`. It rejects document filtering:
its Wiki API cannot constrain a page to selected documents. It has no entity
relation HTTP adapter; the pinned release's graph query is an internal Agent
Tool and `/wiki/graph` is a page-link graph.
The optional LightRAG plugin implements entity/relationship retrieval against
`/query/data`, with explicit user/agent credential bindings. It maps returned
one-edge paths and joins their `source_id` values to actual retrieved chunks.
It does not invent multi-hop paths or treat graph summaries as document quotes.
Graph facts are unattributed text hits; their corresponding document chunks
are separate attributable hits. The v1 source wire model, ID hashing, evidence
validation, budgets, ordinary delegation and replay readers are unchanged.
No graph UI or new graph-source schema is introduced in this phase.
The shared trusted context currently carries user, executing agent and scope;
connections and upstream tenancy remain operator-owned plugin configuration.
Later space/authorization/trace work must extend that contract deliberately,
not infer user authority from labels or treat synthetic tests as a live trace.
Tests: `test_knowledge_retrieval_tools.py` covers all eight configurations,
real ToolNode calls, shared sources, replay, delegation and budget pruning;
provider tests exercise API-shaped synthetic responses, access denial,
unsupported filters and evidence joins. The opt-in `test_knowledge_phase1_live.py` also passed against real OrbStack
WeKnora/LightRAG services with bge-m3 and a GLM-5.3 three-tool agent loop.
See the [live validation record](../../examples/deerflow-extension-lightrag/VALIDATION.md)
for exact coverage, reproduction and limits; this is not browser-chat acceptance.
Configured generic retrieval tools must resolve to a registered provider advertising
that tool's operation. Capability resolution rejects an invalid pair and logs the
tool name, provider registration name and missing operation for the operator;
it does not silently intersect it into an empty capability set. Model-visible
errors remain generic so provider details are not exposed in tool results.

View File

@ -59,7 +59,7 @@ No match: skip inspection, log ID. Tests: `test_read_mark_tool_call_correlation.
1. **InputSanitizationMiddleware** - First, so it is the outermost `wrap_model_call` wrapper; every inner middleware (including LLM retries) sees sanitized messages. `additional_kwargs.original_user_content` is server-owned provenance: Gateway strips caller-supplied values for non-internal run requests, trusted IM calls may carry the string they captured before adding transport/file context, and the middleware replaces any non-string value before wrapping. Uploads and sanitization retain first-writer-wins only for validated strings. Caller markers are marked `untrusted_input`, never stripped; scope is every turn.
**KnowledgeScopeMiddleware** follows input sanitization: it exposes only Gateway-admitted execution scope, removes scope/display data from model messages, and blocks `knowledge_search` when disabled without reading storage or RAGFlow.
**KnowledgeScopeMiddleware** follows input sanitization: it exposes only Gateway-admitted execution scope, removes scope/display data from model messages, and blocks `knowledge_search`, `knowledge_entries_search` and `entity_relations_search` when disabled without reading storage or RAGFlow.
2. **ToolOutputBudgetMiddleware** - Budgets model-bound text/JSON with limits/exemptions; externalizes to thread outputs (`tool_output.storage_subdir`, default `.tool-results`, `TOOL_RESULTS_DIRNAME`) with synopsis/`read_file` refs. Preserve mixed-result media. Restore blob refs across hosts; hash/write failures fall back inline. Internal files bypass workspace/delivery scans. Publication/elision/bash exit marker: [contract](TOOL_OUTPUT_PERSISTENCE.md). Elision: `tool_call_args`; retain newest `keep_recent_writes` writes. Controls: `elide_superseded_writes`, `superseded_write_min_chars`.
Tests: `tests/test_tool_output_synopsis_budget.py`.
3. **ToolResultSanitizationMiddleware** - Neutralizes framework/injection tags (e.g. `<system-reminder>`) and boundary markers in *remote-content* tool results (`web_fetch`/`web_search`/`image_search`/`web_capture`) so attacker-controlled fetched pages cannot forge trusted framework context. Mirrors `InputSanitizationMiddleware`'s user-input guardrail for the other untrusted-content entry point; sits inner of `ToolOutputBudgetMiddleware`. Local tool output (bash/read_file) is left untouched. Scope is a name-based allowlist for the first-party web tools, plus every MCP-sourced tool via its `deerflow_mcp` metadata tag, so an MCP server naming its fetcher `fetch_url` is still covered

View File

@ -22,7 +22,7 @@ from deerflow.knowledge_scope import (
)
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY
_KNOWLEDGE_SEARCH_TOOL_NAME = "knowledge_search"
_KNOWLEDGE_SEARCH_TOOL_NAMES = frozenset({"knowledge_search", "knowledge_entries_search", "entity_relations_search"})
def _runtime_context(value: object) -> dict[str, Any] | None:
@ -80,14 +80,14 @@ class KnowledgeScopeMiddleware(AgentMiddleware[AgentState]):
tools = list(request.tools)
scope = _scope_from_runtime(request.runtime)
if scope is not None and scope["mode"] == "disabled":
tools = [tool for tool in tools if getattr(tool, "name", None) != _KNOWLEDGE_SEARCH_TOOL_NAME]
tools = [tool for tool in tools if getattr(tool, "name", None) not in _KNOWLEDGE_SEARCH_TOOL_NAMES]
if messages == list(request.messages) and tools == list(request.tools):
return request
return request.override(messages=messages, tools=tools)
@staticmethod
def _disabled_tool_message(request: ToolCallRequest) -> ToolMessage | None:
if str(request.tool_call.get("name") or "") != _KNOWLEDGE_SEARCH_TOOL_NAME:
if str(request.tool_call.get("name") or "") not in _KNOWLEDGE_SEARCH_TOOL_NAMES:
return None
scope = _scope_from_runtime(getattr(request, "runtime", None))
if scope is None or scope["mode"] != "disabled":
@ -95,7 +95,7 @@ class KnowledgeScopeMiddleware(AgentMiddleware[AgentState]):
return ToolMessage(
content="Error: Knowledge search is disabled for this turn.",
tool_call_id=str(request.tool_call.get("id") or "missing_tool_call_id"),
name=_KNOWLEDGE_SEARCH_TOOL_NAME,
name=str(request.tool_call.get("name")),
status="error",
)

View File

@ -1,7 +1,9 @@
"""Read-only knowledge contracts with optional catalog and filter capabilities."""
from .contract import (
EntityRelationsProvider,
KnowledgeCapabilities,
KnowledgeEntriesProvider,
KnowledgeRequestContext,
KnowledgeSource,
RetrievalCatalogItem,
@ -16,6 +18,8 @@ from .contract import (
from .sources import format_result, sources_from_messages
__all__ = [
"EntityRelationsProvider",
"KnowledgeEntriesProvider",
"KnowledgeCapabilities",
"KnowledgeRequestContext",
"KnowledgeSource",

View File

@ -16,7 +16,11 @@ class KnowledgeCapabilities(BaseModel):
Search-only providers need no catalog, scope, or management implementation.
"""
operations: frozenset[Literal["search", "sources", "catalog", "documents", "dataset_filter", "document_filter"]] = frozenset({"search"})
operations: frozenset[
Literal[
"search", "sources", "catalog", "documents", "dataset_filter", "document_filter", "entries_search", "entries_dataset_filter", "entries_document_filter", "relations_search", "relations_dataset_filter", "relations_document_filter"
]
] = frozenset({"search"})
model_config = ConfigDict(frozen=True)
@ -121,3 +125,15 @@ class RetrievalCatalogProvider(Protocol):
class RetrievalDocumentsProvider(Protocol):
async def documents(self, dataset_id: str, *, context: KnowledgeRequestContext, page: int, page_size: int, search: str) -> RetrievalCatalogPage: ...
class KnowledgeEntriesProvider(Protocol):
"""Optional entry retrieval; filters are advertised independently of search."""
async def entries_search(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult: ...
class EntityRelationsProvider(Protocol):
"""Return only upstream entities/edges/path evidence, never inferred edges."""
async def relations_search(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult: ...

View File

@ -8,6 +8,7 @@ through the operator-controlled extension registry and the generic core tool.
from __future__ import annotations
import inspect
import logging
from typing import Any
from deerflow.knowledge_scope import execution_scope
@ -18,41 +19,115 @@ RAGFLOW_KNOWLEDGE_TOOL = "deerflow.community.ragflow.tools:knowledge_search_tool
LIGHTRAG_KNOWLEDGE_TOOL = "deerflow.community.lightrag.tools:knowledge_search_tool"
GENERIC_KNOWLEDGE_TOOL = "deerflow.knowledge.tools:knowledge_search_tool"
logger = logging.getLogger(__name__)
KNOWLEDGE_TOOL_OPERATIONS = {
"knowledge_search": "search",
"knowledge_entries_search": "entries_search",
"entity_relations_search": "relations_search",
}
GENERIC_KNOWLEDGE_TOOLS = {name: f"deerflow.knowledge.tools:{name}_tool" for name in KNOWLEDGE_TOOL_OPERATIONS}
def operation_capabilities(capabilities: KnowledgeCapabilities, operation: str) -> KnowledgeCapabilities:
if operation == "search":
return KnowledgeCapabilities(operations=capabilities.operations & {"search", "sources", "catalog", "documents", "dataset_filter", "document_filter"})
if operation not in {"entries_search", "relations_search"}:
raise ValueError("Unknown knowledge retrieval operation")
prefix = operation.removesuffix("search")
operations = {name for name in ("sources", "catalog", "documents") if capabilities.supports(name)}
if capabilities.supports(operation):
operations.add("search")
for name in ("dataset_filter", "document_filter"):
if capabilities.supports(prefix + name):
operations.add(name)
return KnowledgeCapabilities(operations=operations)
def validate_provider(provider: Any) -> None:
capabilities = getattr(provider, "capabilities", None)
if not isinstance(capabilities, KnowledgeCapabilities) or not capabilities.supports("search"):
raise ValueError("Knowledge providers require KnowledgeCapabilities with search")
methods = {"search"}
if not isinstance(capabilities, KnowledgeCapabilities) or not any(capabilities.supports(op) for op in KNOWLEDGE_TOOL_OPERATIONS.values()):
raise ValueError("Knowledge providers require KnowledgeCapabilities with a search operation")
methods = {"search"} if capabilities.supports("search") else set()
if capabilities.supports("catalog"):
methods.add("catalog")
if capabilities.supports("documents"):
methods.add("documents")
if capabilities.supports("dataset_filter") or capabilities.supports("document_filter"):
methods.add("search_scoped")
for operation in ("entries_search", "relations_search"):
if capabilities.supports(operation):
methods.add(operation)
prefix = operation.removesuffix("search")
if any(capabilities.supports(prefix + name) for name in ("dataset_filter", "document_filter")) and not capabilities.supports(operation):
raise ValueError("Filter capability requires its retrieval operation")
for method in methods:
if not inspect.iscoroutinefunction(getattr(provider, method, None)):
raise ValueError(f"Knowledge provider capability requires async {method}")
def get_knowledge_capabilities(app_config: Any, *, extensions: Any = None) -> KnowledgeCapabilities:
def _configured_tools(app_config: Any) -> list[tuple[str, Any]]:
get_tool = getattr(app_config, "get_tool_config", None)
tool = get_tool("knowledge_search") if callable(get_tool) else None
use = getattr(tool, "use", None)
if use == RAGFLOW_KNOWLEDGE_TOOL:
from deerflow.community.ragflow.formatting import CAPABILITIES
if not callable(get_tool):
return []
result = []
for name in KNOWLEDGE_TOOL_OPERATIONS:
tool = get_tool(name)
if tool is None:
continue
expected = GENERIC_KNOWLEDGE_TOOLS[name]
if getattr(tool, "use", None) == expected or (name == "knowledge_search" and getattr(tool, "use", None) in {RAGFLOW_KNOWLEDGE_TOOL, LIGHTRAG_KNOWLEDGE_TOOL}):
result.append((name, tool))
return result
return CAPABILITIES
if use == LIGHTRAG_KNOWLEDGE_TOOL:
from deerflow.community.lightrag.formatting import CAPABILITIES
return CAPABILITIES
provider = _registered_provider(tool, extensions=extensions)
return provider.capabilities if provider is not None else KnowledgeCapabilities(operations=frozenset())
def get_knowledge_capabilities(app_config: Any, *, extensions: Any = None) -> KnowledgeCapabilities:
"""One scope must be enforceable by every configured retrieval operation.
Different provider registrations have no shared resource-ID namespace;
their combination supports all/disabled only. Never reinterpret selected IDs.
"""
configured = _configured_tools(app_config)
if not configured:
return KnowledgeCapabilities(operations=frozenset())
projected = []
identities = set()
for name, tool in configured:
use = getattr(tool, "use", None)
if use == RAGFLOW_KNOWLEDGE_TOOL:
from deerflow.community.ragflow.formatting import CAPABILITIES
capabilities = CAPABILITIES
identities.add(use)
elif use == LIGHTRAG_KNOWLEDGE_TOOL:
from deerflow.community.lightrag.formatting import CAPABILITIES
capabilities = CAPABILITIES
identities.add(use)
else:
provider = _registered_provider(tool, extensions=extensions)
provider_name = (getattr(tool, "model_extra", None) or {}).get("provider")
operation = KNOWLEDGE_TOOL_OPERATIONS[name]
if provider is None or not provider.capabilities.supports(operation):
logger.error(
"Invalid knowledge tool configuration: tool=%r provider=%r operation=%r (%s)",
name,
provider_name,
operation,
"not registered" if provider is None else "operation not advertised",
)
raise ValueError("Invalid knowledge tool configuration; check operator logs")
capabilities = operation_capabilities(provider.capabilities, operation)
identities.add((getattr(tool, "model_extra", None) or {}).get("provider"))
projected.append(set(capabilities.operations))
operations = set.intersection(*projected)
if len(identities) > 1:
operations -= {"catalog", "documents", "dataset_filter", "document_filter"}
return KnowledgeCapabilities(operations=operations)
def _registered_provider(tool: Any, *, extensions: Any = None) -> RetrievalProvider | None:
if getattr(tool, "use", None) != GENERIC_KNOWLEDGE_TOOL:
if getattr(tool, "use", None) not in GENERIC_KNOWLEDGE_TOOLS.values():
return None
from deerflow.extensions import get_loaded_extensions
@ -64,9 +139,9 @@ def _registered_provider(tool: Any, *, extensions: Any = None) -> RetrievalProvi
return None
def resolve_knowledge_provider(app_config: Any, *, extensions: Any = None) -> RetrievalProvider:
get_tool = getattr(app_config, "get_tool_config", None)
tool = get_tool("knowledge_search") if callable(get_tool) else None
def resolve_knowledge_provider(app_config: Any, *, extensions: Any = None, tool_name: str | None = None) -> RetrievalProvider:
configured = _configured_tools(app_config)
tool = next((tool for name, tool in configured if tool_name is None or name == tool_name), None)
if getattr(tool, "use", None) == RAGFLOW_KNOWLEDGE_TOOL:
from deerflow.community.ragflow.provider import RAGFlowKnowledgeProvider
@ -89,16 +164,21 @@ def validate_scope_capabilities(scope: object, capabilities: KnowledgeCapabiliti
return canonical
async def search_provider(provider: RetrievalProvider, query: str, *, scope: object | None, user_id: str, agent_name: str | None) -> RetrievalResult:
canonical = validate_scope_capabilities(scope, provider.capabilities) if scope is not None else None
async def search_provider(provider: RetrievalProvider, query: str, *, scope: object | None, user_id: str, agent_name: str | None, operation: str = "search") -> RetrievalResult:
capabilities = operation_capabilities(provider.capabilities, operation)
if not capabilities.supports("search"):
raise ValueError("Knowledge retrieval operation is unavailable")
canonical = validate_scope_capabilities(scope, capabilities) if scope is not None else None
if canonical is not None and canonical["mode"] == "disabled":
raise ValueError("Knowledge search is disabled for this turn")
context = KnowledgeRequestContext(user_id=user_id, agent_name=agent_name, scope=canonical)
contextual_search = getattr(provider, "search_scoped", None)
contextual_search = getattr(provider, "search_scoped" if operation == "search" else operation, None)
if callable(contextual_search):
result = await contextual_search(query, context=context)
elif canonical is not None and canonical["mode"] == "selected":
raise ValueError("Knowledge provider cannot apply the selected scope")
elif operation != "search":
raise ValueError("Knowledge retrieval operation is unavailable")
else:
result = await provider.search(query)
# Validate provider values before they can become model-visible evidence.

View File

@ -8,7 +8,7 @@ from langchain_core.tools import StructuredTool
from deerflow.config import get_app_config
from deerflow.config.app_config import AppConfig
from deerflow.extensions import resolve_run_extensions
from deerflow.knowledge.providers import resolve_knowledge_provider, search_provider
from deerflow.knowledge.providers import KNOWLEDGE_TOOL_OPERATIONS, get_knowledge_capabilities, resolve_knowledge_provider, search_provider, validate_scope_capabilities
from deerflow.knowledge.sources import format_result
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_RUNTIME_KEY, execution_scope
from deerflow.runtime.user_context import resolve_runtime_user_id
@ -32,11 +32,13 @@ def _execution_agent_name(context: Mapping, config: object = None) -> str:
return name
async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> tuple[str, dict | None]:
async def execute_knowledge_search(query: str, runtime: Runtime, *, tool_name: str = "knowledge_search") -> tuple[str, dict | None]:
"""Retrieve evidence from the configured knowledge provider.
Args:
query: Specific question or search terms for private documents.
query: Specific question or search terms for the configured retrieval tool.
runtime: Trusted execution context and run configuration.
tool_name: Configured document, Wiki entry, or entity relation tool.
"""
if not query.strip():
return "Error: query must not be empty.", None
@ -49,12 +51,16 @@ async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> tuple[st
app_config = context.get("app_config")
if not isinstance(app_config, AppConfig):
app_config = get_app_config()
provider = resolve_knowledge_provider(app_config, extensions=resolve_run_extensions(context))
extensions = resolve_run_extensions(context)
if scope is not None:
validate_scope_capabilities(scope, get_knowledge_capabilities(app_config, extensions=extensions))
provider = resolve_knowledge_provider(app_config, extensions=extensions, tool_name=tool_name)
result = await search_provider(
provider,
query.strip(),
scope=scope,
user_id=resolve_runtime_user_id(runtime),
operation=KNOWLEDGE_TOOL_OPERATIONS[tool_name],
agent_name=_execution_agent_name(context, getattr(runtime, "config", None)),
)
return format_result(result)
@ -66,10 +72,27 @@ async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> tuple[st
return "Error: Knowledge retrieval failed; check the provider and selected scope.", None
knowledge_search_tool = StructuredTool.from_function(
coroutine=_knowledge_search_entrypoint,
name="knowledge_search",
response_format="content_and_artifact",
description="Search accessible knowledge within this turn's scope. Cite only supplied [citation:Title](#knowledge-source-ID) links exactly; never invent source locators.",
parse_docstring=True,
)
async def _knowledge_search_entrypoint(query: str, runtime: Runtime) -> tuple[str, dict | None]:
return await execute_knowledge_search(query, runtime)
async def _entries_search_entrypoint(query: str, runtime: Runtime) -> tuple[str, dict | None]:
return await execute_knowledge_search(query, runtime, tool_name="knowledge_entries_search")
async def _relations_search_entrypoint(query: str, runtime: Runtime) -> tuple[str, dict | None]:
return await execute_knowledge_search(query, runtime, tool_name="entity_relations_search")
def _tool(name, coroutine, description):
return StructuredTool.from_function(
coroutine=coroutine,
name=name,
response_format="content_and_artifact",
description=description + " Within this turn's scope. Cite only supplied [citation:Title](#knowledge-source-ID) links exactly; never invent source locators or relations.",
)
knowledge_search_tool = _tool("knowledge_search", _knowledge_search_entrypoint, "Search accessible document evidence.")
knowledge_entries_search_tool = _tool("knowledge_entries_search", _entries_search_entrypoint, "Search accessible published Wiki and knowledge entries.")
entity_relations_search_tool = _tool("entity_relations_search", _relations_search_entrypoint, "Search upstream entities and relations. Returned paths may be incomplete; absence does not disprove a relation.")

View File

@ -38,3 +38,30 @@ async def test_weknora_ssl_setup_is_off_loop(weknora_provider, monkeypatch):
monkeypatch.setattr(httpx.AsyncClient, "send", send)
catalog = await weknora_provider.catalog(context=KnowledgeRequestContext("alice", "lead_agent"), page=1, page_size=20, search="")
assert catalog.items[0].name == "Synthetic"
@pytest.mark.asyncio
async def test_lightrag_relation_ssl_setup_is_off_loop(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[3] / "examples/deerflow-extension-lightrag"))
from deerflow_extension_lightrag import LightRAGProvider
from deerflow.knowledge import KnowledgeRequestContext
monkeypatch.setenv("LR_BLOCKING_KEY", "synthetic")
provider = LightRAGProvider({"base_url": "https://lightrag.invalid", "api_key_env": "LR_BLOCKING_KEY", "user_ids": ["alice"]})
async def send(self, request, **kwargs):
return httpx.Response(200, request=request, json={"status": "success", "data": {"entities": [], "relationships": [], "chunks": []}})
original = ssl.create_default_context
def ssl_context(*args, **kwargs):
try:
asyncio.get_running_loop()
except RuntimeError:
return original(*args, **kwargs)
raise AssertionError("SSL trust loading must run off the event loop")
monkeypatch.setattr(ssl, "create_default_context", ssl_context)
monkeypatch.setattr(httpx.AsyncClient, "send", send)
assert not (await provider.relations_search("q", context=KnowledgeRequestContext("alice"))).hits

View File

@ -59,6 +59,7 @@ EXPECTED_GUIDANCE_PATHS = {
"examples/deerflow-extension-agent-teams/AGENTS.md",
"examples/deerflow-extension-workflows/AGENTS.md",
"examples/deerflow-extension-weknora/AGENTS.md",
"examples/deerflow-extension-lightrag/AGENTS.md",
}

View File

@ -0,0 +1,21 @@
"""Browser CI installs its dependencies before executing browser tests."""
from pathlib import Path
import pytest
import yaml
@pytest.mark.parametrize(("workflow", "job"), [("e2e-tests.yml", "e2e-tests"), ("replay-e2e.yml", "fullstack-replay-render")])
def test_browser_ci_installs_chromium_before_running_tests(workflow, job):
root = Path(__file__).resolve().parents[2]
config = yaml.safe_load((root / ".github/workflows" / workflow).read_text(encoding="utf-8"))
browser_job = config["jobs"][job]
# Runner images are maintained by CI owners; do not freeze a temporary
# OS compatibility workaround in an unrelated backend unit test.
steps = browser_job["steps"]
installs = [index for index, step in enumerate(steps) if "playwright install chromium --with-deps" in step.get("run", "")]
tests = [index for index, step in enumerate(steps) if "pnpm exec playwright test" in step.get("run", "")]
assert installs, "Browser dependencies must be installed"
assert tests, "The browser job must execute tests"
assert max(installs) < min(tests), "Install Chromium before running browser tests"

View File

@ -0,0 +1,166 @@
"""Opt-in new Phase 1 checks using existing Wiki and indexed graph data.
No ingestion or resource mutations. Credentials are supplied by environment;
fixtures must contain attributable published entries and graph evidence.
"""
import json
import os
from pathlib import Path
import pytest
pytestmark = [pytest.mark.live, pytest.mark.asyncio]
def _required(*names):
if os.getenv("DEER_FLOW_RUN_LIVE_TESTS") != "1":
pytest.skip("live knowledge checks are opt-in")
if any(not os.getenv(name) for name in names):
pytest.fail("Set all documented live provider environment variables")
return {name: os.environ[name] for name in names}
def _evidence(result):
from deerflow.knowledge import format_result, sources_from_messages
from deerflow.knowledge.sources import forward_sources, prune_sources
content, artifact = format_result(result)
message = json.loads(json.dumps({"type": "tool", "content": content, "artifact": artifact}))
sources = sources_from_messages([message])
assert sources, "Live fixture must return attributable evidence"
for source in sources:
assert source.page is None and source.url is None
assert any(hit.source and source.title == hit.source.title and source.excerpt.rstrip("…") in hit.content for hit in result.hits)
forwarded, envelope = forward_sources(content, [source.model_dump(mode="json") for source in sources])
assert sources_from_messages([{"type": "tool", "content": forwarded, "artifact": envelope}]) == sources
assert prune_sources("removed", artifact)[1]["knowledge_sources"] == []
return sources
async def test_weknora_live_published_entries(monkeypatch):
env = _required("WEKNORA_BASE_URL", "WEKNORA_TENANT_ID", "WEKNORA_WIKI_BASE_ID", "WEKNORA_API_KEY", "WEKNORA_WIKI_QUERY")
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-weknora"))
from deerflow_extension_weknora import WeKnoraProvider
from deerflow.knowledge import KnowledgeRequestContext
provider = WeKnoraProvider({"spaces": [{"base_url": env["WEKNORA_BASE_URL"], "tenant_id": env["WEKNORA_TENANT_ID"], "api_key_env": "WEKNORA_API_KEY", "user_ids": ["live-probe"], "knowledge_base_ids": [env["WEKNORA_WIKI_BASE_ID"]]}]})
page = await provider.catalog(context=KnowledgeRequestContext("live-probe", "researcher"), page=1, page_size=100, search="")
assert len(page.items) == 1
scope = {"version": 1, "mode": "selected", "dataset_ids": [page.items[0].id]}
result = await provider.entries_search(env["WEKNORA_WIKI_QUERY"], context=KnowledgeRequestContext("live-probe", "researcher", scope))
assert all(source.provider == "weknora" for source in _evidence(result))
with pytest.raises(PermissionError):
await provider.entries_search(env["WEKNORA_WIKI_QUERY"], context=KnowledgeRequestContext("unbound", "researcher", scope))
with pytest.raises(ValueError, match="document"):
await provider.entries_search(env["WEKNORA_WIKI_QUERY"], context=KnowledgeRequestContext("live-probe", "researcher", {**scope, "document_filters": [{"dataset_id": page.items[0].id, "document_ids": ["unsupported"]}]}))
async def test_lightrag_live_relation_evidence(monkeypatch):
env = _required("LIGHTRAG_BASE_URL", "LIGHTRAG_API_KEY", "LIGHTRAG_RELATION_QUERY")
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-lightrag"))
from deerflow_extension_lightrag import LightRAGProvider
from deerflow.knowledge import KnowledgeRequestContext
provider = LightRAGProvider({"base_url": env["LIGHTRAG_BASE_URL"], "api_key_env": "LIGHTRAG_API_KEY", "user_ids": ["live-probe"], "timeout": 120})
result = await provider.relations_search(env["LIGHTRAG_RELATION_QUERY"], context=KnowledgeRequestContext("live-probe", "researcher"))
assert any("Upstream relation (one edge):" in hit.content for hit in result.hits), "Live fixture must have actual relations"
assert all(source.provider == "lightrag" for source in _evidence(result))
for scope in ({"version": 1, "mode": "disabled"}, {"version": 1, "mode": "selected", "dataset_ids": ["unsupported"]}):
with pytest.raises(ValueError):
await provider.relations_search(env["LIGHTRAG_RELATION_QUERY"], context=KnowledgeRequestContext("live-probe", "researcher", scope))
with pytest.raises(PermissionError):
await provider.relations_search(env["LIGHTRAG_RELATION_QUERY"], context=KnowledgeRequestContext("unbound", "researcher"))
async def test_live_glm_three_tools_and_citations(monkeypatch):
if os.getenv("KNOWLEDGE_RUN_MODEL_TEST") != "1":
pytest.skip("generative model validation requires a separate opt-in")
env = _required(
"E2E_LLM_MODEL",
"E2E_LLM_BASE_URL",
"E2E_LLM_API_KEY",
"KNOWLEDGE_MODEL_QUESTION",
"WEKNORA_BASE_URL",
"WEKNORA_TENANT_ID",
"WEKNORA_WIKI_BASE_ID",
"WEKNORA_KNOWLEDGE_BASE_ID",
"WEKNORA_API_KEY",
"LIGHTRAG_BASE_URL",
"LIGHTRAG_API_KEY",
)
import re
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
for package in ("weknora", "lightrag"):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / f"examples/deerflow-extension-{package}"))
from deerflow_extension_lightrag import LightRAGProvider
from deerflow_extension_weknora import WeKnoraProvider
from deerflow.agents.middlewares.knowledge_scope_middleware import KnowledgeScopeMiddleware
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 sources_from_messages, tools
from deerflow.knowledge.sources import forward_sources, prune_sources
from deerflow.runtime.serialization import serialize_channel_values_for_api
wiki = WeKnoraProvider(
{
"spaces": [
{
"base_url": env["WEKNORA_BASE_URL"],
"tenant_id": env["WEKNORA_TENANT_ID"],
"api_key_env": "WEKNORA_API_KEY",
"user_ids": ["live-probe"],
"knowledge_base_ids": [env["WEKNORA_WIKI_BASE_ID"], env["WEKNORA_KNOWLEDGE_BASE_ID"]],
}
]
}
)
graph = LightRAGProvider({"base_url": env["LIGHTRAG_BASE_URL"], "api_key_env": "LIGHTRAG_API_KEY", "user_ids": ["live-probe"], "timeout": 120})
registry = ExtensionRegistry()
with registry.attributed_to("live:providers"):
registry.knowledge_provider("weknora", wiki)
registry.knowledge_provider("lightrag-relations", graph)
mapping = {"knowledge_search": "weknora", "knowledge_entries_search": "weknora", "entity_relations_search": "lightrag-relations"}
config = AppConfig(sandbox=SandboxConfig(use="example.remote:Sandbox"), tools=[ToolConfig(name=name, group="knowledge", use=f"deerflow.knowledge.tools:{name}_tool", provider=provider) for name, provider in mapping.items()])
model = ChatOpenAI(model=env["E2E_LLM_MODEL"], base_url=env["E2E_LLM_BASE_URL"], api_key=env["E2E_LLM_API_KEY"], timeout=240, max_retries=0, extra_body=json.loads(os.getenv("E2E_LLM_EXTRA_BODY", "{}")))
agent = create_agent(
model,
tools=[getattr(tools, name + "_tool") for name in mapping],
middleware=[KnowledgeScopeMiddleware()],
context_schema=dict,
system_prompt="Use all three knowledge retrieval tools to answer the user's question from real evidence. Copy citation links exactly from tool output. Never invent locators or relations. State any tool failures.",
)
state = await agent.ainvoke(
{"messages": [HumanMessage(content=env["KNOWLEDGE_MODEL_QUESTION"])]},
context={"app_config": config, "user_id": "live-probe", "__knowledge_scope_execution": {"version": 1, "mode": "all"}, EXTENSION_SNAPSHOT_CONTEXT_KEY: registry.build()},
config={"recursion_limit": 16},
)
messages = state["messages"]
results = [message for message in messages if isinstance(message, ToolMessage)]
assert set(mapping) <= {message.name for message in results}
assert all(isinstance(message.content, str) and not message.content.startswith("Error:") for message in results)
for name in mapping:
assert sources_from_messages([message for message in results if message.name == name]), f"{name} must supply actual evidence"
answer = messages[-1].content
assert isinstance(answer, str)
if os.getenv("KNOWLEDGE_EXPECT_CLEAN_ANSWER") == "1":
for message in messages:
if message.type == "ai" and isinstance(message.content, str):
assert "<think>" not in message.content and "</think>" not in message.content
cited = set(re.findall(r"#knowledge-source-([a-f0-9]{32})", answer))
sources = sources_from_messages(messages)
assert cited and cited <= {source.id for source in sources}
history = json.loads(json.dumps(serialize_channel_values_for_api({"messages": messages})))
assert sources_from_messages(history["messages"]) == sources
report, envelope = forward_sources(answer, [source.model_dump(mode="json") for source in sources])
assert {source.id for source in sources_from_messages([{"type": "tool", "content": report, "artifact": envelope}])} == cited
assert prune_sources("removed", envelope)[1]["knowledge_sources"] == []

View File

@ -0,0 +1,143 @@
"""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

View File

@ -0,0 +1,149 @@
"""Synthetic LightRAG v1.5.7 graph evidence; no live service required."""
import json
import re
from pathlib import Path
import httpx
import pytest
from deerflow.knowledge import KnowledgeRequestContext, format_result, sources_from_messages
@pytest.fixture
def graph(monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-lightrag"))
from deerflow_extension_lightrag import LightRAGProvider
monkeypatch.setenv("LR_TEST_KEY", "synthetic-key")
state = {
"entities": [{"entity_name": "Cedar", "description": "A tree"}],
"relationships": [{"src_id": "Cedar", "tgt_id": "Forest", "description": "grows in", "source_id": "chunk-1"}],
"chunks": [{"chunk_id": "chunk-1", "file_path": "trees.md", "content": "Cedar grows in forests."}],
"references": [],
}
calls = []
def handle(request):
calls.append(request)
assert request.url.path == "/query/data"
assert request.headers["X-API-Key"] == "synthetic-key"
assert json.loads(request.content)["mode"] == "hybrid"
return httpx.Response(200, json={"status": "success", "data": state})
return LightRAGProvider({"base_url": "https://lr.example", "api_key_env": "LR_TEST_KEY", "user_ids": ["alice"]}, transport=httpx.MockTransport(handle)), calls, state
@pytest.mark.asyncio
async def test_relationship_only_cites_joined_real_chunks(graph):
provider, _, state = graph
result = await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
content, artifact = format_result(result)
assert "Cedar — Forest" in content
sources = sources_from_messages([{"type": "tool", "name": "entity_relations_search", "content": content, "artifact": artifact}])
assert len(sources) == 1 and sources[0].excerpt == "Cedar grows in forests."
assert sources[0].page is None and sources[0].url is None
state["relationships"][0]["source_id"] = "missing"
content, artifact = format_result(await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice")))
assert "Cedar — Forest" in content
assert sources_from_messages([{"type": "tool", "content": content, "artifact": artifact}]) == []
@pytest.mark.asyncio
async def test_graph_rejects_scope_and_identity_before_network(graph):
provider, calls, _ = graph
with pytest.raises(PermissionError):
await provider.relations_search("q", context=KnowledgeRequestContext("mallory"))
for scope in ({"version": 1, "mode": "disabled"}, {"version": 1, "mode": "selected", "dataset_ids": ["kb"]}):
with pytest.raises(ValueError):
await provider.relations_search("q", context=KnowledgeRequestContext("alice", scope=scope))
assert calls == []
@pytest.mark.asyncio
async def test_graph_does_not_infer_edges_or_cite_unrelated_chunks(graph):
provider, _, state = graph
state["relationships"] = []
result = await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
assert all(hit.source is None for hit in result.hits)
assert all("one edge" not in hit.content for hit in result.hits)
@pytest.mark.asyncio
async def test_graph_redacts_secrets_and_rejects_malformed_relations(graph):
provider, _, state = graph
state["relationships"][0]["description"] = "synthetic-key"
state["chunks"][0]["content"] = "synthetic-key evidence"
result = await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
assert "synthetic-key" not in result.model_dump_json()
state["relationships"] = {"invalid": "shape"}
with pytest.raises(ValueError):
await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
@pytest.mark.asyncio
@pytest.mark.parametrize("status", [301, 401, 403, 500])
async def test_graph_upstream_failure_never_retries(graph, status):
provider, _, _ = graph
requests = []
def handle(request):
requests.append(request)
return httpx.Response(status, headers={"location": "https://other.invalid"}, text="synthetic-key")
provider._transport = httpx.MockTransport(handle)
with pytest.raises((PermissionError, RuntimeError)) as exc:
await provider.relations_search("Cedar", context=KnowledgeRequestContext("alice"))
assert len(requests) == 1
assert "synthetic-key" not in str(exc.value)
@pytest.mark.asyncio
async def test_shared_chunk_is_emitted_once_with_each_edge_attributed_within_budget(graph):
provider, _, state = graph
state["chunks"][0]["content"] = "Shared evidence. " * 60
state["relationships"] = [{"src_id": f"Node{i}", "tgt_id": "Forest", "description": "grows in", "source_id": "chunk-1<SEP>chunk-1"} for i in range(20)]
result = await provider.relations_search("forest", context=KnowledgeRequestContext("alice"))
assert len([hit for hit in result.hits if hit.source]) == 1
content, artifact = format_result(result)
for i in range(20):
assert f"Node{i} — Forest" in content
assert content.count("Evidence: result [1].") == 20
assert len(artifact["knowledge_sources"]) == 1
assert len(content) <= 8000
short, short_artifact = format_result(result, max_total_chars=1100)
if "Evidence: result [1]." in short:
assert short_artifact["knowledge_sources"]
@pytest.mark.asyncio
async def test_edges_reference_only_their_own_chunks_and_reuse_shared_positions(graph):
provider, _, state = graph
state["chunks"].append({"chunk_id": "chunk-2", "file_path": "second.md", "content": "Second evidence."})
state["relationships"] = [
{"src_id": "A", "tgt_id": "B", "source_id": "chunk-1"},
{"src_id": "B", "tgt_id": "C", "source_id": "chunk-2<SEP>chunk-1"},
{"src_id": "C", "tgt_id": "D", "source_id": "missing"},
]
result = await provider.relations_search("q", context=KnowledgeRequestContext("alice"))
assert result.hits[1].content.startswith("Evidence: result [1].")
assert result.hits[3].content.startswith("Evidence: result [3], result [1].")
assert "No retrieved document evidence" in result.hits[4].content
assert len([hit for hit in result.hits if hit.source]) == 2
for budget in [0, 50, 200, 500, 8000]:
content, _ = format_result(result, max_total_chars=budget)
for ref in re.findall(r"result \[(\d+)\]", content):
assert f"[{ref}] [citation:" in content
@pytest.mark.asyncio
async def test_hit_budget_distinguishes_omitted_evidence_from_missing_evidence(graph):
provider, _, state = graph
state["chunks"] = [{"chunk_id": f"c{i}", "file_path": "evidence.md", "content": f"Evidence {i}."} for i in range(50)]
state["relationships"] = [{"src_id": f"A{i}", "tgt_id": "B", "source_id": f"c{i}"} for i in range(50)]
result = await provider.relations_search("q", context=KnowledgeRequestContext("alice"))
assert len(result.hits) == 100
tail_edge = result.hits[-2].content
assert "A49 — B" in tail_edge
assert "Evidence omitted for this edge: bounded view." in tail_edge
assert "No retrieved document evidence" not in tail_edge

View File

@ -96,6 +96,9 @@ async def test_knowledge_provider_receives_real_subagent_execution_identity(clas
provider = SimpleNamespace(capabilities=KnowledgeCapabilities(operations={"search", "dataset_filter"}), search_scoped=AsyncMock(return_value=RetrievalResult()))
monkeypatch.setattr(tools, "get_app_config", _default_app_config)
monkeypatch.setattr(tools, "resolve_knowledge_provider", lambda *args, **kwargs: provider)
# This identity-focused fixture supplies the configured scope intersection
# alongside its stub provider; real configuration is covered by ToolNode tests.
monkeypatch.setattr(tools, "get_knowledge_capabilities", lambda *args, **kwargs: provider.capabilities)
scope = {"version": 1, "mode": "selected", "dataset_ids": ["private"]}
async def capturing_astream(state, *, context, **kwargs):

View File

@ -26,11 +26,13 @@ def adapter(monkeypatch):
if state.get("status"):
return httpx.Response(state["status"], text="synthetic-secret upstream body")
if request.url.path == "/api/v1/knowledge-bases":
data = state.get("bases", [{"id": "kb-1", "name": "Synthetic base"}])
data = state.get("bases", [{"id": "kb-1", "name": "Synthetic base", "capabilities": {"wiki": True}}])
elif request.url.path == "/api/v1/knowledge-bases/kb-1/knowledge":
return httpx.Response(200, json={"success": True, "data": [state["document"]], **state.get("pagination", {"total": 1})})
elif request.url.path == "/api/v1/knowledge/doc-1":
data = state["document"]
elif request.url.path == "/api/v1/knowledgebase/kb-1/wiki/search":
return httpx.Response(200, json={"pages": state.get("pages", [])})
elif request.url.path == "/api/v1/knowledge-search":
return httpx.Response(403, json={"success": False, "error": "KB-restricted keys reject global knowledge_ids"})
elif request.url.path == "/api/v1/knowledge-bases/kb-1/hybrid-search":
@ -398,3 +400,105 @@ async def test_retrieval_response_size_limit_without_retry(adapter):
await provider.search_scoped("Cedar", context=ctx)
assert len(posts) == 1
assert not any("/knowledge/" in r.url.path for r in calls)
@pytest.mark.asyncio
async def test_published_entries_use_real_wiki_evidence(adapter):
provider, calls, state, *_ = adapter
ctx = await selection(provider, docs=False)
state["pages"] = [{"knowledge_base_id": "kb-1", "slug": "cedar", "title": "Cedar entry", "status": "published", "summary": "Cedar entry evidence."}]
result = await provider.entries_search("Cedar", context=ctx)
assert result.hits[0].source.title == "Cedar entry"
assert result.hits[0].source.excerpt == "Cedar entry evidence."
assert calls[-1].url.params["q"] == "Cedar"
content, artifact = format_result(result)
assert len(sources_from_messages([{"type": "tool", "name": "knowledge_entries_search", "content": content, "artifact": artifact}])) == 1
state["pages"][0]["status"] = "draft"
assert not (await provider.entries_search("Cedar", context=ctx)).hits
state["pages"][0]["knowledge_base_id"] = "foreign"
with pytest.raises(PermissionError):
await provider.entries_search("Cedar", context=ctx)
@pytest.mark.asyncio
async def test_entries_reject_document_filter_before_network(adapter):
provider, calls, *_ = adapter
ctx = await selection(provider)
calls.clear()
with pytest.raises(ValueError, match="document"):
await provider.entries_search("Cedar", context=ctx)
assert calls == []
@pytest.mark.asyncio
async def test_entries_skip_non_wiki_bases_and_reject_unbound_identity(adapter):
provider, calls, state, *_ = adapter
state["bases"] = [{"id": "kb-1", "name": "Document only", "capabilities": {"wiki": False}}]
assert not (await provider.entries_search("q", context=KnowledgeRequestContext("alice", "lead_agent"))).hits
assert not any("/wiki/" in str(request.url) for request in calls)
calls.clear()
with pytest.raises(PermissionError):
await provider.entries_search("q", context=KnowledgeRequestContext("mallory", "lead_agent"))
assert not calls
@pytest.mark.asyncio
async def test_wiki_empty_phrase_falls_back_to_bounded_keywords_with_same_scope(adapter):
provider, calls, state, _, transport = adapter
ctx = await selection(provider, docs=False)
seen = []
page = {"knowledge_base_id": "kb-1", "slug": "cedar", "title": "青杉", "status": "published", "summary": "林岚负责青杉计划"}
async def handler(request):
if request.url.path.endswith("/wiki/search"):
seen.append(request)
return httpx.Response(200, json={"pages": [page] if request.url.params["q"] in {"青杉", "林岚"} else []})
return await transport.handle_async_request(request)
provider._transport = httpx.MockTransport(handler)
result = await provider.entries_search("青杉 计划 负责人 林岚 星河实验室 青杉", context=ctx)
assert [r.url.params["q"] for r in seen] == ["青杉 计划 负责人 林岚 星河实验室 青杉", "青杉", "计划", "负责人", "林岚"]
assert all(r.url.path == "/api/v1/knowledgebase/kb-1/wiki/search" for r in seen)
assert all(r.headers["X-API-Key"] == "synthetic-secret" for r in seen)
assert len(result.hits) == 1
assert result.hits[0].source.excerpt == "林岚负责青杉计划"
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", ["foreign", "malformed", "http"])
async def test_wiki_keyword_fallback_preserves_fail_closed_errors(adapter, failure):
provider, _, _, _, transport = adapter
seen = []
async def handler(request):
if not request.url.path.endswith("/wiki/search"):
return await transport.handle_async_request(request)
seen.append(request.url.params["q"])
if len(seen) == 1:
return httpx.Response(200, json={"pages": []})
if failure == "http":
return httpx.Response(403)
if failure == "malformed":
return httpx.Response(200, json={"pages": "bad"})
return httpx.Response(200, json={"pages": [{"knowledge_base_id": "foreign", "status": "published"}]})
provider._transport = httpx.MockTransport(handler)
with pytest.raises((PermissionError, RuntimeError)):
await provider.entries_search("青杉 林岚", context=KnowledgeRequestContext("alice", "lead_agent"))
assert seen == ["青杉 林岚", "青杉"]
@pytest.mark.asyncio
async def test_wiki_nonempty_phrase_does_not_fan_out(adapter):
provider, calls, state, *_ = adapter
state["pages"] = [{"knowledge_base_id": "kb-1", "status": "published", "title": "青杉", "summary": "evidence"}]
assert (await provider.entries_search("青杉 林岚", context=KnowledgeRequestContext("alice", "lead_agent"))).hits
assert len([r for r in calls if r.url.path.endswith("/wiki/search")]) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(("query", "expected"), [("青杉", ["青杉"]), ("a b", ["a b"]), ("青杉 青杉 林岚", ["青杉 青杉 林岚", "青杉", "林岚"])])
async def test_wiki_empty_fallback_never_repeats_queries_or_sends_short_terms(adapter, query, expected):
provider, calls, *_ = adapter
assert not (await provider.entries_search(query, context=KnowledgeRequestContext("alice", "lead_agent"))).hits
assert [r.url.params["q"] for r in calls if r.url.path.endswith("/wiki/search")] == expected

View File

@ -0,0 +1,19 @@
# LightRAG retrieval extension
Keep HTTP calls, connection bindings and response mapping in this package.
Use the core contextual tool dispatcher and unchanged source envelope.
Entity/edge summaries are not document excerpts. Join source_id to returned
chunk_id only; never infer edges or multi-hop paths, or use file_path alone as
evidence. Reject selected scope, missing bindings, HTTP errors and redirects
without retrying with broader credentials. Offload SSL client construction.
Tests: backend/tests/test_lightrag_relations_extension.py and the shared
three-tool suite. Their responses are synthetic. Separate opt-in checks in
backend/tests/test_knowledge_phase1_live.py require existing indexed fixtures;
the GLM loop requires KNOWLEDGE_RUN_MODEL_TEST=1. See VALIDATION.md for live
results and limits. Never let live tests upload data or silently enable API usage.
Shared graph evidence chunks are emitted once per returned chunk ID, before the
first referencing edge. Every edge names its evidence result positions; prefix
truncation therefore cannot leave a retained edge pointing to omitted evidence.
At the hit limit, distinguish omitted evidence from absent retrieved evidence.

View File

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
Copyright (c) 2025-2026 DeerFlow Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,85 @@
# LightRAG entity relation retrieval
Optional read-only plugin for #6269's **new Phase 1**. Provides document retrieval
and entity relations through the existing tool configuration, scope admission,
shared evidence formatter and citation readers. No management or frontend plugin
is required. Requires a host with the new `relations_search` capability.
The response mapping follows LightRAG v1.5.7 commit
[`28ff1b05f2ac3f3e6fa14dd2cd33656579bd0c9c`](https://github.com/HKUDS/LightRAG/blob/28ff1b05f2ac3f3e6fa14dd2cd33656579bd0c9c/lightrag/utils.py)
(`convert_to_user_format`), using `POST /query/data` in `hybrid` mode.
Install from the repository root:
```sh
make extension-install SOURCE=/absolute/path/to/examples/deerflow-extension-lightrag
```
Merge into the installed plugin configuration and restart Gateway:
```yaml
plugins:
- use: deerflow_extension_lightrag:install
enabled: true
required: true
config:
base_url: https://lightrag.example.com
api_key_env: LIGHTRAG_API_KEY
user_ids: ["YOUR_DEERFLOW_USER_ID"]
# Optional exact current executors, including allowed subagents:
agent_names: ["lead_agent", "researcher"]
top_k: 20
timeout: 30
knowledge_base:
enabled: true
tool_groups:
- name: knowledge
tools:
- name: entity_relations_search
group: knowledge
use: deerflow.knowledge.tools:entity_relations_search_tool
provider: lightrag-relations
# Optional document search using the same authorized connection:
- name: knowledge_search
group: knowledge
use: deerflow.knowledge.tools:knowledge_search_tool
provider: lightrag-relations
```
Set `LIGHTRAG_API_KEY` in the Gateway environment. This plugin requires an explicit
credential and exact user binding; it never falls back to an anonymous or more
privileged connection. Bound users share the credential's upstream visibility;
this is not upstream user impersonation. Upstream authorization remains final.
Remove a tool entry to disable that operation. The legacy built-in LightRAG
document tool remains compatible and does not require this plugin.
## Evidence and limits
- Returns upstream entities and **one-edge paths** (`src_id`, `tgt_id`, description).
It does not compute multi-hop paths, infer new edges, or claim graph completeness.
- Each edge's `source_id` is split using LightRAG's `<SEP>` delimiter and joined to
retrieved `chunks[].chunk_id`. Only those actual chunks can receive document
citations, using their file title or the returned reference mapping.
- Edge descriptions and entity summaries remain unattributed graph facts. A
returned edge without matching retrieved chunks is explicitly marked as lacking
document evidence. Merely sharing `file_path` does not establish evidence.
- Uses the existing `KnowledgeSource` envelope unchanged. Graph descriptions and
document excerpts are separate hits, so a graph summary cannot become a quotation.
- Scope is limited to `all` (the bound workspace) or `disabled`. Dataset/document
selections are rejected before requests; no unrestricted retry. Combining
different registered providers also disables the shared selector because their
resource IDs have no common namespace.
- Requests do not follow redirects, have a timeout and a 4 MiB response cap.
Core bounds displayed results and citations; returned graphs are partial views.
Offline synthetic tests cover HTTP requests, permissions, evidence joins,
malformed payloads, secret redaction, combinations, delegation, replay and budgets.
A real OrbStack deployment with bge-m3 and GLM-5.3 also passed provider and
three-tool agent checks; see the [validation record](VALIDATION.md) for versions,
reproduction steps and limits. Offline passes alone are not live acceptance.
Deployment verification must inspect both relation facts and original evidence.
Shared graph evidence chunks are emitted once per returned chunk ID, before the
first referencing edge. Every edge names its evidence result positions; prefix
truncation therefore cannot leave a retained edge pointing to omitted evidence.

View File

@ -0,0 +1,234 @@
# Local Phase 1 integration validation — 2026-10-20
The new Phase 1 retrieval chain was exercised against real local services on
Apple Silicon macOS with OrbStack. The corpus contains explicitly synthetic
Chinese people, organizations, project relations and policy text. GLM-5.3
received only these synthetic fixtures and integration prompts.
## Deployment
- LightRAG core **1.5.7**, API **0344**; official `ghcr.io/hkuds/lightrag:v1.5.7`
arm64 image pinned to
`sha256:04716d2f10557342e033395c6490903ed0849a41ac5f89008736a7383e8bad7a`.
API published at `127.0.0.1:19621`, with an explicit API key.
- WeKnora **v0.8.3**, reusing the document-only local validation stack described
in [its validation record](../deerflow-extension-weknora/VALIDATION.md).
API: `127.0.0.1:18082`; upstream UI: `127.0.0.1:18081`.
- Native Ollama **0.40.4**, local **bge-m3**, 1024 embedding dimensions.
Both container connectivity and a two-input embedding request were verified.
- GLM **glm-5.3**, OpenAI-compatible endpoint configured in local `.env.local`.
Provider credentials and resource IDs are outside the repository. LightRAG
connects to native embeddings through `host.docker.internal:11434`.
- LightRAG uses its file-backed stores: NetworkX graph, vector and JSON stores
persisted in dedicated named volumes. No prebuilt mock graph was loaded.
## Synthetic data and processing
A new published WeKnora Wiki entry states that Lin Lan (林岚) leads the Cedar
project (青杉计划), funded by Star River Laboratory (星河实验室). A dedicated
`retrieve`-only key is restricted to this Wiki base and the existing synthetic
document base. The original document fixtures were retained.
LightRAG ingested `synthetic-cedar-relations.md` through `POST /documents/text`.
GLM extracted **5 entities and 5 relations**; bge-m3 embedded them and the one
original text chunk. Upstream status reached `processed` with `chunks_count=1`.
Actual relations include the project lead, funding and employment connections.
The initial extraction with default GLM reasoning remained in processing for
several minutes. The test pipeline was cancelled and the container recreated
with `OPENAI_LLM_EXTRA_BODY='{"thinking":{"type":"disabled"}}'`,
`OPENAI_LLM_MAX_TOKENS=6000` and `ENTITY_EXTRACTION_USE_JSON=true`. Recovery of
the same document completed successfully. No provider adapter code or source
mapping was changed to make the live responses pass. These settings are local
validation choices, not new DeerFlow defaults.
## Observed results
| Check | Result |
| --- | --- |
| Existing WeKnora document retrieval live regression | 1 passed |
| Published Wiki retrieval with restricted key and selected base | Passed |
| Wiki unbound user and unsupported document filtering | Rejected |
| Real LightRAG relation retrieval from the generated graph | Passed |
| Relation `source_id` joins to actual returned original chunks | Attributable document evidence returned |
| LightRAG unbound user, selected scope and disabled scope | Rejected before retrieval |
| Real GLM document + Wiki tool loop | Both tools invoked; 3 validated cited sources |
| Real GLM document + Wiki + relation tool loop | All three tools invoked and each supplied attributable evidence |
| Final answer source handles | Every cited handle belongs to actual tool evidence |
| JSON history serialization and replay | Source records preserved |
| Shared source forwarding and pruning | Cited evidence retained; removed evidence drops sources |
| New opt-in live test module including GLM | **3 passed in 23.67 seconds** |
| Focused offline provider/tool tests with live opt-ins absent | **81 passed, 3 skipped** |
The generative test executes a real LangChain agent loop using DeerFlow's
StructuredTools, extension snapshot, trusted runtime context, shared source
formatter and `KnowledgeScopeMiddleware`. Tool calls are generated by GLM,
not inserted as predetermined AIMessage calls.
## Reproduction
Set `DEER_FLOW_RUN_LIVE_TESTS=1` and supply:
- Wiki: `WEKNORA_BASE_URL`, `WEKNORA_TENANT_ID`, `WEKNORA_WIKI_BASE_ID`,
`WEKNORA_API_KEY`, `WEKNORA_WIKI_QUERY`.
- Graph: `LIGHTRAG_BASE_URL`, `LIGHTRAG_API_KEY`, `LIGHTRAG_RELATION_QUERY`.
- Optional generative check: `KNOWLEDGE_RUN_MODEL_TEST=1`, `E2E_LLM_MODEL`,
`E2E_LLM_BASE_URL`, `E2E_LLM_API_KEY`, `WEKNORA_KNOWLEDGE_BASE_ID` and
`KNOWLEDGE_MODEL_QUESTION`. The question should cover attributable facts in
all three existing fixtures. The WeKnora key must read both configured bases.
From the repository root:
```sh
uv run --no-sync --project backend pytest \
backend/tests/test_knowledge_phase1_live.py -m live -q --override-ini=addopts=
```
Tests are read-only and require already indexed fixtures; they do not upload,
create pages, or change upstream credentials. The generative check has a
separate opt-in and incurs model API usage. Without it, only the provider checks
run. No credentials or raw API responses should be added to this record.
## Real DeerFlow browser acceptance
On 2026-10-20 the current checkout was started with the actual Gateway, Next.js
frontend and an OrbStack nginx ingress at loopback port 2026. An isolated local
runtime home, SQLite persistence and the local auth-disabled synthetic `default`
user were used. Both providers bind that exact user. The browser submitted real
chat messages through the normal streaming runtime; no browser API mocks were used.
Session: `a598bdcd-1acc-49e8-adcd-70f95b704c00`.
| Browser check | Observed result |
| --- | --- |
| First real chat | All three requested tools called; completed in 26 seconds |
| Document facts | 680 RMB/night and 15 calendar days, correct clickable document citation |
| Relation facts | 林岚 leads 青杉计划; 星河实验室 funds it; citation opens the actual original chunk |
| Initial Wiki query | Model expanded query to `青杉 计划 负责人 资助机构 林岚 星河实验室`; zero hits, honestly reported |
| Follow-up Wiki query | Exact `青杉` returned the published entry; completed in 9 seconds |
| Wiki citation | Opens provider/base and summary containing `CEDAR-WIKI-2026` |
| Browser refresh | Document, Wiki and relation citation buttons all reopen their original evidence |
| Persisted Gateway state | Actual tool calls and source artifacts retained (document 2, relation 5, Wiki follow-up 1) |
Core retrieval, citation clicks and browser history replay passed. This is **not
an unqualified chat-UX pass**: this GLM endpoint emitted English reasoning text
and a closing `</think>` in ordinary message content, visible in the browser,
even with the local `thinking.type=disabled` setting. Wiki keyword expansion also
needed a user follow-up to get a hit. Neither behavior was hidden or mocked away.
The mixed-provider configuration does not advertise catalog/dataset selection,
so the UI hides its knowledge scope selector; disabled scope was not exercised
through this browser session (the earlier tool-level checks still apply).
Local reproduction and evidence (outside version control):
`start-browser.py`,
`browser-state.json`, and `browser-{document,wiki,graph}-replay.jpg`.
The launcher uses private fixture credentials and `.env.local`; do not commit them.
To reproduce the interaction, ask all three tools for the Cedar reimbursement,
lead and funding facts, follow up with an exact Wiki query `青杉`, open each source,
refresh the chat URL and reopen each source.
## Browser fixes and re-acceptance
The two chat issues above were reproduced and fixed on 2026-10-20:
- WeKnora's lexical Wiki endpoint returned no pages for the complete query
`青杉 计划 负责人 资助机构 林岚 星河实验室`. The extension now retries an
empty successful result with up to four distinct whitespace-separated keywords
of at least two characters, keeping the same authorized KB and credentials.
Duplicate title/excerpt pairs are merged. Schema, authorization and HTTP errors
still fail immediately; nonempty original results do not trigger fallback.
- The local GLM-compatible proxy ignores the native `thinking.type=disabled`
switch. Direct synthetic probes reproduced reasoning embedded in content with
that payload. The same endpoint returned clean content with the chat-template
switch below. The local browser launcher and model live-test runner now use it;
no frontend text stripping or global change to official GLM profiles was made.
```yaml
# For the tested proxy's glm-5.3 deployment, not a universal GLM default.
extra_body:
chat_template_kwargs:
enable_thinking: false
```
New browser session: `81bf7e33-d0a2-4cd4-9dfe-f0d8b2cd1ee5`.
The first turn explicitly used the complete previously failing Wiki query,
called all three tools, and returned all four correct facts with **three actual
source citations**, in 26 seconds. The Wiki source opened its published summary.
After refresh the citation reopened with the same evidence. A second turn used
that same long query and returned a clean one-sentence answer with a real Wiki
citation in 5 seconds. Neither turn displayed English reasoning or think tags.
Persisted Gateway messages independently confirm the long query, Wiki evidence
in both turns, clean assistant content, and all three cited source handles.
- Wiki adapter regressions: **65 passed**, including bounded retries, deduplication,
nonempty-result preservation, single/short terms, and fail-closed errors.
- Provider/model factory focused run: **243 passed, 3 skipped** (before the last
three additional keyword edge cases).
- Strict blocking-I/O suite after the fix: **264 passed, 1 skipped**.
- Full backend regression: **34,543 passed, 669 skipped, 1 failed** across four
shards. The failure is the previously reproduced Node reporter acceptance
case `failing-todo-failure-name-color-spec`; it also fails in isolation. A TUI
timing failure observed during the baseline passed both isolation and rerun.
- Real provider + model live checks: **3 passed in 26.79 seconds**, using the long
Wiki query and clean-answer assertion.
- Live model test accepts optional JSON `E2E_LLM_EXTRA_BODY`; set
`KNOWLEDGE_EXPECT_CLEAN_ANSWER=1` to reject inline think tags in assistant content.
Local evidence: `browser-fixed-state.json`, `browser-fixed-final.jpg`,
`browser-fixed-replay.jpg`, `probe-thinking.py`, and the corrected
`start-browser.py`/`browser-config.yaml` under the local validation directory.
The two previously reported issues pass this re-acceptance. Historical messages
from the earlier session are retained as originally generated.
## Limits
This validates provider retrieval, real model/tool/evidence execution and local
browser chat replay. It is not a concurrency/load test or production
authorization audit. Source forwarding is tested through the shared helper;
no second generative subagent was launched in this live run.
Relation evidence consists of upstream one-edge paths. Multi-hop discovery,
graph completeness, WeKnora entity-graph HTTP access, graph visualization and
management pipelines remain outside this delivery. Upstream extraction during
fixture preparation does not implement DeerFlow's planned Phase 2 pipeline.
## Shared-evidence budget review follow-up
The adapter now emits each referenced chunk ID once, before its first edge,
and labels every edge with the corresponding result positions. A regression
with 20 edges sharing a long chunk preserves all 20 edges under the shared
8,000-character budget with one source record. A second regression covers
partially shared chunks, missing IDs and prefix truncation without dangling
references. Graph descriptions remain unattributed; only original chunk text
receives a source citation.
Validation after this change: relation adapter tests **10 passed**; real
WeKnora/LightRAG/GLM checks **3 passed in 22.80 seconds** after restarting the
local provider services; blocking-I/O **266 passed, 1 skipped**. Extension
catalog E2E **10 passed**, including English/Chinese LightRAG descriptions and
catalog discovery with the runtime API unavailable. The old E2E expectation
of 10 bundled extensions was updated to 11.
## Hit-budget boundary follow-up — 2026-10-21
The last retained edge now says `Evidence omitted for this edge: bounded view.`
when the 98-hit threshold prevented its first valid chunk from being emitted.
Missing/unmatched chunk IDs retain the distinct no-retrieved-evidence wording.
A 50-edge/50-chunk regression fails before the change and passes afterwards.
The repeated test-loop `re` import was moved to module scope.
The preceding remote head passed all four backend shards, frontend unit tests,
blocking-I/O, lint, package checks and guidance validation. Both browser CI jobs
failed before tests because ubuntu-latest rolled to Ubuntu 26.04, which the
locked Playwright 1.59.1 cannot install Chromium dependencies on. Those two
jobs now explicitly use Ubuntu 24.04, with a workflow regression checking both
runner selection and preservation of the Chromium dependency-install step.
## Runner regression correction — 2026-10-22
Maintainers restored both browser jobs to `ubuntu-latest`; all four E2E shards
and full-stack replay passed remotely at `7e7710d03`. The earlier backend
regression still pinned Ubuntu 24.04 and was the sole failure in backend shards
1 and 3. It now checks Chromium dependency installation precedes browser test
execution, without imposing a runner version. The maintainer-selected runner
and E2E sharding remain unchanged.

View File

@ -0,0 +1,13 @@
"""Optional LightRAG document and entity relation retrieval plugin."""
from deerflow_extension_api import extension
from .provider import LightRAGProvider
__all__ = ["LightRAGProvider", "install"]
@extension(api="0.2.7", name="lightrag-relations")
def install(registry, config):
if not registry.knowledge_provider("lightrag-relations", LightRAGProvider(dict(config))):
raise RuntimeError("This host does not support knowledge providers")

View File

@ -0,0 +1,150 @@
"""LightRAG v1.5.7 /query/data adapter; graph summaries are not document evidence."""
from __future__ import annotations
import asyncio
import json
import os
import httpx
from deerflow.community.lightrag.formatting import normalize_retrieval_result
from deerflow.community.provider_credentials import reject_url_userinfo
from deerflow.knowledge import KnowledgeCapabilities, KnowledgeRequestContext, RetrievalHit, RetrievalResult
from deerflow.knowledge.providers import validate_scope_capabilities
from deerflow.knowledge.sources import redact_strings
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, field_validator
class Settings(BaseModel):
model_config = ConfigDict(extra="forbid")
base_url: AnyHttpUrl
api_key_env: str = Field(min_length=1)
user_ids: list[str] = Field(min_length=1, max_length=1000)
agent_names: list[str] | None = None
timeout: float = Field(default=30, gt=0, le=120)
top_k: int = Field(default=20, ge=1, le=100)
@field_validator("base_url")
@classmethod
def validate_url(cls, value):
return reject_url_userinfo(value)
def _rows(data: dict, key: str) -> list[dict]:
value = data.get(key, [])
if not isinstance(value, list) or len(value) > 1000 or any(not isinstance(item, dict) for item in value):
raise ValueError("Invalid LightRAG retrieval rows")
return value
def _text(value: object) -> str:
return value.strip()[:10_000] if isinstance(value, str) else ""
def normalize_relations(data: dict) -> RetrievalResult:
"""Keep returned one-edge paths; join source_id only to actual chunk IDs.
LightRAG stores multiple IDs with <SEP>. Never treat file_path on an edge
as a document quotation or infer extra edges from co-occurring entities.
"""
chunks = {}
for chunk in _rows(data, "chunks"):
chunk_id = chunk.get("chunk_id")
if isinstance(chunk_id, str) and chunk_id:
if chunk_id in chunks:
raise ValueError("Ambiguous LightRAG chunk identity")
chunks[chunk_id] = chunk
references = _rows(data, "references")
hits = []
evidence_positions = {}
for relation in _rows(data, "relationships"):
source, target = _text(relation.get("src_id")), _text(relation.get("tgt_id"))
if not source or not target:
continue
description = _text(relation.get("description"))
path = f"Upstream relation (one edge): {source} — {target}\n{description}"
ids = relation.get("source_id")
evidence_ids = [key for key in dict.fromkeys(ids.split("<SEP>")) if key in chunks] if isinstance(ids, str) else []
positions = []
evidence_omitted = False
for key in evidence_ids:
if key not in evidence_positions:
normalized = normalize_retrieval_result({"chunks": [chunks[key]], "references": references})
if not normalized.hits:
continue
# Reserve one hit for the edge and one for the bounded-view note.
if len(hits) >= 98:
evidence_omitted = True
break
hits.append(normalized.hits[0])
evidence_positions[key] = len(hits)
positions.append(evidence_positions[key])
if positions:
# Evidence precedes its users, so prefix budgeting cannot leave an
# emitted edge referring to a chunk omitted by the shared formatter.
evidence_refs = ", ".join(f"result [{position}]" for position in positions)
hits.append(RetrievalHit(content=f"Evidence: {evidence_refs}.\n{path}"))
elif evidence_omitted:
hits.append(RetrievalHit(content=path + "\nEvidence omitted for this edge: bounded view."))
else:
hits.append(RetrievalHit(content=path + "\nNo retrieved document evidence for this edge."))
if len(hits) >= 99:
break
for entity in _rows(data, "entities"):
if len(hits) >= 99:
break
name = _text(entity.get("entity_name"))
if name:
hits.append(RetrievalHit(content=f"Upstream entity: {name}\n{_text(entity.get('description'))}"))
if hits:
hits = hits[:99]
hits.append(RetrievalHit(content="Graph retrieval is a bounded view of upstream data, not a complete graph or proof that other relations are absent."))
return RetrievalResult(hits=hits)
class LightRAGProvider:
capabilities = KnowledgeCapabilities(operations={"search", "sources", "relations_search"})
def __init__(self, config: dict, *, transport: httpx.AsyncBaseTransport | None = None):
self.settings = Settings.model_validate(config)
self._transport = transport
async def search(self, query: str) -> RetrievalResult:
raise PermissionError("LightRAG requires a trusted request context")
async def _data(self, query: str, context: KnowledgeRequestContext) -> dict:
if context.user_id not in self.settings.user_ids or (self.settings.agent_names is not None and context.agent_name not in self.settings.agent_names):
raise PermissionError("No LightRAG binding for this identity")
if context.scope is not None:
scope = validate_scope_capabilities(context.scope, self.capabilities)
if scope["mode"] == "disabled":
raise ValueError("Knowledge retrieval is disabled")
if not query.strip():
raise ValueError("Empty query")
key = os.getenv(self.settings.api_key_env, "").strip()
if not key or not key.isascii() or any(ord(char) < 33 or ord(char) == 127 for char in key):
raise ValueError("Missing or invalid LightRAG credential")
try:
async with await asyncio.to_thread(httpx.AsyncClient, base_url=str(self.settings.base_url).rstrip("/"), headers={"X-API-Key": key}, timeout=self.settings.timeout, follow_redirects=False, transport=self._transport) as client:
async with client.stream("POST", "/query/data", json={"query": query.strip(), "mode": "hybrid", "top_k": self.settings.top_k, "chunk_top_k": self.settings.top_k}) as response:
if response.status_code in {401, 403}:
raise PermissionError("LightRAG denied access")
if response.status_code != 200:
raise RuntimeError("LightRAG retrieval failed")
raw = bytearray()
async for part in response.aiter_bytes():
raw.extend(part)
if len(raw) > 4 * 1024 * 1024:
raise RuntimeError("LightRAG response is too large")
payload = json.loads(raw)
except (httpx.HTTPError, ValueError):
raise RuntimeError("LightRAG retrieval failed") from None
if not isinstance(payload, dict) or payload.get("status") != "success" or not isinstance(payload.get("data"), dict):
raise RuntimeError("Invalid LightRAG retrieval response")
return redact_strings(payload["data"], key)
async def search_scoped(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult:
return normalize_retrieval_result(await self._data(query, context))
async def relations_search(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult:
return normalize_relations(await self._data(query, context))

View File

@ -0,0 +1,25 @@
[project]
name = "deerflow-extension-lightrag"
version = "0.1.0"
description = "Read-only LightRAG document and entity relation provider for DeerFlow"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
dependencies = ["deerflow-extension-api>=0.2.7,<0.3", "deerflow-harness>=2.1,<3", "httpx>=0.27,<1", "pydantic>=2,<3"]
[project.entry-points."deerflow.extensions"]
lightrag = "deerflow_extension_lightrag:install"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["deerflow_extension_lightrag"]
[tool.ruff]
line-length = 240
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]

View File

@ -18,3 +18,13 @@ Dataset IDs derive from endpoint/tenant/native base, not credentials or labels.
Tests live in `backend/tests/test_weknora_extension*.py`; live tests require
explicit opt-in and an already indexed instance. Do not fabricate a live result.
See README for version references, configuration and the authorization model.
Optional entries_search uses KB-bound Wiki search and published pages only.
Check capabilities.wiki and returned base membership; reject document filters
before HTTP. Wiki responses use a separate pages envelope. Never advertise
entity relations based on the Wiki page-link graph.
Wiki lexical search retries only successful empty pages with up to four distinct
whitespace-separated keywords of at least two characters. Keep retries in the
same authorized base, validate every response, deduplicate title/excerpt pairs,
and never retry provider/auth/schema errors. Nonempty phrase results stay intact.

View File

@ -179,7 +179,44 @@ against v0.8.3's `internal/handler/knowledgebase.go`, `internal/handler/session/
This package registers the current `RetrievalProvider` contract rather than
importing the older branch's provider discovery or native citation format.
Uploads, deletion, processing management, Wiki/Graph, a knowledge management
Uploads, deletion, processing management, Wiki/Graph management, a knowledge management
page and new DeerFlow ACL persistence remain outside the read-only delivery.
Shared-contract durable batch source forwarding retains the existing core
limitation; ordinary subagent forwarding uses the shared evidence path.
## New Phase 1: published knowledge entries
On a host containing #6269's new Phase 1, add this independent tool entry:
```yaml
- name: knowledge_entries_search
group: knowledge
use: deerflow.knowledge.tools:knowledge_entries_search_tool
provider: weknora
```
Keep or remove `knowledge_search` independently. Both use the same plugin
connection bindings and current executor identity. Entries use v0.8.3's
`GET /api/v1/knowledgebase/{kb_id}/wiki/search?q=...&limit=...`, reusing the
published-page approach from superbuzzy's original `search_wiki()` while
returning the current shared evidence contract. The endpoint returns `{pages}`
rather than the document API's `{success,data}` envelope. Only Wiki-enabled
bases and published pages are included; returned base membership is checked.
Wiki search supports dataset filtering but not document filtering: a generated
page may combine several documents. The shared selector therefore offers only
base selection when both document and entry tools are configured. Recovered
unsupported document selections fail closed, with no unfiltered retry.
The pinned WeKnora API exposes Wiki links at `/wiki/graph`, not a public entity
relation retrieval endpoint. This package does not advertise `relations_search`
or substitute that page graph for entity relations. Use a provider with real
relation evidence, such as the optional [LightRAG plugin](../deerflow-extension-lightrag/README.md).
New Wiki retrieval has also passed real OrbStack/GLM integration checks; see the
2026-10-20 follow-up in [the validation record](VALIDATION.md).
Wiki search first sends the original query. If that returns no pages, it tries
at most four distinct whitespace-separated keywords (at least two characters),
within the same authorized knowledge base, and merges duplicate evidence. This
helps model-generated keyword combinations match the upstream lexical search;
it does not perform unrestricted search or retry authorization/provider errors.

View File

@ -94,3 +94,20 @@ the restricted-key live test and the two-document isolation probes pass after it
- Opt-in live test against this deployment: **1 passed**, including the real
ToolNode, replay, forwarding and pruning checks above.
- Ruff lint/format, extension wheel packaging and agent-guidance checks passed.
## New Phase 1 follow-up — 2026-10-20
The existing OrbStack deployment and bge-m3 model were restarted, retaining
original documents; the original live document test passed again. A separate
synthetic Wiki base and published page were created, with a dedicated
`retrieve`-only key restricted to the test Wiki and document bases.
`test_knowledge_phase1_live.py` verified real published-page search, selected
base constraints, unbound-user denial, unsupported document-filter rejection,
source replay, shared forwarding and pruning. GLM-5.3 then generated actual
calls to the document and Wiki tools, producing three valid cited sources.
A subsequent three-tool run also included a real LightRAG graph and passed.
See the [combined validation record](../deerflow-extension-lightrag/VALIDATION.md)
for versions, reproduction instructions, results and precise coverage limits.
This follow-up does not claim a WeKnora entity-graph endpoint or browser-chat
acceptance.

View File

@ -37,6 +37,7 @@ class _Dataset:
binding: _Binding
native_id: str
name: str
wiki_enabled: bool = False
@property
def id(self) -> str:
@ -65,7 +66,7 @@ def _pagination(page: int, page_size: int) -> None:
class WeKnoraProvider:
capabilities = KnowledgeCapabilities(operations={"search", "sources", "catalog", "documents", "dataset_filter", "document_filter"})
capabilities = KnowledgeCapabilities(operations={"search", "sources", "catalog", "documents", "dataset_filter", "document_filter", "entries_search", "entries_dataset_filter"})
def __init__(self, config: dict[str, Any], *, transport: httpx.AsyncBaseTransport | None = None):
self.settings = Settings.model_validate(config)
@ -84,7 +85,7 @@ class WeKnoraProvider:
raise PermissionError("No WeKnora space is bound to this user and executing agent")
return bindings
async def _request(self, binding: _Binding, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
async def _request(self, binding: _Binding, method: str, path: str, *, envelope: bool = True, **kwargs: Any) -> dict[str, Any]:
# Client per request prevents tenant/credential mutation across users and
# event loops. SSL trust loading runs off-loop. Never follow redirects
# carrying privileged credentials.
@ -113,7 +114,11 @@ class WeKnoraProvider:
except (httpx.HTTPError, ValueError):
# No raw body, URL, request headers or credential-bearing cause.
raise RuntimeError("WeKnora request failed or returned invalid JSON") from None
if not isinstance(payload, dict) or payload.get("success") is not True or "data" not in payload:
if not isinstance(payload, dict):
raise RuntimeError("Invalid WeKnora response")
if not envelope:
return payload
if payload.get("success") is not True or "data" not in payload:
raise RuntimeError("WeKnora returned an unsuccessful response")
return payload
@ -130,7 +135,9 @@ class WeKnoraProvider:
base_id = native_id(base.get("id"))
if allowed is not None and base_id not in allowed:
continue
dataset = _Dataset(binding, base_id, binding.text(base.get("name"), 500) or "Unnamed knowledge base")
base_capabilities = base.get("capabilities")
wiki_enabled = isinstance(base_capabilities, dict) and base_capabilities.get("wiki") is True
dataset = _Dataset(binding, base_id, binding.text(base.get("name"), 500) or "Unnamed knowledge base", wiki_enabled)
if dataset.id in result:
raise RuntimeError("Duplicate WeKnora knowledge base")
result[dataset.id] = dataset
@ -232,3 +239,56 @@ class WeKnoraProvider:
score = score if type(score) in {float, int} and math.isfinite(score) else None
hits.append(RetrievalHit(content=text, source=source, score=score))
return RetrievalResult(hits=hits)
async def entries_search(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult:
"""Published Wiki search, using the upstream KB-bound read endpoint.
Wiki pages can combine several source documents; v0.8.3's search API
cannot enforce document filters. Reject them before contacting upstream.
"""
scope = execution_scope(context.scope) if context.scope is not None else {"mode": "all"}
if scope["mode"] == "disabled" or not query.strip():
raise ValueError("Knowledge search is disabled or query is empty")
if scope.get("document_filters"):
raise ValueError("Wiki retrieval does not support document filters")
datasets = await self._datasets(context)
selected = scope["dataset_ids"] if scope["mode"] == "selected" else list(datasets)
if not set(selected) <= datasets.keys():
raise PermissionError("Selected WeKnora knowledge base is unavailable")
hits = []
for dataset_id in selected:
ds = datasets[dataset_id]
if not ds.wiki_enabled:
continue
# Wiki search is lexical, unlike document hybrid search. A model's
# space-separated keyword combination can miss a matching title.
# Retry only an empty successful response, within the same bound KB.
queries = [query.strip()]
seen = set()
for keyword in queries:
payload = await self._request(ds.binding, "GET", f"/api/v1/knowledgebase/{ds.native_id}/wiki/search", envelope=False, params={"q": keyword, "limit": self.settings.match_count})
if "pages" not in payload:
raise RuntimeError("Invalid WeKnora Wiki response")
pages = _items(payload["pages"])
if len(pages) > 100:
raise RuntimeError("WeKnora response exceeds the supported result limit")
if keyword == queries[0] and not pages:
terms = list(dict.fromkeys(term for term in query.split() if len(term) >= 2 and term != keyword))
queries.extend(terms[:4])
for page in pages:
if page.get("knowledge_base_id") != ds.native_id:
raise PermissionError("WeKnora returned an entry outside the selected base")
if page.get("status") != "published":
continue
title = ds.binding.text(page.get("title"), 500)
content = ds.binding.text(page.get("summary") or page.get("content"), 100_000)
identity = (title, content)
if content and identity not in seen:
seen.add(identity)
source = KnowledgeSource(provider="weknora", title=title, excerpt=content, dataset_name=ds.name) if title else None
hits.append(RetrievalHit(content=content, source=source))
if len(hits) >= 100:
break
if len(hits) >= 100:
break
return RetrievalResult(hits=hits[:100])

View File

@ -45,6 +45,7 @@ export const extensionCatalog = [
package: "deerflow-extension-knowledge",
namespace: null,
},
{ key: "lightrag", package: "deerflow-extension-lightrag", namespace: null },
{ key: "weknora", package: "deerflow-extension-weknora", namespace: null },
{ key: "example", package: "deerflow-extension-example", namespace: null },
] as const;

View File

@ -80,6 +80,11 @@ export const enUS: Translations = {
description:
"Demonstrate read-only retrieval and structured tool results using fixed sample documents, without an external knowledge service.",
},
lightrag: {
title: "LightRAG",
description:
"Retrieve documents and entity relations from LightRAG with shared source citations.",
},
weknora: {
title: "WeKnora knowledge retrieval",
description:

View File

@ -36,6 +36,7 @@ export interface Translations {
| "retrieval"
| "knowledge"
| "weknora"
| "lightrag"
| "example",
{ title: string; description: string }
>;

View File

@ -76,6 +76,10 @@ export const zhCN: Translations = {
description:
"使用固定示例文档演示只读检索与结构化工具结果,不连接外部知识库。",
},
lightrag: {
title: "LightRAG",
description: "从 LightRAG 检索文档与实体关系,并使用统一的来源引用。",
},
weknora: {
title: "WeKnora 知识检索",
description:

View File

@ -448,7 +448,14 @@ for (const locale of ["en-US", "zh-CN"]) {
exact: true,
}),
).toBeVisible();
await expect(page.getByRole("article")).toHaveCount(10);
await expect(page.getByRole("article")).toHaveCount(11);
const lightrag = page.getByRole("article").filter({ hasText: "LightRAG" });
await expect(lightrag).toHaveCount(1);
await expect(lightrag).toContainText(
zh
? "从 LightRAG 检索文档与实体关系,并使用统一的来源引用。"
: "Retrieve documents and entity relations from LightRAG with shared source citations.",
);
const weknoraTitle = zh
? "WeKnora 知识检索"
: "WeKnora knowledge retrieval";
@ -665,10 +672,10 @@ test("catalog remains discoverable when runtime discovery fails", async ({
await expect(
page.getByRole("alert").filter({ hasText: "Extensions unavailable." }),
).toBeVisible();
await expect(page.getByRole("article")).toHaveCount(10);
await expect(page.getByRole("article")).toHaveCount(11);
await expect(
page.getByRole("article").filter({ hasText: "Catalog extension" }),
).toHaveCount(10);
).toHaveCount(11);
await expect(
page.getByRole("article").filter({ hasText: "Script workflows" }),
).toHaveCount(1);

View File

@ -164,3 +164,14 @@ test("WeKnora discovery is localized without implying runtime activation", () =>
expect(entry?.loaded).toBeUndefined();
}
});
test("LightRAG discovery is localized without claiming a runtime registration", () => {
for (const copy of [enUS.extensions.catalog, zhCN.extensions.catalog]) {
const entry = extensionDirectory([], copy).find(
(item) => item.package === "deerflow-extension-lightrag",
);
expect(entry?.title).toBe("LightRAG");
expect(entry?.description).toBe(copy.lightrag.description);
expect(entry?.loaded).toBeUndefined();
}
});