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
This commit is contained in:
Wenchao An 2026-09-20 16:31:35 +08:00 committed by GitHub
parent 2b6254f76d
commit 1f437f86c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 702 additions and 30 deletions

View File

@ -1166,8 +1166,8 @@ is off. Set `knowledge_base.scope_selection_enabled: true` in `config.yaml`
while using the built-in RAGFlow `knowledge_search` provider to allow all
permitted datasets, selected datasets/files, or no retrieval for a turn. The
same config flag controls both chat types; when disabled, neither composer
shows the selector or submits a scope. The choice resets to all when the page
is refreshed or another conversation is opened; each sent human message keeps
shows the selector or submits a scope. The choice resets to the custom agent’s saved default (or all when unbound)
when the page is refreshed or another conversation is opened; each sent human message keeps
an immutable scope snapshot for replay and history. The Gateway validates
every snapshot, intersects it with the operator's dataset allowlist, propagates
the execution-only scope to native and durable subagents, and removes it from
@ -1175,6 +1175,22 @@ model inputs and external traces. Client-supplied internal runtime controls
and credentials are also stripped from run context before execution or
checkpoint persistence. Idempotent retries accept both canonical snapshots and
legacy raw run inputs, preserving retry compatibility across upgrades.
Custom agents can save a **Default knowledge** selection from **Agents → Agent
settings**, including optional file filters or retrieval off. Selecting all
knowledge bases clears the binding. The same `knowledge_scope` field is available
on agent create/update APIs and in the agent's stored configuration; omitted
updates preserve it and `null` clears it. It is a default, not an authorization
boundary: an explicit per-message selection overrides it, and the operator's
allowlist still applies at retrieval time. Gateway runs without a message scope
(including scheduled and channel turns) use and snapshot the saved default even
when the composer selector is hidden. Regenerate/resume retain the original
turn's scope, including legacy unscoped turns, rather than picking up later
configuration changes. Unknown or unavailable selections never broaden retrieval.
Idempotent retries keep the original run and scope when a default is added,
changed, or cleared, including unscoped runs accepted before this feature.
This default applies to Gateway-hosted custom-agent turns; direct harness/client
integrations continue to supply their own execution scope.
The `knowledge_base` block is provider-neutral and only controls whether the
knowledge capability and selector are enabled. RAGFlow connection, dataset
allowlist, and retrieval parameters (`base_url`, `api_key`, `datasets`,

View File

@ -63,6 +63,7 @@ def admit_message_knowledge_scope(
) -> 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.
"""
@ -102,7 +103,11 @@ def admit_message_knowledge_scope(
target_index = scoped_indexes[0]
raw_scope = messages[target_index].additional_kwargs[KNOWLEDGE_SCOPE_KEY]
else:
return None
# 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:

View File

@ -21,6 +21,7 @@ from deerflow.config.agents_config import (
)
from deerflow.config.app_config import get_app_config
from deerflow.config.paths import get_paths
from deerflow.knowledge_scope import KnowledgeScope, canonicalize_knowledge_scope
from deerflow.persistence.agents import AgentDeleteOutcome, AgentExistsError, get_agent_store
from deerflow.runtime.user_context import get_effective_user_id
@ -46,6 +47,7 @@ class AgentResponse(BaseModel):
model: str | None = Field(default=None, description="Optional model override")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)")
knowledge_scope: KnowledgeScope | None = Field(default=None, description="Default RAGFlow scope for new turns; null inherits operator scope")
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all, []=none)")
allowed_subagents: list[str] | None = Field(default=None, description="Subagent allowlist (None=all enabled, []=none)")
model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)")
@ -69,6 +71,7 @@ class AgentCreateRequest(BaseModel):
model: str | None = Field(default=None, description="Optional model override")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)")
knowledge_scope: KnowledgeScope | None = Field(default=None, description="Default RAGFlow scope for new turns; null inherits operator scope")
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all enabled, []=none)")
allowed_subagents: list[str] | None = Field(default=None, description="Subagent allowlist (None=all enabled, []=none)")
model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)")
@ -85,6 +88,7 @@ class AgentUpdateRequest(BaseModel):
model: str | None = Field(default=None, description="Updated model override")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)")
knowledge_scope: KnowledgeScope | None = Field(default=None, description="Default RAGFlow scope for new turns; null inherits operator scope")
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None=all, []=none)")
allowed_subagents: list[str] | None = Field(default=None, description="Updated subagent allowlist (None=all, []=none)")
model_settings: AgentModelSettings | None = Field(default=None, description="Updated per-agent sampling overrides")
@ -202,6 +206,7 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False
tool_groups=agent_cfg.tool_groups,
skills=agent_cfg.skills,
mcp_plugins=agent_cfg.mcp_plugins,
knowledge_scope=agent_cfg.knowledge_scope,
allowed_subagents=agent_cfg.allowed_subagents,
model_settings=agent_cfg.model_settings,
thinking_enabled=agent_cfg.thinking_enabled,
@ -343,6 +348,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
config_data["description"] = request.description
if request.tool_groups is not None:
config_data["tool_groups"] = request.tool_groups
if request.knowledge_scope is not None:
config_data["knowledge_scope"] = canonicalize_knowledge_scope(request.knowledge_scope)
if request.mcp_plugins is not None:
config_data["mcp_plugins"] = request.mcp_plugins
if request.skills is not None:
@ -428,7 +435,7 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
# Use model_fields_set to distinguish "field omitted" from "explicitly set to null".
# This is critical for skills where None means "inherit all" (not "don't change").
fields_set = request.model_fields_set
config_changed = bool(fields_set & ({"display_name", "description", "tool_groups", "skills", "mcp_plugins", "allowed_subagents"} | set(_MODEL_BEHAVIOR_FIELDS)))
config_changed = bool(fields_set & ({"display_name", "description", "tool_groups", "skills", "mcp_plugins", "knowledge_scope", "allowed_subagents"} | set(_MODEL_BEHAVIOR_FIELDS)))
updated: dict | None = None
if config_changed:
@ -443,6 +450,9 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
if new_tool_groups is not None:
updated["tool_groups"] = new_tool_groups
if "knowledge_scope" in fields_set:
updated["knowledge_scope"] = canonicalize_knowledge_scope(request.knowledge_scope) if request.knowledge_scope is not None else None
if "mcp_plugins" in fields_set:
updated["mcp_plugins"] = request.mcp_plugins

View File

@ -8,6 +8,7 @@ frames, and consuming stream bridge events. Router modules
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
@ -1744,11 +1745,19 @@ async def start_run(
config = build_run_config(thread_id, body.config, run_metadata, assistant_id=body.assistant_id)
await apply_checkpoint_to_run_config(config, body=body, thread_id=thread_id, request=request)
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
# The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None), internal=is_internal_caller)
if not is_internal_caller:
# ``body.config`` is free-form and copied verbatim by
# ``build_run_config``; scrub internal-only keys smuggled there.
strip_internal_context_keys(config)
replay_kind = run_metadata.get("replay_kind")
target_message_id = run_metadata.get("regenerate_from_message_id")
scope_graph_input = graph_input if isinstance(graph_input, dict) else {"messages": []}
scope_messages = scope_graph_input.get("messages")
candidate_has_scope = isinstance(scope_messages, list) and any(isinstance(message, BaseMessage) and KNOWLEDGE_SCOPE_KEY in message.additional_kwargs for message in scope_messages)
current_human_message = _current_human_message(graph_input)
current_message_has_scope = current_human_message is not None and KNOWLEDGE_SCOPE_KEY in current_human_message.additional_kwargs
replay_requires_scope_recovery = isinstance(graph_input, Command) or (isinstance(target_message_id, str) and bool(target_message_id) and (replay_kind != "edit" or not current_message_has_scope))
@ -1768,17 +1777,32 @@ async def start_run(
if is_scope_recovery
else None
)
# Match lead-agent assembly: runtime context overrides configurable.
# Older API/channel callers may name an agent through context while
# retaining lead_agent as their routing assistant ID.
scope_runtime_config = dict(config.get("configurable") or {})
if isinstance(config.get("context"), dict):
scope_runtime_config.update(config["context"])
scope_assistant_id = scope_runtime_config.get("agent_name") or _DEFAULT_ASSISTANT_ID
# Bootstrap assembly intentionally does not load an agent config: the
# new agent may not exist yet and setup_agent creates its definition.
agent_config = (
await _load_scope_agent_config(
assistant_id=body.assistant_id,
assistant_id=scope_assistant_id,
user_id=owner_user_id or (str(user.id) if user is not None else None),
)
if candidate_has_scope or recovery_scope is not None
if not scope_runtime_config.get("is_bootstrap")
else None
)
# Keep the pre-default identity even when the agent is initially
# unbound: adding a default must not reject an already-accepted retry.
# The durable input still exposes the original accepted scope.
request_input = _canonical_run_record_input(body.input, graph_input) if idempotency_key else None
knowledge_default_request_hash = hashlib.sha256(json.dumps(request_input, sort_keys=True, ensure_ascii=False).encode()).hexdigest() if idempotency_key else None
accepts_knowledge_default = not is_scope_recovery and not current_message_has_scope and knowledge_default_request_hash is not None
admitted_knowledge_scope = admit_message_knowledge_scope(
scope_graph_input,
assistant_id=body.assistant_id,
assistant_id=scope_assistant_id,
app_config=run_ctx.app_config or get_app_config(),
agent_config=agent_config,
recovery_scope=recovery_scope,
@ -1792,15 +1816,6 @@ async def start_run(
)
run_record_input = _canonical_run_record_input(body.input, graph_input)
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
# The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None), internal=is_internal_caller)
if not is_internal_caller:
# ``body.config`` is free-form and copied verbatim by
# ``build_run_config``; scrub internal-only keys smuggled there.
strip_internal_context_keys(config)
internal_owner_user = await resolve_trusted_internal_owner_for_attribution(request, owner_user_id)
inject_authenticated_user_context(
config,
@ -1960,6 +1975,7 @@ async def start_run(
# config built above keeps the secrets for the actual run.
kwargs={
"input": run_record_input,
**({"knowledge_default_request_hash": knowledge_default_request_hash} if accepts_knowledge_default else {}),
"config": redact_config_secrets(body.config),
**({"conversation_references": conversation_references} if conversation_references else {}),
},
@ -1977,7 +1993,14 @@ async def start_run(
# record. Accept the raw request as well for records written
# by older Gateway versions, while comparing canonical
# retries to the same representation as the stored record.
if (stored_input != body.input and stored_input != run_record_input) or record.assistant_id != body.assistant_id or stored.get("conversation_references", []) != conversation_references:
matches_default_request = knowledge_default_request_hash is not None and stored.get("knowledge_default_request_hash") == knowledge_default_request_hash
# Pre-feature unscoped records may already contain normalized
# messages, but have no digest. Compare them before injecting
# today's default; explicit scopes and recovery do not use
# this compatibility path.
matches_legacy_default_request = accepts_knowledge_default and "knowledge_default_request_hash" not in stored and stored_input == request_input
matches_input = matches_default_request or matches_legacy_default_request or stored_input == body.input or stored_input == run_record_input
if not matches_input or record.assistant_id != body.assistant_id or stored.get("conversation_references", []) != conversation_references:
raise HTTPException(
status_code=409,
detail="Idempotency-Key already used with a different request",

View File

@ -108,6 +108,21 @@ Gateway API endpoints and `DeerFlowClient` methods can modify MCP servers and sk
Values beginning with `$` are resolved from the environment when the file is loaded, and an unset variable becomes `""`. Runtime writers (MCP router, skill toggle, `DeerFlowClient`) therefore read the raw file with `read_raw_extensions_config`, merge into it (`set_raw_skill_enabled` for skill state), check the candidate with `validate_raw_extensions_config`, and write that raw dict. They never serialize an `ExtensionsConfig` model back to disk: its resolved values would persist secrets in plaintext and erase the references. When the file does not exist yet, the Gateway skill toggle seeds only the cached skill states. `tests/test_extensions_config_raw_writes.py` and the placeholder tests in `tests/test_client.py` pin this.
`AgentConfig.knowledge_scope` uses the versioned `KnowledgeScope` contract as a
Gateway new-turn default. The API preserves omitted updates and clears explicit
null; file and SQL stores persist it in the existing config document. Keep it
outside `MANAGED_AGENT_CONFIG_FIELDS` so harness self-updates preserve it;
`setup_agent` also preserves the binding when re-bootstrapping. Admission uses
explicit message scope before this default and snapshots it onto the current
human message. Resolve the executing agent with runtime context over configurable,
including legacy callers using `context.agent_name`; bootstrap skips defaults.
Recovery (including a legacy null scope) never reapplies defaults.
The operator allowlist is still checked by retrieval; this is not authorization.
Unscoped new runs persist pre-default request digests even for unbound agents.
Digest-free legacy retries compare pre-default canonical input; explicit scopes
and recovery are excluded. Retries preserve the original run across binding edits.
The file-backed singleton entrypoints additionally merge administrator-managed shared
models from the encrypted runtime-home catalog. YAML entries win name conflicts;
managed changes create new effective snapshots and do not alter an active runtime

View File

@ -16,6 +16,7 @@ from typing import Annotated, Literal
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, field_validator, model_validator
from deerflow.config.paths import get_paths
from deerflow.knowledge_scope import KnowledgeScope
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@ -220,6 +221,9 @@ class AgentConfig(BaseModel):
# Stable MCP installation IDs. None inherits all; [] selects none.
# This is tool selection, not a replacement for host authorization.
mcp_plugins: list[str] | None = None
# Default for new Gateway turns; explicit message scope overrides it.
# Kept outside managed fields so harness self-updates preserve the binding.
knowledge_scope: KnowledgeScope | None = None
# Controls which deployment-level subagents this custom agent may invoke:
# None = all currently enabled definitions, [] = none, list = allowlist.
# The default Lead Agent has no AgentConfig and therefore keeps access to

View File

@ -6,6 +6,7 @@ from langgraph.types import Command
from deerflow.config.agents_config import SOUL_FILENAME, validate_agent_name
from deerflow.config.paths import get_paths
from deerflow.knowledge_scope import canonicalize_knowledge_scope
from deerflow.persistence.agents import get_agent_store
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.tools.types import Runtime
@ -64,6 +65,8 @@ def setup_agent(
except FileNotFoundError:
pass # First bootstrap has no user-authored label to preserve.
else:
if existing.knowledge_scope is not None:
config_data["knowledge_scope"] = canonicalize_knowledge_scope(existing.knowledge_scope)
if existing.display_name is not None:
config_data["display_name"] = existing.display_name
if description:

View File

@ -0,0 +1,113 @@
"""Persisted custom-agent knowledge defaults, using the existing scope contract."""
from types import SimpleNamespace
import pytest
from langchain_core.messages import HumanMessage
from pydantic import ValidationError
from app.gateway.knowledge_scope_admission import admit_message_knowledge_scope
from app.gateway.routers.agents import AgentCreateRequest, AgentUpdateRequest, create_agent_endpoint, get_agent, update_agent
from deerflow.config.agents_api_config import load_agents_api_config_from_dict
from deerflow.config.agents_config import AgentConfig, preserve_non_managed_fields
from deerflow.knowledge_scope import canonicalize_knowledge_scope
SCOPE = {
"version": 1,
"mode": "selected",
"dataset_ids": ["policies"],
"document_filters": [{"dataset_id": "policies", "document_ids": ["leave"]}],
"display": {"datasets": [{"id": "policies", "name": "Policies", "documents": [{"id": "leave", "name": "Leave.pdf"}]}]},
}
def test_agent_config_validates_and_preserves_knowledge_default():
config = AgentConfig(name="researcher", knowledge_scope=SCOPE)
assert canonicalize_knowledge_scope(config.knowledge_scope) == SCOPE
assert preserve_non_managed_fields(config)["knowledge_scope"]["dataset_ids"] == ["policies"]
with pytest.raises(ValidationError):
AgentConfig(name="researcher", knowledge_scope={"version": 1, "mode": "selected", "dataset_ids": []})
@pytest.fixture
def agent_home(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
load_agents_api_config_from_dict({"enabled": True})
yield
load_agents_api_config_from_dict({})
@pytest.mark.asyncio
async def test_api_round_trip_preserve_replace_and_clear(agent_home):
created = await create_agent_endpoint(AgentCreateRequest(name="researcher", knowledge_scope=SCOPE))
assert canonicalize_knowledge_scope(created.knowledge_scope) == SCOPE
updated = await update_agent("researcher", AgentUpdateRequest(description="Updated"))
assert canonicalize_knowledge_scope(updated.knowledge_scope) == SCOPE
await update_agent("researcher", AgentUpdateRequest(knowledge_scope={"version": 1, "mode": "disabled"}))
assert canonicalize_knowledge_scope((await get_agent("researcher")).knowledge_scope) == {"version": 1, "mode": "disabled"}
await update_agent("researcher", AgentUpdateRequest(knowledge_scope=None))
assert (await get_agent("researcher")).knowledge_scope is None
@pytest.mark.parametrize("override", [None, {"version": 1, "mode": "all"}, {"version": 1, "mode": "disabled"}])
def test_default_is_snapshotted_but_explicit_turn_selection_wins(override):
message = HumanMessage(content="Policy?", additional_kwargs={} if override is None else {"knowledge_scope": override})
graph_input = {"messages": [message]}
agent = SimpleNamespace(tool_groups=None, knowledge_scope=SCOPE)
app = SimpleNamespace(knowledge_base=SimpleNamespace(enabled=True), get_tool_config=lambda _: SimpleNamespace(use="deerflow.community.ragflow.tools:knowledge_search_tool"))
result = admit_message_knowledge_scope(graph_input, assistant_id="researcher", app_config=app, agent_config=agent)
expected = override or SCOPE
assert graph_input["messages"][0].additional_kwargs["knowledge_scope"] == expected
assert result == {key: value for key, value in expected.items() if key != "display"}
assert message.additional_kwargs == ({} if override is None else {"knowledge_scope": override})
@pytest.mark.parametrize("recovered", [None, {"version": 1, "mode": "disabled"}])
def test_recovery_does_not_pick_up_changed_agent_default(recovered):
graph_input = {"messages": [HumanMessage(content="Policy?")]}
app = SimpleNamespace(knowledge_base=SimpleNamespace(enabled=True), get_tool_config=lambda _: SimpleNamespace(use="deerflow.community.ragflow.tools:knowledge_search_tool"))
result = admit_message_knowledge_scope(graph_input, assistant_id="researcher", app_config=app, agent_config=SimpleNamespace(tool_groups=None, knowledge_scope=SCOPE), recovery=True, recovery_scope=recovered)
assert result == recovered
assert graph_input["messages"][0].additional_kwargs == ({} if recovered is None else {"knowledge_scope": recovered})
@pytest.mark.asyncio
async def test_harness_setup_and_self_update_preserve_binding(agent_home):
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.tools.builtins.setup_agent_tool import setup_agent
from deerflow.tools.builtins.update_agent_tool import update_agent as update_tool
await create_agent_endpoint(AgentCreateRequest(name="researcher", knowledge_scope=SCOPE))
runtime = SimpleNamespace(context={"agent_name": "researcher", "user_id": get_effective_user_id()}, config={"configurable": {}}, tool_call_id="setup-1")
result = setup_agent.func(soul="Updated soul", description="Rebootstrapped", runtime=runtime)
assert "successfully" in result.update["messages"][0].content
assert canonicalize_knowledge_scope((await get_agent("researcher")).knowledge_scope) == SCOPE
assert (await get_agent("researcher")).description == "Rebootstrapped"
update_tool.func(description="Updated by agent", runtime=runtime)
assert canonicalize_knowledge_scope((await get_agent("researcher")).knowledge_scope) == SCOPE
assert (await get_agent("researcher")).description == "Updated by agent"
@pytest.mark.asyncio
async def test_same_name_file_agents_keep_separate_bindings(agent_home):
from app.gateway.services import _load_scope_agent_config
from deerflow.persistence.agents import get_agent_store
store = get_agent_store()
store.create("researcher", {"knowledge_scope": SCOPE}, "Soul", user_id="alice")
store.create("researcher", {"knowledge_scope": {"version": 1, "mode": "disabled"}}, "Soul", user_id="bob")
alice = await _load_scope_agent_config(assistant_id="researcher", user_id="alice")
bob = await _load_scope_agent_config(assistant_id="researcher", user_id="bob")
assert canonicalize_knowledge_scope(alice.knowledge_scope) == SCOPE
assert bob.knowledge_scope.mode == "disabled"
@pytest.mark.parametrize("tool_groups", [[], ["web"]])
def test_default_never_reenables_agent_knowledge_tools(tool_groups):
from fastapi import HTTPException
app = SimpleNamespace(knowledge_base=SimpleNamespace(enabled=True), get_tool_config=lambda _: SimpleNamespace(use="deerflow.community.ragflow.tools:knowledge_search_tool"))
with pytest.raises(HTTPException) as error:
admit_message_knowledge_scope({"messages": [HumanMessage(content="Policy?")]}, assistant_id="researcher", app_config=app, agent_config=SimpleNamespace(tool_groups=tool_groups, knowledge_scope=SCOPE))
assert error.value.status_code == 422

View File

@ -272,3 +272,13 @@ def test_delete_removes_memory_dir_when_row_exists(store, tmp_path, monkeypatch)
assert store.delete("real", user_id="u1") == "deleted"
assert not mem_dir.exists()
def test_knowledge_defaults_round_trip_and_stay_owner_scoped(store):
from deerflow.knowledge_scope import canonicalize_knowledge_scope
scope = {"version": 1, "mode": "selected", "dataset_ids": ["policies"]}
store.create("researcher", {"knowledge_scope": scope}, "Soul", user_id="u1")
store.create("researcher", {"knowledge_scope": {"version": 1, "mode": "disabled"}}, "Soul", user_id="u2")
assert canonicalize_knowledge_scope(store.get("researcher", user_id="u1").knowledge_scope) == scope
assert store.get("researcher", user_id="u2").knowledge_scope.mode == "disabled"

View File

@ -4671,3 +4671,161 @@ class TestForgedFrameworkInjectionMarkers:
processed = InputSanitizationMiddleware()._try_process(_Request(graph_input["messages"]))
assert "<system-reminder>" not in str(processed.messages[0].content), forged
@pytest.mark.parametrize(
("assistant_id", "run_options", "effective_agent"),
[
("researcher", {}, "researcher"),
("researcher", {"config": {"context": {"agent_name": "policy-expert"}}}, "policy-expert"),
("lead_agent", {"context": {"agent_name": "policy-expert"}}, "policy-expert"),
],
)
@pytest.mark.asyncio
async def test_agent_knowledge_default_reaches_run_and_idempotent_retry_keeps_original(_stub_app_config, assistant_id, run_options, effective_agent):
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run
from deerflow.config.agents_config import AgentConfig
from deerflow.runtime import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
set_app_config(
AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"knowledge_base": {"enabled": True},
"tools": [{"name": "knowledge_search", "group": "knowledge", "use": "deerflow.community.ragflow.tools:knowledge_search_tool"}],
}
)
)
scope = {"version": 1, "mode": "selected", "dataset_ids": ["policies"]}
agent = AgentConfig(name=effective_agent, knowledge_scope=scope)
load = AsyncMock(return_value=agent)
request = _make_start_run_request(RunManager(store=MemoryRunStore()))
body = RunCreateRequest(assistant_id=assistant_id, input={"messages": [{"type": "human", "content": "Policy?"}]}, **run_options)
captured = {}
async def fake_run_agent(*_args, **kwargs):
captured.update(kwargs)
with patch("app.gateway.services.resolve_agent_factory", return_value=object()), patch("app.gateway.services.run_agent", side_effect=fake_run_agent), patch("app.gateway.services._load_scope_agent_config", new=load):
record = await start_run(body, "thread-default-scope", request, idempotency_key="request-1")
await record.task
assert captured["knowledge_scope"] == scope
assert captured["graph_input"]["messages"][0].additional_kwargs["knowledge_scope"] == scope
assert record.kwargs["input"]["messages"][0]["additional_kwargs"]["knowledge_scope"] == scope
# Editing/clearing the binding must neither change nor reject the same
# already-accepted request; different prompts still conflict.
for edited_scope in (None, {"version": 1, "mode": "selected", "dataset_ids": ["other"]}, {"version": 1, "mode": "disabled"}):
load.return_value = AgentConfig(name=effective_agent, knowledge_scope=edited_scope)
reused = await start_run(body, "thread-default-scope", request, idempotency_key="request-1")
assert reused.run_id == record.run_id
assert reused.kwargs["input"]["messages"][0]["additional_kwargs"]["knowledge_scope"] == scope
with pytest.raises(HTTPException) as exc:
await start_run(body.model_copy(update={"input": {"messages": [{"type": "human", "content": "Different?"}]}}), "thread-default-scope", request, idempotency_key="request-1")
assert exc.value.status_code == 409
assert load.await_args.kwargs["assistant_id"] == effective_agent
@pytest.mark.parametrize("run_store_backend", ["memory", "sql"])
@pytest.mark.parametrize("record_format", ["current", "legacy-canonical", "legacy-raw"])
@pytest.mark.asyncio
async def test_initially_unbound_agent_retry_preserves_original_run(_stub_app_config, tmp_path, run_store_backend, record_format):
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from langchain_core.messages import HumanMessage
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run
from deerflow.config.agents_config import AgentConfig
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.persistence.run import RunRepository
from deerflow.runtime import RunManager, RunStatus
from deerflow.runtime.runs.store.memory import MemoryRunStore
set_app_config(
AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"knowledge_base": {"enabled": True},
"tools": [{"name": "knowledge_search", "group": "knowledge", "use": "deerflow.community.ragflow.tools:knowledge_search_tool"}],
}
)
)
body = RunCreateRequest(assistant_id="researcher", input={"messages": [{"type": "human", "content": "Policy?"}]})
load = AsyncMock(return_value=AgentConfig(name="researcher"))
worker = AsyncMock()
try:
if run_store_backend == "sql":
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'runs.db'}", sqlite_dir=str(tmp_path))
store = RunRepository(get_session_factory())
else:
store = MemoryRunStore()
owner = RunManager(store=store, worker_id="owner")
request = _make_start_run_request(owner)
with patch("app.gateway.services.resolve_agent_factory", return_value=object()), patch("app.gateway.services.run_agent", new=worker), patch("app.gateway.services._load_scope_agent_config", new=load):
if record_format == "current":
original = await start_run(body, "thread-unbound", request, idempotency_key="request-1")
await original.task
else:
# Seed the actual pre-feature wire formats, without a digest.
stored_input = body.input if record_format == "legacy-raw" else {"messages": [HumanMessage(content="Policy?").model_dump(mode="json")]}
original = await owner.create_or_reject("thread-unbound", "researcher", kwargs={"input": stored_input}, idempotency_key="request-1")
await owner.set_status(original.run_id, RunStatus.success)
snapshot = json.loads(json.dumps(original.kwargs["input"]))
# A new Gateway worker must recover identity from durable storage.
peer = _make_start_run_request(RunManager(store=store, worker_id="peer"), thread_store=request.app.state.thread_store)
for edited_scope in (None, {"version": 1, "mode": "selected", "dataset_ids": ["policies"]}, {"version": 1, "mode": "disabled"}, None):
load.return_value = AgentConfig(name="researcher", knowledge_scope=edited_scope)
reused = await start_run(body, "thread-unbound", peer, idempotency_key="request-1")
assert reused.run_id == original.run_id
assert reused.kwargs["input"] == snapshot
assert reused.task is None
for changed_message in (
{"type": "human", "content": "Different?"},
{"type": "human", "content": "Policy?", "additional_kwargs": {"knowledge_scope": {"version": 1, "mode": "disabled"}}},
):
with pytest.raises(HTTPException) as exc:
await start_run(body.model_copy(update={"input": {"messages": [changed_message]}}), "thread-unbound", peer, idempotency_key="request-1")
assert exc.value.status_code == 409
assert worker.await_count == (1 if record_format == "current" else 0)
stored = await store.get(original.run_id)
assert stored["kwargs"]["input"] == snapshot
if record_format == "current":
assert stored["kwargs"]["knowledge_default_request_hash"]
else:
assert "knowledge_default_request_hash" not in stored["kwargs"]
finally:
if run_store_backend == "sql":
await close_engine()
@pytest.mark.parametrize(
"bootstrap_kwargs",
[
{"context": {"is_bootstrap": True}},
{"config": {"configurable": {"is_bootstrap": True}}},
{"config": {"context": {"is_bootstrap": True}}},
{"config": {"configurable": {"is_bootstrap": False}, "context": {"is_bootstrap": True}}},
],
)
@pytest.mark.asyncio
async def test_knowledge_default_lookup_does_not_break_new_agent_bootstrap(_stub_app_config, bootstrap_kwargs):
from unittest.mock import AsyncMock, patch
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run
from deerflow.runtime import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
request = _make_start_run_request(RunManager(store=MemoryRunStore()))
load = AsyncMock(side_effect=FileNotFoundError("not created yet"))
with patch("app.gateway.services.resolve_agent_factory", return_value=object()), patch("app.gateway.services.run_agent", new=AsyncMock()), patch("app.gateway.services._load_scope_agent_config", new=load):
record = await start_run(RunCreateRequest(assistant_id="new-researcher", input={"messages": [{"type": "human", "content": "Create this agent"}]}, **bootstrap_kwargs), "thread-bootstrap-default", request)
await record.task
load.assert_not_awaited()

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -203,3 +203,11 @@ outside code/images. Dialog excerpts render as plain text, never HTML or nested
Markdown. Source records retain retrieval-time evidence, not live documents.
Resolve knowledge destinations before testing the label in message and artifact
link renderers: Sources lists use ordinary titles without a `citation:` prefix.
Custom-agent settings persist `knowledge_scope` as an optional retrieval default.
Use `knowledgeScopeToSelection` to restore all execution IDs, falling back to IDs
where the bounded display snapshot lacks names. Only dirty knowledge settings
are sent on save. Custom-agent chat derives its initial selection from the saved
binding; explicit page-local overrides survive new-thread route replacement and
reset on conversation changes. Gateway supplies defaults for clients without a
selector; frontend visibility must not become a runtime enforcement boundary.

View File

@ -45,7 +45,7 @@ import {
} from "@/core/features";
import { useI18n } from "@/core/i18n/hooks";
import {
ALL_KNOWLEDGE_SCOPE,
knowledgeScopeToSelection,
buildKnowledgeScopeSnapshot,
KNOWLEDGE_SCOPE_KEY,
type KnowledgeScopeSelection,
@ -114,7 +114,7 @@ export default function AgentChatPage() {
const agentKnowledgeEnabled =
agent !== null &&
(agent.tool_groups == null || agent.tool_groups.includes("knowledge"));
const [knowledgeScope, setKnowledgeScope] =
const [knowledgeScopeOverride, setKnowledgeScope] =
useState<KnowledgeScopeSelection | null>(null);
const previousConversationRef = useRef({
agentName: agent_name,
@ -122,12 +122,12 @@ export default function AgentChatPage() {
isNewThread,
});
useEffect(() => {
setKnowledgeScope((current) => {
if (!selectorVisible) return null;
return current ?? ALL_KNOWLEDGE_SCOPE;
});
}, [selectorVisible]);
const knowledgeScope = useMemo(
() =>
knowledgeScopeOverride ??
knowledgeScopeToSelection(agent?.knowledge_scope),
[knowledgeScopeOverride, agent?.knowledge_scope],
);
useEffect(() => {
const previous = previousConversationRef.current;
@ -137,7 +137,7 @@ export default function AgentChatPage() {
previous.isNewThread &&
!isNewThread;
if (!isNewThreadRouteReplacement) {
setKnowledgeScope(selectorVisible ? ALL_KNOWLEDGE_SCOPE : null);
setKnowledgeScope(null);
}
}
previousConversationRef.current = {

View File

@ -23,10 +23,18 @@ import {
} from "@/components/ui/select";
import { useUpdateAgent } from "@/core/agents";
import type { Agent, ReasoningEffort } from "@/core/agents";
import { useKnowledgeBaseEnabled } from "@/core/features";
import { useI18n } from "@/core/i18n/hooks";
import {
buildKnowledgeScopeSnapshot,
knowledgeScopeToSelection,
type KnowledgeScopeSelection,
} from "@/core/knowledge";
import { useModels } from "@/core/models/hooks";
import { useSubagents } from "@/core/subagents";
import { KnowledgeScopeSelector } from "../knowledge-scope-selector";
import { AgentCapabilitySelection } from "./agent-capability-selection";
import {
allowedSubagentsToSelection,
@ -71,6 +79,12 @@ export function AgentSettingsDialog({
}: AgentSettingsDialogProps) {
const { t } = useI18n();
const { models } = useModels();
const { scopeSelectionEnabled } = useKnowledgeBaseEnabled();
const [knowledgeSelection, setKnowledgeSelection] =
useState<KnowledgeScopeSelection>(() =>
knowledgeScopeToSelection(agent.knowledge_scope),
);
const [knowledgeChanged, setKnowledgeChanged] = useState(false);
const { subagents } = useSubagents();
const subagentDescriptionId = useId();
const updateAgent = useUpdateAgent();
@ -161,6 +175,12 @@ export function AgentSettingsDialog({
name: agent.name,
request: {
display_name: displayName.trim() || null,
...(knowledgeChanged && {
knowledge_scope:
knowledgeSelection.mode === "all"
? null
: buildKnowledgeScopeSnapshot(knowledgeSelection),
}),
...(!sameSelection(plugins, initialSelections.plugins) && {
mcp_plugins: plugins,
}),
@ -202,6 +222,56 @@ export function AgentSettingsDialog({
onPluginsChange={setPlugins}
onSkillsChange={setSkills}
/>
{(scopeSelectionEnabled || agent.knowledge_scope) && (
<div className="space-y-1.5 rounded-md border p-3">
<p className="text-sm font-medium">
{t.agents.settingsKnowledge}
</p>
<p className="text-muted-foreground text-xs">
{t.agents.settingsKnowledgeHint}
</p>
<KnowledgeScopeSelector
agentName={agent.name}
selection={knowledgeSelection}
description={t.agents.settingsKnowledgeHint}
showLabel
disabled={updateAgent.isPending}
unavailableReason={
!scopeSelectionEnabled
? t.knowledge.scope.loadFailed
: agent.tool_groups != null &&
!agent.tool_groups.includes("knowledge")
? t.knowledge.scope.agentUnavailable
: undefined
}
onChange={(selection) => {
setKnowledgeSelection(selection);
setKnowledgeChanged(true);
}}
/>
{knowledgeSelection.mode === "selected" && (
<p className="text-muted-foreground text-xs [overflow-wrap:anywhere]">
{knowledgeSelection.datasets
.map((dataset) => dataset.name)
.join(", ")}
</p>
)}
{knowledgeSelection.mode !== "all" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={updateAgent.isPending}
onClick={() => {
setKnowledgeSelection({ mode: "all" });
setKnowledgeChanged(true);
}}
>
{t.agents.settingsKnowledgeReset}
</Button>
)}
</div>
)}
<div className="space-y-1.5">
<label htmlFor="agent-display-name" className="text-sm font-medium">
{t.agents.settingsDisplayName}

View File

@ -241,12 +241,16 @@ export function KnowledgeScopeSelector({
selection,
disabled = false,
unavailableReason,
description,
showLabel = false,
onChange,
}: {
agentName: string;
selection: KnowledgeScopeSelection;
disabled?: boolean;
unavailableReason?: string;
description?: string;
showLabel?: boolean;
onChange: (selection: KnowledgeScopeSelection) => void;
}) {
const { t } = useI18n();
@ -340,12 +344,13 @@ export function KnowledgeScopeSelector({
)}
data-testid="knowledge-scope-trigger"
disabled={disabled || Boolean(unavailableReason)}
size="icon-sm"
size={showLabel ? "sm" : "icon-sm"}
type="button"
variant="ghost"
onClick={() => setOpen(true)}
>
<DatabaseIcon className="size-4" />
{showLabel && label}
</Button>
);
@ -355,7 +360,9 @@ export function KnowledgeScopeSelector({
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t.knowledge.scope.title}</DialogTitle>
<DialogDescription>{t.knowledge.scope.description}</DialogDescription>
<DialogDescription>
{description ?? t.knowledge.scope.description}
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-4 text-sm">
{(["all", "selected", "disabled"] as const).map((mode) => (

View File

@ -1,3 +1,5 @@
import type { KnowledgeScopeSnapshot } from "@/core/knowledge";
export interface AgentModelSettings {
temperature?: number | null;
max_tokens?: number | null;
@ -13,6 +15,7 @@ export interface Agent {
tool_groups: string[] | null;
skills: string[] | null;
mcp_plugins?: string[] | null;
knowledge_scope?: KnowledgeScopeSnapshot | null;
allowed_subagents?: string[] | null;
model_settings?: AgentModelSettings | null;
thinking_enabled?: boolean | null;
@ -28,6 +31,7 @@ export interface CreateAgentRequest {
tool_groups?: string[] | null;
skills?: string[] | null;
mcp_plugins?: string[] | null;
knowledge_scope?: KnowledgeScopeSnapshot | null;
allowed_subagents?: string[] | null;
model_settings?: AgentModelSettings | null;
thinking_enabled?: boolean | null;
@ -42,6 +46,7 @@ export interface UpdateAgentRequest {
tool_groups?: string[] | null;
skills?: string[] | null;
mcp_plugins?: string[] | null;
knowledge_scope?: KnowledgeScopeSnapshot | null;
allowed_subagents?: string[] | null;
model_settings?: AgentModelSettings | null;
thinking_enabled?: boolean | null;

View File

@ -918,6 +918,10 @@ export const enUS: Translations = {
"Supports Unicode. Leave blank to use the agent identifier",
settingsDescription:
"Choose a display name and model defaults for this agent. Model changes take effect on the next message.",
settingsKnowledge: "Default knowledge",
settingsKnowledgeHint:
"New conversations use this knowledge scope. Individual messages can override it. Selecting all knowledge bases removes the binding.",
settingsKnowledgeReset: "Use all knowledge bases",
settingsModel: "Default model",
settingsModelDefault: "Use global default",
settingsTemperature: "Temperature",

View File

@ -754,6 +754,9 @@ export interface Translations {
settingsDisplayNameTooLong: string;
settingsDisplayNameHint: string;
settingsDescription: string;
settingsKnowledge: string;
settingsKnowledgeHint: string;
settingsKnowledgeReset: string;
settingsModel: string;
settingsModelDefault: string;
settingsTemperature: string;

View File

@ -857,6 +857,10 @@ export const zhCN: Translations = {
settingsDisplayNameHint: "支持中文等 Unicode 字符,留空时使用智能体标识",
settingsDescription:
"为该智能体设置显示名称和默认模型,模型修改在下一条消息生效。",
settingsKnowledge: "默认知识库",
settingsKnowledgeHint:
"新对话默认使用此知识库范围,单条消息可以临时覆盖。选择全部知识库可解除绑定。",
settingsKnowledgeReset: "使用全部知识库",
settingsModel: "默认模型",
settingsModelDefault: "使用全局默认",
settingsTemperature: "温度",

View File

@ -314,3 +314,36 @@ export function readKnowledgeScopeSnapshot(
}
return snapshot;
}
/** Restore the complete execution selection; omitted display names fall back to IDs. */
export function knowledgeScopeToSelection(
snapshot: KnowledgeScopeSnapshot | null | undefined,
): KnowledgeScopeSelection {
if (snapshot?.mode !== "selected") {
return { mode: snapshot?.mode ?? "all" };
}
return {
mode: "selected",
datasets: (snapshot.dataset_ids ?? []).map((id) => {
const display = snapshot.display?.datasets.find((item) => item.id === id);
const filter = snapshot.document_filters?.find(
(item) => item.dataset_id === id,
);
return {
id,
name: display?.name ?? id,
documents: filter
? {
mode: "selected",
items: filter.document_ids.map((documentId) => ({
id: documentId,
name:
display?.documents?.find((item) => item.id === documentId)
?.name ?? documentId,
})),
}
: { mode: "all" },
};
}),
};
}

View File

@ -0,0 +1,138 @@
import { expect, test } from "@playwright/test";
import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api";
for (const width of [1280, 390]) {
test(`agent knowledge binding persists and seeds chat at ${width}px`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width, height: 800 });
const agent: Record<string, unknown> & { name: string } = {
name: "researcher",
description: "Policy specialist",
tool_groups: ["knowledge"],
};
mockLangGraphAPI(page, {
agents: [agent],
features: { knowledgeScopeSelectionEnabled: true },
});
const saves: Record<string, unknown>[] = [];
await page.route("**/api/agents/researcher", (route) => {
if (route.request().method() === "PUT") {
const body = route.request().postDataJSON() as Record<string, unknown>;
saves.push(body);
Object.assign(agent, body);
}
return route.fulfill({ json: agent });
});
await page.route("**/api/knowledge/retrieval-catalog/datasets?*", (route) =>
route.fulfill({
json: {
items: [
{ id: "policies", name: "Company policies", selectable: true },
],
page: 1,
page_size: 100,
total: 1,
},
}),
);
await page.goto("/workspace/agents");
await page.getByTitle("Agent settings", { exact: true }).click();
await page.getByTestId("knowledge-scope-trigger").click();
await page.getByLabel("Selected knowledge bases").check();
await page.getByLabel("Company policies", { exact: true }).check();
await page.getByRole("button", { name: "Apply", exact: true }).click();
await expect(
page
.getByRole("dialog", { name: "Agent settings", exact: true })
.getByText("Company policies", { exact: true }),
).toBeVisible();
const dialog = page.getByRole("dialog", {
name: "Agent settings",
exact: true,
});
await expect(dialog).toHaveCSS("opacity", "1");
await page.screenshot({
path: testInfo.outputPath(`agent-knowledge-${width}.png`),
});
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => saves.length).toBe(1);
expect(saves[0]).toMatchObject({
knowledge_scope: {
version: 1,
mode: "selected",
dataset_ids: ["policies"],
},
});
await page.getByTitle("Agent settings", { exact: true }).click();
await expect(
page
.getByRole("dialog", { name: "Agent settings", exact: true })
.getByText("Company policies", { exact: true }),
).toBeVisible();
// Saving another field must not overwrite a concurrently edited binding.
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => saves.length).toBe(2);
expect(saves[1]).not.toHaveProperty("knowledge_scope");
let submitted: Record<string, unknown> | undefined;
await page.route("**/api/langgraph/threads/*/runs/stream", (route) => {
submitted = route.request().postDataJSON() as Record<string, unknown>;
return handleRunStream(route);
});
await page.goto("/workspace/agents/researcher/chats/new");
await expect(page.getByTestId("knowledge-scope-trigger")).toHaveAttribute(
"aria-label",
"Knowledge · 1 base",
);
await page
.getByPlaceholder(/how can i assist you/i)
.fill("Find the leave policy");
await page.getByRole("button", { name: "Submit" }).click();
await expect.poll(() => submitted).toBeDefined();
expect(submitted).toMatchObject({
assistant_id: "researcher",
input: {
messages: [
{
additional_kwargs: {
knowledge_scope: { mode: "selected", dataset_ids: ["policies"] },
},
},
],
},
});
// Refresh rehydrates the saved agent default; an explicit per-turn opt-out wins.
await page.goto("/workspace/agents/researcher/chats/new");
await expect(page.getByTestId("knowledge-scope-trigger")).toHaveAttribute(
"aria-label",
"Knowledge · 1 base",
);
await page.getByTestId("knowledge-scope-trigger").click();
await page.getByLabel("Off", { exact: true }).check();
await page.getByRole("button", { name: "Apply", exact: true }).click();
submitted = undefined;
await page
.getByPlaceholder(/how can i assist you/i)
.fill("Do not search knowledge");
await page.getByRole("button", { name: "Submit" }).click();
await expect.poll(() => submitted).toBeDefined();
expect(submitted).toMatchObject({
input: {
messages: [
{ additional_kwargs: { knowledge_scope: { mode: "disabled" } } },
],
},
});
await page.goto("/workspace/agents");
await page.getByTitle("Agent settings", { exact: true }).click();
await page
.getByRole("button", { name: "Use all knowledge bases", exact: true })
.click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => saves.length).toBe(3);
expect(saves[2]).toHaveProperty("knowledge_scope", null);
});
}

View File

@ -19,6 +19,9 @@ const { mutateAsync } = rs.hoisted(() => ({
rs.mock("@/core/agents", () => ({
useUpdateAgent: () => ({ mutateAsync, isPending: false }),
}));
rs.mock("@/core/features", () => ({
useKnowledgeBaseEnabled: () => ({ scopeSelectionEnabled: false }),
}));
rs.mock("@/core/models/hooks", () => ({ useModels: () => ({ models: [] }) }));
rs.mock("@/core/subagents", () => ({
useSubagents: () => ({ subagents: [] }),

View File

@ -2,6 +2,7 @@ import { describe, expect, test } from "@rstest/core";
import {
buildKnowledgeScopeSnapshot,
knowledgeScopeToSelection,
cloneKnowledgeScopeSelection,
readKnowledgeScopeSnapshot,
type KnowledgeScopeSelection,
@ -157,3 +158,42 @@ describe("knowledge scope snapshots", () => {
).toBeNull();
});
});
describe("agent knowledge defaults", () => {
test("restores every execution ID even when display labels were capped", () => {
const selection = knowledgeScopeToSelection({
version: 1,
mode: "selected",
dataset_ids: ["a", "b"],
document_filters: [{ dataset_id: "b", document_ids: ["one", "two"] }],
display: { datasets: [{ id: "a", name: "Policies" }] },
});
expect(selection).toEqual({
mode: "selected",
datasets: [
{ id: "a", name: "Policies", documents: { mode: "all" } },
{
id: "b",
name: "b",
documents: {
mode: "selected",
items: [
{ id: "one", name: "one" },
{ id: "two", name: "two" },
],
},
},
],
});
expect(buildKnowledgeScopeSnapshot(selection)).toMatchObject({
dataset_ids: ["a", "b"],
document_filters: [{ dataset_id: "b", document_ids: ["one", "two"] }],
});
});
test("missing defaults inherit all while disabled stays disabled", () => {
expect(knowledgeScopeToSelection(null)).toEqual({ mode: "all" });
expect(knowledgeScopeToSelection({ version: 1, mode: "disabled" })).toEqual(
{ mode: "disabled" },
);
});
});