deer-flow/backend/app/gateway/knowledge_scope_admission.py
Wenchao An 1f437f86c6
feat(agents): persist default knowledge scopes for custom agents (#5579)
* feat(agents): persist default knowledge scopes for custom agents

* style: format agent knowledge guidance

* fix(i18n): clarify default knowledge reset hint

* fix(knowledge): preserve retries for initially unbound agents
2026-09-20 16:31:35 +08:00

136 lines
5.0 KiB
Python

"""Gateway trust-boundary checks for per-message knowledge scope."""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from langchain_core.messages import BaseMessage, HumanMessage
from pydantic import ValidationError
from deerflow.knowledge_scope import (
KNOWLEDGE_SCOPE_KEY,
canonicalize_knowledge_scope,
execution_scope,
)
RAGFLOW_KNOWLEDGE_SEARCH_PROVIDER = "deerflow.community.ragflow.tools:knowledge_search_tool"
def assistant_supports_knowledge_scope(
*,
assistant_id: str | None,
app_config: Any,
agent_config: Any | None,
) -> bool:
"""Return whether this exact assistant/provider pairing is supported."""
if not assistant_id:
return False
knowledge_base = getattr(app_config, "knowledge_base", None)
if not getattr(knowledge_base, "enabled", False):
return False
get_tool_config = getattr(app_config, "get_tool_config", None)
tool = get_tool_config("knowledge_search") if callable(get_tool_config) else None
if getattr(tool, "use", None) != RAGFLOW_KNOWLEDGE_SEARCH_PROVIDER:
return False
# The main assistant has no custom-agent config row. Its knowledge tool is
# controlled solely by the app-level provider configuration.
if assistant_id == "lead_agent":
return True
if agent_config is None:
return False
tool_groups = getattr(agent_config, "tool_groups", None)
return tool_groups is None or "knowledge" in tool_groups
def _replace_scope(message: HumanMessage, scope: dict[str, Any] | None) -> HumanMessage:
additional_kwargs = dict(message.additional_kwargs or {})
if scope is None:
additional_kwargs.pop(KNOWLEDGE_SCOPE_KEY, None)
else:
additional_kwargs[KNOWLEDGE_SCOPE_KEY] = scope
return message.model_copy(update={"additional_kwargs": additional_kwargs})
def admit_message_knowledge_scope(
graph_input: dict[str, Any],
*,
assistant_id: str | None,
app_config: Any,
agent_config: Any | None,
recovery_scope: object | None = None,
recovery: bool = False,
) -> dict[str, Any] | None:
"""Canonicalize the sole eligible HumanMessage and return execution scope.
New turns without an explicit scope inherit the custom agent default.
During regenerate/resume recovery, the server-resolved source snapshot is
authoritative and replaces any client-supplied value.
"""
messages = graph_input.get("messages")
if not isinstance(messages, list):
return None
scoped_indexes: list[int] = []
for index, message in enumerate(messages):
if not isinstance(message, BaseMessage):
continue
additional_kwargs = message.additional_kwargs
if KNOWLEDGE_SCOPE_KEY not in additional_kwargs:
continue
if not isinstance(message, HumanMessage):
raise HTTPException(
status_code=422,
detail="knowledge_scope is allowed only on the current HumanMessage",
)
scoped_indexes.append(index)
if len(scoped_indexes) > 1:
raise HTTPException(
status_code=422,
detail="knowledge_scope is allowed on only one new HumanMessage",
)
target_indexes = [index for index, message in enumerate(messages) if isinstance(message, HumanMessage)]
target_index = target_indexes[-1] if target_indexes else None
if scoped_indexes and scoped_indexes[0] != target_index:
raise HTTPException(
status_code=422,
detail="knowledge_scope is allowed only on the current HumanMessage",
)
if recovery:
raw_scope = recovery_scope
elif scoped_indexes:
target_index = scoped_indexes[0]
raw_scope = messages[target_index].additional_kwargs[KNOWLEDGE_SCOPE_KEY]
else:
# Resolve only new turns here. Recovery (including a legacy None
# snapshot) must never pick up a subsequently edited agent default.
raw_scope = getattr(agent_config, "knowledge_scope", None)
if raw_scope is None:
return None
canonical: dict[str, Any] | None = None
if raw_scope is not None:
if not assistant_supports_knowledge_scope(
assistant_id=assistant_id,
app_config=app_config,
agent_config=agent_config,
):
raise HTTPException(
status_code=422,
detail="knowledge_scope is not supported by this assistant or knowledge provider",
)
try:
canonical = canonicalize_knowledge_scope(raw_scope)
except ValidationError as exc:
raise HTTPException(
status_code=422,
detail=f"Invalid knowledge_scope: {exc.errors()[0]['msg']}",
) from exc
elif scoped_indexes and not recovery:
raise HTTPException(status_code=422, detail="knowledge_scope must be an object")
if target_index is not None:
messages[target_index] = _replace_scope(messages[target_index], canonical)
return execution_scope(canonical) if canonical is not None else None