fix(config): reject booleans for the subagent and ACP invocation backstops (#6242)

YAML booleans coerce into the ge=1 integer backstops that bound every
subagent and ACP invocation: timeout_seconds: true became 1 (the executor's
future.result(timeout=...) then killed every subagent run after one second)
and max_turns: true became 1 (the LangGraph recursion budget collapsed to a
single super-step). The ACP agent timeout mirrors subagents.timeout_seconds
and shared the hole. false was already rejected by ge=1, so only true
slipped through - silently. Mirror the app-config boolean guard: reject
bools before coercion in SubagentOverrideConfig, CustomSubagentConfig and
SubagentsAppConfig (timeout_seconds/max_turns/max_total_per_run) and
ACPAgentConfig (timeout_seconds), keeping None, integers, and numeric
strings as-is.

Signed-off-by: yetuge <2219677952@qq.com>
This commit is contained in:
yetuge 2026-10-04 12:41:13 +08:00 committed by GitHub
parent 1f7600e355
commit e59f6e5e92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 99 additions and 2 deletions

View File

@ -3,7 +3,9 @@
import logging
from collections.abc import Mapping
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ValidationInfo, field_validator
from deerflow.config._boolean_guards import reject_boolean
logger = logging.getLogger(__name__)
@ -36,6 +38,11 @@ class ACPAgentConfig(BaseModel):
),
)
@field_validator("timeout_seconds", mode="before")
@classmethod
def _reject_boolean_timeout(cls, value: object, info: ValidationInfo) -> object:
return reject_boolean(value, info, kind="an integer")
_acp_agents: dict[str, ACPAgentConfig] = {}

View File

@ -2,8 +2,9 @@
import logging
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ValidationInfo, field_validator
from deerflow.config._boolean_guards import reject_boolean
from deerflow.config.prompt_overlay import PromptOverlay
from deerflow.config.token_budget_config import TokenBudgetConfig
@ -114,6 +115,11 @@ class SubagentOverrideConfig(BaseModel):
description="Per-run token budget override for this subagent (None = use the global subagents.token_budget default). Symmetric with timeout_seconds/max_turns.",
)
@field_validator("timeout_seconds", "max_turns", mode="before")
@classmethod
def _reject_boolean_override_backstops(cls, value: object, info: ValidationInfo) -> object:
return reject_boolean(value, info, kind="an integer")
class CustomSubagentConfig(BaseModel):
"""User-defined subagent type declared in config.yaml."""
@ -151,6 +157,11 @@ class CustomSubagentConfig(BaseModel):
description="Maximum execution time in seconds",
)
@field_validator("timeout_seconds", "max_turns", mode="before")
@classmethod
def _reject_boolean_custom_backstops(cls, value: object, info: ValidationInfo) -> object:
return reject_boolean(value, info, kind="an integer")
class SubagentsAppConfig(BaseModel):
"""Configuration for the subagent system."""
@ -171,6 +182,12 @@ class SubagentsAppConfig(BaseModel):
le=MAX_TOTAL_SUBAGENTS_PER_RUN,
description="Default total number of subagent delegations allowed in one lead-agent run. This is a deterministic backstop against repeated legal-sized task batches. Valid range: 1-50.",
)
@field_validator("timeout_seconds", "max_turns", "max_total_per_run", mode="before")
@classmethod
def _reject_boolean_global_backstops(cls, value: object, info: ValidationInfo) -> object:
return reject_boolean(value, info, kind="an integer")
token_budget: TokenBudgetConfig = Field(
default_factory=default_subagent_token_budget,
description="Default per-run token budget for subagents — a cost-ceiling backstop that engages by default (#3875 Phase 2). Set enabled: false to disable, or override per agent via agents.<name>.token_budget.",

View File

@ -0,0 +1,73 @@
"""YAML booleans must not coerce into the subagent and ACP invocation backstops.
`config.yaml` legitimately mixes booleans (`enabled: true`) with integer knobs on
neighbouring lines, so a typo like `subagents.timeout_seconds: true` loads cleanly —
and Pydantic coerces it to `1`. The subagent executor bounds every run with
`future.result(timeout=self.config.timeout_seconds)`, so 1800 seconds becomes a
1-second kill, and `resolve_recursion_limit(self.config.max_turns)` turns a
`max_turns: true` typo into a LangGraph budget of a single super-step. The ACP
agent timeout mirrors `subagents.timeout_seconds` and has the same hole. Mirrors
the app-config, loop-detection, and tool-output boolean guards already in the repo.
"""
import pytest
from pydantic import ValidationError
from deerflow.config.acp_config import ACPAgentConfig
from deerflow.config.subagents_config import CustomSubagentConfig, SubagentOverrideConfig, SubagentsAppConfig
BACKSTOP_FIELDS = ("timeout_seconds", "max_turns")
GLOBAL_INT_FIELDS = ("timeout_seconds", "max_turns", "max_total_per_run")
@pytest.mark.parametrize("value", [True, False])
@pytest.mark.parametrize("field", BACKSTOP_FIELDS)
def test_override_backstops_reject_booleans(field: str, value: bool) -> None:
with pytest.raises(ValidationError, match="must be an integer, not a boolean"):
SubagentOverrideConfig(**{field: value})
@pytest.mark.parametrize("value", [True, False])
@pytest.mark.parametrize("field", BACKSTOP_FIELDS)
def test_custom_backstops_reject_booleans(field: str, value: bool) -> None:
with pytest.raises(ValidationError, match="must be an integer, not a boolean"):
CustomSubagentConfig(description="d", system_prompt="s", **{field: value})
@pytest.mark.parametrize("value", [True, False])
@pytest.mark.parametrize("field", GLOBAL_INT_FIELDS)
def test_global_backstops_reject_booleans(field: str, value: bool) -> None:
with pytest.raises(ValidationError, match="must be an integer, not a boolean"):
SubagentsAppConfig(**{field: value})
@pytest.mark.parametrize("field", BACKSTOP_FIELDS)
def test_override_backstops_keep_none_and_numeric_inputs(field: str) -> None:
config = SubagentOverrideConfig(**{field: None})
assert getattr(config, field) is None
config = SubagentOverrideConfig(**{field: "30"})
assert getattr(config, field) == 30
def test_custom_backstops_keep_numeric_inputs() -> None:
config = CustomSubagentConfig(description="d", system_prompt="s", max_turns="25", timeout_seconds=600)
assert config.max_turns == 25
assert config.timeout_seconds == 600
def test_global_backstops_keep_numeric_inputs() -> None:
config = SubagentsAppConfig(timeout_seconds="900", max_turns="40", max_total_per_run=8)
assert config.timeout_seconds == 900
assert config.max_turns == 40
assert config.max_total_per_run == 8
@pytest.mark.parametrize("value", [True, False])
def test_acp_timeout_rejects_booleans(value: bool) -> None:
with pytest.raises(ValidationError, match="must be an integer, not a boolean"):
ACPAgentConfig(command="cmd", description="d", timeout_seconds=value)
def test_acp_timeout_keeps_numeric_input() -> None:
config = ACPAgentConfig(command="cmd", description="d", timeout_seconds="600")
assert config.timeout_seconds == 600