mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
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:
parent
a1349a1fe1
commit
07f0d74f70
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user