mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
* feat(memory): add Jev pre-screening and signal classification over a shared TypeSafe client - refactor(typesafe): extract a shared Jev (TypeSafe) client and unify its configuration: one connection-precedence rule, credential fingerprint, wire-size counting and error taxonomy, shared by the guardrail gate and the memory hooks. - feat(memory): wire `memory.prescreen` and `memory.signal_classification` into DeerMem through one injected judge hook (both off by default, `enforce` gated by the shadow evaluation). - config: add the top-level `typesafe:` block and the two judging slots to `config.example.yaml` and the Helm values, bump `config_version` to 50, and sync the Helm README example with it. - test(memory): add the shadow-evaluation script (`eval_memory_prescreen.py`) with per-stratum gates, censoring and the no-network baseline. Review follow-ups folded in: - `mutations_accepted` counts only facts that survive capacity enforcement and the upload-event scrub that precedes persistence, only removals/reinforcements of ids that existed, and persisted near-duplicate merges (matched by id, or by identity for legacy id-less facts); it is published only after persistence succeeds. - The cached manager hot-reloads its judge when a memory slot or the shared `typesafe` block changes (including a credential rotation); publication is serialized and revalidates the config generation, so a build started before an `enforce` -> `off` rollback cannot land after it. - Cache provenance is per answer: the `cached` flag and the reported model follow the answers a side actually consumed, across partial merges and answerless retries. - An oversized noul integer stays a per-question error instead of raising. - Incremental call savings use the shared scored denominator. - The sensitive-stratum gate requires evidence for each of identity, preference and correction. * fix(typesafe): resolve one credential per layer and refuse unusable connection values Review follow-ups on the shared TypeSafe client (#5906): - A consumer's `api_key_env` is no longer overridden by a literal `api_key` in the shared `typesafe:` block: the first layer that sets either credential setting decides it, and inside one layer the literal key wins (the standalone semantics). A consumer whose endpoint does not accept the block's key no longer sends it on every call, and the missing-credential message keeps naming the variable that layer configured. - `base_url` is validated where it is configured: `http(s)` only, with no query, fragment, embedded credentials, whitespace or control characters, and a malformed port fails at construction instead of on every request (httpx raises `UnsupportedProtocol` / `InvalidURL` per call, and the query would have been swallowed into the appended endpoint path). - A credential that cannot travel as a header value — surrounding whitespace from a secret mounted from a file, a non-printable character — is refused when the connection resolves. It used to construct fine and then put the whole `Bearer <key>` value into h11's `LocalProtocolError` message, which the guardrail middleware's `logger.exception` prints. - The client raises transport failures without chaining the original exception, so that message can no longer carry the credential through the cause chain; the exception type stays in the message for diagnosis. - `TypeSafeConfig` validates in strict mode, agreeing with the consumer `config` path: `max_attempts: true` was silently read as `1`. An integer stays a usable float, so `timeout: 5` keeps working. Tests: 20 cases across `tests/test_typesafe_config.py` and `tests/test_typesafe_client.py`, each red on the previous revision and green now. 367 focused typesafe/memory/config tests, `make test-blocking-io` (167) and the eval-script suites (50) pass; `ruff check` and `ruff format --check` are clean. Docs synced: `backend/docs/GUARDRAILS.md`, `typesafe/AGENTS.md`, `config.example.yaml`. * fix(typesafe,memory): bound the reply, record request failures, gate enforce on the evidence Second review round on #5906: the remaining items from the review thread and from the follow-up pass. Typesafe client (the reply is treated as hostile input too): - Send `Accept-Encoding: identity`, refuse a body that carries a non-identity `Content-Encoding`, and read through a bounded `iter_bytes` that stops past `MAX_RESPONSE_BYTES` (64 KiB). A gzip body that expands to 128 MiB used to be decoded in full and, on the gate path, allowed the tool call unexamined; it is now `invalid_response` (a denial with `fail_closed: true`). - Map `RecursionError` from `json.loads` to `invalid_response`: deep nesting escaped the module's taxonomy, so the memory consumers lost the round's record instead of recording a fallback. Memory judge: - A request-level failure is recorded as `request_failed`, never `no_verdict` (`REASON_REQUEST_FAILED` was defined and never emitted, so an unreachable endpoint was indistinguishable from a provider with no usable answer). The adapters' `decide()` now propagates `TypeSafeError` for the coordinator to record and log; `no_verdict` stays for a question-level failure, and an ineligible side keeps its own reason even when the round's request failed. Shadow evaluation (the enablement gate reads the evidence, not one number): - The miss-rate gate requires the exact one-sided Clopper-Pearson upper bound to meet the 1% target; a sample that only meets the point estimate is `INSUFFICIENT` (0/250 was reported as a pass with a 1.19% bound). - New `baseline_incremental_savings` gate: a baseline that saves as many calls as the model — the shipped all-trivial sample — fails instead of enabling. - Negative counts (`mutations_accepted`, message / batch / token counts) are treated as absent rather than scored: `-3` read as "nothing lost", adding a phantom saving to the numerator and the denominator. Docs: the design documents cited but absent from the repository are replaced with in-repo pointers (`MEMORY_IMPROVEMENTS.md`, `agents/memory/AGENTS.md`, `GUARDRAILS.md`), and the gate and limit wording now matches what the code enforces. Tests: 31 new or updated cases, each red on the previous revision (compressed body refused even when it decodes to a valid envelope, body over the cap refused, body at the cap answered, deep nesting mapped, `request_failed` on each request-level failure, the bound and baseline gates, the negative counter). 454 focused tests, `make test-blocking-io` (167) and the eval suites pass; `ruff check` and `ruff format --check` are clean. * fix(memory-eval): pair the baseline comparison on the same records Review follow-up on the incremental-savings gate added in ed930478 (P2): both sides of the comparison are now the records the no-network baseline can score (`trivial_only` plus a readable signals list). Subtracting that subset's heuristic savings from model savings over *all* scorable skips credited the model with batches the baseline never saw: 300 reviewed skips with a single annotated record computed 300-1=299 "incremental", passed the gate and set `enable_recommended`, while the paired comparison was model=1 against heuristic=1 (incremental 0). - `Evidence` carries the paired numbers (`baseline_paired_skips`, `baseline_model_saved_calls`, `baseline_heuristic_saved_calls`) instead of one heuristic count, filled by `build_report` from the baseline summary. - The gate reports `INSUFFICIENT` when no scored skip carries the annotation and when the annotation covers only a subset of the scored skips (the message carries the paired counts and the `paired of scored` coverage), `FAIL` on a non-positive paired gain, and `PASS` only on a positive gain over the full paired set. - Docs state the pairing/coverage requirement in `backend/docs/MEMORY_IMPROVEMENTS.md` and in the script docstring. Tests: the reviewer's counterexample as a report-level regression (it failed with `assert 'PASS' == 'INSUFFICIENT'` on ed930478 while `enable_recommended` was true), a unit case for the paired-subset `INSUFFICIENT`, and the renamed paired fields. 344 focused tests, `make test-blocking-io` (167) and the eval suites pass; `ruff check` / `ruff format --check` clean. * fix(typesafe): bound the sync body read by the deadline `deadline_seconds` only covered the sync path's headers, the completed body and the parse, so an endpoint that drips bytes just under the read timeout held the caller for one read per piece — the whole of `MAX_RESPONSE_BYTES` past the budget. The read loop now checks the budget before every transport read, which bounds the overshoot to one read. The pieces are the transport's own reads, not re-chunked output: `iter_bytes(chunk_size)` pulls many reads inside a single yielded chunk, so a check at that granularity would never see a drip. `tests/test_typesafe_client.py` pins it with a valid envelope delivered as 24 pieces of 50 ms against a 0.2 s deadline: red before (24 pieces pulled), green now (~5), on both the sync and async paths. Docstring and `typesafe/AGENTS.md` track the new granularity. * fix(memory): read the YAML off spelling, and veto a skip over the whole batch Two review findings on the memory judging path. `memory.prescreen.mode` / `memory.signal_classification.mode` rejected the `mode: off` spelling `config.example.yaml` ships and a rollback types: YAML 1.1 parses the unquoted value as a boolean, so both fields saw `False`. The failed reload left the previous judge running, so both fields now normalize `False` to `off` before the `Literal` runs. `True` stays a validation error (no mode it could mean). The examples quote the value, and the docs record why. The pre-screen's L3 veto read `detect_signals`' 6-message window while the batch it answers for is the whole post-watermark feed, so an explicit correction in an earlier turn of an eight-message batch did not stop the skip that then advanced the watermark past it. `detect_signals` gains a `window` parameter (default unchanged) and the updater scans the full feed for the veto; the extraction hint and the reinforcement gate keep the window. Both regressions are red on the previous head: the config test reproduced the `input_value=False` rejection through `AppConfig.from_file`, and the batch test reproduced the prescreen judging (and skipping) a batch whose first turn carries the correction.
961 lines
36 KiB
Python
961 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
from pydantic import ValidationError
|
|
|
|
import deerflow.config.app_config as app_config_module
|
|
from deerflow.config.acp_config import load_acp_config_from_dict
|
|
from deerflow.config.agents_api_config import get_agents_api_config, load_agents_api_config_from_dict
|
|
from deerflow.config.app_config import AppConfig, get_app_config, peek_loaded_app_config, reset_app_config
|
|
from deerflow.config.checkpointer_config import get_checkpointer_config, load_checkpointer_config_from_dict
|
|
from deerflow.config.database_config import DatabaseConfig
|
|
from deerflow.config.guardrails_config import get_guardrails_config, load_guardrails_config_from_dict
|
|
from deerflow.config.memory_config import get_memory_config, load_memory_config_from_dict
|
|
from deerflow.config.stream_bridge_config import get_stream_bridge_config, load_stream_bridge_config_from_dict
|
|
from deerflow.config.subagents_config import get_subagents_app_config, load_subagents_config_from_dict
|
|
from deerflow.config.summarization_config import get_summarization_config, load_summarization_config_from_dict
|
|
from deerflow.config.title_config import get_title_config, load_title_config_from_dict
|
|
from deerflow.config.tool_search_config import get_tool_search_config, load_tool_search_config_from_dict
|
|
from deerflow.runtime.checkpointer import get_checkpointer, reset_checkpointer
|
|
from deerflow.runtime.store import get_store, reset_store
|
|
from deerflow.storage import BlobNotConfiguredError, get_blob_store, get_blob_store_if_enabled, reset_blob_store
|
|
|
|
|
|
def _reset_config_singletons() -> None:
|
|
load_title_config_from_dict({})
|
|
load_summarization_config_from_dict({})
|
|
load_memory_config_from_dict({})
|
|
load_agents_api_config_from_dict({})
|
|
load_subagents_config_from_dict({})
|
|
load_tool_search_config_from_dict({})
|
|
load_guardrails_config_from_dict({})
|
|
load_checkpointer_config_from_dict(None)
|
|
load_stream_bridge_config_from_dict(None)
|
|
load_acp_config_from_dict({})
|
|
reset_checkpointer()
|
|
reset_store()
|
|
reset_app_config()
|
|
|
|
|
|
def _write_config(path: Path, *, model_name: str, supports_thinking: bool) -> None:
|
|
path.write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"models": [
|
|
{
|
|
"name": model_name,
|
|
"use": "langchain_openai:ChatOpenAI",
|
|
"model": "gpt-test",
|
|
"supports_thinking": supports_thinking,
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _write_config_with_agents_api(
|
|
path: Path,
|
|
*,
|
|
model_name: str,
|
|
supports_thinking: bool,
|
|
agents_api: dict | None = None,
|
|
) -> None:
|
|
config = {
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"models": [
|
|
{
|
|
"name": model_name,
|
|
"use": "langchain_openai:ChatOpenAI",
|
|
"model": "gpt-test",
|
|
"supports_thinking": supports_thinking,
|
|
}
|
|
],
|
|
}
|
|
if agents_api is not None:
|
|
config["agents_api"] = agents_api
|
|
|
|
path.write_text(yaml.safe_dump(config), encoding="utf-8")
|
|
|
|
|
|
def _write_config_with_sections(path: Path, sections: dict | None = None) -> None:
|
|
config = {
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"models": [
|
|
{
|
|
"name": "first-model",
|
|
"use": "langchain_openai:ChatOpenAI",
|
|
"model": "gpt-test",
|
|
}
|
|
],
|
|
}
|
|
if sections:
|
|
config.update(sections)
|
|
|
|
path.write_text(yaml.safe_dump(config), encoding="utf-8")
|
|
|
|
|
|
def _write_extensions_config(path: Path) -> None:
|
|
path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
|
|
|
|
|
|
def test_checkpoint_channel_mode_defaults_to_full() -> None:
|
|
assert DatabaseConfig().checkpoint_channel_mode == "full"
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["full", "delta"])
|
|
def test_checkpoint_channel_mode_accepts_supported_values(mode: str) -> None:
|
|
assert DatabaseConfig(checkpoint_channel_mode=mode).checkpoint_channel_mode == mode
|
|
|
|
|
|
def test_checkpoint_channel_mode_rejects_unknown_value() -> None:
|
|
with pytest.raises(ValidationError):
|
|
DatabaseConfig(checkpoint_channel_mode="auto")
|
|
|
|
|
|
def test_checkpoint_delta_defaults_to_production_cadence() -> None:
|
|
assert DatabaseConfig().checkpoint_delta.snapshot_frequency == 10
|
|
|
|
|
|
def test_checkpoint_delta_accepts_custom_snapshot_frequency() -> None:
|
|
assert DatabaseConfig(checkpoint_delta={"snapshot_frequency": 250}).checkpoint_delta.snapshot_frequency == 250
|
|
|
|
|
|
@pytest.mark.parametrize("value", [0, -1])
|
|
def test_checkpoint_delta_rejects_non_positive_snapshot_frequency(value: int) -> None:
|
|
with pytest.raises(ValidationError):
|
|
DatabaseConfig(checkpoint_delta={"snapshot_frequency": value})
|
|
|
|
|
|
def test_legacy_snapshot_frequency_maps_to_nested_key(caplog: pytest.LogCaptureFixture) -> None:
|
|
with caplog.at_level("WARNING"):
|
|
config = DatabaseConfig(checkpoint_delta_snapshot_frequency=1000)
|
|
assert config.checkpoint_delta.snapshot_frequency == 1000
|
|
assert "checkpoint_delta_snapshot_frequency is deprecated" in caplog.text
|
|
|
|
|
|
def test_nested_snapshot_frequency_wins_over_legacy_key(caplog: pytest.LogCaptureFixture) -> None:
|
|
with caplog.at_level("WARNING"):
|
|
config = DatabaseConfig(
|
|
checkpoint_delta_snapshot_frequency=1000,
|
|
checkpoint_delta={"snapshot_frequency": 250},
|
|
)
|
|
assert config.checkpoint_delta.snapshot_frequency == 250
|
|
assert "the nested key wins" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize("value", [0, -1])
|
|
def test_legacy_snapshot_frequency_rejects_non_positive_value(value: int) -> None:
|
|
with pytest.raises(ValidationError):
|
|
DatabaseConfig(checkpoint_delta_snapshot_frequency=value)
|
|
|
|
|
|
def test_checkpoint_graph_cache_defaults() -> None:
|
|
assert DatabaseConfig().checkpoint_graph_cache.accessor_graph_max == 64
|
|
|
|
|
|
def test_checkpoint_graph_cache_accepts_custom_cap() -> None:
|
|
assert DatabaseConfig(checkpoint_graph_cache={"accessor_graph_max": 8}).checkpoint_graph_cache.accessor_graph_max == 8
|
|
|
|
|
|
@pytest.mark.parametrize("value", [0, -1])
|
|
def test_checkpoint_graph_cache_rejects_non_positive_cap(value: int) -> None:
|
|
with pytest.raises(ValidationError):
|
|
DatabaseConfig(checkpoint_graph_cache={"accessor_graph_max": value})
|
|
|
|
|
|
def test_resolve_checkpoint_graph_cache_max_tolerates_stub_configs() -> None:
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
from deerflow.config.database_config import resolve_checkpoint_graph_cache_max
|
|
|
|
assert resolve_checkpoint_graph_cache_max(None, "accessor_graph_max", 64) == 64
|
|
assert resolve_checkpoint_graph_cache_max(SimpleNamespace(), "accessor_graph_max", 64) == 64
|
|
assert resolve_checkpoint_graph_cache_max(MagicMock(), "accessor_graph_max", 64) == 64
|
|
stub = SimpleNamespace(checkpoint_graph_cache=SimpleNamespace(accessor_graph_max=3))
|
|
assert resolve_checkpoint_graph_cache_max(stub, "accessor_graph_max", 64) == 3
|
|
|
|
|
|
def test_config_example_does_not_enable_empty_extensions_block_by_default():
|
|
config_example_path = Path(__file__).resolve().parents[2] / "config.example.yaml"
|
|
|
|
config_data = yaml.safe_load(config_example_path.read_text(encoding="utf-8"))
|
|
|
|
assert "extensions" not in config_data
|
|
|
|
|
|
def test_app_config_defaults_missing_database_to_sqlite(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config(config_path, model_name="first-model", supports_thinking=False)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
|
|
config = AppConfig.from_file(str(config_path))
|
|
|
|
assert config.database.backend == "sqlite"
|
|
assert config.database.sqlite_dir == ".deer-flow/data"
|
|
|
|
|
|
def test_app_config_preserves_config_yaml_extension_middlewares(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
extensions_path.write_text(
|
|
json.dumps({"mcpServers": {}, "skills": {}, "middlewares": ["pkg.from_file:FileMiddleware"]}),
|
|
encoding="utf-8",
|
|
)
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{
|
|
"extensions": {
|
|
"middlewares": ["pkg.from_yaml:YamlMiddleware"],
|
|
}
|
|
},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
|
|
config = AppConfig.from_file(str(config_path))
|
|
|
|
assert config.extensions.middlewares == ["pkg.from_yaml:YamlMiddleware"]
|
|
|
|
|
|
def test_app_config_normalizes_config_yaml_extension_aliases_before_override(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
extensions_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"mcpServers": {
|
|
"from-file": {
|
|
"command": "file-mcp",
|
|
}
|
|
},
|
|
"skills": {},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{
|
|
"extensions": {
|
|
"mcp_servers": {
|
|
"from-yaml": {
|
|
"command": "yaml-mcp",
|
|
}
|
|
},
|
|
}
|
|
},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
|
|
config = AppConfig.from_file(str(config_path))
|
|
|
|
assert set(config.extensions.mcp_servers) == {"from-yaml"}
|
|
assert config.extensions.mcp_servers["from-yaml"].command == "yaml-mcp"
|
|
|
|
|
|
def test_app_config_loads_extension_middlewares_from_extensions_config(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
extensions_path.write_text(
|
|
json.dumps({"mcpServers": {}, "skills": {}, "middlewares": ["pkg.from_file:FileMiddleware"]}),
|
|
encoding="utf-8",
|
|
)
|
|
_write_config_with_sections(config_path)
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
|
|
config = AppConfig.from_file(str(config_path))
|
|
|
|
assert config.extensions.middlewares == ["pkg.from_file:FileMiddleware"]
|
|
|
|
|
|
def test_app_config_loads_middleware_kwargs_from_config_yaml(tmp_path, monkeypatch):
|
|
from deerflow.config.extensions_config import ConfiguredMiddlewareSpec
|
|
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
extensions_path.write_text(
|
|
json.dumps({"mcpServers": {}, "skills": {}, "middlewares": ["pkg.from_file:FileMiddleware"]}),
|
|
encoding="utf-8",
|
|
)
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{
|
|
"extensions": {
|
|
"middlewares": [
|
|
"pkg.from_yaml:PlainMiddleware",
|
|
{"class": "pkg.from_yaml:KwargsMiddleware", "kwargs": {"max_tool_calls": 4}},
|
|
],
|
|
}
|
|
},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
|
|
config = AppConfig.from_file(str(config_path))
|
|
|
|
assert config.extensions.middlewares[0] == "pkg.from_yaml:PlainMiddleware"
|
|
spec = config.extensions.middlewares[1]
|
|
assert isinstance(spec, ConfiguredMiddlewareSpec)
|
|
assert spec.class_path == "pkg.from_yaml:KwargsMiddleware"
|
|
assert spec.kwargs == {"max_tool_calls": 4}
|
|
|
|
|
|
def test_app_config_defaults_empty_database_to_sqlite(tmp_path, monkeypatch):
|
|
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(
|
|
{
|
|
"database": {},
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
|
|
config = AppConfig.from_file(str(config_path))
|
|
|
|
assert config.database.backend == "sqlite"
|
|
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_coerces_commented_out_object_sections(tmp_path, monkeypatch):
|
|
"""Commenting out every entry under an object key makes PyYAML parse it as None.
|
|
|
|
Same documented ``cp config.example.yaml config.yaml`` flow as the list
|
|
sections: object sections (memory, summarization, ...) must fall back to
|
|
their defaults instead of raising ``Input should be a valid dictionary``.
|
|
"""
|
|
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"},
|
|
"memory": None,
|
|
"summarization": None,
|
|
"guardrails": None,
|
|
"tool_output": None,
|
|
"run_events": None,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
|
|
config = AppConfig.from_file(str(config_path))
|
|
|
|
# Each present-but-null object section falls back to a real default config
|
|
# object of the expected type (not merely non-None).
|
|
assert type(config.memory).__name__ == "MemoryConfig"
|
|
assert type(config.summarization).__name__ == "SummarizationConfig"
|
|
assert type(config.guardrails).__name__ == "GuardrailsConfig"
|
|
assert type(config.tool_output).__name__ == "ToolOutputConfig"
|
|
assert type(config.run_events).__name__ == "RunEventsConfig"
|
|
|
|
|
|
def test_app_config_null_required_section_still_errors(tmp_path, monkeypatch):
|
|
"""A present-but-null *required* section still errors.
|
|
|
|
``sandbox`` has no default, so dropping a ``sandbox: null`` key leaves the
|
|
required field absent — there is nothing to fall back to (per
|
|
``_drop_null_config_sections``), unlike the optional object sections above.
|
|
"""
|
|
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": None}), encoding="utf-8")
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
|
|
with pytest.raises(ValidationError):
|
|
AppConfig.from_file(str(config_path))
|
|
|
|
|
|
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"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config(config_path, model_name="first-model", supports_thinking=False)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
reset_app_config()
|
|
|
|
try:
|
|
initial = get_app_config()
|
|
assert initial.models[0].supports_thinking is False
|
|
|
|
_write_config(config_path, model_name="first-model", supports_thinking=True)
|
|
next_mtime = config_path.stat().st_mtime + 5
|
|
os.utime(config_path, (next_mtime, next_mtime))
|
|
|
|
reloaded = get_app_config()
|
|
assert reloaded.models[0].supports_thinking is True
|
|
assert reloaded is not initial
|
|
finally:
|
|
reset_app_config()
|
|
|
|
|
|
def test_peek_loaded_app_config_survives_a_missing_file(tmp_path, monkeypatch):
|
|
"""A host running on a config keeps that value after the file disappears.
|
|
|
|
This is the distinction ``app.gateway.authz`` needs for the plugin
|
|
authorization gate: a failed ``get_app_config()`` under a host that has a
|
|
configuration means "the policy I am running on is unreadable right now",
|
|
not "authorization was never configured".
|
|
"""
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config(config_path, model_name="model-a", supports_thinking=False)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
reset_app_config()
|
|
|
|
try:
|
|
assert peek_loaded_app_config() is None
|
|
|
|
get_app_config()
|
|
|
|
config_path.unlink()
|
|
with pytest.raises(FileNotFoundError):
|
|
get_app_config()
|
|
|
|
loaded = peek_loaded_app_config()
|
|
assert loaded is not None
|
|
assert loaded.models[0].name == "model-a"
|
|
finally:
|
|
reset_app_config()
|
|
|
|
|
|
def test_get_app_config_reloads_when_content_digest_changes_without_metadata(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config(config_path, model_name="model-a", supports_thinking=False)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
_reset_config_singletons()
|
|
|
|
try:
|
|
initial = get_app_config()
|
|
initial_mtime = app_config_module._app_config_mtime
|
|
initial_signature = app_config_module._app_config_signature
|
|
assert initial.models[0].name == "model-a"
|
|
assert initial_signature is not None
|
|
|
|
_write_config(config_path, model_name="model-b", supports_thinking=False)
|
|
|
|
real_get_config_signature = app_config_module._get_config_signature
|
|
|
|
def stale_metadata_signature(path: Path):
|
|
current_signature = real_get_config_signature(path)
|
|
assert current_signature is not None
|
|
return (initial_signature[0], initial_signature[1], current_signature[2])
|
|
|
|
real_read_config_with_signature = app_config_module._read_config_with_signature
|
|
|
|
def stale_metadata_reader(path: Path):
|
|
# The loader signs the bytes it parses; on a mount with stale
|
|
# metadata its stat sees the same stale mtime/size as the probe.
|
|
data, current_signature = real_read_config_with_signature(path)
|
|
return data, (initial_signature[0], initial_signature[1], current_signature[2])
|
|
|
|
monkeypatch.setattr(app_config_module, "_get_config_mtime", lambda _path: initial_mtime)
|
|
monkeypatch.setattr(app_config_module, "_get_config_signature", stale_metadata_signature)
|
|
monkeypatch.setattr(app_config_module, "_read_config_with_signature", stale_metadata_reader)
|
|
|
|
reloaded = get_app_config()
|
|
assert reloaded.models[0].name == "model-b"
|
|
assert reloaded is not initial
|
|
assert app_config_module._app_config_signature is not None
|
|
assert app_config_module._app_config_signature[:2] == initial_signature[:2]
|
|
assert app_config_module._app_config_signature[2] != initial_signature[2]
|
|
finally:
|
|
_reset_config_singletons()
|
|
|
|
|
|
def test_get_app_config_reloads_when_config_path_changes(tmp_path, monkeypatch):
|
|
config_a = tmp_path / "config-a.yaml"
|
|
config_b = tmp_path / "config-b.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config(config_a, model_name="model-a", supports_thinking=False)
|
|
_write_config(config_b, model_name="model-b", supports_thinking=True)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_a))
|
|
reset_app_config()
|
|
|
|
try:
|
|
first = get_app_config()
|
|
assert first.models[0].name == "model-a"
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_b))
|
|
second = get_app_config()
|
|
assert second.models[0].name == "model-b"
|
|
assert second is not first
|
|
finally:
|
|
reset_app_config()
|
|
|
|
|
|
def test_get_app_config_resets_agents_api_config_when_section_removed(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config_with_agents_api(
|
|
config_path,
|
|
model_name="first-model",
|
|
supports_thinking=False,
|
|
agents_api={"enabled": True},
|
|
)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
reset_app_config()
|
|
|
|
try:
|
|
initial = get_app_config()
|
|
assert initial.models[0].name == "first-model"
|
|
assert get_agents_api_config().enabled is True
|
|
|
|
_write_config_with_agents_api(
|
|
config_path,
|
|
model_name="first-model",
|
|
supports_thinking=False,
|
|
)
|
|
next_mtime = config_path.stat().st_mtime + 5
|
|
os.utime(config_path, (next_mtime, next_mtime))
|
|
|
|
reloaded = get_app_config()
|
|
assert reloaded is not initial
|
|
assert get_agents_api_config().enabled is False
|
|
finally:
|
|
reset_app_config()
|
|
|
|
|
|
def test_get_app_config_resets_singleton_configs_when_sections_removed(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{
|
|
"title": {"enabled": False, "max_words": 3},
|
|
"summarization": {"enabled": True},
|
|
"memory": {"enabled": False, "max_facts": 50},
|
|
"subagents": {"timeout_seconds": 42, "agents": {"reviewer": {"max_turns": 2}}},
|
|
"tool_search": {"enabled": True},
|
|
"guardrails": {"enabled": True, "fail_closed": False},
|
|
"checkpointer": {"type": "memory"},
|
|
"stream_bridge": {"type": "memory", "queue_maxsize": 12},
|
|
},
|
|
)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
reset_app_config()
|
|
|
|
try:
|
|
get_app_config()
|
|
assert get_title_config().enabled is False
|
|
assert get_summarization_config().enabled is True
|
|
assert get_memory_config().enabled is False
|
|
assert get_subagents_app_config().timeout_seconds == 42
|
|
assert get_tool_search_config().enabled is True
|
|
assert get_guardrails_config().enabled is True
|
|
assert get_checkpointer_config() is not None
|
|
assert get_stream_bridge_config() is not None
|
|
|
|
_write_config_with_sections(config_path)
|
|
next_mtime = config_path.stat().st_mtime + 5
|
|
os.utime(config_path, (next_mtime, next_mtime))
|
|
|
|
get_app_config()
|
|
assert get_title_config().enabled is True
|
|
assert get_summarization_config().enabled is False
|
|
assert get_memory_config().enabled is True
|
|
assert get_subagents_app_config().timeout_seconds == 1800
|
|
assert get_tool_search_config().enabled is False
|
|
assert get_guardrails_config().enabled is False
|
|
assert get_checkpointer_config() is None
|
|
assert get_stream_bridge_config() is None
|
|
finally:
|
|
_reset_config_singletons()
|
|
|
|
|
|
def test_get_app_config_resets_persistence_runtime_singletons_when_checkpointer_removed(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config_with_sections(config_path, {"checkpointer": {"type": "memory"}})
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
reset_checkpointer()
|
|
reset_store()
|
|
reset_app_config()
|
|
|
|
try:
|
|
get_app_config()
|
|
initial_checkpointer = get_checkpointer()
|
|
initial_store = get_store()
|
|
|
|
_write_config_with_sections(config_path)
|
|
next_mtime = config_path.stat().st_mtime + 5
|
|
os.utime(config_path, (next_mtime, next_mtime))
|
|
|
|
get_app_config()
|
|
|
|
assert get_checkpointer_config() is None
|
|
assert get_checkpointer() is not initial_checkpointer
|
|
assert get_store() is not initial_store
|
|
finally:
|
|
_reset_config_singletons()
|
|
|
|
|
|
def test_get_app_config_keeps_persistence_runtime_singletons_when_checkpointer_unchanged(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{
|
|
"title": {"enabled": False},
|
|
"checkpointer": {"type": "memory"},
|
|
},
|
|
)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
_reset_config_singletons()
|
|
|
|
try:
|
|
get_app_config()
|
|
initial_checkpointer = get_checkpointer()
|
|
initial_store = get_store()
|
|
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{
|
|
"title": {"enabled": True},
|
|
"checkpointer": {"type": "memory"},
|
|
},
|
|
)
|
|
next_mtime = config_path.stat().st_mtime + 5
|
|
os.utime(config_path, (next_mtime, next_mtime))
|
|
|
|
get_app_config()
|
|
|
|
assert get_checkpointer() is initial_checkpointer
|
|
assert get_store() is initial_store
|
|
finally:
|
|
_reset_config_singletons()
|
|
|
|
|
|
def test_get_app_config_does_not_reset_persistence_singletons_when_database_changes(tmp_path, monkeypatch):
|
|
# ``database`` is a restart-required field: the ORM engine is built once at
|
|
# startup and never rebuilt on a config.yaml edit. Resetting only the sync
|
|
# checkpointer/store singletons on a live ``postgres_schema`` change would
|
|
# half-migrate the deployment (new checkpoint/store tables in the new schema,
|
|
# ORM rows still in the old one). So a ``database`` change must NOT trigger a
|
|
# partial reset -- the operator must restart.
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{"database": {"backend": "postgres", "postgres_url": "postgresql://localhost/db", "postgres_schema": "schema_a"}},
|
|
)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
_reset_config_singletons()
|
|
|
|
reset_calls = {"checkpointer": 0, "store": 0}
|
|
|
|
def _reset_checkpointer() -> None:
|
|
reset_calls["checkpointer"] += 1
|
|
|
|
def _reset_store() -> None:
|
|
reset_calls["store"] += 1
|
|
|
|
monkeypatch.setattr("deerflow.runtime.checkpointer.reset_checkpointer", _reset_checkpointer)
|
|
monkeypatch.setattr("deerflow.runtime.store.reset_store", _reset_store)
|
|
|
|
try:
|
|
get_app_config()
|
|
reset_calls["checkpointer"] = 0
|
|
reset_calls["store"] = 0
|
|
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{"database": {"backend": "postgres", "postgres_url": "postgresql://localhost/db", "postgres_schema": "schema_b"}},
|
|
)
|
|
next_mtime = config_path.stat().st_mtime + 5
|
|
os.utime(config_path, (next_mtime, next_mtime))
|
|
|
|
get_app_config()
|
|
|
|
assert get_checkpointer_config() is None
|
|
assert reset_calls == {"checkpointer": 0, "store": 0}
|
|
finally:
|
|
_reset_config_singletons()
|
|
|
|
|
|
def test_get_app_config_does_not_mutate_singletons_when_reload_validation_fails(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{
|
|
"title": {"enabled": False},
|
|
"tool_search": {"enabled": True},
|
|
"checkpointer": {"type": "memory"},
|
|
},
|
|
)
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
_reset_config_singletons()
|
|
|
|
try:
|
|
previous_app_config = get_app_config()
|
|
initial_checkpointer = get_checkpointer()
|
|
initial_store = get_store()
|
|
|
|
_write_config_with_sections(
|
|
config_path,
|
|
{
|
|
"title": False,
|
|
"tool_search": False,
|
|
"checkpointer": {"type": "memory"},
|
|
},
|
|
)
|
|
next_mtime = config_path.stat().st_mtime + 5
|
|
os.utime(config_path, (next_mtime, next_mtime))
|
|
|
|
with pytest.raises(ValidationError):
|
|
get_app_config()
|
|
|
|
assert app_config_module._app_config is previous_app_config
|
|
assert get_title_config().enabled is False
|
|
assert get_tool_search_config().enabled is True
|
|
assert get_checkpointer_config() is not None
|
|
assert get_checkpointer() is initial_checkpointer
|
|
assert get_store() is initial_store
|
|
finally:
|
|
_reset_config_singletons()
|
|
|
|
|
|
def test_get_memory_config_self_syncs_without_prior_get_app_config(tmp_path, monkeypatch):
|
|
"""get_memory_config() triggers reload when file changes without prior get_app_config().
|
|
|
|
Background memory paths (updater/queue/storage) never call get_app_config()
|
|
directly. This test pins the fix: calling get_memory_config() in isolation
|
|
— after mutating the file, with no intervening get_app_config() — reflects
|
|
the new value.
|
|
"""
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
|
|
_write_config_with_sections(config_path, {"memory": {"enabled": False}})
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
reset_app_config()
|
|
|
|
try:
|
|
get_app_config()
|
|
assert get_memory_config().enabled is False
|
|
|
|
_write_config_with_sections(config_path, {"memory": {"enabled": True}})
|
|
next_mtime = config_path.stat().st_mtime + 5
|
|
os.utime(config_path, (next_mtime, next_mtime))
|
|
|
|
assert get_memory_config().enabled is True
|
|
finally:
|
|
_reset_config_singletons()
|
|
|
|
|
|
def test_blob_store_follows_config_file_reload(tmp_path, monkeypatch):
|
|
"""The storage accessor must see config edits without a prior app-config read."""
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
first_root = tmp_path / "first"
|
|
second_root = tmp_path / "second"
|
|
_write_config_with_sections(config_path, {"blob_storage": {"enabled": True, "backend_config": {"root": str(first_root)}}})
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
reset_app_config()
|
|
reset_blob_store()
|
|
|
|
try:
|
|
get_app_config()
|
|
first_store = get_blob_store()
|
|
|
|
_write_config_with_sections(config_path, {"blob_storage": {"enabled": True, "backend_config": {"root": str(second_root)}}})
|
|
second_store = get_blob_store()
|
|
assert second_store is not first_store
|
|
ref = second_store.put_bytes(b"after reload", kind="tool-output")
|
|
assert (second_root / ref.kind / ref.sha256[:2] / ref.sha256).is_file()
|
|
assert not (first_root / ref.kind / ref.sha256[:2] / ref.sha256).exists()
|
|
|
|
_write_config_with_sections(config_path, {"blob_storage": {"enabled": False}})
|
|
assert get_blob_store_if_enabled() is None
|
|
with pytest.raises(BlobNotConfiguredError):
|
|
get_blob_store()
|
|
finally:
|
|
reset_blob_store()
|
|
_reset_config_singletons()
|
|
|
|
|
|
def test_get_memory_config_falls_back_on_broken_config(tmp_path, monkeypatch):
|
|
"""get_memory_config() does not crash on transiently broken config.yaml.
|
|
|
|
get_app_config() can raise yaml.YAMLError, ValidationError, or ValueError.
|
|
get_memory_config() catches them and returns the last-good singleton.
|
|
"""
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
|
|
_write_config_with_sections(config_path, {"memory": {"enabled": False}})
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
reset_app_config()
|
|
|
|
try:
|
|
get_app_config()
|
|
assert get_memory_config().enabled is False
|
|
|
|
config_path.write_text("memory: {enabled: true\n")
|
|
next_mtime = config_path.stat().st_mtime + 5
|
|
os.utime(config_path, (next_mtime, next_mtime))
|
|
|
|
assert get_memory_config().enabled is False
|
|
finally:
|
|
_reset_config_singletons()
|
|
|
|
|
|
# The two judging modes as an operator writes them. `mode: off` is spelled the way
|
|
# config.example.yaml ships it and the way a rollback types it.
|
|
_MODES_CONFIG = """\
|
|
sandbox:
|
|
use: deerflow.sandbox.local:LocalSandboxProvider
|
|
models:
|
|
- name: first-model
|
|
use: langchain_openai:ChatOpenAI
|
|
model: gpt-test
|
|
memory:
|
|
prescreen:
|
|
mode: {prescreen}
|
|
use: deerflow.agents.memory.prescreen.typesafe:TypeSafeMemoryPrescreen
|
|
signal_classification:
|
|
mode: {classification}
|
|
use: deerflow.agents.memory.signals.typesafe:TypeSafeSignalClassifier
|
|
"""
|
|
|
|
|
|
def test_an_unquoted_off_mode_loads_and_rolls_back(tmp_path, monkeypatch):
|
|
"""`mode: off` is a YAML boolean, and both judging modes must still read it as off.
|
|
|
|
YAML 1.1 parses an unquoted ``off`` as ``False``. Rejecting it would fail the
|
|
config *reload* that returns a deployment to off -- the failed load leaves the
|
|
previous judge (and its judging requests) running -- so the documented spelling
|
|
has to resolve to the ``off`` mode, not raise.
|
|
"""
|
|
config_path = tmp_path / "config.yaml"
|
|
extensions_path = tmp_path / "extensions_config.json"
|
|
_write_extensions_config(extensions_path)
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
|
|
|
def write_modes(prescreen: str, classification: str) -> None:
|
|
config_path.write_text(_MODES_CONFIG.format(prescreen=prescreen, classification=classification), encoding="utf-8")
|
|
|
|
try:
|
|
write_modes("enforce", "hints")
|
|
enforcing = AppConfig.from_file(str(config_path))
|
|
assert enforcing.memory.prescreen.mode == "enforce"
|
|
assert enforcing.memory.signal_classification.mode == "hints"
|
|
|
|
write_modes("off", "off")
|
|
rolled_back = AppConfig.from_file(str(config_path))
|
|
|
|
assert rolled_back.memory.prescreen.mode == "off"
|
|
assert rolled_back.memory.signal_classification.mode == "off"
|
|
finally:
|
|
_reset_config_singletons()
|