diff --git a/CHANGELOG.md b/CHANGELOG.md index e87a8e7bb4..2747885c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -327,6 +327,17 @@ This release closes that milestone with **181 merged pull requests**. ### Fixed +- **doctor:** `make doctor` now checks the config file the Gateway actually + loads. It always inspected `/config.yaml` and ignored + `DEER_FLOW_CONFIG_PATH` and `DEER_FLOW_PROJECT_ROOT`, so a missing override + that stops the Gateway from starting still reported `✓ config.yaml found` + and `✓ config.yaml loadable`, and a valid override pointing elsewhere got + the wrong file checked. Doctor now resolves the path through the harness's + own resolver and hands it the location variables the way `make dev` does: + `.env` values override the shell (expanding an unquoted leading `~`), and + an unset or empty `DEER_FLOW_PROJECT_ROOT` becomes the checkout. An override + the Gateway would reject fails `config.yaml found` with the Gateway's + error, and the config checks skip. ([#5987]) - **database:** `DatabaseConfig` now validates `pool_size`, `pool_recycle`, and `command_timeout` strictly. Previously, YAML booleans (`true`/`false`) were coerced to `1`/`0` respectively, allowing `pool_size: true` (pool size 1) and @@ -6214,4 +6225,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#5964]: https://github.com/bytedance/deer-flow/pull/5964 [#5981]: https://github.com/bytedance/deer-flow/pull/5981 [#5982]: https://github.com/bytedance/deer-flow/pull/5982 +[#5987]: https://github.com/bytedance/deer-flow/pull/5987 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 7d12e9b2c3..6edbacfb1f 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -332,6 +332,15 @@ ### 修复 +- **doctor:** `make doctor` 现在检查 Gateway 实际加载的配置文件。此前它 + 固定检查 `/config.yaml`,忽略 `DEER_FLOW_CONFIG_PATH` 与 + `DEER_FLOW_PROJECT_ROOT`:指向不存在路径、会让 Gateway 无法启动的覆盖值仍会 + 显示 `✓ config.yaml found` 与 `✓ config.yaml loadable`,而指向其他有效文件的 + 覆盖值则会让它检查错误的文件。现在 doctor 通过 harness 自身的解析器确定路径, + 并像 `make dev` 一样处理这两个位置变量:`.env` 中的值覆盖 shell 中的值(未加 + 引号的开头 `~` 会展开),`DEER_FLOW_PROJECT_ROOT` 未设置或为空时取仓库根目录。 + Gateway 会拒绝的覆盖值会让 `config.yaml found` 失败并给出 Gateway 的错误, + 其余配置检查随之跳过。([#5987]) - **数据库:** `DatabaseConfig` 现在严格校验 `pool_size`、`pool_recycle` 与 `command_timeout`。此前,YAML 布尔值(`true`/`false`)会被强制转换为 `1`/`0`, 导致 `pool_size: true`(变成仅 1 个连接)和 `command_timeout: true`(变成 1 秒超时) @@ -5272,3 +5281,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#5964]: https://github.com/bytedance/deer-flow/pull/5964 [#5981]: https://github.com/bytedance/deer-flow/pull/5981 [#5982]: https://github.com/bytedance/deer-flow/pull/5982 +[#5987]: https://github.com/bytedance/deer-flow/pull/5987 diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index aa007a6b20..3f7b3b6e2d 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -53,9 +53,11 @@ application repositories continue to use `database`. Configuration priority: 1. Explicit `config_path` argument -2. `DEER_FLOW_CONFIG_PATH` environment variable -3. `config.yaml` in current directory (backend/) -4. `config.yaml` in parent directory (project root - **recommended location**) +2. `DEER_FLOW_CONFIG_PATH` environment variable (a missing file is an error, not a fallthrough) +3. `config.yaml` under `DEER_FLOW_PROJECT_ROOT`, or the current directory when it is unset +4. Legacy `backend/config.yaml`, then repository-root `config.yaml` (project root is the **recommended location**) + +`scripts/doctor.py` calls `AppConfig.resolve_config_path` rather than copying this order. Config values starting with `$` are resolved as environment variables (e.g., `$OPENAI_API_KEY`). `ModelConfig` also declares `use_responses_api` and `output_version` so OpenAI `/v1/responses` can be enabled explicitly while still using `langchain_openai:ChatOpenAI`. diff --git a/backend/tests/test_doctor.py b/backend/tests/test_doctor.py index 0f7039c3dc..4a19ac5458 100644 --- a/backend/tests/test_doctor.py +++ b/backend/tests/test_doctor.py @@ -6,12 +6,14 @@ Run from repo root: from __future__ import annotations +import builtins import importlib.util import json import sys from pathlib import Path import doctor +import pytest REPO_ROOT = Path(__file__).resolve().parents[2] @@ -112,6 +114,103 @@ class TestCheckConfigExists: assert result.status == "ok" +# --------------------------------------------------------------------------- +# resolve_config_path +# --------------------------------------------------------------------------- + + +@pytest.fixture +def runtime_path_env(monkeypatch): + """Clear the runtime path variables and restore them after the test. + + ``main()`` defaults ``DEER_FLOW_PROJECT_ROOT`` in ``os.environ`` the way + ``make dev`` does. ``monkeypatch.delenv`` records nothing for an unset + variable, so set each one first to make teardown remove what main() adds. + """ + for name in ("DEER_FLOW_CONFIG_PATH", "DEER_FLOW_PROJECT_ROOT"): + monkeypatch.setenv(name, "") + monkeypatch.delenv(name) + return monkeypatch + + +class TestResolveConfigPath: + def test_uses_config_path_env(self, tmp_path, runtime_path_env): + cfg = tmp_path / "elsewhere.yaml" + cfg.write_text("config_version: 5\n") + runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(cfg)) + runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path / "root")) + + assert doctor.resolve_config_path() == (cfg, None) + + def test_missing_config_path_env_fails(self, tmp_path, runtime_path_env): + (tmp_path / "config.yaml").write_text("config_version: 5\n") + missing = tmp_path / "missing.yaml" + runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(missing)) + runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path)) + + path, failure = doctor.resolve_config_path() + + assert path == missing + assert failure is not None + assert failure.status == "fail" + assert "DEER_FLOW_CONFIG_PATH" in failure.detail + assert "DEER_FLOW_CONFIG_PATH" in failure.fix + + def test_uses_config_under_project_root_env(self, tmp_path, runtime_path_env): + cfg = tmp_path / "config.yaml" + cfg.write_text("config_version: 5\n") + runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path)) + + assert doctor.resolve_config_path() == (cfg, None) + + def test_missing_project_root_env_fails(self, tmp_path, runtime_path_env): + missing_root = tmp_path / "missing-root" + runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(missing_root)) + + path, failure = doctor.resolve_config_path() + + assert not path.exists() + assert failure is not None + assert failure.status == "fail" + assert "DEER_FLOW_PROJECT_ROOT" in failure.detail + assert "DEER_FLOW_PROJECT_ROOT" in failure.fix + + def test_no_config_anywhere_is_a_plain_missing_config(self, tmp_path, runtime_path_env): + from deerflow.config import app_config + + runtime_path_env.setattr(app_config, "_legacy_config_candidates", lambda: ()) + runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path)) + + path, failure = doctor.resolve_config_path() + + assert path == tmp_path / "config.yaml" + assert failure is None + assert doctor.check_config_exists(path).fix == "Run 'make setup' to create it" + + @pytest.mark.parametrize("error", [ImportError("no module"), TypeError("ABI mismatch"), SyntaxError("bad syntax")], ids=lambda e: type(e).__name__) + def test_unimportable_harness_is_reported_not_raised(self, tmp_path, runtime_path_env, error): + # A broken backend environment is exactly what doctor must diagnose, + # whatever the harness raises while its module body executes. + real_import = builtins.__import__ + + def broken_import(name, *args, **kwargs): + if name == "deerflow.config.app_config": + raise error + return real_import(name, *args, **kwargs) + + runtime_path_env.setattr(builtins, "__import__", broken_import) + runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path)) + + path, failure = doctor.resolve_config_path() + + assert path == tmp_path / "config.yaml" + assert failure is not None + assert failure.status == "fail" + assert "harness" in failure.detail + assert type(error).__name__ in failure.detail + assert failure.fix == "Run 'make install'" + + # --------------------------------------------------------------------------- # check_config_version # --------------------------------------------------------------------------- @@ -869,17 +968,35 @@ class TestCheckSandbox: # --------------------------------------------------------------------------- -class TestMainExitCode: - def test_returns_int(self, tmp_path, monkeypatch, capsys): - """main() should return 0 or 1 without raising.""" - repo_root = tmp_path / "repo" - scripts_dir = repo_root / "scripts" - scripts_dir.mkdir(parents=True) - fake_doctor = scripts_dir / "doctor.py" - fake_doctor.write_text("# test-only shim for __file__ resolution\n") +def _fake_checkout(tmp_path: Path, monkeypatch) -> Path: + """Point doctor at an empty checkout under ``tmp_path``. - monkeypatch.chdir(repo_root) - monkeypatch.setattr(doctor, "__file__", str(fake_doctor)) + The harness's legacy config fallback is pinned to the same checkout, so + whether the real repository has a ``config.yaml`` cannot leak in. + """ + from deerflow.config import app_config + + repo_root = tmp_path / "repo" + scripts_dir = repo_root / "scripts" + scripts_dir.mkdir(parents=True) + fake_doctor = scripts_dir / "doctor.py" + fake_doctor.write_text("# test-only shim for __file__ resolution\n") + + monkeypatch.chdir(repo_root) + monkeypatch.setattr(doctor, "__file__", str(fake_doctor)) + monkeypatch.setattr( + app_config, + "_legacy_config_candidates", + lambda: (repo_root / "backend" / "config.yaml", repo_root / "config.yaml"), + ) + return repo_root + + +class TestMainExitCode: + def test_returns_int(self, tmp_path, runtime_path_env, capsys): + """main() should return 0 or 1 without raising.""" + monkeypatch = runtime_path_env + _fake_checkout(tmp_path, monkeypatch) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("TAVILY_API_KEY", raising=False) @@ -892,3 +1009,138 @@ class TestMainExitCode: assert output assert "config.yaml" in output assert ".env" in output + + +class TestMainConfigResolution: + def test_missing_config_path_env_fails_even_with_a_checkout_config(self, tmp_path, runtime_path_env, capsys): + repo_root = _fake_checkout(tmp_path, runtime_path_env) + (repo_root / "config.yaml").write_text("config_version: 5\nmodels: []\n") + runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "missing.yaml")) + + exit_code = doctor.main() + + output = capsys.readouterr().out + assert exit_code == 1 + assert "✗ config.yaml found" in output + assert "DEER_FLOW_CONFIG_PATH" in output + # The checkout's config.yaml is not the one the Gateway would read, so + # nothing is reported about it. + assert "— config.yaml loadable" in output + assert "— models configured" in output + + def test_checks_the_config_named_by_config_path_env(self, tmp_path, runtime_path_env, capsys): + _fake_checkout(tmp_path, runtime_path_env) + cfg = tmp_path / "custom.yaml" + cfg.write_text("config_version: 5\nmodels: []\n") + runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(cfg)) + + doctor.main() + + output = capsys.readouterr().out + assert "✓ config.yaml found" in output + assert "✗ models configured (no models found)" in output + + @pytest.mark.parametrize("project_root_env", [None, ""], ids=["unset", "empty"]) + def test_defaults_project_root_to_the_checkout_like_make_dev(self, tmp_path, runtime_path_env, capsys, project_root_env): + repo_root = _fake_checkout(tmp_path, runtime_path_env) + # `make dev` runs from backend/, but serve.sh pins an unset or empty + # runtime root to the checkout, so the Gateway prefers + # /config.yaml over the legacy backend/config.yaml. + # Resolving from the cwd would pick the backend copy instead. + if project_root_env is not None: + runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", project_root_env) + backend_dir = repo_root / "backend" + backend_dir.mkdir() + runtime_path_env.chdir(backend_dir) + (backend_dir / "config.yaml").write_text("config_version: 5\nmodels: []\n") + (repo_root / "config.yaml").write_text("config_version: 5\nmodels:\n - name: checkout-model\n") + + doctor.main() + + output = capsys.readouterr().out + assert "✓ config.yaml found" in output + assert "✓ models configured (1 model(s))" in output + + def test_missing_project_root_env_fails(self, tmp_path, runtime_path_env, capsys): + repo_root = _fake_checkout(tmp_path, runtime_path_env) + (repo_root / "config.yaml").write_text("config_version: 5\nmodels: []\n") + runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path / "missing-root")) + + exit_code = doctor.main() + + output = capsys.readouterr().out + assert exit_code == 1 + assert "✗ config.yaml found" in output + assert "DEER_FLOW_PROJECT_ROOT" in output + + def test_dotenv_config_path_overrides_the_shell_like_make_dev(self, tmp_path, runtime_path_env, capsys): + # serve.sh sources .env over the shell, so the Gateway loads the .env + # config even when the shell exports a different (missing) one. + repo_root = _fake_checkout(tmp_path, runtime_path_env) + cfg = tmp_path / "from-dotenv.yaml" + cfg.write_text("config_version: 5\nmodels:\n - name: dotenv-model\n") + (repo_root / ".env").write_text(f"DEER_FLOW_CONFIG_PATH={cfg}\n") + runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "missing.yaml")) + + doctor.main() + + output = capsys.readouterr().out + assert "✓ config.yaml found" in output + assert "✓ models configured (1 model(s))" in output + + def test_dotenv_project_root_overrides_the_shell_like_make_dev(self, tmp_path, runtime_path_env, capsys): + repo_root = _fake_checkout(tmp_path, runtime_path_env) + (repo_root / "config.yaml").write_text("config_version: 5\nmodels:\n - name: checkout-model\n") + (repo_root / ".env").write_text(f"DEER_FLOW_PROJECT_ROOT={repo_root}\n") + runtime_path_env.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path / "missing-root")) + + doctor.main() + + output = capsys.readouterr().out + assert "✓ config.yaml found" in output + assert "✓ models configured (1 model(s))" in output + + def test_empty_dotenv_config_path_clears_the_shell_value_like_make_dev(self, tmp_path, runtime_path_env, capsys): + # Sourcing `DEER_FLOW_CONFIG_PATH=` exports an empty value, which the + # resolver skips, so the Gateway falls back to /config.yaml. + repo_root = _fake_checkout(tmp_path, runtime_path_env) + (repo_root / "config.yaml").write_text("config_version: 5\nmodels:\n - name: checkout-model\n") + (repo_root / ".env").write_text("DEER_FLOW_CONFIG_PATH=\n") + runtime_path_env.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "missing.yaml")) + + doctor.main() + + output = capsys.readouterr().out + assert "✓ config.yaml found" in output + assert "✓ models configured (1 model(s))" in output + + @pytest.mark.parametrize( + ("name", "value", "found"), + [ + ("DEER_FLOW_CONFIG_PATH", "~/cfg.yaml", True), + ("DEER_FLOW_PROJECT_ROOT", "~/repo", True), + # bash leaves a quoted tilde literal, so the Gateway fails too. + ("DEER_FLOW_CONFIG_PATH", '"~/cfg.yaml"', False), + ], + ids=["config-path", "project-root", "quoted-config-path"], + ) + def test_dotenv_location_tilde_expands_like_source(self, tmp_path, runtime_path_env, capsys, name, value, found): + # serve.sh's `source .env` expands an unquoted leading `~` (bash tilde + # expansion in an assignment) and keeps a quoted one literal. + repo_root = _fake_checkout(tmp_path, runtime_path_env) + for home_var in ("HOME", "USERPROFILE"): + runtime_path_env.setenv(home_var, str(tmp_path)) + (tmp_path / "cfg.yaml").write_text("config_version: 5\nmodels:\n - name: home-model\n") + (repo_root / "config.yaml").write_text("config_version: 5\nmodels:\n - name: checkout-model\n") + (repo_root / ".env").write_text(f"{name}={value}\n") + + exit_code = doctor.main() + + output = capsys.readouterr().out + if found: + assert "✓ config.yaml found" in output + assert "✓ models configured (1 model(s))" in output + else: + assert exit_code == 1 + assert "✗ config.yaml found" in output + assert "~/cfg.yaml" in output diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 5bd376ae70..c5888bcbb3 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -40,6 +40,20 @@ where `make up` replaced the operator's secret with a generated one. its real-Compose cases run against the installed `docker` CLI and against any standalone binaries listed in `DEER_FLOW_TEST_COMPOSE_BINARIES`. +`doctor.py` checks the config file the Gateway would load, not a fixed +`/config.yaml`. It mirrors how `serve.sh` hands the two +config-location variables to the Gateway: `.env` values for +`DEER_FLOW_CONFIG_PATH` / `DEER_FLOW_PROJECT_ROOT` override the shell (other +keys stay shell-first), an unquoted leading `~` in them expands as `source` +does (a quoted one stays literal), and an unset or empty +`DEER_FLOW_PROJECT_ROOT` becomes the checkout. It then asks the harness +(`AppConfig.resolve_config_path`) instead of re-implementing its order. An +override the Gateway would reject (`DEER_FLOW_CONFIG_PATH` missing, +`DEER_FLOW_PROJECT_ROOT` not a directory) fails `config.yaml found` with the +Gateway's error, and the config-dependent checks skip. Any failure to import +the harness is reported, never raised: doctor diagnoses broken environments. +Pinned by `backend/tests/test_doctor.py::TestMainConfigResolution`. + Root `make install` runs pre-commit through uv, so uv's tool bin directory need not be on `PATH`. diff --git a/scripts/doctor.py b/scripts/doctor.py index a18641fb0d..a97de499c5 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -273,6 +273,64 @@ def check_nginx() -> CheckResult: ) +# Environment variables that choose which config file the Gateway loads. +CONFIG_LOCATION_ENV_VARS = ("DEER_FLOW_CONFIG_PATH", "DEER_FLOW_PROJECT_ROOT") + + +def _unquoted_dotenv_keys(env_path: Path) -> set[str]: + """Return the ``.env`` keys whose value is written without quotes.""" + from dotenv.parser import parse_stream + + with open(env_path, encoding="utf-8") as stream: + return {binding.key for binding in parse_stream(stream) if binding.key and binding.original.string.split("=", 1)[-1].lstrip()[:1] not in ("'", '"')} + + +def resolve_config_path() -> tuple[Path, CheckResult | None]: + """Locate the config.yaml the Gateway would read. + + Delegates to the harness resolver (``DEER_FLOW_CONFIG_PATH``, then + ``config.yaml`` under ``DEER_FLOW_PROJECT_ROOT``, then the legacy + backend/repository-root locations) so doctor checks the same file the + Gateway loads. When an environment override would stop the Gateway from + starting, returns the path it names (which does not exist) and a failed + ``config.yaml found`` result carrying the Gateway's error. + """ + config_env = os.environ.get("DEER_FLOW_CONFIG_PATH") + default_path = Path(os.environ.get("DEER_FLOW_PROJECT_ROOT") or ".") / "config.yaml" + try: + from deerflow.config.app_config import AppConfig + except Exception as exc: + # Keep diagnosing a broken backend environment instead of crashing + # (any import-time failure, as in check_config_loadable); the + # YAML-only checks still run against the most likely path. + return Path(config_env) if config_env else default_path, CheckResult( + "config.yaml found", + "fail", + f"cannot import the DeerFlow harness to resolve it ({type(exc).__name__}: {exc})", + fix="Run 'make install'", + ) + + try: + return AppConfig.resolve_config_path(), None + except FileNotFoundError as exc: + if not config_env: + # Nothing at the default locations: a plain missing config. + return default_path, None + return Path(config_env), CheckResult( + "config.yaml found", + "fail", + str(exc), + fix="Point DEER_FLOW_CONFIG_PATH at an existing config.yaml, or unset it", + ) + except ValueError as exc: + return Path(os.environ["DEER_FLOW_PROJECT_ROOT"]) / "config.yaml", CheckResult( + "config.yaml found", + "fail", + str(exc), + fix="Point DEER_FLOW_PROJECT_ROOT at the DeerFlow checkout, or unset it", + ) + + def check_config_exists(config_path: Path) -> CheckResult: if config_path.exists(): return CheckResult("config.yaml found", "ok") @@ -760,18 +818,31 @@ def check_env_file(project_root: Path) -> CheckResult: def main() -> int: project_root = Path(__file__).resolve().parents[1] - config_path = project_root / "config.yaml" # Load .env early so key checks work try: - from dotenv import load_dotenv + from dotenv import dotenv_values, load_dotenv env_path = project_root / ".env" if env_path.exists(): load_dotenv(env_path, override=False) + # `make dev` (scripts/serve.sh) sources .env over the shell, so + # for the variables that choose the config file, .env wins. + unquoted = _unquoted_dotenv_keys(env_path) + for name, value in dotenv_values(env_path).items(): + if name in CONFIG_LOCATION_ENV_VARS and value is not None: + # `source` expands an unquoted leading `~`, not a quoted one. + os.environ[name] = os.path.expanduser(value) if name in unquoted else value except ImportError: pass + # serve.sh then replaces an unset or empty runtime root with the + # checkout, so config resolution and the loadable check see what the + # Gateway sees. + if not os.environ.get("DEER_FLOW_PROJECT_ROOT"): + os.environ["DEER_FLOW_PROJECT_ROOT"] = str(project_root) + config_path, config_failure = resolve_config_path() + print() print(bold("DeerFlow Health Check")) print("═" * 40) @@ -792,7 +863,7 @@ def main() -> int: cfg_checks: list[CheckResult] = [ check_env_file(project_root), check_frontend_env(project_root), - check_config_exists(config_path), + config_failure or check_config_exists(config_path), check_config_version(config_path, project_root), check_config_loadable(config_path), check_models_configured(config_path),