From 8db16bb3d80930f7628f38fa33b057fc5bf221f0 Mon Sep 17 00:00:00 2001 From: ly-wang19 <94427531+ly-wang19@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:45:28 +0800 Subject: [PATCH] fix(config): coerce null config.yaml list sections to empty list (#3434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying config.example.yaml to config.yaml and starting DeerFlow crashed with `pydantic ValidationError: models — Input should be a valid list [input_value=None]`, because the example ships every entry under `models:` commented out, so PyYAML parses the key as null. Reported in #1444. Add a field_validator(mode="before") on AppConfig that coerces null models/tools/tool_groups to [] (matching their default_factory=list), and emit an actionable warning from from_file when no models are configured (pointing to config.example.yaml / make setup). Adds regression tests. Closes #1444 Co-authored-by: ly-wang19 Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Willem Jiang --- .../harness/deerflow/config/app_config.py | 22 +++++++- backend/tests/test_app_config_reload.py | 51 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 842b49d7a..7352d0af7 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -7,7 +7,7 @@ from typing import Any, Self import yaml from dotenv import load_dotenv -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict @@ -148,6 +148,21 @@ class AppConfig(BaseModel): ), ) + @field_validator("models", "tools", "tool_groups", mode="before") + @classmethod + def _coerce_null_list_sections(cls, value: Any) -> Any: + """Treat a present-but-empty config section as an empty list. + + Commenting out every entry under a top-level YAML key — e.g. ``models:`` + with only comments beneath it, exactly as shipped in + ``config.example.yaml`` — makes PyYAML parse the value as ``None``. + Without this, the documented ``cp config.example.yaml config.yaml`` + first-run flow crashes with an opaque ``Input should be a valid list`` + pydantic error. Coercing ``None`` to ``[]`` keeps that flow working and + matches the field's own ``default_factory=list``. + """ + return [] if value is None else value + @classmethod def resolve_config_path(cls, config_path: str | None = None) -> Path: """Resolve the config file path. @@ -209,6 +224,11 @@ class AppConfig(BaseModel): config_data["extensions"] = extensions_config.model_dump() result = cls.model_validate(config_data) + if not result.models: + logger.warning( + "No models are configured in %s. Add at least one entry under `models:` (see the commented examples in config.example.yaml) or run `make setup`.", + resolved_path, + ) acp_agents = cls._validate_acp_agents(config_data.get("acp_agents", {})) cls._apply_singleton_configs(result, acp_agents) return result diff --git a/backend/tests/test_app_config_reload.py b/backend/tests/test_app_config_reload.py index 3f744aee2..c0bc00bff 100644 --- a/backend/tests/test_app_config_reload.py +++ b/backend/tests/test_app_config_reload.py @@ -140,6 +140,57 @@ def test_app_config_defaults_empty_database_to_sqlite(tmp_path, monkeypatch): assert config.database.sqlite_dir == ".deer-flow/data" +def test_app_config_coerces_commented_out_list_sections(tmp_path, monkeypatch): + """Commenting out every entry under a list key makes PyYAML parse it as None. + + Regression for the documented ``cp config.example.yaml config.yaml`` flow + (issue #1444): such a config must load with empty lists instead of raising + ``Input should be a valid list``. + """ + config_path = tmp_path / "config.yaml" + extensions_path = tmp_path / "extensions_config.json" + _write_extensions_config(extensions_path) + config_path.write_text( + yaml.safe_dump( + { + "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, + "models": None, + "tools": None, + "tool_groups": None, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) + + config = AppConfig.from_file(str(config_path)) + + assert config.models == [] + assert config.tools == [] + assert config.tool_groups == [] + + +def test_app_config_warns_when_no_models_configured(tmp_path, monkeypatch, caplog): + config_path = tmp_path / "config.yaml" + extensions_path = tmp_path / "extensions_config.json" + _write_extensions_config(extensions_path) + config_path.write_text( + yaml.safe_dump( + { + "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, + "models": None, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) + + with caplog.at_level("WARNING", logger="deerflow.config.app_config"): + AppConfig.from_file(str(config_path)) + + assert "No models are configured" in caplog.text + + def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch): config_path = tmp_path / "config.yaml" extensions_path = tmp_path / "extensions_config.json"