mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
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:
parent
2b6254f76d
commit
1f437f86c6
20
README.md
20
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
|
while using the built-in RAGFlow `knowledge_search` provider to allow all
|
||||||
permitted datasets, selected datasets/files, or no retrieval for a turn. The
|
permitted datasets, selected datasets/files, or no retrieval for a turn. The
|
||||||
same config flag controls both chat types; when disabled, neither composer
|
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
|
shows the selector or submits a scope. The choice resets to the custom agent’s saved default (or all when unbound)
|
||||||
is refreshed or another conversation is opened; each sent human message keeps
|
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
|
an immutable scope snapshot for replay and history. The Gateway validates
|
||||||
every snapshot, intersects it with the operator's dataset allowlist, propagates
|
every snapshot, intersects it with the operator's dataset allowlist, propagates
|
||||||
the execution-only scope to native and durable subagents, and removes it from
|
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
|
and credentials are also stripped from run context before execution or
|
||||||
checkpoint persistence. Idempotent retries accept both canonical snapshots and
|
checkpoint persistence. Idempotent retries accept both canonical snapshots and
|
||||||
legacy raw run inputs, preserving retry compatibility across upgrades.
|
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
|
The `knowledge_base` block is provider-neutral and only controls whether the
|
||||||
knowledge capability and selector are enabled. RAGFlow connection, dataset
|
knowledge capability and selector are enabled. RAGFlow connection, dataset
|
||||||
allowlist, and retrieval parameters (`base_url`, `api_key`, `datasets`,
|
allowlist, and retrieval parameters (`base_url`, `api_key`, `datasets`,
|
||||||
|
|||||||
@ -63,6 +63,7 @@ def admit_message_knowledge_scope(
|
|||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Canonicalize the sole eligible HumanMessage and return execution scope.
|
"""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
|
During regenerate/resume recovery, the server-resolved source snapshot is
|
||||||
authoritative and replaces any client-supplied value.
|
authoritative and replaces any client-supplied value.
|
||||||
"""
|
"""
|
||||||
@ -102,7 +103,11 @@ def admit_message_knowledge_scope(
|
|||||||
target_index = scoped_indexes[0]
|
target_index = scoped_indexes[0]
|
||||||
raw_scope = messages[target_index].additional_kwargs[KNOWLEDGE_SCOPE_KEY]
|
raw_scope = messages[target_index].additional_kwargs[KNOWLEDGE_SCOPE_KEY]
|
||||||
else:
|
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
|
canonical: dict[str, Any] | None = None
|
||||||
if raw_scope is not None:
|
if raw_scope is not None:
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from deerflow.config.agents_config import (
|
|||||||
)
|
)
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import get_app_config
|
||||||
from deerflow.config.paths import get_paths
|
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.persistence.agents import AgentDeleteOutcome, AgentExistsError, get_agent_store
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
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")
|
model: str | None = Field(default=None, description="Optional model override")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
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)")
|
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)")
|
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)")
|
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)")
|
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")
|
model: str | None = Field(default=None, description="Optional model override")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
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)")
|
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)")
|
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)")
|
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)")
|
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")
|
model: str | None = Field(default=None, description="Updated model override")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
|
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)")
|
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)")
|
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)")
|
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")
|
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,
|
tool_groups=agent_cfg.tool_groups,
|
||||||
skills=agent_cfg.skills,
|
skills=agent_cfg.skills,
|
||||||
mcp_plugins=agent_cfg.mcp_plugins,
|
mcp_plugins=agent_cfg.mcp_plugins,
|
||||||
|
knowledge_scope=agent_cfg.knowledge_scope,
|
||||||
allowed_subagents=agent_cfg.allowed_subagents,
|
allowed_subagents=agent_cfg.allowed_subagents,
|
||||||
model_settings=agent_cfg.model_settings,
|
model_settings=agent_cfg.model_settings,
|
||||||
thinking_enabled=agent_cfg.thinking_enabled,
|
thinking_enabled=agent_cfg.thinking_enabled,
|
||||||
@ -343,6 +348,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
|||||||
config_data["description"] = request.description
|
config_data["description"] = request.description
|
||||||
if request.tool_groups is not None:
|
if request.tool_groups is not None:
|
||||||
config_data["tool_groups"] = request.tool_groups
|
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:
|
if request.mcp_plugins is not None:
|
||||||
config_data["mcp_plugins"] = request.mcp_plugins
|
config_data["mcp_plugins"] = request.mcp_plugins
|
||||||
if request.skills is not None:
|
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".
|
# 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").
|
# This is critical for skills where None means "inherit all" (not "don't change").
|
||||||
fields_set = request.model_fields_set
|
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
|
updated: dict | None = None
|
||||||
if config_changed:
|
if config_changed:
|
||||||
@ -443,6 +450,9 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
|||||||
if new_tool_groups is not None:
|
if new_tool_groups is not None:
|
||||||
updated["tool_groups"] = new_tool_groups
|
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:
|
if "mcp_plugins" in fields_set:
|
||||||
updated["mcp_plugins"] = request.mcp_plugins
|
updated["mcp_plugins"] = request.mcp_plugins
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ frames, and consuming stream bridge events. Router modules
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
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)
|
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)
|
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")
|
replay_kind = run_metadata.get("replay_kind")
|
||||||
target_message_id = run_metadata.get("regenerate_from_message_id")
|
target_message_id = run_metadata.get("regenerate_from_message_id")
|
||||||
scope_graph_input = graph_input if isinstance(graph_input, dict) else {"messages": []}
|
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_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
|
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))
|
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
|
if is_scope_recovery
|
||||||
else None
|
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 = (
|
agent_config = (
|
||||||
await _load_scope_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),
|
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
|
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(
|
admitted_knowledge_scope = admit_message_knowledge_scope(
|
||||||
scope_graph_input,
|
scope_graph_input,
|
||||||
assistant_id=body.assistant_id,
|
assistant_id=scope_assistant_id,
|
||||||
app_config=run_ctx.app_config or get_app_config(),
|
app_config=run_ctx.app_config or get_app_config(),
|
||||||
agent_config=agent_config,
|
agent_config=agent_config,
|
||||||
recovery_scope=recovery_scope,
|
recovery_scope=recovery_scope,
|
||||||
@ -1792,15 +1816,6 @@ async def start_run(
|
|||||||
)
|
)
|
||||||
run_record_input = _canonical_run_record_input(body.input, graph_input)
|
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)
|
internal_owner_user = await resolve_trusted_internal_owner_for_attribution(request, owner_user_id)
|
||||||
inject_authenticated_user_context(
|
inject_authenticated_user_context(
|
||||||
config,
|
config,
|
||||||
@ -1960,6 +1975,7 @@ async def start_run(
|
|||||||
# config built above keeps the secrets for the actual run.
|
# config built above keeps the secrets for the actual run.
|
||||||
kwargs={
|
kwargs={
|
||||||
"input": run_record_input,
|
"input": run_record_input,
|
||||||
|
**({"knowledge_default_request_hash": knowledge_default_request_hash} if accepts_knowledge_default else {}),
|
||||||
"config": redact_config_secrets(body.config),
|
"config": redact_config_secrets(body.config),
|
||||||
**({"conversation_references": conversation_references} if conversation_references else {}),
|
**({"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
|
# record. Accept the raw request as well for records written
|
||||||
# by older Gateway versions, while comparing canonical
|
# by older Gateway versions, while comparing canonical
|
||||||
# retries to the same representation as the stored record.
|
# 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(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail="Idempotency-Key already used with a different request",
|
detail="Idempotency-Key already used with a different request",
|
||||||
|
|||||||
@ -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.
|
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
|
The file-backed singleton entrypoints additionally merge administrator-managed shared
|
||||||
models from the encrypted runtime-home catalog. YAML entries win name conflicts;
|
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
|
managed changes create new effective snapshots and do not alter an active runtime
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from typing import Annotated, Literal
|
|||||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, field_validator, model_validator
|
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, StringConstraints, field_validator, model_validator
|
||||||
|
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.knowledge_scope import KnowledgeScope
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -220,6 +221,9 @@ class AgentConfig(BaseModel):
|
|||||||
# Stable MCP installation IDs. None inherits all; [] selects none.
|
# Stable MCP installation IDs. None inherits all; [] selects none.
|
||||||
# This is tool selection, not a replacement for host authorization.
|
# This is tool selection, not a replacement for host authorization.
|
||||||
mcp_plugins: list[str] | None = None
|
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:
|
# Controls which deployment-level subagents this custom agent may invoke:
|
||||||
# None = all currently enabled definitions, [] = none, list = allowlist.
|
# None = all currently enabled definitions, [] = none, list = allowlist.
|
||||||
# The default Lead Agent has no AgentConfig and therefore keeps access to
|
# The default Lead Agent has no AgentConfig and therefore keeps access to
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from langgraph.types import Command
|
|||||||
|
|
||||||
from deerflow.config.agents_config import SOUL_FILENAME, validate_agent_name
|
from deerflow.config.agents_config import SOUL_FILENAME, validate_agent_name
|
||||||
from deerflow.config.paths import get_paths
|
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.persistence.agents import get_agent_store
|
||||||
from deerflow.runtime.user_context import resolve_runtime_user_id
|
from deerflow.runtime.user_context import resolve_runtime_user_id
|
||||||
from deerflow.tools.types import Runtime
|
from deerflow.tools.types import Runtime
|
||||||
@ -64,6 +65,8 @@ def setup_agent(
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass # First bootstrap has no user-authored label to preserve.
|
pass # First bootstrap has no user-authored label to preserve.
|
||||||
else:
|
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:
|
if existing.display_name is not None:
|
||||||
config_data["display_name"] = existing.display_name
|
config_data["display_name"] = existing.display_name
|
||||||
if description:
|
if description:
|
||||||
|
|||||||
113
backend/tests/test_agent_knowledge_scope.py
Normal file
113
backend/tests/test_agent_knowledge_scope.py
Normal 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
|
||||||
@ -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 store.delete("real", user_id="u1") == "deleted"
|
||||||
assert not mem_dir.exists()
|
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"
|
||||||
|
|||||||
@ -4671,3 +4671,161 @@ class TestForgedFrameworkInjectionMarkers:
|
|||||||
processed = InputSanitizationMiddleware()._try_process(_Request(graph_input["messages"]))
|
processed = InputSanitizationMiddleware()._try_process(_Request(graph_input["messages"]))
|
||||||
|
|
||||||
assert "<system-reminder>" not in str(processed.messages[0].content), forged
|
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()
|
||||||
|
|||||||
BIN
docs/pr-evidence/agent-knowledge-desktop.png
Normal file
BIN
docs/pr-evidence/agent-knowledge-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/pr-evidence/agent-knowledge-mobile.png
Normal file
BIN
docs/pr-evidence/agent-knowledge-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@ -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.
|
Markdown. Source records retain retrieval-time evidence, not live documents.
|
||||||
Resolve knowledge destinations before testing the label in message and artifact
|
Resolve knowledge destinations before testing the label in message and artifact
|
||||||
link renderers: Sources lists use ordinary titles without a `citation:` prefix.
|
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.
|
||||||
|
|||||||
@ -45,7 +45,7 @@ import {
|
|||||||
} from "@/core/features";
|
} from "@/core/features";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
ALL_KNOWLEDGE_SCOPE,
|
knowledgeScopeToSelection,
|
||||||
buildKnowledgeScopeSnapshot,
|
buildKnowledgeScopeSnapshot,
|
||||||
KNOWLEDGE_SCOPE_KEY,
|
KNOWLEDGE_SCOPE_KEY,
|
||||||
type KnowledgeScopeSelection,
|
type KnowledgeScopeSelection,
|
||||||
@ -114,7 +114,7 @@ export default function AgentChatPage() {
|
|||||||
const agentKnowledgeEnabled =
|
const agentKnowledgeEnabled =
|
||||||
agent !== null &&
|
agent !== null &&
|
||||||
(agent.tool_groups == null || agent.tool_groups.includes("knowledge"));
|
(agent.tool_groups == null || agent.tool_groups.includes("knowledge"));
|
||||||
const [knowledgeScope, setKnowledgeScope] =
|
const [knowledgeScopeOverride, setKnowledgeScope] =
|
||||||
useState<KnowledgeScopeSelection | null>(null);
|
useState<KnowledgeScopeSelection | null>(null);
|
||||||
const previousConversationRef = useRef({
|
const previousConversationRef = useRef({
|
||||||
agentName: agent_name,
|
agentName: agent_name,
|
||||||
@ -122,12 +122,12 @@ export default function AgentChatPage() {
|
|||||||
isNewThread,
|
isNewThread,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const knowledgeScope = useMemo(
|
||||||
setKnowledgeScope((current) => {
|
() =>
|
||||||
if (!selectorVisible) return null;
|
knowledgeScopeOverride ??
|
||||||
return current ?? ALL_KNOWLEDGE_SCOPE;
|
knowledgeScopeToSelection(agent?.knowledge_scope),
|
||||||
});
|
[knowledgeScopeOverride, agent?.knowledge_scope],
|
||||||
}, [selectorVisible]);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previous = previousConversationRef.current;
|
const previous = previousConversationRef.current;
|
||||||
@ -137,7 +137,7 @@ export default function AgentChatPage() {
|
|||||||
previous.isNewThread &&
|
previous.isNewThread &&
|
||||||
!isNewThread;
|
!isNewThread;
|
||||||
if (!isNewThreadRouteReplacement) {
|
if (!isNewThreadRouteReplacement) {
|
||||||
setKnowledgeScope(selectorVisible ? ALL_KNOWLEDGE_SCOPE : null);
|
setKnowledgeScope(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
previousConversationRef.current = {
|
previousConversationRef.current = {
|
||||||
|
|||||||
@ -23,10 +23,18 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useUpdateAgent } from "@/core/agents";
|
import { useUpdateAgent } from "@/core/agents";
|
||||||
import type { Agent, ReasoningEffort } from "@/core/agents";
|
import type { Agent, ReasoningEffort } from "@/core/agents";
|
||||||
|
import { useKnowledgeBaseEnabled } from "@/core/features";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import {
|
||||||
|
buildKnowledgeScopeSnapshot,
|
||||||
|
knowledgeScopeToSelection,
|
||||||
|
type KnowledgeScopeSelection,
|
||||||
|
} from "@/core/knowledge";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { useSubagents } from "@/core/subagents";
|
import { useSubagents } from "@/core/subagents";
|
||||||
|
|
||||||
|
import { KnowledgeScopeSelector } from "../knowledge-scope-selector";
|
||||||
|
|
||||||
import { AgentCapabilitySelection } from "./agent-capability-selection";
|
import { AgentCapabilitySelection } from "./agent-capability-selection";
|
||||||
import {
|
import {
|
||||||
allowedSubagentsToSelection,
|
allowedSubagentsToSelection,
|
||||||
@ -71,6 +79,12 @@ export function AgentSettingsDialog({
|
|||||||
}: AgentSettingsDialogProps) {
|
}: AgentSettingsDialogProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
|
const { scopeSelectionEnabled } = useKnowledgeBaseEnabled();
|
||||||
|
const [knowledgeSelection, setKnowledgeSelection] =
|
||||||
|
useState<KnowledgeScopeSelection>(() =>
|
||||||
|
knowledgeScopeToSelection(agent.knowledge_scope),
|
||||||
|
);
|
||||||
|
const [knowledgeChanged, setKnowledgeChanged] = useState(false);
|
||||||
const { subagents } = useSubagents();
|
const { subagents } = useSubagents();
|
||||||
const subagentDescriptionId = useId();
|
const subagentDescriptionId = useId();
|
||||||
const updateAgent = useUpdateAgent();
|
const updateAgent = useUpdateAgent();
|
||||||
@ -161,6 +175,12 @@ export function AgentSettingsDialog({
|
|||||||
name: agent.name,
|
name: agent.name,
|
||||||
request: {
|
request: {
|
||||||
display_name: displayName.trim() || null,
|
display_name: displayName.trim() || null,
|
||||||
|
...(knowledgeChanged && {
|
||||||
|
knowledge_scope:
|
||||||
|
knowledgeSelection.mode === "all"
|
||||||
|
? null
|
||||||
|
: buildKnowledgeScopeSnapshot(knowledgeSelection),
|
||||||
|
}),
|
||||||
...(!sameSelection(plugins, initialSelections.plugins) && {
|
...(!sameSelection(plugins, initialSelections.plugins) && {
|
||||||
mcp_plugins: plugins,
|
mcp_plugins: plugins,
|
||||||
}),
|
}),
|
||||||
@ -202,6 +222,56 @@ export function AgentSettingsDialog({
|
|||||||
onPluginsChange={setPlugins}
|
onPluginsChange={setPlugins}
|
||||||
onSkillsChange={setSkills}
|
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">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="agent-display-name" className="text-sm font-medium">
|
<label htmlFor="agent-display-name" className="text-sm font-medium">
|
||||||
{t.agents.settingsDisplayName}
|
{t.agents.settingsDisplayName}
|
||||||
|
|||||||
@ -241,12 +241,16 @@ export function KnowledgeScopeSelector({
|
|||||||
selection,
|
selection,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
unavailableReason,
|
unavailableReason,
|
||||||
|
description,
|
||||||
|
showLabel = false,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
agentName: string;
|
agentName: string;
|
||||||
selection: KnowledgeScopeSelection;
|
selection: KnowledgeScopeSelection;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
unavailableReason?: string;
|
unavailableReason?: string;
|
||||||
|
description?: string;
|
||||||
|
showLabel?: boolean;
|
||||||
onChange: (selection: KnowledgeScopeSelection) => void;
|
onChange: (selection: KnowledgeScopeSelection) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -340,12 +344,13 @@ export function KnowledgeScopeSelector({
|
|||||||
)}
|
)}
|
||||||
data-testid="knowledge-scope-trigger"
|
data-testid="knowledge-scope-trigger"
|
||||||
disabled={disabled || Boolean(unavailableReason)}
|
disabled={disabled || Boolean(unavailableReason)}
|
||||||
size="icon-sm"
|
size={showLabel ? "sm" : "icon-sm"}
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<DatabaseIcon className="size-4" />
|
<DatabaseIcon className="size-4" />
|
||||||
|
{showLabel && label}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -355,7 +360,9 @@ export function KnowledgeScopeSelector({
|
|||||||
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-2xl">
|
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t.knowledge.scope.title}</DialogTitle>
|
<DialogTitle>{t.knowledge.scope.title}</DialogTitle>
|
||||||
<DialogDescription>{t.knowledge.scope.description}</DialogDescription>
|
<DialogDescription>
|
||||||
|
{description ?? t.knowledge.scope.description}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
{(["all", "selected", "disabled"] as const).map((mode) => (
|
{(["all", "selected", "disabled"] as const).map((mode) => (
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { KnowledgeScopeSnapshot } from "@/core/knowledge";
|
||||||
|
|
||||||
export interface AgentModelSettings {
|
export interface AgentModelSettings {
|
||||||
temperature?: number | null;
|
temperature?: number | null;
|
||||||
max_tokens?: number | null;
|
max_tokens?: number | null;
|
||||||
@ -13,6 +15,7 @@ export interface Agent {
|
|||||||
tool_groups: string[] | null;
|
tool_groups: string[] | null;
|
||||||
skills: string[] | null;
|
skills: string[] | null;
|
||||||
mcp_plugins?: string[] | null;
|
mcp_plugins?: string[] | null;
|
||||||
|
knowledge_scope?: KnowledgeScopeSnapshot | null;
|
||||||
allowed_subagents?: string[] | null;
|
allowed_subagents?: string[] | null;
|
||||||
model_settings?: AgentModelSettings | null;
|
model_settings?: AgentModelSettings | null;
|
||||||
thinking_enabled?: boolean | null;
|
thinking_enabled?: boolean | null;
|
||||||
@ -28,6 +31,7 @@ export interface CreateAgentRequest {
|
|||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
mcp_plugins?: string[] | null;
|
mcp_plugins?: string[] | null;
|
||||||
|
knowledge_scope?: KnowledgeScopeSnapshot | null;
|
||||||
allowed_subagents?: string[] | null;
|
allowed_subagents?: string[] | null;
|
||||||
model_settings?: AgentModelSettings | null;
|
model_settings?: AgentModelSettings | null;
|
||||||
thinking_enabled?: boolean | null;
|
thinking_enabled?: boolean | null;
|
||||||
@ -42,6 +46,7 @@ export interface UpdateAgentRequest {
|
|||||||
tool_groups?: string[] | null;
|
tool_groups?: string[] | null;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
mcp_plugins?: string[] | null;
|
mcp_plugins?: string[] | null;
|
||||||
|
knowledge_scope?: KnowledgeScopeSnapshot | null;
|
||||||
allowed_subagents?: string[] | null;
|
allowed_subagents?: string[] | null;
|
||||||
model_settings?: AgentModelSettings | null;
|
model_settings?: AgentModelSettings | null;
|
||||||
thinking_enabled?: boolean | null;
|
thinking_enabled?: boolean | null;
|
||||||
|
|||||||
@ -918,6 +918,10 @@ export const enUS: Translations = {
|
|||||||
"Supports Unicode. Leave blank to use the agent identifier",
|
"Supports Unicode. Leave blank to use the agent identifier",
|
||||||
settingsDescription:
|
settingsDescription:
|
||||||
"Choose a display name and model defaults for this agent. Model changes take effect on the next message.",
|
"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",
|
settingsModel: "Default model",
|
||||||
settingsModelDefault: "Use global default",
|
settingsModelDefault: "Use global default",
|
||||||
settingsTemperature: "Temperature",
|
settingsTemperature: "Temperature",
|
||||||
|
|||||||
@ -754,6 +754,9 @@ export interface Translations {
|
|||||||
settingsDisplayNameTooLong: string;
|
settingsDisplayNameTooLong: string;
|
||||||
settingsDisplayNameHint: string;
|
settingsDisplayNameHint: string;
|
||||||
settingsDescription: string;
|
settingsDescription: string;
|
||||||
|
settingsKnowledge: string;
|
||||||
|
settingsKnowledgeHint: string;
|
||||||
|
settingsKnowledgeReset: string;
|
||||||
settingsModel: string;
|
settingsModel: string;
|
||||||
settingsModelDefault: string;
|
settingsModelDefault: string;
|
||||||
settingsTemperature: string;
|
settingsTemperature: string;
|
||||||
|
|||||||
@ -857,6 +857,10 @@ export const zhCN: Translations = {
|
|||||||
settingsDisplayNameHint: "支持中文等 Unicode 字符,留空时使用智能体标识",
|
settingsDisplayNameHint: "支持中文等 Unicode 字符,留空时使用智能体标识",
|
||||||
settingsDescription:
|
settingsDescription:
|
||||||
"为该智能体设置显示名称和默认模型,模型修改在下一条消息生效。",
|
"为该智能体设置显示名称和默认模型,模型修改在下一条消息生效。",
|
||||||
|
settingsKnowledge: "默认知识库",
|
||||||
|
settingsKnowledgeHint:
|
||||||
|
"新对话默认使用此知识库范围,单条消息可以临时覆盖。选择全部知识库可解除绑定。",
|
||||||
|
settingsKnowledgeReset: "使用全部知识库",
|
||||||
settingsModel: "默认模型",
|
settingsModel: "默认模型",
|
||||||
settingsModelDefault: "使用全局默认",
|
settingsModelDefault: "使用全局默认",
|
||||||
settingsTemperature: "温度",
|
settingsTemperature: "温度",
|
||||||
|
|||||||
@ -314,3 +314,36 @@ export function readKnowledgeScopeSnapshot(
|
|||||||
}
|
}
|
||||||
return snapshot;
|
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" },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
138
frontend/tests/e2e/agent-knowledge-binding.spec.ts
Normal file
138
frontend/tests/e2e/agent-knowledge-binding.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -19,6 +19,9 @@ const { mutateAsync } = rs.hoisted(() => ({
|
|||||||
rs.mock("@/core/agents", () => ({
|
rs.mock("@/core/agents", () => ({
|
||||||
useUpdateAgent: () => ({ mutateAsync, isPending: false }),
|
useUpdateAgent: () => ({ mutateAsync, isPending: false }),
|
||||||
}));
|
}));
|
||||||
|
rs.mock("@/core/features", () => ({
|
||||||
|
useKnowledgeBaseEnabled: () => ({ scopeSelectionEnabled: false }),
|
||||||
|
}));
|
||||||
rs.mock("@/core/models/hooks", () => ({ useModels: () => ({ models: [] }) }));
|
rs.mock("@/core/models/hooks", () => ({ useModels: () => ({ models: [] }) }));
|
||||||
rs.mock("@/core/subagents", () => ({
|
rs.mock("@/core/subagents", () => ({
|
||||||
useSubagents: () => ({ subagents: [] }),
|
useSubagents: () => ({ subagents: [] }),
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { describe, expect, test } from "@rstest/core";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildKnowledgeScopeSnapshot,
|
buildKnowledgeScopeSnapshot,
|
||||||
|
knowledgeScopeToSelection,
|
||||||
cloneKnowledgeScopeSelection,
|
cloneKnowledgeScopeSelection,
|
||||||
readKnowledgeScopeSnapshot,
|
readKnowledgeScopeSnapshot,
|
||||||
type KnowledgeScopeSelection,
|
type KnowledgeScopeSelection,
|
||||||
@ -157,3 +158,42 @@ describe("knowledge scope snapshots", () => {
|
|||||||
).toBeNull();
|
).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" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user