feat(knowledge): unify read-only retrieval and source contracts (phase 1) (#6716)

* feat(knowledge): unify read-only retrieval and source contracts

* fix(knowledge): address citation and contract review feedback

* fix(knowledge): resolve citations in delegated task reports

* docs(frontend): keep agent guidance within CI size budget

* docs(backend): preserve merged guidance chain headroom
This commit is contained in:
Xuehao Xu 2026-10-14 14:16:08 +08:00 committed by GitHub
parent 000b5d8cb0
commit 201b9840cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1676 additions and 76 deletions

View File

@ -1352,6 +1352,27 @@ establish that a package is valid or readable. The scope note remains visible
even when there are no YAML warnings; check Gateway logs for other load failures.
Parsing remains strict and invalid skills stay unavailable to agents.
#### Read-only knowledge sources
Configure `knowledge_search` with either
`deerflow.community.ragflow.tools:knowledge_search_tool` or
`deerflow.community.lightrag.tools:knowledge_search_tool` in `config.yaml`.
Both support attributable retrieval evidence. LightRAG and RAGFlow responses
without complete provider locators use the shared DeerFlow source contract;
complete RAGFlow responses retain their existing citation snapshots and scope
controls. Knowledge citations show the
provider, document title, and retrieved excerpt in chat, including after reload
and when a subagent performed the retrieval. Missing page numbers and public
URLs remain absent; sources without a URL are readable as local evidence.
Inline citations remain readable after Markdown sanitization, and supplied page
numbers use the selected interface language. New providers use the shared
envelope; existing RAGFlow citation histories remain supported.
Expand a completed subtask card to inspect its report's forwarded knowledge
sources, including when reopening a saved conversation.
Existing web citations and the text-only Python `knowledge_search(query)` APIs
remain supported. No knowledge management service or page is required.
See [the knowledge contract guide](backend/docs/KNOWLEDGE.md).
Skills are what make DeerFlow do *almost anything*.
A standard Agent Skill is a structured capability module — a Markdown file that defines a workflow, best practices, and references to supporting resources. DeerFlow ships with built-in skills for research, report generation, slide creation, web pages, image and video generation, and more. But the real power is extensibility: add your own skills, replace the built-in ones, or combine them into compound workflows.

View File

@ -168,10 +168,12 @@ environment with `uv run --no-sync --project backend` from the repository root.
`--project` preserves the caller's working directory and relative runtime/config
selectors; `--no-sync` retains installed extras such as PostgreSQL drivers.
More specific `AGENTS.md` files in backend code directories contain the subsystem sections split from this file. Follow the nearest file in the directory tree.
Read the nearest scoped `AGENTS.md` for subsystem ownership.
## Architecture
Read-only knowledge: [contract](docs/KNOWLEDGE.md).
### Harness / App Split
The backend is split into two layers with a strict dependency direction:

View File

@ -393,11 +393,18 @@ def _strip_external_message_metadata(message: Any) -> Any:
additional_kwargs.pop(ORIGINAL_USER_CONTENT_KEY, None)
for key in _SERVER_OWNED_MESSAGE_METADATA_KEYS:
additional_kwargs.pop(key, None)
from deerflow.knowledge.sources import strip_source_artifact
artifact = getattr(message, "artifact", None)
clean_artifact = strip_source_artifact(artifact)
if _is_human_message_like(message):
additional_kwargs = _mark_untrusted_framework_markers(additional_kwargs, message.name)
if additional_kwargs == message.additional_kwargs:
if additional_kwargs == message.additional_kwargs and clean_artifact is artifact:
return message
return message.model_copy(update={"additional_kwargs": additional_kwargs})
update = {"additional_kwargs": additional_kwargs}
if clean_artifact is not artifact:
update["artifact"] = clean_artifact
return message.model_copy(update=update)
def _strip_external_metadata_from_message_like(item: Any) -> Any:
@ -413,6 +420,12 @@ def _strip_external_metadata_from_message_like(item: Any) -> Any:
return _strip_external_message_metadata(item)
if not isinstance(item, dict):
return item
from deerflow.knowledge.sources import strip_source_artifact
artifact = item.get("artifact")
clean_artifact = strip_source_artifact(artifact)
if clean_artifact is not artifact:
item = {**item, "artifact": clean_artifact}
# A missing (or non-dict) ``additional_kwargs`` is the most natural request
# shape, and it still needs the mark: the messages reducer coerces the dict
# with ``convert_to_messages``, which supplies ``additional_kwargs={}``, so an

132
backend/docs/KNOWLEDGE.md Normal file
View File

@ -0,0 +1,132 @@
# Read-only knowledge contract (#6269, phase 1)
`deerflow.knowledge` owns `RetrievalResult`, `RetrievalHit`, `KnowledgeSource`,
`KnowledgeCapabilities`, and the search-only `RetrievalProvider` protocol.
These depend on no knowledge management service, resource store, or catalog.
Existing RAGFlow scope admission and selection UI remain authoritative.
The shared contract does not add scope selection or WeKnora support to LightRAG.
## Sources and citations
A source requires `provider`, a real document `title`, and retrieved `excerpt`.
Optional `dataset_name`, HTTP(S) `url`, and positive integer `page` must come
from actual provider evidence. Never derive pages from chunk ordinals or URLs
from local file paths. Unattributed chunks remain usable text without citations.
Opaque IDs hash the provider, title, excerpt, and supplied locators. Provider
dataset UUIDs, chunk IDs, and response-local reference IDs are not exposed in
citations. The model uses `[citation:Title](#knowledge-source-ID)` to refer to
the exact evidence. Both StructuredTools use `content_and_artifact`, with this
envelope on `ToolMessage.artifact`:
```json
{
"knowledge_sources_version": 1,
"knowledge_sources": [
{
"id": "<32 lowercase hex characters>",
"provider": "lightrag",
"title": "docs/guide.md",
"excerpt": "Actual retrieved document text"
}
]
}
```
Existing streaming, checkpoints, and run-event serialization preserve that
artifact. Readers require the envelope version, valid source fields, the source
handle, and actual excerpt in tool content. Backend readers recompute source IDs.
Assistant labels do not establish evidence. Gateway run input and checkpoint
writes strip the reserved source keys from caller artifacts.
The reserved envelope keys are the shared reader's trust boundary, not the
tool name. Server-side producers that write these keys become evidence
authorities and must supply validated retrieval evidence; naming a tool
`knowledge_search` grants no authority to caller artifacts. This allows new
provider tools without extending a name allowlist. Native RAGFlow readers
continue to apply their existing tool-name allowlist.
The wire schema lives at `contracts/knowledge_sources.v1.json`; backend and
frontend tests pin the version, field bounds, and source IDs to that schema.
Complete RAGFlow responses retain the native version-one
`knowledge_sources: {version, sources}` snapshots and `#knowledge-ID` links
already supported by the Gateway, frontend, ordinary tasks, and durable batch
exports. RAGFlow responses without complete provider locators fall back to the
shared contract above; LightRAG uses it directly. Both formats remain readable
after replay. When a task cites both formats, native RAGFlow snapshots stay in
`knowledge_sources`, while shared records live in `retrieval_sources` with
`knowledge_sources_version: 1`. Readers validate the same evidence and version;
Gateway strips all three keys from untrusted writes. Budget pruning must not
overwrite a native snapshot with shared records.
The shared envelope is the intended default for new provider integrations.
Convergence should happen at adapter output boundaries: migrate newly produced
RAGFlow evidence to the shared form once native scope, budgets, delegation and
durable batch forwarding have equivalent coverage. Keep native readers for
persisted citations and exports; do not rewrite historical IDs or fabricate
missing locators. Removing native writers/readers requires a separate migration
proposal and compatibility tests, not a full migration in phase 1.
The frontend resolves citations against preceding tool messages in loaded thread
history, displaying provider, title, excerpt, and only supplied locators. Absent
evidence stays unresolved until loaded. Historical web citations retain their
existing display. Old numbered knowledge text lacks structured evidence, so
replay never invents metadata for it. Direct Python text APIs keep their format.
Inline shared references accept both the raw `#knowledge-source-ID` and the
sanitized `#user-content-knowledge-source-ID`; the sanitizer's DOM-clobbering
protection stays enabled. Missing or malformed references render inert labels.
`format_result` budgets chunk text and source evidence together: a cited hit
that cannot fit is omitted as a whole. Results display at most 100 hits, matching
the bounded artifact readers. Only displayed excerpts enter artifacts.
The global tool-output budget prunes evidence removed by truncation or
externalization and replaces complete orphan citations with their labels.
Subagents accumulate at most 100 validated sources across values frames before
terminal publication, surviving compaction. Ordinary `task` forwards only
sources whose handles appear in the child report, with a bounded evidence
appendix. Existing native RAGFlow durable batch forwarding is retained;
shared-contract durable batch forwarding is outside this phase.
The accumulator scans only the changed history suffix, restarting at changed
prefixes after compaction; null messages are treated as empty frames. The
frontend caches validation by immutable message object in a WeakMap, so repeated
stream updates do not rescan unchanged tool excerpts.
## Adapters and capabilities
Both tools advertise `search` and `sources` in
`metadata.knowledge_capabilities.operations`. Optional `catalog`, `documents`,
`dataset_filter`, and `document_filter` describe separate operations. Search
support implies none of them. These metadata fields do not replace existing
RAGFlow scope admission or change operator-configured retrieval scope.
RAGFlow maps normalized chunk content, document title, dataset name, and
`doc_aggs` title fallback. Dataset resolution, embedding-group ordering, access
errors, and credential redaction retain their existing behavior.
LightRAG consumes `/query/data` document chunks, joining missing `file_path`
through `chunks[].reference_id` and `references[].reference_id`. Entities and
relationships alone cannot produce document citations. Missing attribution and
`unknown_source` remain unattributed. The response fields were checked against
LightRAG v1.5.7, commit `28ff1b05f2ac3f3e6fa14dd2cd33656579bd0c9c`, in
[`convert_to_user_format` and `generate_reference_list_from_chunks`](https://github.com/HKUDS/LightRAG/blob/28ff1b05f2ac3f3e6fa14dd2cd33656579bd0c9c/lightrag/utils.py).
Neither adapter fabricates page numbers or public document URLs.
## Validation
Offline tests use explicitly synthetic API-shaped responses for real
StructuredTool execution, budgets, credential redaction, delegation, replay,
caller forgery, and frontend display. These are not live provider responses.
Verify actual retrieved evidence from an existing indexed LightRAG workspace
with the read-only opt-in check from `backend/`:
```bash
DEER_FLOW_RUN_LIVE_TESTS=1 \
LIGHTRAG_BASE_URL=http://localhost:9621 \
LIGHTRAG_EVIDENCE_QUERY='a question matching an indexed document' \
uv run pytest tests/test_knowledge_contract_live.py -m live -q
```
Set `LIGHTRAG_API_KEY` in the environment if required. This test performs no
upload or resource changes. It requires attributable hits and checks each
citation against the real returned chunk evidence.

View File

@ -719,7 +719,7 @@ def _patch_tool_message(
trigger = _effective_trigger(tool_name, config)
citation_result = None
if tool_name in {"knowledge_search", "task"} and trigger > 0 and len(text) > trigger:
citation_result = budget_source_artifact(text, msg.artifact, trigger, summary=(budgeted[0] if budgeted else text) if tool_name == "task" else "")
citation_result = budget_source_artifact(text, msg.artifact, trigger, summary=(budgeted.replacement if budgeted else text) if tool_name == "task" else "")
if citation_result is not None:
replacement, update["artifact"] = citation_result
transform_kind = "truncated"
@ -728,6 +728,11 @@ def _patch_tool_message(
transform_kind = budgeted.transform_kind
else:
return msg
from deerflow.knowledge.sources import prune_sources
replacement, artifact = prune_sources(replacement, update.get("artifact", msg.artifact))
if artifact is not msg.artifact or "artifact" in update:
update["artifact"] = artifact
update["content"] = [{"type": "text", "text": replacement}, *retained_blocks] if retained_blocks else replacement
if getattr(msg, "response_metadata", None):
update["response_metadata"] = dict(msg.response_metadata)

View File

@ -5,13 +5,29 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from deerflow.knowledge import KnowledgeCapabilities, KnowledgeSource, RetrievalHit, RetrievalResult
from deerflow.knowledge.sources import truncate as _truncate
def _truncate(value: str, max_chars: int, *, marker: str = "…") -> str:
if len(value) <= max_chars:
return value
if max_chars <= len(marker):
return marker[:max_chars]
return f"{value[: max_chars - len(marker)].rstrip()}{marker}"
CAPABILITIES = KnowledgeCapabilities(operations=frozenset({"search", "sources"}))
def normalize_retrieval_result(result: Mapping[str, Any]) -> RetrievalResult:
"""Use /query/data chunks and joined references, never graph summaries."""
references = _reference_file_paths(result.get("references"))
hits = []
for chunk in _chunks(result.get("chunks")):
content = chunk.get("content")
if not isinstance(content, str) or not content.strip():
continue
content = content.strip()[:100_000]
title = chunk.get("file_path")
if not isinstance(title, str) or not title.strip():
title = references.get(str(chunk.get("reference_id")))
source = None
if isinstance(title, str) and title.strip() and title.strip().lower() not in {"unknown", "unknown_source", "unknown document"}:
source = KnowledgeSource(provider="lightrag", title=title.strip()[:500], excerpt=content)
hits.append(RetrievalHit(content=content, source=source))
return RetrievalResult(hits=hits)
def _chunks(value: object) -> list[Mapping[str, Any]]:

View File

@ -11,9 +11,11 @@ from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, Valida
from deerflow.community.provider_credentials import redact_secret, reject_url_userinfo, resolve_api_key
from deerflow.config import get_app_config
from deerflow.knowledge import format_result
from deerflow.knowledge.sources import redact_strings, source_artifact
from .client import LightRAGAPIError, LightRAGClient, LightRAGConnectionError, LightRAGProtocolError
from .formatting import format_retrieval_result
from .formatting import CAPABILITIES, format_retrieval_result, normalize_retrieval_result
logger = logging.getLogger(__name__)
@ -94,7 +96,7 @@ def _tool_error(exc: Exception, settings: _LightRAGRetrievalSettings) -> str:
return "Error: An unexpected LightRAG retrieval error occurred; try again later."
async def knowledge_search(query: str) -> str:
async def _search(query: str, *, with_sources: bool = False) -> str | tuple[str, dict]:
"""Search the operator-configured LightRAG instance.
LightRAG has no dataset catalog to scope: the deployment's single indexed
@ -117,6 +119,9 @@ async def knowledge_search(query: str) -> str:
top_k=settings.top_k,
chunk_top_k=settings.chunk_top_k,
)
if with_sources:
normalized = normalize_retrieval_result(redact_strings(result, _api_key(settings)))
return format_result(normalized, max_chars_per_chunk=settings.max_chars_per_chunk, max_total_chars=settings.max_total_chars)
formatted = format_retrieval_result(
result,
max_chars_per_chunk=settings.max_chars_per_chunk,
@ -129,18 +134,27 @@ async def knowledge_search(query: str) -> str:
return _tool_error(exc, settings)
async def knowledge_search(query: str) -> str:
"""Legacy text-only API; graph tool calls additionally carry source artifacts."""
result = await _search(query)
assert isinstance(result, str)
return result
def _tool_description() -> str:
base = "Search the operator-approved LightRAG knowledge base and return compact, citation-numbered source chunks retrieved with the configured graph/vector mode."
base += " Cite only returned evidence using the exact [citation:Title](#knowledge-source-ID) links supplied with each chunk; never invent source locators."
return f"{base} Internal identifiers are never shown to the model."
async def _knowledge_search_entrypoint(query: str) -> str:
async def _knowledge_search_entrypoint(query: str) -> tuple[str, dict]:
"""Search the operator-configured LightRAG knowledge base.
Args:
query: Specific question or search terms to retrieve from the configured private documents.
"""
return await knowledge_search(query)
result = await _search(query, with_sources=True)
return result if isinstance(result, tuple) else (result, source_artifact([]))
knowledge_search_tool = StructuredTool.from_function(
@ -148,4 +162,6 @@ knowledge_search_tool = StructuredTool.from_function(
name="knowledge_search",
description=_tool_description(),
parse_docstring=True,
response_format="content_and_artifact",
metadata={"knowledge_capabilities": CAPABILITIES.model_dump(mode="json")},
)

View File

@ -2,16 +2,35 @@
from __future__ import annotations
import math
from collections.abc import Callable, Mapping
from typing import Any
from deerflow.knowledge import KnowledgeCapabilities, KnowledgeSource, RetrievalHit, RetrievalResult
from deerflow.knowledge.sources import truncate as _truncate
def _truncate(value: str, max_chars: int, *, marker: str = "…") -> str:
if len(value) <= max_chars:
return value
if max_chars <= len(marker):
return marker[:max_chars]
return f"{value[: max_chars - len(marker)].rstrip()}{marker}"
CAPABILITIES = KnowledgeCapabilities(operations=frozenset({"search", "sources"}))
def normalize_retrieval_result(result: Mapping[str, Any], *, dataset_names_by_id: Mapping[str, str]) -> RetrievalResult:
"""Map public retrieval fields to attributable evidence, without locators."""
aggregates = _document_aggregates(result.get("doc_aggs"))
names = {str(item["doc_id"]): item["doc_name"] for item in aggregates if item.get("doc_id") and isinstance(item.get("doc_name"), str)}
hits = []
chunks = result.get("chunks")
for chunk in chunks if isinstance(chunks, list) else []:
if not isinstance(chunk, Mapping) or not isinstance(chunk.get("content"), str):
continue
content = chunk["content"].strip()
if not content:
continue
title = chunk.get("document_keyword") or names.get(str(chunk.get("document_id")))
source = None
if isinstance(title, str) and title.strip():
source = KnowledgeSource(provider="ragflow", title=title.strip()[:500], excerpt=content[:100_000], dataset_name=dataset_names_by_id.get(str(chunk.get("dataset_id"))))
score = _score(chunk.get("similarity"))
hits.append(RetrievalHit(content=content[:100_000], source=source, score=score if score is not None and math.isfinite(score) else None))
return RetrievalResult(hits=hits)
def _document_aggregates(value: object) -> list[Mapping[str, Any]]:

View File

@ -14,6 +14,8 @@ from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, Valida
from deerflow.community.provider_credentials import redact_secret, reject_url_userinfo, resolve_api_key
from deerflow.config import get_app_config
from deerflow.knowledge import format_result
from deerflow.knowledge.sources import redact_strings
from deerflow.knowledge_scope import (
KNOWLEDGE_SCOPE_RUNTIME_KEY,
canonicalize_knowledge_scope,
@ -22,7 +24,7 @@ from deerflow.knowledge_scope import (
from deerflow.tools.types import Runtime
from .client import RAGFlowAPIError, RAGFlowClient, RAGFlowConnectionError, RAGFlowProtocolError
from .formatting import format_retrieval_result, format_retrieval_sources
from .formatting import CAPABILITIES, format_retrieval_result, format_retrieval_sources, normalize_retrieval_result
logger = logging.getLogger(__name__)
@ -565,6 +567,9 @@ async def knowledge_search(
max_total_chars=settings.max_total_chars,
redact=lambda value: redact_secret(value, _api_key(settings)),
)
if artifact is None:
normalized = normalize_retrieval_result(redact_strings(result, _api_key(settings)), dataset_names_by_id=redact_strings(names_by_id, _api_key(settings)))
content, artifact = format_result(normalized, max_chars_per_chunk=settings.max_chars_per_chunk, max_total_chars=settings.max_total_chars)
if artifact is not None:
_source_artifact.update(artifact)
return content
@ -628,6 +633,7 @@ knowledge_search_tool = StructuredTool.from_function(
response_format="content_and_artifact",
description=_tool_description(),
parse_docstring=True,
metadata={"knowledge_capabilities": CAPABILITIES.model_dump(mode="json")},
)

View File

@ -0,0 +1,6 @@
"""Read-only knowledge contracts; no catalog or management dependency."""
from .contract import KnowledgeCapabilities, KnowledgeSource, RetrievalHit, RetrievalProvider, RetrievalResult
from .sources import format_result, sources_from_messages
__all__ = ["KnowledgeCapabilities", "KnowledgeSource", "RetrievalHit", "RetrievalProvider", "RetrievalResult", "format_result", "sources_from_messages"]

View File

@ -0,0 +1,68 @@
"""Provider-neutral retrieval evidence and operation-level capabilities."""
from __future__ import annotations
import hashlib
import json
from typing import Literal, Protocol
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, computed_field, field_validator
class KnowledgeCapabilities(BaseModel):
"""An operation is supported only when explicitly advertised.
Search-only providers need no catalog, scope, or management implementation.
Scope admission and adapters for these optional operations belong to phase 2.
"""
operations: frozenset[Literal["search", "sources", "catalog", "documents", "dataset_filter", "document_filter"]] = frozenset({"search"})
def supports(self, operation: str) -> bool:
return operation in self.operations
class KnowledgeSource(BaseModel):
"""One attributable excerpt actually returned by a retrieval tool.
Titles and optional locators are provider evidence, never inferred links,
page numbers, or local reference ordinals. IDs expose no provider internals.
"""
model_config = ConfigDict(frozen=True, extra="ignore", str_strip_whitespace=True)
provider: str = Field(min_length=1, max_length=100)
title: str = Field(min_length=1, max_length=500)
excerpt: str = Field(min_length=1, max_length=100_000)
dataset_name: str | None = Field(default=None, max_length=500)
url: HttpUrl | None = None
page: int | None = Field(default=None, gt=0, le=9_007_199_254_740_991, strict=True)
@field_validator("url")
@classmethod
def reject_credentials(cls, value: HttpUrl | None) -> HttpUrl | None:
if value is not None and (value.username is not None or value.password is not None):
raise ValueError("source URL must not contain credentials")
return value
@computed_field
@property
def id(self) -> str:
identity = [self.provider, self.title, self.excerpt, self.dataset_name, str(self.url) if self.url else None, self.page]
return hashlib.sha256(json.dumps(identity, ensure_ascii=False).encode("utf-8")).hexdigest()[:32]
class RetrievalHit(BaseModel):
content: str
source: KnowledgeSource | None = None
score: float | None = Field(default=None, allow_inf_nan=False)
class RetrievalResult(BaseModel):
hits: list[RetrievalHit] = Field(default_factory=list)
class RetrievalProvider(Protocol):
capabilities: KnowledgeCapabilities
async def search(self, query: str) -> RetrievalResult: ...

View File

@ -0,0 +1,155 @@
"""Shared validation, evidence-preserving budgets, and delegation forwarding."""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from typing import Any
from pydantic import ValidationError
from .contract import KnowledgeSource, RetrievalResult
ARTIFACT_VERSION = 1
TRUNCATION_MARKER = "… (response truncated)"
def redact_strings(value: Any, secret: str | None) -> Any:
"""Redact before normalization so text and artifact never expose secrets."""
if isinstance(value, str):
return value.replace(secret, "[REDACTED]") if secret else value
if isinstance(value, Mapping):
return {key: redact_strings(item, secret) for key, item in value.items()}
if isinstance(value, list):
return [redact_strings(item, secret) for item in value]
return value
def strip_source_artifact(artifact: Any) -> Any:
"""Remove only server-owned evidence keys from untrusted writes."""
if isinstance(artifact, Mapping) and ("knowledge_sources" in artifact or "knowledge_sources_version" in artifact or "retrieval_sources" in artifact):
return {key: value for key, value in artifact.items() if key not in {"knowledge_sources", "knowledge_sources_version", "retrieval_sources"}}
return artifact
def prune_sources(content: str, artifact: Any) -> tuple[str, Any]:
"""Budget transformations retain only evidence still visible to the model."""
if not isinstance(artifact, Mapping) or artifact.get("knowledge_sources_version") != ARTIFACT_VERSION:
return content, artifact
sources = sources_from_messages([{"type": "tool", "content": content, "artifact": artifact}])
allowed = {source.id for source in sources}
pattern = r"\[citation:((?:\\.|[^\]\\])*)\]\(#knowledge-source-([a-f0-9]{32})\)"
content = re.sub(pattern, lambda match: match[0] if match[2] in allowed else match[1], content)
if "retrieval_sources" in artifact:
return content, {**artifact, "retrieval_sources": [source.model_dump(mode="json", exclude_none=True) for source in sources]}
return content, {**artifact, **source_artifact(sources)}
def truncate(value: str, max_chars: int, *, marker: str = "…") -> str:
max_chars = max(0, max_chars)
if len(value) <= max_chars:
return value
if max_chars <= len(marker):
return marker[:max_chars]
return f"{value[: max_chars - len(marker)].rstrip()}{marker}"
def source_artifact(sources: Iterable[KnowledgeSource]) -> dict[str, Any]:
return {"knowledge_sources_version": ARTIFACT_VERSION, "knowledge_sources": [source.model_dump(mode="json", exclude_none=True) for source in sources]}
def citation(source: KnowledgeSource) -> str:
# Escape labels before inserting provider-controlled titles into markdown.
title = source.title.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]").replace("\n", " ")
return f"[citation:{title}](#knowledge-source-{source.id})"
def format_result(result: RetrievalResult, *, max_chars_per_chunk: int = 800, max_total_chars: int = 8000) -> tuple[str, dict[str, Any]]:
"""Budget text and its evidence together; never cut a citation in half."""
entries: list[str] = []
sources: list[KnowledgeSource] = []
used_chars = 0
budget = max(0, max_total_chars)
truncated = False
for index, hit in enumerate(result.hits, 1):
if index > 100:
truncated = True
break # Match the bounded artifact readers and delegation ledger.
content = truncate(hit.content.strip(), max_chars_per_chunk)
if not content:
continue
source = hit.source
# Evidence must be from this hit. Adapters cannot attach unrelated text.
if source is not None and source.excerpt != hit.content.strip():
source = None
if content == "…" and len(hit.content.strip()) > len(content):
source = None # A truncation marker alone is not retrieved evidence.
if source is not None:
source = KnowledgeSource.model_validate({**source.model_dump(exclude={"id"}), "excerpt": content})
header = f"[{index}] {citation(source) if source else 'Unattributed content'}\n"
separator = "\n\n" if entries else ""
remaining = budget - used_chars - len(separator)
entry = header + content
if len(entry) > remaining:
# Drop the entire hit when it cannot fit with real evidence. This
# keeps the stable source identity independent of total budget.
truncated = True
break
entries.append(separator + entry)
used_chars += len(separator) + len(entry)
if source is not None:
sources.append(source)
text = "".join(entries)
if truncated:
remaining = budget - len(text)
text += truncate(TRUNCATION_MARKER, remaining, marker="")
elif not text:
text = truncate("No relevant content found.", budget, marker="")
return text, source_artifact(sources)
def sources_from_messages(messages: Iterable[Any]) -> list[KnowledgeSource]:
"""Accept native and serialized ToolMessages, never assistant claims.
Child values accumulation scans changed message suffixes and retains bounded
evidence across compaction. Replay deduplicates by evidence identity.
"""
sources: dict[str, KnowledgeSource] = {}
for message in messages:
if isinstance(message, Mapping):
kind, artifact, content = message.get("type"), message.get("artifact"), message.get("content")
else:
kind, artifact, content = getattr(message, "type", None), getattr(message, "artifact", None), getattr(message, "content", None)
if kind != "tool" or not isinstance(content, str) or not isinstance(artifact, Mapping) or type(artifact.get("knowledge_sources_version")) is not int or artifact.get("knowledge_sources_version") != ARTIFACT_VERSION:
continue
raw_sources = artifact.get("knowledge_sources") if isinstance(artifact.get("knowledge_sources"), list) else artifact.get("retrieval_sources")
if not isinstance(raw_sources, list):
continue
for raw in raw_sources[:100]:
try:
source = KnowledgeSource.model_validate(raw)
except (ValidationError, TypeError):
continue
if not isinstance(raw, Mapping) or raw.get("id") != source.id:
continue
if source.excerpt in content and f"#knowledge-source-{source.id}" in content:
sources[source.id] = source
return list(sources.values())[-100:]
def forward_sources(report: str, raw_sources: Iterable[Any], *, max_total_chars: int = 8000) -> tuple[str, dict[str, Any]]:
"""Forward only citations backed by child tool evidence, with excerpts."""
hits = []
from .contract import RetrievalHit
for raw in raw_sources:
try:
source = KnowledgeSource.model_validate(raw)
except (ValidationError, TypeError):
continue
if isinstance(raw, Mapping) and raw.get("id") == source.id and f"#knowledge-source-{source.id}" in report:
hits.append(RetrievalHit(content=source.excerpt, source=source))
if not hits:
return prune_sources(report, source_artifact([]))
evidence, artifact = format_result(RetrievalResult(hits=hits), max_chars_per_chunk=100_000, max_total_chars=max_total_chars)
return prune_sources(f"{report}\n\nKnowledge evidence:\n{evidence}", artifact)

View File

@ -1,5 +1,14 @@
### Subagent System (`packages/harness/deerflow/subagents/`)
Validated ToolMessage knowledge evidence accumulates across values frames in
`SubagentResult.knowledge_sources` (100 sources, deduplicated by evidence ID).
Publication precedes terminal state and survives compaction. Ordinary `task`
forwards reported handles with a bounded evidence appendix and source artifact.
Mixed task results keep native RAGFlow snapshots in `knowledge_sources` and
shared evidence in `retrieval_sources`; pruning must preserve the native snapshot.
Shared evidence scans start at the changed message suffix; compaction or prefix
replacement resets that boundary. Null frames must not crash the accumulator.
Result-reader acceptance runs on SQLite and, with TEST_POSTGRES_URI, an isolated
PostgreSQL schema; both exercise submission, completion, reopen and ToolNode
continuation without paid model work. Always clean up the temporary schema.

View File

@ -173,6 +173,8 @@ class SubagentResult:
admission_failure: bool = False
tool_receipts: list[dict[str, Any]] | None = field(default=None, kw_only=True)
bash_executions: list[dict[str, Any]] | None = field(default=None, kw_only=True)
knowledge_sources: list[dict[str, Any]] = field(default_factory=list, kw_only=True)
_knowledge_messages: list[Any] = field(default_factory=list, init=False, repr=False)
cancel_event: threading.Event = field(default_factory=threading.Event, repr=False)
_state_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
@ -224,6 +226,29 @@ class SubagentResult:
return None
return [dict(receipt) for receipt in self.tool_receipts]
def update_knowledge_sources(self, messages: list[Any] | None) -> None:
"""Accumulate validated retrieval evidence before terminal publication."""
from deerflow.knowledge import sources_from_messages
with self._state_lock:
if self.status.is_terminal:
return
if messages is None:
return
# Values histories grow cumulatively, but compaction/replacements
# can change the prefix. Rescan from the first changed message.
start = 0
for previous, current in zip(self._knowledge_messages, messages):
if previous != current:
break
start += 1
sources = sources_from_messages(messages[start:]) if start < len(messages) else []
self._knowledge_messages = list(messages)
merged = {source["id"]: source for source in self.knowledge_sources}
for source in sources:
merged[source.id] = source.model_dump(mode="json", exclude_none=True)
self.knowledge_sources = list(merged.values())[-100:]
def try_set_terminal(
self,
status: SubagentStatus,
@ -1826,6 +1851,7 @@ class SubagentExecutor:
# harvesting includes a tool result that completed while the
# cancellation request was in flight.
final_state = chunk
result.update_knowledge_sources(chunk.get("messages") or [])
result.update_tool_receipts(terminal_receipts())
result.update_bash_executions(current_bash_executions())
@ -1845,7 +1871,7 @@ class SubagentExecutor:
# several ToolMessages when the model emits multiple tool calls in
# one turn, so capturing only messages[-1] would drop all but the
# last output (#3779). Dedup/serialization live in capture_step_message.
messages = chunk.get("messages", [])
messages = chunk.get("messages") or []
previous_count = len(ai_messages)
processed_message_count = capture_new_step_messages(messages, ai_messages, seen_message_ids, processed_message_count)
if len(ai_messages) > previous_count:

View File

@ -635,12 +635,23 @@ def _task_result_command(
source_messages: list[dict] | None = None,
receipt_verdict: dict | None = None,
acceptance_verdict: dict | None = None,
knowledge_sources: list[dict] | None = None,
) -> Command:
content, metadata_error = format_subagent_result_message(status, result=result, error=error, stop_reason=stop_reason)
if acceptance_verdict is not None:
# RFC #4651 PR4: the rendered checklist rides the model-visible result
# text; metadata carries the structured verdict for the ledger/judge.
content = f"{content}\n\n{render_acceptance_section(acceptance_verdict)}"
artifact = cited_source_artifact(source_messages or [], content, include_omissions=True)
if knowledge_sources:
from deerflow.knowledge.sources import forward_sources
content, unified_artifact = forward_sources(content, knowledge_sources)
if artifact is None:
artifact = unified_artifact
else:
# Preserve the native RAGFlow envelope for existing history and batch readers.
artifact = {**artifact, "retrieval_sources": unified_artifact["knowledge_sources"], "knowledge_sources_version": 1}
return Command(
update={
"messages": [
@ -648,7 +659,7 @@ def _task_result_command(
content=content,
tool_call_id=tool_call_id,
name="task",
artifact=cited_source_artifact(source_messages or [], content, include_omissions=True),
artifact=artifact,
additional_kwargs=make_subagent_additional_kwargs(
status,
result=result,
@ -1129,6 +1140,7 @@ async def task_tool(
source_messages=getattr(result, "ai_messages", None),
receipt_verdict=receipt_verdict,
acceptance_verdict=acceptance_verdict,
knowledge_sources=getattr(result, "knowledge_sources", None),
)
elif result.status == SubagentStatus.FAILED:
_report_subagent_usage(runtime, result)

View File

@ -0,0 +1,196 @@
"""Provider-independent evidence and budget regressions (offline)."""
import json
from pathlib import Path
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from pydantic import ValidationError
from deerflow.knowledge import KnowledgeSource, RetrievalHit, RetrievalResult, format_result, sources_from_messages
def test_source_schema_matches_cross_component_contract():
from jsonschema import Draft202012Validator
from deerflow.knowledge.sources import ARTIFACT_VERSION
schema = json.loads((Path(__file__).parents[2] / "contracts/knowledge_sources.v1.json").read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
properties = schema["$defs"]["source"]["properties"]
model = KnowledgeSource.model_json_schema(mode="serialization")["properties"]
for name in ("provider", "title", "excerpt"):
assert model[name]["maxLength"] == properties[name]["maxLength"]
assert model[name]["minLength"] == properties[name]["minLength"]
assert model["dataset_name"]["anyOf"][0]["maxLength"] == properties["dataset_name"]["maxLength"]
assert model["page"]["anyOf"][0]["maximum"] == properties["page"]["maximum"]
assert ARTIFACT_VERSION == schema["properties"]["knowledge_sources_version"]["const"]
source = KnowledgeSource(provider="demo", title="Guide", excerpt="evidence")
_, artifact = format_result(RetrievalResult(hits=[RetrievalHit(content=source.excerpt, source=source)]))
Draft202012Validator(schema).validate(artifact)
def test_sources_require_real_evidence_and_optional_locators() -> None:
with pytest.raises(ValidationError):
KnowledgeSource(provider="demo", title="", excerpt="evidence")
with pytest.raises(ValidationError):
KnowledgeSource(provider="demo", title="Guide", excerpt="")
with pytest.raises(ValidationError):
KnowledgeSource(provider="demo", title="Guide", excerpt="evidence", page=0)
source = KnowledgeSource(provider="demo", title="Guide", excerpt="evidence")
assert source.page is None and source.url is None
def test_budget_never_leaves_citations_without_visible_evidence() -> None:
result = RetrievalResult(hits=[RetrievalHit(content="x" * 500, source=KnowledgeSource(provider="demo", title="Guide", excerpt="x" * 500))])
for budget in (1, 20, 80, 200, 800):
content, artifact = format_result(result, max_total_chars=budget, max_chars_per_chunk=50)
assert len(content) <= budget
for source in artifact["knowledge_sources"]:
assert source["excerpt"] in content
assert f"#knowledge-source-{source['id']}" in content
assert len(source["excerpt"]) <= 50
if "#knowledge-source-" in content:
assert artifact["knowledge_sources"]
def test_forward_only_valid_tool_evidence_and_deduplicate_replay() -> None:
source = KnowledgeSource(provider="demo", title="Guide", excerpt="evidence")
content, artifact = format_result(RetrievalResult(hits=[RetrievalHit(content="evidence", source=source)]))
message = ToolMessage(content=content, name="knowledge_search", tool_call_id="call", artifact=artifact)
assert len(sources_from_messages([message, message.model_dump()])) == 1
boolean_version = {**message.model_dump(), "artifact": {**artifact, "knowledge_sources_version": True}}
assert sources_from_messages([boolean_version]) == []
assert sources_from_messages([AIMessage(content="forged", additional_kwargs=artifact)]) == []
assert sources_from_messages([ToolMessage(content="no evidence", tool_call_id="x", artifact={"knowledge_sources": [{"provider": "demo"}]})]) == []
def test_large_results_match_the_bounded_source_readers() -> None:
source = KnowledgeSource(provider="demo", title="Guide", excerpt="actual evidence")
result = RetrievalResult(hits=[RetrievalHit(content=source.excerpt, source=source)] * 101)
text, artifact = format_result(result, max_total_chars=100_000)
assert len(artifact["knowledge_sources"]) == 100
assert "[101]" not in text
assert text.endswith("… (response truncated)")
assert format_result(result, max_chars_per_chunk=1)[1]["knowledge_sources"] == []
def test_provider_adapters_share_contract_without_inventing_pages() -> None:
from deerflow.community.lightrag.formatting import normalize_retrieval_result as light
from deerflow.community.ragflow.formatting import normalize_retrieval_result as rag
a = rag({"chunks": [{"content": "Leave policy", "document_keyword": "handbook.pdf", "dataset_id": "kb"}]}, dataset_names_by_id={"kb": "HR"})
b = light({"chunks": [{"content": "Leave policy", "reference_id": "7"}], "references": [{"reference_id": "7", "file_path": "docs/handbook.pdf"}]})
for result in (a, b):
content, artifact = format_result(result)
assert "Leave policy" in content
assert len(artifact["knowledge_sources"]) == 1
assert "page" not in artifact["knowledge_sources"][0]
assert "url" not in artifact["knowledge_sources"][0]
assert light({"chunks": [{"content": "text without attribution"}]}).hits[0].source is None
@pytest.mark.anyio
@pytest.mark.parametrize("provider", ["ragflow", "lightrag"])
async def test_real_tool_message_carries_redacted_evidence(monkeypatch, provider) -> None:
from importlib import import_module
tools = import_module(f"deerflow.community.{provider}.tools")
config = import_module(f"test_{provider}_tools")
result = {"chunks": [{"content": f"Evidence with {provider}-secret", "document_keyword": "guide.pdf", "file_path": "docs/guide.md", "dataset_id": config.DATASET_ID_1 if provider == "ragflow" else "unused"}]}
if provider == "ragflow":
fake = config.FakeRAGFlowClient(datasets_by_id={config.DATASET_ID_1: [config._dataset(config.DATASET_ID_1, "HR")]}, retrieval=result)
else:
fake = config.FakeLightRAGClient(data=result)
config._install(monkeypatch, fake)
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
builder = StateGraph(MessagesState)
builder.add_node("tools", ToolNode([tools.knowledge_search_tool]))
builder.add_edge(START, "tools")
builder.add_edge("tools", END)
state = await builder.compile().ainvoke({"messages": [AIMessage(content="", tool_calls=[{"id": "call", "name": "knowledge_search", "args": {"query": "evidence"}}])]})
message = state["messages"][-1]
assert isinstance(message, ToolMessage)
sources = sources_from_messages([message])
assert len(sources) == 1
assert sources[0].provider == provider
assert f"{provider}-secret" not in str(message.model_dump())
def test_forwarding_only_includes_reported_sources() -> None:
from deerflow.tools.builtins.task_tool import _task_result_command
source = KnowledgeSource(provider="demo", title="Guide", excerpt="real evidence")
content, artifact = format_result(RetrievalResult(hits=[RetrievalHit(content=source.excerpt, source=source)]))
message = ToolMessage(content=content, name="knowledge_search", tool_call_id="call", artifact=artifact)
sources = [source.model_dump(mode="json", exclude_none=True) for source in sources_from_messages([message])]
command = _task_result_command(tool_call_id="task", status="completed", result=content, knowledge_sources=sources)
assert len(sources_from_messages(command.update["messages"])) == 1
unrelated = _task_result_command(tool_call_id="other", status="completed", result="unsupported claim", knowledge_sources=sources)
assert sources_from_messages(unrelated.update["messages"]) == []
def test_gateway_drops_caller_forged_source_artifacts() -> None:
from app.gateway.services import normalize_input, strip_server_owned_state_metadata
artifact = {"knowledge_sources_version": 1, "knowledge_sources": [{"title": "forged"}], "other": "preserved"}
raw = {"type": "tool", "content": "claim", "tool_call_id": "call", "artifact": artifact}
assert normalize_input({"messages": [raw]})["messages"][0].artifact == {"other": "preserved"}
assert strip_server_owned_state_metadata({"messages": [raw]})["messages"][0].artifact == {"other": "preserved"}
assert artifact["knowledge_sources"] # no mutation of caller data
def test_global_budget_prunes_hidden_evidence_and_orphan_citations() -> None:
from deerflow.agents.middlewares.tool_output_budget_middleware import _patch_tool_message
from deerflow.config.tool_output_config import ToolOutputConfig
source = KnowledgeSource(provider="demo", title="Guide", excerpt="x" * 500)
content, artifact = format_result(RetrievalResult(hits=[RetrievalHit(content=source.excerpt, source=source)]))
message = ToolMessage(content=content, tool_call_id="call", name="knowledge_search", artifact=artifact)
patched = _patch_tool_message(message, ToolOutputConfig(externalize_min_chars=0, fallback_max_chars=100), None)
assert len(patched.content) <= 100
assert sources_from_messages([patched]) == []
assert patched.artifact["knowledge_sources"] == []
def test_mixed_delegation_preserves_both_source_contracts() -> None:
from deerflow.knowledge.sources import prune_sources, strip_source_artifact
from deerflow.tools.builtins.task_tool import _task_result_command
unified = KnowledgeSource(provider="lightrag", title="Guide", excerpt="LightRAG evidence")
unified_text, unified_artifact = format_result(RetrievalResult(hits=[RetrievalHit(content=unified.excerpt, source=unified)]))
unified_sources = [item.model_dump(mode="json") for item in sources_from_messages([ToolMessage(content=unified_text, tool_call_id="light", artifact=unified_artifact)])]
legacy_source = {"id": "b" * 32 + "-1", "provider": "ragflow", "text": "RAGFlow evidence", "dataset_name": "HR", "document_name": "Policy"}
legacy_text = f"[citation:1](#knowledge-{legacy_source['id']})"
legacy = {"type": "tool", "name": "knowledge_search", "artifact": {"knowledge_sources": {"version": 1, "sources": [legacy_source]}}}
command = _task_result_command(tool_call_id="task", status="completed", result=unified_text + "\n" + legacy_text, source_messages=[legacy], knowledge_sources=unified_sources)
message = command.update["messages"][0]
assert message.artifact["knowledge_sources"]["sources"] == [legacy_source]
assert sources_from_messages([message])[0].provider == "lightrag"
_, pruned = prune_sources(legacy_text, message.artifact)
assert pruned["retrieval_sources"] == []
assert pruned["knowledge_sources"]["sources"] == [legacy_source]
alternate = {key: value for key, value in message.artifact.items() if key != "knowledge_sources"}
assert sources_from_messages([message.model_copy(update={"artifact": alternate})])[0].provider == "lightrag"
_, pruned_alternate = prune_sources(legacy_text, alternate)
assert pruned_alternate["retrieval_sources"] == []
assert strip_source_artifact({**message.artifact, "other": "kept"}) == {"other": "kept"}
@pytest.mark.anyio
async def test_ragflow_fallback_honors_disabled_runtime_scope(monkeypatch) -> None:
from types import SimpleNamespace
from deerflow.community.ragflow import tools
from deerflow.knowledge_scope import KNOWLEDGE_SCOPE_RUNTIME_KEY
def forbidden():
pytest.fail("disabled knowledge scope must not resolve settings or access the provider")
monkeypatch.setattr(tools, "_settings_or_error", forbidden)
runtime = SimpleNamespace(context={KNOWLEDGE_SCOPE_RUNTIME_KEY: {"version": 1, "mode": "disabled"}})
text, artifact = await tools._knowledge_search_entrypoint("policy", runtime)
assert "disabled" in text
assert artifact is None

View File

@ -0,0 +1,29 @@
"""Opt-in validation against real LightRAG /query/data evidence; no uploads."""
import os
import pytest
from deerflow.community.lightrag.client import LightRAGClient
from deerflow.community.lightrag.formatting import normalize_retrieval_result
from deerflow.knowledge import format_result, sources_from_messages
@pytest.mark.live
@pytest.mark.anyio
async def test_lightrag_live_evidence_contract() -> None:
if os.getenv("DEER_FLOW_RUN_LIVE_TESTS") != "1":
pytest.skip("live knowledge checks are opt-in")
base_url, query = os.getenv("LIGHTRAG_BASE_URL"), os.getenv("LIGHTRAG_EVIDENCE_QUERY")
if not base_url or not query:
pytest.skip("set LIGHTRAG_BASE_URL and LIGHTRAG_EVIDENCE_QUERY for an indexed workspace")
result = await LightRAGClient(base_url=base_url, api_key=os.getenv("LIGHTRAG_API_KEY")).query_data(query, mode="mix", chunk_top_k=8)
normalized = normalize_retrieval_result(result)
assert any(hit.source for hit in normalized.hits), "Live query must retrieve attributable document text"
text, artifact = format_result(normalized)
sources = sources_from_messages([{"type": "tool", "content": text, "artifact": artifact}])
assert sources, "At least one real retrieved source must fit the default budget"
for source in sources:
assert any(hit.source and hit.source.title == source.title and source.excerpt.rstrip("…") in hit.content for hit in normalized.hits)
assert source.provider == "lightrag"
assert source.page is None # /query/data does not supply page evidence

View File

@ -54,6 +54,39 @@ _MOCKED_MODULE_NAMES = [
_LANGGRAPH_HAS_ROOT_LINEAGE_STREAM_REGRESSION = Version(package_version("langgraph")) >= Version("1.2.6")
def test_knowledge_sources_survive_compaction_and_terminal_publication(_setup_executor_classes):
from langchain_core.messages import AIMessage, ToolMessage
from deerflow.knowledge import KnowledgeSource, RetrievalHit, RetrievalResult, format_result
classes = _setup_executor_classes
source = KnowledgeSource(provider="demo", title="Guide", excerpt="real evidence")
content, artifact = format_result(RetrievalResult(hits=[RetrievalHit(content=source.excerpt, source=source)]))
result = classes["SubagentResult"](task_id="task", trace_id="trace", status=classes["SubagentStatus"].RUNNING)
result.update_knowledge_sources([ToolMessage(content=content, tool_call_id="call", artifact=artifact)])
result.update_knowledge_sources([AIMessage(content="compacted")])
assert len(result.knowledge_sources) == 1
result.try_set_terminal(classes["SubagentStatus"].COMPLETED, result=content)
result.update_knowledge_sources([])
assert len(result.knowledge_sources) == 1
def test_knowledge_accumulator_scans_only_changed_tail(_setup_executor_classes, monkeypatch):
import deerflow.knowledge as knowledge
classes = _setup_executor_classes
scanned = []
monkeypatch.setattr(knowledge, "sources_from_messages", lambda messages: scanned.append(list(messages)) or [])
result = classes["SubagentResult"](task_id="task", trace_id="trace", status=classes["SubagentStatus"].RUNNING)
first, second, compacted = {"type": "ai", "content": "first"}, {"type": "ai", "content": "second"}, {"type": "ai", "content": "summary"}
result.update_knowledge_sources([first])
result.update_knowledge_sources([first, second])
result.update_knowledge_sources([first, second])
result.update_knowledge_sources([compacted])
result.update_knowledge_sources(None)
assert scanned == [[first], [second], [compacted]]
def _default_app_config():
return SimpleNamespace(
tool_search=SimpleNamespace(enabled=False),

View File

@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Shared retrieval source envelope v1",
"description": "Server-owned tool evidence; readers also require the handle and excerpt in tool content. Mixed native RAGFlow artifacts carry this array under retrieval_sources.",
"type": "object",
"required": ["knowledge_sources_version", "knowledge_sources"],
"properties": {
"knowledge_sources_version": { "const": 1, "type": "integer" },
"knowledge_sources": {
"type": "array",
"maxItems": 100,
"items": { "$ref": "#/$defs/source" }
}
},
"$defs": {
"source": {
"type": "object",
"required": ["id", "provider", "title", "excerpt"],
"properties": {
"id": { "type": "string", "pattern": "^[a-f0-9]{32}$" },
"provider": { "type": "string", "minLength": 1, "maxLength": 100 },
"title": { "type": "string", "minLength": 1, "maxLength": 500 },
"excerpt": { "type": "string", "minLength": 1, "maxLength": 100000 },
"dataset_name": { "type": "string", "maxLength": 500 },
"url": {
"type": "string",
"format": "uri",
"pattern": "^https?://",
"description": "No username or password allowed."
},
"page": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }
}
}
}
}

View File

@ -76,6 +76,23 @@ Keep IM notification preferences viewer-fenced and separate from browser alerts;
## Architecture
Knowledge evidence is parsed in `core/citations/sources.ts` from version-1
ToolMessage artifacts. Message rows receive sources from preceding loaded tool
messages. `#knowledge-source-ID` links resolve to `KnowledgeCitation` and an
expandable source panel with actual excerpts. Missing URLs/pages remain absent.
Web citations and old history remain compatible. Never infer evidence from
assistant labels or numbered legacy text.
Shared records use the `RetrievalSource` type (native RAGFlow retains
`KnowledgeSource`). Accept raw and `#user-content-knowledge-source-ID`
fragments without disabling sanitizer clobber protection. Validate immutable
tool messages through `createKnowledgeSourceReader` to reuse per-object parses.
Cross-component field bounds live in `contracts/knowledge_sources.v1.json`.
Completed `SubtaskCard` reports read shared evidence only from the matching
`task` ToolMessage after its invocation in the current subagent group, selected
by `findSubtaskResultMessage`. Prompts/earlier steps do not borrow final evidence.
Remount report Markdown when validated source IDs change: Streamdown caches
unchanged text blocks independently of renderer components.
```
Frontend (Next.js) ──▶ LangGraph SDK ──▶ LangGraph Backend (lead_agent)
├── Sub-Agents
@ -86,22 +103,16 @@ Users create **threads** (conversations), send messages, set thread-scoped `/goa
### Source Layout (`src/`)
- **`app/`** — Next.js App Router. Routes include `/` (landing), `/showcase/[thread_id]` (allowlisted public read-only demos), `/workspace/chats/[thread_id]` (authenticated chat), `/workspace/agents/[agent_name]` and `/workspace/agents/new` (custom agents), `/artifacts/view` (chrome-free window that renders Markdown or CSV/TSV artifacts with the panel's own renderer), `/blog/…`, the `(auth)/{login,setup,auth/callback}` flow, `/[lang]/docs/…`, and `/api/…` route handlers (e.g. `/api/memory`).
- **`components/`** — React components:
- `ui/` — Shadcn UI primitives (auto-generated, ESLint-ignored)
- `ai-elements/` — Vercel AI SDK elements (auto-generated, ESLint-ignored)
- `workspace/` — Chat page components (messages, artifacts, settings)
- `landing/` — Landing page sections
- `docs/` — Docs / MDX rendering components
- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `subagents/` (runtime worker catalog and administrator mutations), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `integrations/` (managed third-party integration status/install clients such as Lark CLI), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `input-polish/` (pre-send draft rewrite API), `voice-input/` (browser speech-recognition helpers), `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`.
- **`hooks/`** — Shared React hooks
- **`lib/`** — Utilities (`cn()` from clsx + tailwind-merge)
- **`content/`** — MDX content (blog posts, docs) rendered by the app
- **`styles/`** — Global CSS with Tailwind v4 `@import` syntax and CSS variables for theming
- **`typings/`** — Ambient TypeScript declarations
- Root files: `env.js` (env validation), `mdx-components.ts` (MDX component map)
- `app/` — Next.js routes: chat/workspace, agents, auth, showcase, artifact views,
localized docs/blog, and API handlers.
- `components/` — generated `ui/` and `ai-elements/`; application components under
`workspace/`, `landing/`, and `docs/`.
- `core/` — domain logic, API clients, state/hooks, i18n, and Markdown renderers.
- `hooks/`, `lib/`, `typings/` — shared hooks, utilities, and ambient types.
- `content/`, `styles/` — MDX content and Tailwind styles.
- `env.js`, `mdx-components.ts` — environment validation and MDX component map.
More specific `AGENTS.md` files under `src/` contain the frontend sections split from this file.
Read [src/AGENTS.md](src/AGENTS.md) for component and domain ownership.
## Code Style

View File

@ -17,6 +17,8 @@ import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { KnowledgeEvidence } from "./knowledge-citation";
export function CitationSourcesPanel({
className,
sources,
@ -44,33 +46,47 @@ export function CitationSourcesPanel({
</span>
</summary>
<ol className="border-border/60 divide-border/60 max-h-80 divide-y overflow-y-auto overscroll-contain border-t">
{sources.map((source, index) => (
<li key={source.id} className="flex min-w-0 items-center gap-2 p-2">
<span className="text-muted-foreground w-5 shrink-0 text-right tabular-nums">
{index + 1}
</span>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-muted flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1 transition-colors"
>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate font-medium">
{source.title}
</span>
<span className="text-muted-foreground block truncate">
{source.domain}
</span>
{sources.map((source, index) =>
source.knowledge ? (
<li key={source.id} id={source.id.slice(1)} className="p-3">
<details>
<summary className="cursor-pointer">
{index + 1}. {source.title} · {source.domain} ·{" "}
{t.citations.citeCount(source.count)}
</summary>
<div className="mt-2">
<KnowledgeEvidence source={source.knowledge} />
</div>
</details>
</li>
) : (
<li key={source.id} className="flex min-w-0 items-center gap-2 p-2">
<span className="text-muted-foreground w-5 shrink-0 text-right tabular-nums">
{index + 1}
</span>
<span className="text-muted-foreground shrink-0">
{t.citations.citeCount(source.count)}
</span>
<ExternalLinkIcon className="text-muted-foreground size-3.5 shrink-0" />
</a>
<CitationSourceCopyButton source={source} />
</li>
))}
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-muted flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1 transition-colors"
>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate font-medium">
{source.title}
</span>
<span className="text-muted-foreground block truncate">
{source.domain}
</span>
</span>
<span className="text-muted-foreground shrink-0">
{t.citations.citeCount(source.count)}
</span>
<ExternalLinkIcon className="text-muted-foreground size-3.5 shrink-0" />
</a>
<CitationSourceCopyButton source={source} />
</li>
),
)}
</ol>
</details>
);

View File

@ -0,0 +1,62 @@
import { Badge } from "@/components/ui/badge";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import type { RetrievalSource } from "@/core/citations/sources";
import { useI18n } from "@/core/i18n/hooks";
/** Local evidence is readable even when the provider returned no public URL. */
export function KnowledgeCitation({ source }: { source: RetrievalSource }) {
return (
<HoverCard closeDelay={0} openDelay={0}>
<HoverCardTrigger asChild>
<button
type="button"
className="inline-flex items-center"
onClick={(event) => event.stopPropagation()}
>
<Badge
variant="secondary"
className="mx-0.5 rounded-full px-2 py-0.5 text-xs font-normal"
>
{source.title}
</Badge>
</button>
</HoverCardTrigger>
<HoverCardContent className="max-h-80 w-80 overflow-y-auto text-xs">
<KnowledgeEvidence source={source} />
</HoverCardContent>
</HoverCard>
);
}
export function KnowledgeEvidence({ source }: { source: RetrievalSource }) {
const { t } = useI18n();
return (
<div className="space-y-2 break-words">
<p className="font-medium">{source.title}</p>
<p className="text-muted-foreground">
{source.provider}
{source.dataset_name ? ` / ${source.dataset_name}` : ""}
{source.page
? ` · ${t.citations.sourcePages(String(source.page))}`
: ""}
</p>
<blockquote className="border-border border-l-2 pl-2 whitespace-pre-wrap">
{source.excerpt}
</blockquote>
{source.url && (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
{source.url}
</a>
)}
</div>
);
}

View File

@ -1,10 +1,15 @@
import type { AnchorHTMLAttributes, ComponentProps } from "react";
import { resolveMarkdownArtifactURL } from "@/core/artifacts/utils";
import {
retrievalSourceId,
type RetrievalSource,
} from "@/core/citations/sources";
import { knowledgeSourceId } from "@/core/knowledge/sources";
import { cn } from "@/lib/utils";
import { CitationLink, extractReactNodeText } from "../citations/citation-link";
import { KnowledgeCitation } from "../citations/knowledge-citation";
/**
* Schemes we are willing to render as a navigable ``<a href=...>``.
@ -86,11 +91,23 @@ function isExternalUrl(href: string | undefined): boolean {
* Passing a `threadId` also resolves `/mnt/` artifact links; without it those
* links fall through to the default external-link handling.
*/
export function createMarkdownLinkComponent(threadId?: string) {
export function createMarkdownLinkComponent(
threadId?: string,
knowledgeSources: readonly RetrievalSource[] = [],
) {
return function MarkdownLink({
href,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement>) {
if (/^#(?:user-content-)?knowledge-source-/.test(href ?? "")) {
const id = retrievalSourceId(href);
const source = knowledgeSources.find((item) => item.id === id);
return source ? (
<KnowledgeCitation source={source} />
) : (
<span>{props.children}</span>
);
}
// Reject unsafe schemes up front so a prompt-injected / pasted href can
// never reach the rendered anchor — including through the citation
// branch (which renders <a href={href}> directly). Check before the

View File

@ -36,7 +36,10 @@ import {
resolveArtifactURL,
resolveMessageImageURL,
} from "@/core/artifacts/utils";
import { extractCitationSources } from "@/core/citations/sources";
import {
type RetrievalSource,
extractCitationSources,
} from "@/core/citations/sources";
import { readConversationReferences } from "@/core/conversation-references";
import { useI18n } from "@/core/i18n/hooks";
import {
@ -145,6 +148,7 @@ export function MessageListItem({
runId,
threadId,
artifactPaths = [],
knowledgeSources = [],
showCopyButton = true,
showWorkspaceChanges = false,
durationSeconds,
@ -158,6 +162,7 @@ export function MessageListItem({
isLoading?: boolean;
threadId: string;
artifactPaths?: readonly string[];
knowledgeSources?: readonly RetrievalSource[];
feedback?: FeedbackData | null;
runId?: string;
showCopyButton?: boolean;
@ -230,6 +235,7 @@ export function MessageListItem({
isLoading={isLoading}
threadId={threadId}
artifactPaths={artifactPaths}
knowledgeSources={knowledgeSources}
runId={runId}
showWorkspaceChanges={showWorkspaceChanges}
durationSeconds={durationSeconds}
@ -409,6 +415,7 @@ function MessageContent_({
isLoading = false,
threadId,
artifactPaths,
knowledgeSources = [],
runId,
showWorkspaceChanges = false,
durationSeconds,
@ -419,6 +426,7 @@ function MessageContent_({
isLoading?: boolean;
threadId: string;
artifactPaths: readonly string[];
knowledgeSources?: readonly RetrievalSource[];
runId?: string;
showWorkspaceChanges?: boolean;
durationSeconds?: number;
@ -443,9 +451,9 @@ function MessageContent_({
maxWidth="90%"
/>
),
a: createMarkdownLinkComponent(threadId),
a: createMarkdownLinkComponent(threadId, knowledgeSources),
}),
[artifactPaths, threadId],
[artifactPaths, threadId, knowledgeSources],
);
const rawContent = extractContentFromMessage(message);
@ -492,8 +500,9 @@ function MessageContent_({
return rawContent ?? "";
}, [rawContent, isHuman]);
const citationSources = useMemo(
() => (isHuman ? [] : extractCitationSources(contentToDisplay)),
[contentToDisplay, isHuman],
() =>
isHuman ? [] : extractCitationSources(contentToDisplay, knowledgeSources),
[contentToDisplay, isHuman, knowledgeSources],
);
const filesList =

View File

@ -27,6 +27,10 @@ import {
import { Button } from "@/components/ui/button";
import { KnowledgeSourcesProvider } from "@/components/workspace/citations/knowledge-source";
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
import {
createKnowledgeSourceReader,
type RetrievalSource,
} from "@/core/citations/sources";
import { useI18n } from "@/core/i18n/hooks";
import { getArtifactArchiveCandidatesByGroupIndex } from "@/core/messages/artifact-archive";
import {
@ -88,7 +92,10 @@ import {
} from "@/core/skills/usage";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import { collectRenderedSubtasks } from "@/core/tasks/subtask-render";
import {
collectRenderedSubtasks,
findSubtaskResultMessage,
} from "@/core/tasks/subtask-render";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
@ -382,6 +389,33 @@ export function MessageList({
const [selectionToolbar, setSelectionToolbar] =
useState<SelectionToolbarState | null>(null);
const messages = thread.messages;
const previousKnowledgeSnapshotsRef = useRef(
new Map<string | Message, RetrievalSource[]>(),
);
const readMessageKnowledgeSources = useMemo(createKnowledgeSourceReader, []);
const knowledgeSourcesByMessage = useMemo(() => {
const byMessage = new Map<Message, RetrievalSource[]>();
const nextSnapshots = new Map<string | Message, RetrievalSource[]>();
const evidence = new Map<string, RetrievalSource>();
for (const message of messages) {
for (const source of readMessageKnowledgeSources(message))
evidence.set(source.id, source);
if (message.type === "ai") {
const key = message.id ?? message;
const next = [...evidence.values()];
const previous = previousKnowledgeSnapshotsRef.current.get(key);
const snapshot =
previous?.length === next.length &&
previous.every((source, index) => source.id === next[index]?.id)
? previous
: next;
byMessage.set(message, snapshot);
nextSnapshots.set(key, snapshot);
}
}
previousKnowledgeSnapshotsRef.current = nextSnapshots;
return byMessage;
}, [messages, readMessageKnowledgeSources]);
const groupedMessages = useStableMessageGroups(messages, thread.isLoading);
// Schedule event lines sit at the end of the turn they followed.
const placedTaskEvents = useMemo(
@ -1272,6 +1306,7 @@ export function MessageList({
const item = (
<MessageListItem
message={msg}
knowledgeSources={knowledgeSourcesByMessage.get(msg)}
isLoading={
thread.isLoading &&
groupIndex === groupedMessages.length - 1
@ -1548,6 +1583,7 @@ export function MessageList({
runId={(message as { run_id?: string }).run_id}
isLoading={groupIsLoading}
fallbackTask={fallbackTask}
resultMessage={findSubtaskResultMessage(group, taskId)}
/>,
);
}

View File

@ -1,3 +1,4 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
CheckCircleIcon,
ChevronUp,
@ -17,6 +18,7 @@ import {
import { Shimmer } from "@/components/ai-elements/shimmer";
import { Button } from "@/components/ui/button";
import { ShineBorder } from "@/components/ui/shine-border";
import { readKnowledgeSources } from "@/core/citations/sources";
import { useI18n } from "@/core/i18n/hooks";
import { hasToolCalls } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
@ -44,6 +46,7 @@ import { CitationLink } from "../citations/citation-link";
import { FlipDisplay } from "../flip-display";
import { MarkdownContent } from "./markdown-content";
import { createMarkdownLinkComponent } from "./markdown-link";
export function SubtaskCard({
className,
@ -52,6 +55,7 @@ export function SubtaskCard({
runId,
isLoading,
fallbackTask,
resultMessage,
}: {
className?: string;
taskId: string;
@ -59,8 +63,24 @@ export function SubtaskCard({
runId?: string;
isLoading: boolean;
fallbackTask: Subtask;
resultMessage?: Message;
}) {
const { t } = useI18n();
const reportSources = useMemo(
() =>
resultMessage?.type === "tool" &&
resultMessage.name === "task" &&
resultMessage.tool_call_id === taskId
? readKnowledgeSources([resultMessage])
: [],
[resultMessage, taskId],
);
const reportComponents = useMemo(
() => ({
a: createMarkdownLinkComponent(threadId, reportSources),
}),
[reportSources, threadId],
);
const [collapsed, setCollapsed] = useState(true);
const task = resolveRenderedSubtask(useSubtask(taskId), fallbackTask)!;
const { models, tokenUsageEnabled } = useModels();
@ -255,7 +275,14 @@ export function SubtaskCard({
<ChainOfThoughtStep
label={
task.result ? (
<MarkdownContent content={task.result} isLoading={false} />
<MarkdownContent
// Streamdown caches blocks by text. Re-resolve unchanged
// report text when its validated evidence arrives/changes.
key={reportSources.map((source) => source.id).join(",")}
content={task.result}
isLoading={false}
components={reportComponents}
/>
) : null
}
></ChainOfThoughtStep>

View File

@ -10,8 +10,118 @@ export type CitationSource = {
domain: string;
count: number;
occurrences: CitationOccurrence[];
knowledge?: RetrievalSource;
};
export type RetrievalSource = {
id: string;
provider: string;
title: string;
excerpt: string;
dataset_name?: string;
url?: string;
page?: number;
};
/** Sanitization prefixes fragment destinations to prevent DOM clobbering. */
export function retrievalSourceId(href: string | undefined): string | null {
return (
/^#(?:user-content-)?knowledge-source-([a-f0-9]{32})$/.exec(
href ?? "",
)?.[1] ?? null
);
}
/** SDK message objects are immutable; cache per object without retaining history. */
export function createKnowledgeSourceReader() {
const cache = new WeakMap<object, RetrievalSource[]>();
return (message: object): RetrievalSource[] => {
let sources = cache.get(message);
if (!sources) {
sources = readKnowledgeSources([message]);
cache.set(message, sources);
}
return sources;
};
}
const KNOWLEDGE_REFERENCE_RE =
/(?<!!)\[citation:\s*((?:\\.|[^\]\\])+?)\]\((#knowledge-source-[a-f0-9]{32})\)/gi;
function exceedsTextLimit(text: string, limit: number): boolean {
// JSON Schema/Pydantic count code points; avoid allocating for short text.
return text.length > limit && [...text].length > limit;
}
/** Sources are accepted only from evidence-bearing tool artifacts, including replay. */
export function readKnowledgeSources(
messages: readonly unknown[],
): RetrievalSource[] {
const sources = new Map<string, RetrievalSource>();
for (const value of messages) {
if (!value || typeof value !== "object") continue;
const message = value as Record<string, unknown>;
if (message.type !== "tool" || typeof message.content !== "string")
continue;
const artifact = message.artifact as Record<string, unknown> | undefined;
const rawSources = Array.isArray(artifact?.knowledge_sources)
? artifact.knowledge_sources
: artifact?.retrieval_sources;
if (artifact?.knowledge_sources_version !== 1 || !Array.isArray(rawSources))
continue;
for (const raw of rawSources.slice(0, 100) as unknown[]) {
if (!raw || typeof raw !== "object") continue;
const source = raw as Record<string, unknown>;
if (
typeof source.id !== "string" ||
!/^[a-f0-9]{32}$/.test(source.id) ||
typeof source.provider !== "string" ||
!source.provider.trim() ||
exceedsTextLimit(source.provider, 100) ||
typeof source.title !== "string" ||
!source.title.trim() ||
exceedsTextLimit(source.title, 500) ||
typeof source.excerpt !== "string" ||
!source.excerpt.trim() ||
exceedsTextLimit(source.excerpt, 100_000) ||
(source.dataset_name !== undefined &&
(typeof source.dataset_name !== "string" ||
exceedsTextLimit(source.dataset_name, 500)))
)
continue;
if (
source.page !== undefined &&
(!Number.isSafeInteger(source.page) || (source.page as number) <= 0)
)
continue;
if (
source.url !== undefined &&
(typeof source.url !== "string" || !normalizeUrl(source.url))
)
continue;
if (
!message.content.includes(source.excerpt) ||
!message.content.includes(`#knowledge-source-${source.id}`)
)
continue;
sources.set(source.id, {
id: source.id,
provider: source.provider,
title: source.title,
excerpt: source.excerpt,
...(typeof source.dataset_name === "string"
? { dataset_name: source.dataset_name }
: {}),
...(typeof source.url === "string"
? { url: normalizeUrl(source.url)! }
: {}),
...(typeof source.page === "number" ? { page: source.page } : {}),
});
}
}
return [...sources.values()];
}
// Uses a non-consuming lookbehind (?<!!) to skip image links (![citation:…])
// without eating the boundary char, so back-to-back citations both match. The
// URL sub-pattern consumes either non-paren chars or a balanced (…) group, so
@ -22,7 +132,10 @@ const CITATION_LINK_RE =
const GENERIC_CITATION_TITLES = new Set(["source", "来源"]);
export function extractCitationSources(markdown: string): CitationSource[] {
export function extractCitationSources(
markdown: string,
knowledgeSources: readonly RetrievalSource[] = [],
): CitationSource[] {
if (!markdown) {
return [];
}
@ -59,7 +172,35 @@ export function extractCitationSources(markdown: string): CitationSource[] {
});
}
return Array.from(sourcesByUrl.values());
const evidenceById = new Map(
knowledgeSources.map((source) => [source.id, source]),
);
for (const match of searchable.matchAll(KNOWLEDGE_REFERENCE_RE)) {
const reference = match[2]!;
const evidence = evidenceById.get(
reference.slice("#knowledge-source-".length),
);
if (!evidence) continue;
const index = match.index ?? 0;
const existing = sourcesByUrl.get(reference);
if (existing) {
existing.count += 1;
existing.occurrences.push({ index, title: evidence.title });
} else {
sourcesByUrl.set(reference, {
id: reference,
title: evidence.title,
url: reference,
domain: evidence.provider,
count: 1,
occurrences: [{ index, title: evidence.title }],
knowledge: evidence,
});
}
}
return Array.from(sourcesByUrl.values()).sort(
(a, b) => a.occurrences[0]!.index - b.occurrences[0]!.index,
);
}
export function formatCitationMarkdownReference(
@ -82,6 +223,7 @@ function normalizeUrl(value: string): string | null {
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
if (url.username || url.password) return null;
return url.href;
} catch {
return null;

View File

@ -1,3 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk";
import { extractTextFromMessage, type MessageGroup } from "../messages/utils";
import { resolveSubtaskDescription } from "./presentation";
@ -13,6 +15,32 @@ export interface RenderedSubtasks {
updates: Array<Partial<Subtask> & { id: string }>;
}
/** Only this group's matching result after the task invocation supplies evidence. */
export function findSubtaskResultMessage(
group: MessageGroup,
taskId: string,
): Message | undefined {
if (group.type !== "assistant:subagent") return undefined;
let invoked = false;
for (const message of group.messages) {
if (
message.type === "ai" &&
message.tool_calls?.some(
(call) => call.name === "task" && call.id === taskId,
)
)
invoked = true;
if (
invoked &&
message.type === "tool" &&
message.name === "task" &&
message.tool_call_id === taskId
)
return message;
}
return undefined;
}
export function resolveRenderedSubtask(
liveTask: Subtask | undefined,
fallbackTask: Subtask | undefined,

View File

@ -31,6 +31,31 @@ const sources: CitationSource[] = [
];
describe("CitationSourcesPanel", () => {
it("renders knowledge evidence without inventing external navigation", () => {
const id = "a".repeat(32);
const evidenceSource: CitationSource = {
id: `#knowledge-source-${id}`,
title: "Guide",
url: `#knowledge-source-${id}`,
domain: "lightrag",
count: 1,
occurrences: [{ index: 0, title: "Guide" }],
knowledge: {
id,
provider: "lightrag",
title: "Guide",
excerpt: "Actual retrieved text.",
page: 3,
},
};
const html = renderPanel([evidenceSource], "en-US");
expect(html).toContain("Actual retrieved text.");
expect(html).toContain("lightrag");
expect(html).not.toContain("href=");
expect(html).not.toContain("p. 1");
expect(html).toContain("Pages 3");
expect(renderPanel([evidenceSource], "zh-CN")).toContain("第 3 页");
});
it("renders nothing when there are no sources", () => {
expect(renderPanel([], "en-US")).toBe("");
});

View File

@ -1,8 +1,111 @@
import type { Message } from "@langchain/langgraph-sdk";
import { describe, expect, it } from "@rstest/core";
import { createElement, type ImgHTMLAttributes } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { KnowledgeSourcesProvider } from "@/components/workspace/citations/knowledge-source";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { createMarkdownLinkComponent } from "@/components/workspace/messages/markdown-link";
import { readKnowledgeSources } from "@/core/citations/sources";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
describe("shared knowledge citations through the Markdown pipeline", () => {
const id = "a".repeat(32);
const reference = `#knowledge-source-${id}`;
const source = {
id,
provider: "lightrag",
title: "Retrieved guide",
excerpt: "Real evidence",
};
const sources = readKnowledgeSources([
{
type: "tool",
content: `${reference}\nReal evidence`,
artifact: { knowledge_sources_version: 1, knowledge_sources: [source] },
},
]);
it("keeps native RAGFlow snapshots readable through the same pipeline", () => {
const nativeId = `${id}-1`;
const message = {
type: "tool",
name: "knowledge_search",
content: "retrieved",
tool_call_id: "call",
artifact: {
knowledge_sources: {
version: 1,
sources: [
{
id: nativeId,
provider: "ragflow",
document_name: "Manual.pdf",
dataset_name: "Engineering",
text: "Limit: 42.",
truncated: false,
pages: [3],
},
],
},
},
} as unknown as Message;
const html = renderToStaticMarkup(
createElement(
I18nContext.Provider,
{
value: { locale: "en-US", setLocale: () => undefined, t: enUS },
},
// Required children prop for the provider's createElement-based SSR.
// eslint-disable-next-line react/no-children-prop
createElement(KnowledgeSourcesProvider, {
messages: [message],
children: createElement(MarkdownContent, {
content: `[citation:Manual](#knowledge-${nativeId})`,
isLoading: false,
}),
}),
),
);
expect(html).toContain("<button");
expect(html).toContain("Manual.pdf");
expect(html).not.toContain("unavailable");
});
it.each([reference, `#user-content-knowledge-source-${id}`])(
"resolves evidence after sanitization for %s",
(href) => {
const html = renderMarkdown(`[citation:Label](${href})`, false, {
a: createMarkdownLinkComponent(undefined, sources),
});
expect(html).toContain("<button");
expect(html).toContain(source.title);
expect(html).not.toContain("unavailable");
expect(html).not.toContain("href=");
},
);
it.each([reference, "#knowledge-source-invalid"])(
"keeps missing or malformed evidence inert for %s",
(href) => {
const html = renderMarkdown(`[citation:**Label**](${href})`, false);
expect(html).toContain("Label");
expect(html).not.toContain("<button");
expect(html).not.toContain("href=");
expect(html).not.toContain("unavailable");
},
);
it("retains sanitizer protection on unrelated anchors and unsafe URLs", () => {
const html = renderMarkdown(
"[Section](#section) [citation:Unsafe](javascript:alert%281%29)",
false,
);
expect(html).toContain('href="#user-content-section"');
expect(html).not.toContain('href="javascript:');
});
});
function renderMarkdown(
content: string,
@ -10,7 +113,13 @@ function renderMarkdown(
components?: Parameters<typeof MarkdownContent>[0]["components"],
) {
return renderToStaticMarkup(
createElement(MarkdownContent, { content, isLoading, components }),
createElement(
I18nContext.Provider,
{
value: { locale: "en-US", setLocale: () => undefined, t: enUS },
},
createElement(MarkdownContent, { content, isLoading, components }),
),
);
}

View File

@ -19,7 +19,10 @@ import {
useSubtaskContext,
useUpdateSubtask,
} from "@/core/tasks/context";
import { collectRenderedSubtasks } from "@/core/tasks/subtask-render";
import {
collectRenderedSubtasks,
findSubtaskResultMessage,
} from "@/core/tasks/subtask-render";
import type { Subtask } from "@/core/tasks/types";
rs.mock("@/core/tasks/api", () => ({ fetchSubtaskSteps: rs.fn() }));
@ -117,6 +120,110 @@ function setup(history = false) {
}
describe("SubtaskCard message synchronization", () => {
it.each([false, true])(
"renders forwarded evidence on completion/replay (replay=%s)",
async (replay) => {
const id = "a".repeat(32);
const report = `Answer [citation:Label](#knowledge-source-${id})`;
const invocation = {
type: "ai",
content: "",
tool_calls: [
{
id: "task-1",
name: "task",
args: { description: "Research", prompt: "Find sources" },
},
],
} as unknown as Message;
const result = {
type: "tool",
name: "task",
tool_call_id: "task-1",
content: `Task Succeeded\n${report}\nReal retrieved evidence`,
artifact: {
knowledge_sources_version: 1,
knowledge_sources: [
{
id,
provider: "lightrag",
title: "Retrieved guide",
excerpt: "Real retrieved evidence",
},
],
},
} as unknown as Message;
const client = new QueryClient();
client.setQueryData(["models"], {
models: [],
token_usage: { enabled: false },
});
const draw = (messages: Message[]) => {
const group = {
id: "group-1",
type: "assistant:subagent" as const,
messages,
};
const completed = messages.length > 1;
return (
<QueryClientProvider client={client}>
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<SubtasksProvider>
<SubtaskCard
taskId="task-1"
isLoading={!completed}
fallbackTask={{
id: "task-1",
subagent_type: "researcher",
description: "Research",
prompt: "Find sources",
status: completed ? "completed" : "in_progress",
result: completed ? report : undefined,
}}
resultMessage={findSubtaskResultMessage(group, "task-1")}
/>
</SubtasksProvider>
</I18nContext.Provider>
</QueryClientProvider>
);
};
const messages = replay
? (JSON.parse(JSON.stringify([invocation, result])) as Message[])
: [invocation];
const view = render(draw(messages));
fireEvent.click(screen.getByRole("button", { name: /Research/ }));
if (!replay) view.rerender(draw([invocation, result]));
const citation = await screen.findByRole("button", {
name: "Retrieved guide",
});
fireEvent.focus(citation);
expect(await screen.findByText("Real retrieved evidence")).toBeTruthy();
expect(view.container.textContent).not.toContain("unavailable");
// A different task or non-evidence content must not authorize the report.
view.rerender(
draw([
invocation,
{ ...result, tool_call_id: "other-task" } as Message,
]),
);
expect(
screen.queryByRole("button", { name: "Retrieved guide" }),
).toBeNull();
view.rerender(
draw([invocation, { ...result, content: "No evidence" } as Message]),
);
expect(
screen.queryByRole("button", { name: "Retrieved guide" }),
).toBeNull();
// The final report text is unchanged when its evidence becomes available.
view.rerender(draw([invocation, result]));
expect(
await screen.findByRole("button", { name: "Retrieved guide" }),
).toBeTruthy();
},
);
it("merges a delayed history backfill with live steps after argument synchronization", async () => {
let finishBackfill!: (steps: NonNullable<Subtask["steps"]>) => void;
rs.mocked(fetchSubtaskSteps).mockImplementation(

View File

@ -0,0 +1,173 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "@rstest/core";
import {
readKnowledgeSources,
createKnowledgeSourceReader,
retrievalSourceId,
extractCitationSources,
} from "@/core/citations/sources";
const id = "a".repeat(32);
const ref = `#knowledge-source-${id}`;
const evidence = {
id,
provider: "lightrag",
title: "docs/guide.md",
excerpt: "Real retrieved evidence.",
};
const tool = {
type: "tool",
content: `[citation:Guide](${ref})\nReal retrieved evidence.`,
artifact: { knowledge_sources_version: 1, knowledge_sources: [evidence] },
};
describe("knowledge source evidence", () => {
it("caches immutable messages but parses replacement objects", () => {
const read = createKnowledgeSourceReader();
const first = read(tool);
expect(read(tool)).toBe(first);
expect(read({ ...tool, content: "removed" })).toEqual([]);
expect(read(tool)).toBe(first);
});
it("pins field limits and reference IDs to the cross-component schema", () => {
const schema = JSON.parse(
readFileSync(
resolve(
__dirname,
"../../../../../contracts/knowledge_sources.v1.json",
),
"utf-8",
),
) as {
properties: { knowledge_sources_version: { const: number } };
$defs: {
source: {
properties: Record<string, { maxLength?: number; pattern?: string }>;
};
};
};
expect(tool.artifact.knowledge_sources_version).toBe(
schema.properties.knowledge_sources_version.const,
);
const fields = schema.$defs.source.properties;
expect(new RegExp(fields.id!.pattern!).test(id)).toBe(true);
expect(retrievalSourceId(ref)).toBe(id);
expect(retrievalSourceId(`#user-content-knowledge-source-${id}`)).toBe(id);
for (const invalid of [
id + "a",
id.slice(1),
id.toUpperCase(),
`${id}-1`,
]) {
expect(retrievalSourceId(`#knowledge-source-${invalid}`)).toBeNull();
expect(
readKnowledgeSources([
{
...tool,
artifact: {
...tool.artifact,
knowledge_sources: [{ ...evidence, id: invalid }],
},
},
]),
).toEqual([]);
}
for (const field of ["provider", "title", "excerpt", "dataset_name"]) {
const limit = fields[field]!.maxLength!;
for (const length of [limit, limit + 1]) {
const candidate = { ...evidence, [field]: "x".repeat(length) };
const message = {
...tool,
content: `${ref}\n${candidate.excerpt}`,
artifact: { ...tool.artifact, knowledge_sources: [candidate] },
};
expect(readKnowledgeSources([message]).length).toBe(
length === limit ? 1 : 0,
);
}
}
const unicodeSource = { ...evidence, title: "😀".repeat(500) };
expect(
readKnowledgeSources([
{
...tool,
artifact: {
...tool.artifact,
knowledge_sources: [unicodeSource],
},
},
]),
).toEqual([unicodeSource]);
});
it("reads unified evidence alongside native RAGFlow delegation snapshots", () => {
const mixed = {
...tool,
artifact: {
knowledge_sources_version: 1,
knowledge_sources: { version: 1, sources: [] },
retrieval_sources: [evidence],
},
};
expect(readKnowledgeSources([mixed])).toEqual([evidence]);
expect(
readKnowledgeSources([{ ...mixed, content: "unrelated report" }]),
).toEqual([]);
});
it("resolves escaped document names without replacing genuine evidence titles", () => {
const sources = readKnowledgeSources([tool]);
expect(
extractCitationSources(`[citation:Guide \\[draft\\]](${ref})`, sources)[0]
?.knowledge,
).toEqual(evidence);
});
it("resolves citations from live and replayed tool artifacts", () => {
const sources = readKnowledgeSources([tool, tool]);
expect(sources).toHaveLength(1);
const citations = extractCitationSources(
`Answer [citation:invented title](${ref})`,
sources,
);
expect(citations).toHaveLength(1);
expect(citations[0]?.title).toBe("docs/guide.md");
expect(citations[0]?.knowledge?.excerpt).toBe("Real retrieved evidence.");
expect(citations[0]?.knowledge?.page).toBeUndefined();
expect(citations[0]?.knowledge?.url).toBeUndefined();
});
it("ignores absent evidence, assistant claims, code citations and unsupported versions", () => {
expect(readKnowledgeSources([{ ...tool, type: "ai" }])).toEqual([]);
expect(readKnowledgeSources([{ ...tool, content: "no evidence" }])).toEqual(
[],
);
expect(
readKnowledgeSources([
{
...tool,
artifact: { ...tool.artifact, knowledge_sources_version: 2 },
},
]),
).toEqual([]);
expect(extractCitationSources(`[citation:Guide](${ref})`)).toEqual([]);
expect(
extractCitationSources(
`\`[citation:Guide](${ref})\``,
readKnowledgeSources([tool]),
),
).toEqual([]);
});
it("rejects invalid locators instead of creating navigation links", () => {
const forged = {
...tool,
artifact: {
...tool.artifact,
knowledge_sources: [{ ...evidence, url: "javascript:alert(1)" }],
},
};
expect(readKnowledgeSources([forged])).toEqual([]);
});
});

View File

@ -4,6 +4,7 @@ import { describe, expect, it } from "@rstest/core";
import type { MessageGroup } from "@/core/messages/utils";
import {
collectRenderedSubtasks,
findSubtaskResultMessage,
resolveRenderedSubtask,
} from "@/core/tasks/subtask-render";
import type { Subtask } from "@/core/tasks/types";
@ -28,6 +29,44 @@ function subagentGroup(messages: Message[]) {
}
describe("collectRenderedSubtasks", () => {
it("limits report evidence to the matching result after its invocation in this group", () => {
const invocation = {
type: "ai",
content: "",
tool_calls: [
{
id: "task-1",
name: "task",
args: {},
},
],
} as unknown as Message;
const result = {
type: "tool",
name: "task",
tool_call_id: "task-1",
content: "done",
} as Message;
expect(
findSubtaskResultMessage(subagentGroup([invocation, result]), "task-1"),
).toBe(result);
for (const messages of [
[result, invocation],
[invocation],
[invocation, { ...result, tool_call_id: "task-2" } as Message],
[invocation, { ...result, name: "other_tool" } as Message],
[invocation, { ...result, type: "ai" } as Message],
])
expect(
findSubtaskResultMessage(subagentGroup(messages), "task-1"),
).toBeUndefined();
expect(
findSubtaskResultMessage(
{ id: "other", type: "assistant", messages: [invocation, result] },
"task-1",
),
).toBeUndefined();
});
it("derives an in-progress task while the subagent turn is still streaming", () => {
const groups: MessageGroup[] = [
{