fix(agents): do not hide invalid config with file fallback (#4952)

* fix(agents): do not hide invalid config with file fallback

* test(agents): cover invalid on-disk config fallback

* fix(agents): resolve stores off the event loop

* fix(agents): distinguish missing nested config from main config

* fix(agents): reject missing explicit config path

* test(agents): isolate config fallback test

* test(agents): isolate router blocking IO coverage

* test(agents): pin malformed config.yaml parse-error propagation

An unparseable config.yaml used to be swallowed by the broad
except Exception and silently downgrade to FileAgentStore. The
narrowed except FileNotFoundError already propagates
yaml.ParserError/ScannerError; pin that contract with a real
on-disk config instead of monkeypatched get_app_config.
This commit is contained in:
wutongyuonce 2026-09-02 12:34:09 +08:00 committed by GitHub
parent fe379c4486
commit fb722770e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 137 additions and 23 deletions

View File

@ -19,7 +19,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.persistence.agents import 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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -251,10 +251,14 @@ async def check_agent_name(name: str) -> dict:
_validate_agent_name(name) _validate_agent_name(name)
normalized = _normalize_agent_name(name) normalized = _normalize_agent_name(name)
user_id = get_effective_user_id() user_id = get_effective_user_id()
# Availability is defined by the active backend and stays consistent with # Availability is defined by the active backend and stays consistent with
# create()'s conflict rule (file: per-user or legacy dir; db: a row). The # create()'s conflict rule (file: per-user or legacy dir; db: a row). The
# exists() probe is filesystem IO / a DB round trip, so keep it off the loop. # exists() probe is filesystem IO / a DB round trip, so keep it off the loop.
exists = await asyncio.to_thread(get_agent_store().exists, normalized, user_id=user_id) def _exists() -> bool:
return get_agent_store().exists(normalized, user_id=user_id)
exists = await asyncio.to_thread(_exists)
return {"available": not exists, "name": normalized} return {"available": not exists, "name": normalized}
@ -334,11 +338,10 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
# model / model_settings / thinking_enabled / reasoning_effort (issue #4336). # model / model_settings / thinking_enabled / reasoning_effort (issue #4336).
_apply_model_behavior(config_data, request) _apply_model_behavior(config_data, request)
store = get_agent_store()
def _create_agent() -> AgentResponse: def _create_agent() -> AgentResponse:
# Worker thread: existence checks + persistence (file IO or a DB round # Worker thread: existence checks + persistence (file IO or a DB round
# trip) must stay off the event loop. # trip) must stay off the event loop.
store = get_agent_store()
store.create(normalized_name, config_data, request.soul, user_id=user_id) store.create(normalized_name, config_data, request.soul, user_id=user_id)
logger.info("Created agent '%s'", normalized_name) logger.info("Created agent '%s'", normalized_name)
agent_cfg = load_agent_config(normalized_name, user_id=user_id) agent_cfg = load_agent_config(normalized_name, user_id=user_id)
@ -453,11 +456,14 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
for key, value in preserve_non_managed_fields(agent_cfg).items(): for key, value in preserve_non_managed_fields(agent_cfg).items():
updated.setdefault(key, value) updated.setdefault(key, value)
store = get_agent_store()
# Persist config (when changed) and/or soul (when provided) off the # Persist config (when changed) and/or soul (when provided) off the
# event loop. A no-change PATCH commits nothing and re-reads current state. # event loop. A no-change PATCH commits nothing and re-reads current state.
if updated is not None or request.soul is not None: if updated is not None or request.soul is not None:
await asyncio.to_thread(store.update, name, updated, request.soul, user_id=user_id)
def _update_agent() -> None:
get_agent_store().update(name, updated, request.soul, user_id=user_id)
await asyncio.to_thread(_update_agent)
logger.info(f"Updated agent '{name}'") logger.info(f"Updated agent '{name}'")
@ -560,11 +566,12 @@ async def delete_agent(name: str) -> None:
_validate_agent_name(name) _validate_agent_name(name)
name = _normalize_agent_name(name) name = _normalize_agent_name(name)
user_id = get_effective_user_id() user_id = get_effective_user_id()
store = get_agent_store()
try: try:
# Off the event loop: file rmtree or a DB delete plus memory cleanup. # Off the event loop: file rmtree or a DB delete plus memory cleanup.
outcome = await asyncio.to_thread(store.delete, name, user_id=user_id) def _delete_agent() -> AgentDeleteOutcome:
return get_agent_store().delete(name, user_id=user_id)
outcome = await asyncio.to_thread(_delete_agent)
except Exception as e: except Exception as e:
logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True) logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}")

View File

@ -8,6 +8,7 @@ persistence layer.
from __future__ import annotations from __future__ import annotations
import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from deerflow.persistence.agents.base import ( from deerflow.persistence.agents.base import (
@ -60,10 +61,13 @@ def make_agent_store(config: AppConfig) -> AgentStore:
def get_agent_store() -> AgentStore: def get_agent_store() -> AgentStore:
"""Return the store for the current process's configuration. """Return the store for the current process's configuration.
Defaults to the file backend when no app config can be resolved the free Defaults to the file backend only when search mode cannot find the main app
functions in ``agents_config`` must keep working in lightweight contexts config the free functions in ``agents_config`` must keep working in
(CLI, tests, tools) that never load a full ``config.yaml``. Only an lightweight contexts (CLI, tests, tools) that never load a full
explicit ``agent_storage.backend: db`` diverges from the file default. ``config.yaml``. A missing explicit ``DEER_FLOW_CONFIG_PATH`` or a missing
nested config is an operator error and propagates instead of falling back.
Only an explicit ``agent_storage.backend: db`` diverges from the file
default once configuration resolves successfully.
Cross-process invariant (the ``db`` backend's whole point): the per-run Cross-process invariant (the ``db`` backend's whole point): the per-run
agent build runs in the **graph subprocess**, a different process from the agent build runs in the **graph subprocess**, a different process from the
@ -76,12 +80,20 @@ def get_agent_store() -> AgentStore:
downgraded to node-local ``file``. Pinned by downgraded to node-local ``file``. Pinned by
``test_get_agent_store_resolves_db_backend_from_on_disk_config``. ``test_get_agent_store_resolves_db_backend_from_on_disk_config``.
""" """
from deerflow.config.app_config import get_app_config from deerflow.config.app_config import AppConfig, get_app_config
try: try:
config = get_app_config() config = get_app_config()
except Exception: # noqa: BLE001 — no resolvable config → file default except FileNotFoundError:
return _file_store() if os.getenv("DEER_FLOW_CONFIG_PATH"):
raise
# ``get_app_config()`` also loads optional nested config files. Only
# fall back when the main config itself is absent.
try:
AppConfig.resolve_config_path()
except FileNotFoundError:
return _file_store()
raise
return make_agent_store(config) return make_agent_store(config)

View File

@ -1,11 +1,10 @@
"""Regression anchors: the custom-agent router must not block the event loop. """Regression anchors: the custom-agent router must not block the event loop.
``app.gateway.routers.agents.create_agent_endpoint`` and ``delete_agent`` are The custom-agent handlers resolve the active store, access agent directories,
async route handlers that resolve the agent directory (``Paths.base_dir`` calls and may perform filesystem or database IO. Create/read/check/update/delete all
``Path.resolve``), probe it (``Path.exists``), and create/remove it (``mkdir``, offload their complete blocking operations via ``asyncio.to_thread``; if any of
config/SOUL writes, ``shutil.rmtree``) all blocking IO. Both offload that work that work regresses onto the event loop, the strict Blockbuster gate raises
via ``asyncio.to_thread``; if any of it regresses back onto the event loop, the ``BlockingError`` and these tests fail.
strict Blockbuster gate raises ``BlockingError`` and these tests fail.
Imports live at module scope so the one-time FastAPI app construction (which Imports live at module scope so the one-time FastAPI app construction (which
reads files while building OpenAPI schemas) happens at collection time, not on reads files while building OpenAPI schemas) happens at collection time, not on
@ -23,11 +22,13 @@ import pytest
from app.gateway.routers.agents import ( from app.gateway.routers.agents import (
AgentCreateRequest, AgentCreateRequest,
AgentUpdateRequest,
check_agent_name, check_agent_name,
create_agent_endpoint, create_agent_endpoint,
delete_agent, delete_agent,
get_agent, get_agent,
list_agents, list_agents,
update_agent,
) )
from deerflow.config.agents_api_config import load_agents_api_config_from_dict from deerflow.config.agents_api_config import load_agents_api_config_from_dict
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
@ -36,6 +37,13 @@ from deerflow.runtime.user_context import get_effective_user_id
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@pytest.fixture(autouse=True)
def _isolate_agent_store_config(tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv("DEER_FLOW_CONFIG_PATH", raising=False)
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
monkeypatch.setattr("deerflow.config.app_config._legacy_config_candidates", lambda: ())
async def test_create_agent_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None: async def test_create_agent_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path)) monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None) monkeypatch.setattr("deerflow.config.paths._paths", None)
@ -92,3 +100,17 @@ async def test_read_endpoints_do_not_block_event_loop(tmp_path: Path, monkeypatc
assert (await check_agent_name("never-created-agent"))["available"] is True assert (await check_agent_name("never-created-agent"))["available"] is True
finally: finally:
load_agents_api_config_from_dict({}) load_agents_api_config_from_dict({})
async def test_update_agent_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
load_agents_api_config_from_dict({"enabled": True})
try:
await create_agent_endpoint(AgentCreateRequest(name="loop-update-agent", soul="Original soul"))
response = await update_agent("loop-update-agent", AgentUpdateRequest(description="Updated description"))
assert response.description == "Updated description"
finally:
load_agents_api_config_from_dict({})

View File

@ -263,9 +263,82 @@ def test_get_agent_store_falls_back_to_file_without_config(tmp_path, monkeypatch
"""The ``except -> file`` fallback is for genuinely unresolvable config only """The ``except -> file`` fallback is for genuinely unresolvable config only
(CLI/tests); it must not fire when a config exists that asymmetry is what (CLI/tests); it must not fire when a config exists that asymmetry is what
keeps a misconfigured graph process from silently downgrading db to file.""" keeps a misconfigured graph process from silently downgrading db to file."""
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "does-not-exist.yaml")) monkeypatch.delenv("DEER_FLOW_CONFIG_PATH", raising=False)
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
from deerflow.config import app_config
monkeypatch.setattr(app_config, "_legacy_config_candidates", lambda: ())
try: try:
reset_app_config() reset_app_config()
assert isinstance(get_agent_store(), FileAgentStore) assert isinstance(get_agent_store(), FileAgentStore)
finally: finally:
reset_app_config() reset_app_config()
def test_get_agent_store_does_not_fallback_when_explicit_config_is_missing(tmp_path, monkeypatch):
"""An explicit config path is an operator assertion and must fail closed."""
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "does-not-exist.yaml"))
try:
reset_app_config()
with pytest.raises(FileNotFoundError, match="DEER_FLOW_CONFIG_PATH"):
get_agent_store()
finally:
reset_app_config()
def test_get_agent_store_does_not_hide_invalid_config(monkeypatch):
"""Only missing config falls back; config errors must reach the caller."""
from deerflow.config import app_config
def raise_invalid_config():
raise ValueError("invalid config")
monkeypatch.setattr(app_config, "get_app_config", raise_invalid_config)
with pytest.raises(ValueError, match="invalid config"):
get_agent_store()
def test_get_agent_store_propagates_invalid_on_disk_config(tmp_path, monkeypatch):
"""A present config with an invalid backend must fail instead of falling back."""
cfg_path = tmp_path / "config.yaml"
_write_min_config(cfg_path, {"agent_storage": {"backend": "invalid"}})
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
try:
reset_app_config()
with pytest.raises(ValueError, match="agent_storage.backend"):
get_agent_store()
finally:
reset_app_config()
def test_get_agent_store_propagates_malformed_yaml(tmp_path, monkeypatch):
"""An unparseable config.yaml must surface the parse error, not fall back to file."""
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text("agent_storage: [oops\n", encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
try:
reset_app_config()
with pytest.raises(yaml.YAMLError): # ParserError/ScannerError, previously swallowed
get_agent_store()
finally:
reset_app_config()
def test_get_agent_store_does_not_fallback_when_extensions_config_is_missing(tmp_path, monkeypatch):
"""A missing nested config must not look like a missing main config."""
cfg_path = tmp_path / "config.yaml"
_write_min_config(
cfg_path,
{
"agent_storage": {"backend": "db"},
"database": {"backend": "sqlite", "sqlite_dir": str(tmp_path / "db")},
},
)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(tmp_path / "missing-extensions.json"))
try:
reset_app_config()
with pytest.raises(FileNotFoundError, match="Extensions config"):
get_agent_store()
finally:
reset_app_config()