diff --git a/backend/packages/harness/deerflow/config/acp_config.py b/backend/packages/harness/deerflow/config/acp_config.py index 736ca67f4c..7b2021e6f5 100644 --- a/backend/packages/harness/deerflow/config/acp_config.py +++ b/backend/packages/harness/deerflow/config/acp_config.py @@ -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] = {} diff --git a/backend/packages/harness/deerflow/config/subagents_config.py b/backend/packages/harness/deerflow/config/subagents_config.py index ff46d5347a..8265a27a66 100644 --- a/backend/packages/harness/deerflow/config/subagents_config.py +++ b/backend/packages/harness/deerflow/config/subagents_config.py @@ -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..token_budget.", diff --git a/backend/tests/test_subagents_acp_boolean_backstops.py b/backend/tests/test_subagents_acp_boolean_backstops.py new file mode 100644 index 0000000000..b4a2d8a13e --- /dev/null +++ b/backend/tests/test_subagents_acp_boolean_backstops.py @@ -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