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"