fix(config): coerce null object config sections to their defaults#3573
fix(config): coerce null object config sections to their defaults#3573ly-wang19 wants to merge 1 commit into
Conversation
bytedance#3434 made commented-out list sections (models/tools/tool_groups) parse as [] instead of crashing, but the same scenario still crashes for object sections: commenting out every key under e.g. memory: / summarization: / guardrails: makes PyYAML parse the value as None, and AppConfig then raises "Input should be a valid dictionary" for that section — breaking the documented `cp config.example.yaml config.yaml` first-run flow. Generalize the handling: a model_validator(mode="before") drops None-valued sections so each field falls back to its default (list sections -> [], object sections -> their default config). This subsumes the previous list-only field_validator and the database special-case. Required sections without a default (sandbox) still error when null. Adds test_app_config_coerces_commented_out_object_sections; the existing list-section regression test still passes.
fancyboi999
left a comment
There was a problem hiding this comment.
Read the full change against the field definitions — it's a correct, well-scoped generalization of the #3434 list-only fix, and the broadening to all sections is safe:
_drop_null_config_sections(app_config.py:161) swaps the old 3-fieldfield_validatorfor amodel_validator(mode="before")that strips every present-but-null top-level key, so each section falls back to its default. For the object sections in the bug (memory/summarization/guardrails/tool_output/run_events, alldefault_factory=...) a null value now yields the default config instead of the opaqueInput should be a valid dictionary. Matches the documentedcp config.example.yaml config.yamlflow that #3434 set out to keep working.- I specifically checked the broadening doesn't regress the Optional sections:
checkpointer(:144) andstream_bridge(:151) are the only| Nonetop-level fields, and both areField(default=None)— so dropping a null key resolves toNone, identical to the previous| Noneparsing. No section silently changes value. sandbox(:99) has no default, so a nullsandbox:still errors — the message just shifts fromInput should be a valid dictionarytoField required(the docstring calls this out). Still fails closed, and the test wisely supplies a validsandboxso it doesn't trip on this.model_config = ConfigDict(extra="allow")means dropped unknown-null keys are a non-issue — they were only ever kept asNoneextras anyway.
Test covers the object-section case end-to-end through from_file, asserting each null section becomes a non-None default.
One forward-looking, non-blocking note: this now bakes in an invariant that no top-level field treats null as a meaningful value distinct from its default — true today, since the only Optional fields default to None. If a future section is ever added as X | None = Field(default=<non-None>) where a user's explicit null should mean "disabled/None", this validator would silently override it with the non-None default. Might be worth a one-line comment on the validator capturing that assumption so it's not a surprise later.
Looks good to merge. (Reviewed at code level; I didn't run the suite locally.)
Why
#3434 made commented-out list sections (
models/tools/tool_groups) parse as[]instead of crashing — but the same scenario still crashes for object sections. Commenting out every key under e.g.memory:,summarization:,guardrails:,tool_output:orrun_events:makes PyYAML parse the value asNone, andAppConfigthen raises an opaqueInput should be a valid dictionaryfor that section. This breaks the documentedcp config.example.yaml config.yamlfirst-run flow (the exact flow #3434 fixed), sinceconfig.example.yamlships those object sections and a user disabling one by commenting out its body hits the crash.Repro on
main: a config containingmemory:(null) fails to load with aValidationError.What changed
No change for valid configs. A present-but-null section now falls back to its default instead of crashing: a
model_validator(mode="before")dropsNone-valued top-level sections so each field uses its default (list sections →[]viadefault_factory, object sections → their default config). This generalizes the previous list-onlyfield_validator(now removed as subsumed) and thedatabasespecial-case infrom_fileto every section. Required sections with no default (sandbox) still error when null — there is nothing to fall back to.Surface area
Internal config loading (
packages/harness/deerflow/config/app_config.py) — no frontend, endpoint, agent, sandbox, skills, or dependency change; robustness fix for malformed/partialconfig.yaml.Bug fix verification
backend/tests/test_app_config_reload.py::test_app_config_coerces_commented_out_object_sections.main/ green on branch: yes — onmain,AppConfig.from_fileof a config with a null object section raisesValidationError; the new test reproduces that and passes on this branch. The existingtest_app_config_coerces_commented_out_list_sections(fix(config): coerce null config.yaml list sections to empty list #3434) still passes, confirming the generalization preserves list-section behavior.Validation
In
backend/:uv run pytest tests/test_app_config_reload.py→ 12 passed.uv run ruff check+uv run ruff format --checkon the changed files → clean.AI assistance
Tool(s) used: Claude Code
How: Used to spot that #3434's null-section handling was list-only, reproduce the object-section crash, generalize the coercion via a
model_validator, and add the regression test. I reviewed every line and verified locally with the test + ruff runs above.