From 29ba806fc6428cab19206daf72d17bb7e31b1bef Mon Sep 17 00:00:00 2001 From: Xuehao Xu Date: Thu, 22 Oct 2026 16:54:58 +0800 Subject: [PATCH] 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 --- README.md | 12 + backend/AGENTS.md | 3 +- backend/docs/KNOWLEDGE.md | 70 +++++- .../deerflow/agents/middlewares/AGENTS.md | 2 +- .../middlewares/knowledge_scope_middleware.py | 8 +- .../harness/deerflow/knowledge/__init__.py | 4 + .../harness/deerflow/knowledge/contract.py | 18 +- .../harness/deerflow/knowledge/providers.py | 122 +++++++-- .../harness/deerflow/knowledge/tools.py | 45 +++- .../blocking_io/test_weknora_extension.py | 27 ++ backend/tests/test_agent_guidance_check.py | 1 + backend/tests/test_browser_ci_runner.py | 21 ++ backend/tests/test_knowledge_phase1_live.py | 166 +++++++++++++ .../tests/test_knowledge_retrieval_tools.py | 143 +++++++++++ .../test_lightrag_relations_extension.py | 149 +++++++++++ backend/tests/test_subagent_executor.py | 3 + backend/tests/test_weknora_extension.py | 106 +++++++- .../deerflow-extension-lightrag/AGENTS.md | 19 ++ examples/deerflow-extension-lightrag/LICENSE | 22 ++ .../deerflow-extension-lightrag/README.md | 85 +++++++ .../deerflow-extension-lightrag/VALIDATION.md | 234 ++++++++++++++++++ .../deerflow_extension_lightrag/__init__.py | 13 + .../deerflow_extension_lightrag/provider.py | 150 +++++++++++ .../pyproject.toml | 25 ++ examples/deerflow-extension-weknora/AGENTS.md | 10 + examples/deerflow-extension-weknora/README.md | 39 ++- .../deerflow-extension-weknora/VALIDATION.md | 17 ++ .../deerflow_extension_weknora/provider.py | 68 ++++- frontend/src/core/extensions/catalog.ts | 1 + frontend/src/core/i18n/locales/en-US.ts | 5 + frontend/src/core/i18n/locales/types.ts | 1 + frontend/src/core/i18n/locales/zh-CN.ts | 4 + frontend/tests/e2e/bookmark-plugin.spec.ts | 13 +- .../unit/core/extensions/catalog.test.ts | 11 + 34 files changed, 1566 insertions(+), 51 deletions(-) create mode 100644 backend/tests/test_browser_ci_runner.py create mode 100644 backend/tests/test_knowledge_phase1_live.py create mode 100644 backend/tests/test_knowledge_retrieval_tools.py create mode 100644 backend/tests/test_lightrag_relations_extension.py create mode 100644 examples/deerflow-extension-lightrag/AGENTS.md create mode 100644 examples/deerflow-extension-lightrag/LICENSE create mode 100644 examples/deerflow-extension-lightrag/README.md create mode 100644 examples/deerflow-extension-lightrag/VALIDATION.md create mode 100644 examples/deerflow-extension-lightrag/deerflow_extension_lightrag/__init__.py create mode 100644 examples/deerflow-extension-lightrag/deerflow_extension_lightrag/provider.py create mode 100644 examples/deerflow-extension-lightrag/pyproject.toml diff --git a/README.md b/README.md index 835efd0035..18ef7b1c58 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 8fb288abca..1f1701cdf8 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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 diff --git a/backend/docs/KNOWLEDGE.md b/backend/docs/KNOWLEDGE.md index c3b76997bb..8d2ae3c9a1 100644 --- a/backend/docs/KNOWLEDGE.md +++ b/backend/docs/KNOWLEDGE.md @@ -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`. 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. diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index 53aa87df54..7f5d118061 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -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. ``) 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 diff --git a/backend/packages/harness/deerflow/agents/middlewares/knowledge_scope_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/knowledge_scope_middleware.py index f61e539c73..9fa197fb17 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/knowledge_scope_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/knowledge_scope_middleware.py @@ -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", ) diff --git a/backend/packages/harness/deerflow/knowledge/__init__.py b/backend/packages/harness/deerflow/knowledge/__init__.py index 6a3a7f3fae..d29feb32a2 100644 --- a/backend/packages/harness/deerflow/knowledge/__init__.py +++ b/backend/packages/harness/deerflow/knowledge/__init__.py @@ -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", diff --git a/backend/packages/harness/deerflow/knowledge/contract.py b/backend/packages/harness/deerflow/knowledge/contract.py index 06da9b1ba6..8001c1bc14 100644 --- a/backend/packages/harness/deerflow/knowledge/contract.py +++ b/backend/packages/harness/deerflow/knowledge/contract.py @@ -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: ... diff --git a/backend/packages/harness/deerflow/knowledge/providers.py b/backend/packages/harness/deerflow/knowledge/providers.py index 832bd3d210..f5fee4f1b9 100644 --- a/backend/packages/harness/deerflow/knowledge/providers.py +++ b/backend/packages/harness/deerflow/knowledge/providers.py @@ -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. diff --git a/backend/packages/harness/deerflow/knowledge/tools.py b/backend/packages/harness/deerflow/knowledge/tools.py index 5153872839..dafbd35825 100644 --- a/backend/packages/harness/deerflow/knowledge/tools.py +++ b/backend/packages/harness/deerflow/knowledge/tools.py @@ -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.") diff --git a/backend/tests/blocking_io/test_weknora_extension.py b/backend/tests/blocking_io/test_weknora_extension.py index 2a15b2f633..f7d6ca1d5a 100644 --- a/backend/tests/blocking_io/test_weknora_extension.py +++ b/backend/tests/blocking_io/test_weknora_extension.py @@ -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 diff --git a/backend/tests/test_agent_guidance_check.py b/backend/tests/test_agent_guidance_check.py index 23dc886d45..a1c07f6c70 100644 --- a/backend/tests/test_agent_guidance_check.py +++ b/backend/tests/test_agent_guidance_check.py @@ -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", } diff --git a/backend/tests/test_browser_ci_runner.py b/backend/tests/test_browser_ci_runner.py new file mode 100644 index 0000000000..76078db945 --- /dev/null +++ b/backend/tests/test_browser_ci_runner.py @@ -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" diff --git a/backend/tests/test_knowledge_phase1_live.py b/backend/tests/test_knowledge_phase1_live.py new file mode 100644 index 0000000000..abe7ba488c --- /dev/null +++ b/backend/tests/test_knowledge_phase1_live.py @@ -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 "" not in message.content and "" 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"] == [] diff --git a/backend/tests/test_knowledge_retrieval_tools.py b/backend/tests/test_knowledge_retrieval_tools.py new file mode 100644 index 0000000000..55f724534d --- /dev/null +++ b/backend/tests/test_knowledge_retrieval_tools.py @@ -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 diff --git a/backend/tests/test_lightrag_relations_extension.py b/backend/tests/test_lightrag_relations_extension.py new file mode 100644 index 0000000000..5fcf683f7b --- /dev/null +++ b/backend/tests/test_lightrag_relations_extension.py @@ -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-1chunk-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-2chunk-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 diff --git a/backend/tests/test_subagent_executor.py b/backend/tests/test_subagent_executor.py index cc702a4533..c444ba7448 100644 --- a/backend/tests/test_subagent_executor.py +++ b/backend/tests/test_subagent_executor.py @@ -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): diff --git a/backend/tests/test_weknora_extension.py b/backend/tests/test_weknora_extension.py index db9293e1bf..4e86b8ea92 100644 --- a/backend/tests/test_weknora_extension.py +++ b/backend/tests/test_weknora_extension.py @@ -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 diff --git a/examples/deerflow-extension-lightrag/AGENTS.md b/examples/deerflow-extension-lightrag/AGENTS.md new file mode 100644 index 0000000000..806370f820 --- /dev/null +++ b/examples/deerflow-extension-lightrag/AGENTS.md @@ -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. diff --git a/examples/deerflow-extension-lightrag/LICENSE b/examples/deerflow-extension-lightrag/LICENSE new file mode 100644 index 0000000000..9dc98a4a6b --- /dev/null +++ b/examples/deerflow-extension-lightrag/LICENSE @@ -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. diff --git a/examples/deerflow-extension-lightrag/README.md b/examples/deerflow-extension-lightrag/README.md new file mode 100644 index 0000000000..f42487580d --- /dev/null +++ b/examples/deerflow-extension-lightrag/README.md @@ -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 `` 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. diff --git a/examples/deerflow-extension-lightrag/VALIDATION.md b/examples/deerflow-extension-lightrag/VALIDATION.md new file mode 100644 index 0000000000..0d0de841ea --- /dev/null +++ b/examples/deerflow-extension-lightrag/VALIDATION.md @@ -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 `` 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. diff --git a/examples/deerflow-extension-lightrag/deerflow_extension_lightrag/__init__.py b/examples/deerflow-extension-lightrag/deerflow_extension_lightrag/__init__.py new file mode 100644 index 0000000000..955c5ccea2 --- /dev/null +++ b/examples/deerflow-extension-lightrag/deerflow_extension_lightrag/__init__.py @@ -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") diff --git a/examples/deerflow-extension-lightrag/deerflow_extension_lightrag/provider.py b/examples/deerflow-extension-lightrag/deerflow_extension_lightrag/provider.py new file mode 100644 index 0000000000..6bb8d6f483 --- /dev/null +++ b/examples/deerflow-extension-lightrag/deerflow_extension_lightrag/provider.py @@ -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 . 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("")) 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)) diff --git a/examples/deerflow-extension-lightrag/pyproject.toml b/examples/deerflow-extension-lightrag/pyproject.toml new file mode 100644 index 0000000000..d21b9f7f5b --- /dev/null +++ b/examples/deerflow-extension-lightrag/pyproject.toml @@ -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"] diff --git a/examples/deerflow-extension-weknora/AGENTS.md b/examples/deerflow-extension-weknora/AGENTS.md index 509d797ec3..03e36d1588 100644 --- a/examples/deerflow-extension-weknora/AGENTS.md +++ b/examples/deerflow-extension-weknora/AGENTS.md @@ -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. diff --git a/examples/deerflow-extension-weknora/README.md b/examples/deerflow-extension-weknora/README.md index 28ba8b8e75..a0926517a8 100644 --- a/examples/deerflow-extension-weknora/README.md +++ b/examples/deerflow-extension-weknora/README.md @@ -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. diff --git a/examples/deerflow-extension-weknora/VALIDATION.md b/examples/deerflow-extension-weknora/VALIDATION.md index 257a7e9fd4..70b0d9fed9 100644 --- a/examples/deerflow-extension-weknora/VALIDATION.md +++ b/examples/deerflow-extension-weknora/VALIDATION.md @@ -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. diff --git a/examples/deerflow-extension-weknora/deerflow_extension_weknora/provider.py b/examples/deerflow-extension-weknora/deerflow_extension_weknora/provider.py index b4abc6cc15..001f8f8802 100644 --- a/examples/deerflow-extension-weknora/deerflow_extension_weknora/provider.py +++ b/examples/deerflow-extension-weknora/deerflow_extension_weknora/provider.py @@ -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]) diff --git a/frontend/src/core/extensions/catalog.ts b/frontend/src/core/extensions/catalog.ts index 18db530605..5630c03b0a 100644 --- a/frontend/src/core/extensions/catalog.ts +++ b/frontend/src/core/extensions/catalog.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index a63505b973..0120e4805a 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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: diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index e7498096f1..e09d72a4e0 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -36,6 +36,7 @@ export interface Translations { | "retrieval" | "knowledge" | "weknora" + | "lightrag" | "example", { title: string; description: string } >; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 42645f477d..ac29849b11 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -76,6 +76,10 @@ export const zhCN: Translations = { description: "使用固定示例文档演示只读检索与结构化工具结果,不连接外部知识库。", }, + lightrag: { + title: "LightRAG", + description: "从 LightRAG 检索文档与实体关系,并使用统一的来源引用。", + }, weknora: { title: "WeKnora 知识检索", description: diff --git a/frontend/tests/e2e/bookmark-plugin.spec.ts b/frontend/tests/e2e/bookmark-plugin.spec.ts index 7983268afe..226c2640ca 100644 --- a/frontend/tests/e2e/bookmark-plugin.spec.ts +++ b/frontend/tests/e2e/bookmark-plugin.spec.ts @@ -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); diff --git a/frontend/tests/unit/core/extensions/catalog.test.ts b/frontend/tests/unit/core/extensions/catalog.test.ts index e5704decf5..8941312ee2 100644 --- a/frontend/tests/unit/core/extensions/catalog.test.ts +++ b/frontend/tests/unit/core/extensions/catalog.test.ts @@ -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(); + } +});