Xuehao Xu 201b9840cd
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
2026-10-14 14:16:08 +08:00

156 lines
7.4 KiB
Python

"""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)