fix(agents): clear incompatible knowledge defaults on self-update (#6995)

* fix(agents): clear incompatible knowledge defaults on self-update

* fix(agents): share knowledge default cleanup with HTTP updates

---------

Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
This commit is contained in:
haotianqi666 2026-10-18 11:12:57 +08:00 committed by GitHub
parent a1349a1fe1
commit 07f0d74f70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 273 additions and 11 deletions

View File

@ -1808,7 +1808,13 @@ allow selected groups, or disable all groups. Saved groups stay visible and
removable while the catalog loads or fails to load. A saved group is marked
unavailable only when a successful catalog load confirms it is missing.
Saving a selection that removes `knowledge` also clears the saved default
knowledge selection. When enabling knowledge access for an agent that
knowledge selection. The in-chat `update_agent` tool applies the same rule
when it changes the tool groups to a list without `knowledge`, so later
Gateway turns do not inherit an unusable scope. `PUT /api/agents/{name}` also
clears this inherited default on such a change when `knowledge_scope` is
omitted; an explicitly supplied scope or `null` still takes precedence.
Unrelated updates, unchanged groups, and inheriting all tool groups preserve
the default. When enabling knowledge access for an agent that
previously lacked it, save the tool access first, then reopen settings to
choose its default knowledge bases.

View File

@ -24,6 +24,9 @@ Custom Agent update guards apply legacy shared-file checks only when the
resolved store is `FileAgentStore`, including the `update_agent` tool. DB
imports retain source files for rollback; those files cannot veto SQL updates.
Keep store resolution and filesystem checks off-loop in the HTTP route.
Agent PUT uses `preserve_non_managed_fields(..., new_tool_groups=...)` to clear
an inherited knowledge default when changed groups remove knowledge access.
Explicit scope/null updates still win; unchanged groups are not a migration.
Committed MCP reconciliation failures are logged once by
`fail_mcp_reconciliation`, with only the underlying exception type.

View File

@ -639,7 +639,7 @@ async def update_agent(name: str, body: AgentUpdateRequest, request: Request) ->
# ``github:`` binding — which would otherwise leave the next
# webhook delivery unable to find the agent in the registry and
# silently no-op.
for key, value in preserve_non_managed_fields(agent_cfg).items():
for key, value in preserve_non_managed_fields(agent_cfg, new_tool_groups=new_tool_groups).items():
updated.setdefault(key, value)
# Persist config (when changed) and/or soul (when provided) off the

View File

@ -186,8 +186,11 @@ Values beginning with `$` are resolved from the environment when the file is loa
`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;
null; file and SQL stores persist it in the existing config document. Both
`update_agent` and HTTP PUT pass the effective groups to
`preserve_non_managed_fields`: a changed list excluding knowledge clears the
inherited default, but explicit API scope fields still win. Keep it outside
`MANAGED_AGENT_CONFIG_FIELDS` so unrelated 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,

View File

@ -271,11 +271,11 @@ MANAGED_AGENT_CONFIG_FIELDS: frozenset[str] = frozenset(
)
def preserve_non_managed_fields(existing_cfg: AgentConfig) -> dict[str, object]:
def preserve_non_managed_fields(existing_cfg: AgentConfig, *, new_tool_groups: list[str] | None = None) -> dict[str, object]:
"""Return every top-level field on ``existing_cfg`` not in :data:`MANAGED_AGENT_CONFIG_FIELDS`.
Used by the two surfaces that rewrite a custom agent's ``config.yaml``
(the ``update_agent`` harness tool and the HTTP ``PATCH /api/agents/{name}``
(the ``update_agent`` harness tool and the HTTP ``PUT /api/agents/{name}``
route) to carry forward any hand-authored field — currently ``github``,
and any field added to :class:`AgentConfig` in the future — that the
update API does not expose as an argument. Without this, operators who
@ -286,8 +286,16 @@ def preserve_non_managed_fields(existing_cfg: AgentConfig) -> dict[str, object]:
``exclude_unset=True`` is recursive in Pydantic v2, so a sub-field the
user did not write (and that defaulted to a Pydantic default) is not
materialized into the dict — the file round-trips visually intact.
When ``new_tool_groups`` changes access to exclude knowledge, clear the
inherited knowledge default. Explicit update fields still take precedence
when callers merge this result with ``setdefault``. Omitted/None groups
never remove access, and unchanged groups do not migrate legacy configs.
"""
return existing_cfg.model_dump(exclude_unset=True, exclude=MANAGED_AGENT_CONFIG_FIELDS)
preserved = existing_cfg.model_dump(exclude_unset=True, exclude=MANAGED_AGENT_CONFIG_FIELDS)
if new_tool_groups is not None and new_tool_groups != existing_cfg.tool_groups and "knowledge" not in new_tool_groups and existing_cfg.knowledge_scope is not None:
preserved["knowledge_scope"] = None
return preserved
def resolve_agent_dir(name: str, *, user_id: str | None = None) -> Path:

View File

@ -65,7 +65,7 @@ Only standalone tool discovery without a model falls back to the base profile.
- `ask_clarification` - Request clarification (intercepted by ClarificationMiddleware, which preserves text fallback and adds `artifact.human_input` for Web UI Human Input Cards). Beyond free text and single choice, the request-side v2 protocol supports `fields` (structured form card collecting several values at once; field types: text/textarea/number/select/multi_select/checkbox/date, validated and normalized server-side in the middleware — invalid entries are dropped, unknown types degrade to `text`; a standalone multi-select question is a one-field form). Replies stay on the v1 response protocol (`text`/`option`): the form card submits a readable text summary
- `view_image` - Pass the 20 MiB caller limit to sandbox downloads (see [sandbox download contract](../sandbox/AGENTS.md)). Read image bytes for vision-capable models; both sync and async entry points require `sandbox:execute` before any host or sandbox read. With blob storage enabled, validated bytes are persisted as `kind="viewed-image"` and the checkpoint carries a JSON-safe ref beside the compatibility host path; store initialization/write failures return a generic tool error. Blob-backed model reads validate the ref against the separate image metadata before use and fall through to validated compatibility paths on any store failure. Live sandbox bytes remain the fallback for the same sandbox generation, replacement-sandbox recovery uses only SHA-256-verified synchronized host bytes, and async tool invocation drains blocking reads before cancellation may release the sandbox lease
- `setup_agent` - Bootstrap-only: persist a custom agent's `SOUL.md` and `config.yaml`. Re-bootstrapping preserves the owner's existing `display_name`. Bound only when `is_bootstrap=True`. The default agent's global `SOUL.md` is published through a staged temporary plus `os.replace`, like the agent store's per-agent write, so an interrupted setup keeps the previous persona file instead of leaving it truncated.
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`.
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`. An actual tool-group change excluding `knowledge` clears the saved `knowledge_scope` in the same config write; unrelated updates and unchanged groups preserve it. Keep Gateway rejection of explicit scopes without knowledge access intact.
4. **Subagent tool** (if enabled):
- `task` - Delegate to subagent (`prompt`, `subagent_type`, optional `acceptance_criteria`, and an optional model-visible `description` used only as a short progress label). Execution never depends on `description`; lifecycle display falls back to `prompt` when a provider omits it. Subagent reports are self-reports: the docstring directs the lead to expect `[rN]` receipt citations and verifiable handles while `verification.receipts_enabled` (and explicitly qualifies that disabled receipts mean no citations and no citation verdict), to read the delegation ledger's citation cross-check as execution evidence only, and to attach `acceptance_criteria` for objectively checkable outcomes (canonical forms `file:<path> exists|non-empty`, `file_written:<path>`, `tests_passed:<command>`); criteria are handed to the executor and appended to the subagent's task message as untrusted data (see `subagents/report_contract.py`).
Polling safety timeouts carry the latest published tool receipts into the terminal task metadata before requesting background cancellation.

View File

@ -97,6 +97,9 @@ def update_agent(
``"null"`` / ``"none"`` / ``"undefined"`` for unchanged fields; omit those
fields instead.
Changing ``tool_groups`` to a list without ``knowledge`` also clears the
saved default knowledge selection, so subsequent turns remain usable.
Args:
soul: Optional full replacement SOUL.md content.
description: Optional new one-line description.
@ -227,14 +230,17 @@ def update_agent(
# Preserve every top-level AgentConfig field that this tool does not
# expose as an argument (currently ``github:``, plus any future field
# added to :class:`AgentConfig`). The same helper is used by the HTTP
# ``PATCH /api/agents/{name}`` route so the two surfaces stay in lockstep.
# ``PUT /api/agents/{name}`` route so the two surfaces stay in lockstep.
# Without this, operators who hand-author a ``github:`` block on a custom
# agent would silently lose it the next time the agent self-updates via
# ``update_agent``.
preserved = preserve_non_managed_fields(existing_cfg)
preserved = preserve_non_managed_fields(existing_cfg, new_tool_groups=new_tool_groups)
for key, value in preserved.items():
config_data.setdefault(key, value)
if existing_cfg.knowledge_scope is not None and preserved.get("knowledge_scope") is None:
updated_fields.append("knowledge_scope")
config_changed = bool({"description", "model", "tool_groups", "skills"} & set(updated_fields))
if soul is not None:
updated_fields.append("soul")

View File

@ -1,15 +1,23 @@
"""Persisted custom-agent knowledge defaults, using the existing scope contract."""
from types import SimpleNamespace
from uuid import uuid4
import httpx
import pytest
import pytest_asyncio
from _router_auth_helpers import make_authed_test_app
from fastapi import HTTPException
from langchain.tools import ToolRuntime
from langchain_core.messages import HumanMessage
from pydantic import ValidationError
from app.gateway.auth.models import User
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 app.gateway.routers.agents import AgentCreateRequest, AgentUpdateRequest, create_agent_endpoint, get_agent, router, 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.config.app_config import AppConfig, pop_current_app_config, push_current_app_config
from deerflow.knowledge_scope import canonicalize_knowledge_scope
SCOPE = {
@ -111,3 +119,190 @@ def test_default_never_reenables_agent_knowledge_tools(tool_groups):
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
@pytest.fixture(params=["file", "db"])
def self_update_context(request, agent_home, tmp_path):
from sqlalchemy import create_engine
from deerflow.persistence.agents import AgentRow, get_agent_store
from deerflow.persistence.base import Base
app = AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"agent_storage": {"backend": request.param},
"database": {"backend": "sqlite", "sqlite_dir": str(tmp_path)},
"knowledge_base": {"enabled": True},
"tools": [{"name": "knowledge_search", "group": "knowledge", "use": "deerflow.community.ragflow.tools:knowledge_search_tool"}],
}
)
if request.param == "db":
engine = create_engine(app.database.app_sync_sqlalchemy_url)
Base.metadata.create_all(engine, tables=[AgentRow.__table__])
engine.dispose()
runtime = ToolRuntime(state={}, context={"agent_name": "researcher", "user_id": "alice"}, config={}, stream_writer=lambda _: None, tools=[], tool_call_id="update-1", store=None)
push_current_app_config(app)
try:
yield get_agent_store(), app, runtime
finally:
pop_current_app_config()
@pytest.mark.asyncio
@pytest.mark.parametrize("old_groups", [None, ["web", "knowledge"]])
@pytest.mark.parametrize("new_groups", [[], ["web"]])
@pytest.mark.parametrize("scope", [SCOPE, {"version": 1, "mode": "disabled"}, {"version": 1, "mode": "all"}])
async def test_self_update_removing_knowledge_keeps_next_turn_admissible(self_update_context, old_groups, new_groups, scope):
from app.gateway.services import _load_scope_agent_config
from deerflow.tools.builtins.update_agent_tool import update_agent as update_tool
store, app, runtime = self_update_context
config = {"tool_groups": old_groups, "knowledge_scope": scope, "display_name": "Researcher"}
store.create("researcher", config, "Research soul", user_id="alice")
store.create("researcher", config, "Bob's soul", user_id="bob")
before = await _load_scope_agent_config(assistant_id="researcher", user_id="alice")
admit_message_knowledge_scope({"messages": [HumanMessage(content="Hello")]}, assistant_id="researcher", app_config=app, agent_config=before)
result = update_tool.invoke({"runtime": runtime, "tool_groups": new_groups})
assert result.update["messages"][0].status == "success"
updated = await _load_scope_agent_config(assistant_id="researcher", user_id="alice")
message = HumanMessage(content="Hello again")
assert admit_message_knowledge_scope({"messages": [message]}, assistant_id="researcher", app_config=app, agent_config=updated) is None
assert updated.knowledge_scope is None
assert updated.tool_groups == new_groups
assert updated.display_name == "Researcher"
assert store.get_soul("researcher", user_id="alice") == "Research soul"
assert canonicalize_knowledge_scope(store.get("researcher", user_id="bob").knowledge_scope) == scope
# Clearing a default must not admit an explicit scope without tool access.
with pytest.raises(HTTPException) as error:
admit_message_knowledge_scope({"messages": [HumanMessage(content="Search", additional_kwargs={"knowledge_scope": scope})]}, assistant_id="researcher", app_config=app, agent_config=updated)
assert error.value.status_code == 422
@pytest.mark.parametrize(
"updates",
[{"description": "Updated"}, {"skills": []}, {"soul": "Updated soul"}, {"tool_groups": ["knowledge"]}, {"tool_groups": ["web", "knowledge"]}, {"description": "Updated", "tool_groups": "null"}],
)
def test_self_update_preserves_knowledge_when_access_is_not_removed(self_update_context, updates):
from deerflow.tools.builtins.update_agent_tool import update_agent as update_tool
store, _, runtime = self_update_context
store.create("researcher", {"tool_groups": ["web", "knowledge"], "knowledge_scope": SCOPE}, "Soul", user_id="alice")
result = update_tool.invoke({"runtime": runtime, **updates})
assert result.update["messages"][0].status == "success"
assert canonicalize_knowledge_scope(store.get("researcher", user_id="alice").knowledge_scope) == SCOPE
def test_self_update_does_not_rewrite_untouched_incompatible_default(self_update_context):
from deerflow.tools.builtins.update_agent_tool import update_agent as update_tool
store, _, runtime = self_update_context
store.create("researcher", {"tool_groups": ["web"], "knowledge_scope": SCOPE}, "Soul", user_id="alice")
result = update_tool.invoke({"runtime": runtime, "description": "Updated", "tool_groups": ["web"]})
assert result.update["messages"][0].status == "success"
assert canonicalize_knowledge_scope(store.get("researcher", user_id="alice").knowledge_scope) == SCOPE
def test_self_update_without_knowledge_default_keeps_none(self_update_context):
from deerflow.tools.builtins.update_agent_tool import update_agent as update_tool
store, _, runtime = self_update_context
store.create("researcher", {"tool_groups": ["web", "knowledge"]}, "Soul", user_id="alice")
result = update_tool.invoke({"runtime": runtime, "tool_groups": []})
assert result.update["messages"][0].status == "success"
updated = store.get("researcher", user_id="alice")
assert updated.tool_groups == []
assert updated.knowledge_scope is None
@pytest_asyncio.fixture
async def http_update_context(self_update_context):
store, config, _ = self_update_context
user = User(id=uuid4(), email="agent-scope@example.com", password_hash="unused", system_role="user")
app = make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
app.include_router(router)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://agent.test") as client:
yield client, store, config, str(user.id)
@pytest.mark.asyncio
@pytest.mark.parametrize("old_groups", [None, ["web", "knowledge"]])
@pytest.mark.parametrize("new_groups", [[], ["web"]])
@pytest.mark.parametrize("scope", [SCOPE, {"version": 1, "mode": "disabled"}, {"version": 1, "mode": "all"}])
async def test_http_removing_knowledge_keeps_next_turn_admissible(http_update_context, old_groups, new_groups, scope):
from app.gateway.services import _load_scope_agent_config
client, store, app, owner = http_update_context
config = {"tool_groups": old_groups, "knowledge_scope": scope, "display_name": "Researcher"}
store.create("researcher", config, "Research soul", user_id=owner)
store.create("researcher", config, "Bob's soul", user_id="bob")
response = await client.put("/api/agents/researcher", json={"tool_groups": new_groups})
assert response.status_code == 200, response.text
updated = await _load_scope_agent_config(assistant_id="researcher", user_id=owner)
assert admit_message_knowledge_scope({"messages": [HumanMessage(content="Hello again")]}, assistant_id="researcher", app_config=app, agent_config=updated) is None
assert response.json()["knowledge_scope"] is None
assert updated.knowledge_scope is None
assert updated.tool_groups == new_groups
assert updated.display_name == "Researcher"
assert store.get_soul("researcher", user_id=owner) == "Research soul"
assert canonicalize_knowledge_scope(store.get("researcher", user_id="bob").knowledge_scope) == scope
with pytest.raises(HTTPException) as error:
admit_message_knowledge_scope({"messages": [HumanMessage(content="Search", additional_kwargs={"knowledge_scope": scope})]}, assistant_id="researcher", app_config=app, agent_config=updated)
assert error.value.status_code == 422
@pytest.mark.asyncio
@pytest.mark.parametrize(
"updates,expected",
[
({}, SCOPE),
({"description": "Updated"}, SCOPE),
({"skills": []}, SCOPE),
({"soul": "Updated soul"}, SCOPE),
({"tool_groups": ["knowledge"]}, SCOPE),
({"tool_groups": ["web", "knowledge"]}, SCOPE),
({"tool_groups": None}, SCOPE),
({"knowledge_scope": None}, None),
({"tool_groups": ["web"], "knowledge_scope": None}, None),
({"knowledge_scope": {"version": 1, "mode": "disabled"}}, {"version": 1, "mode": "disabled"}),
({"tool_groups": ["knowledge"], "knowledge_scope": {"version": 1, "mode": "all"}}, {"version": 1, "mode": "all"}),
({"tool_groups": ["web"], "knowledge_scope": {"version": 1, "mode": "disabled"}}, {"version": 1, "mode": "disabled"}),
],
)
async def test_http_updates_preserve_omission_and_explicit_scope_semantics(http_update_context, updates, expected):
client, store, _, owner = http_update_context
store.create("researcher", {"tool_groups": ["web", "knowledge"], "knowledge_scope": SCOPE}, "Soul", user_id=owner)
response = await client.put("/api/agents/researcher", json=updates)
assert response.status_code == 200, response.text
updated = store.get("researcher", user_id=owner)
if expected is None:
assert updated.knowledge_scope is None
assert response.json()["knowledge_scope"] is None
else:
assert canonicalize_knowledge_scope(updated.knowledge_scope) == expected
assert canonicalize_knowledge_scope(response.json()["knowledge_scope"]) == expected
assert updated.tool_groups == updates.get("tool_groups", ["web", "knowledge"])
@pytest.mark.asyncio
async def test_http_unchanged_groups_do_not_migrate_incompatible_default(http_update_context):
client, store, _, owner = http_update_context
store.create("researcher", {"tool_groups": ["web"], "knowledge_scope": SCOPE}, "Soul", user_id=owner)
response = await client.put("/api/agents/researcher", json={"description": "Updated", "tool_groups": ["web"]})
assert response.status_code == 200, response.text
assert canonicalize_knowledge_scope(store.get("researcher", user_id=owner).knowledge_scope) == SCOPE
@pytest.mark.asyncio
async def test_http_without_knowledge_default_keeps_none(http_update_context):
client, store, _, owner = http_update_context
store.create("researcher", {"tool_groups": ["web", "knowledge"]}, "Soul", user_id=owner)
response = await client.put("/api/agents/researcher", json={"tool_groups": []})
assert response.status_code == 200, response.text
updated = store.get("researcher", user_id=owner)
assert updated.tool_groups == []
assert updated.knowledge_scope is None

View File

@ -3527,6 +3527,47 @@ def test_start_run_strips_project_context_message_marker_from_input(_stub_app_co
assert graph_input["messages"][0].additional_kwargs["custom"] == 1
@pytest.mark.asyncio
async def test_self_update_removing_knowledge_allows_next_gateway_run(_stub_app_config, tmp_path, monkeypatch):
from unittest.mock import patch
from app.gateway.services import start_run
from deerflow.persistence.agents import get_agent_store
from deerflow.runtime import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.tools.builtins.update_agent_tool import update_agent
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
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"}],
}
)
)
user_id = get_effective_user_id()
scope = {"version": 1, "mode": "selected", "dataset_ids": ["policies"]}
get_agent_store().create("researcher", {"tool_groups": ["web", "knowledge"], "knowledge_scope": scope}, "Soul", user_id=user_id)
runtime = SimpleNamespace(context={"agent_name": "researcher", "user_id": user_id}, tool_call_id="update-1")
assert update_agent.func(runtime=runtime, tool_groups=["web"]).update["messages"][0].status == "success"
request = _make_start_run_request(RunManager(store=MemoryRunStore()))
captured = {}
async def fake_run_agent(*_args, **kwargs):
captured.update(kwargs)
# Keep persistence, config reload and admission real; replace only execution.
with patch("app.gateway.services.resolve_agent_factory", return_value=object()), patch("app.gateway.services.run_agent", side_effect=fake_run_agent):
record = await start_run(_run_create_request("Hello", assistant_id="researcher"), "thread-after-self-update", request)
await record.task
assert captured["knowledge_scope"] is None
assert "knowledge_scope" not in captured["graph_input"]["messages"][0].additional_kwargs
@pytest.mark.asyncio
@pytest.mark.parametrize("run_store_backend", ["memory", "sql"])
async def test_start_run_peer_idempotent_reuse_does_not_reject_later_runs_after_owner_completes(_stub_app_config, run_store_backend, tmp_path):