diff --git a/README.md b/README.md index 0eef072f4..9be678232 100644 --- a/README.md +++ b/README.md @@ -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`, diff --git a/backend/app/gateway/knowledge_scope_admission.py b/backend/app/gateway/knowledge_scope_admission.py index c275e4296..d031a6308 100644 --- a/backend/app/gateway/knowledge_scope_admission.py +++ b/backend/app/gateway/knowledge_scope_admission.py @@ -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: diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 10dd06103..a677b34d6 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -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 diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index 16583f796..67dd0ca0f 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -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", diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index 639a4ecc0..e507c4fdd 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -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 diff --git a/backend/packages/harness/deerflow/config/agents_config.py b/backend/packages/harness/deerflow/config/agents_config.py index 2b06ca308..9c6999104 100644 --- a/backend/packages/harness/deerflow/config/agents_config.py +++ b/backend/packages/harness/deerflow/config/agents_config.py @@ -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 diff --git a/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py b/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py index 715712dce..151081807 100644 --- a/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/setup_agent_tool.py @@ -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: diff --git a/backend/tests/test_agent_knowledge_scope.py b/backend/tests/test_agent_knowledge_scope.py new file mode 100644 index 000000000..1a0c7f41e --- /dev/null +++ b/backend/tests/test_agent_knowledge_scope.py @@ -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 diff --git a/backend/tests/test_agent_store_sql.py b/backend/tests/test_agent_store_sql.py index e18fa1f0d..309f73dff 100644 --- a/backend/tests/test_agent_store_sql.py +++ b/backend/tests/test_agent_store_sql.py @@ -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" diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 3207634c4..60a30da54 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -4671,3 +4671,161 @@ class TestForgedFrameworkInjectionMarkers: processed = InputSanitizationMiddleware()._try_process(_Request(graph_input["messages"])) assert "" 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() diff --git a/docs/pr-evidence/agent-knowledge-desktop.png b/docs/pr-evidence/agent-knowledge-desktop.png new file mode 100644 index 000000000..380708720 Binary files /dev/null and b/docs/pr-evidence/agent-knowledge-desktop.png differ diff --git a/docs/pr-evidence/agent-knowledge-mobile.png b/docs/pr-evidence/agent-knowledge-mobile.png new file mode 100644 index 000000000..ed39faf7f Binary files /dev/null and b/docs/pr-evidence/agent-knowledge-mobile.png differ diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 6d39b8d2d..676f0165b 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -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. diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index f55dea355..1b389c33c 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -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(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 = { diff --git a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx index 060caa489..7e156feff 100644 --- a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx +++ b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx @@ -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(() => + 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) && ( +
+

+ {t.agents.settingsKnowledge} +

+

+ {t.agents.settingsKnowledgeHint} +

+ { + setKnowledgeSelection(selection); + setKnowledgeChanged(true); + }} + /> + {knowledgeSelection.mode === "selected" && ( +

+ {knowledgeSelection.datasets + .map((dataset) => dataset.name) + .join(", ")} +

+ )} + {knowledgeSelection.mode !== "all" && ( + + )} +
+ )}