fix(config): reject boolean loop detection thresholds (#6017)

* fix(config): reject boolean loop detection thresholds

* refactor(config): 复用循环阈值布尔校验

* Revise CHANGELOG for configuration and upload updates

Updated CHANGELOG to reflect changes in configuration loading and document ownership.

* Update the document of ChangeLog

修复了配置加载阶段的整数阈值字段报错行为,并更新了文档转换的归属记录处理。

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
yc2bgr8 2026-10-02 07:30:10 +08:00 committed by GitHub
parent f840e843d3
commit 63e399f2bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 78 additions and 1 deletions

View File

@ -460,6 +460,14 @@ This release closes that milestone with **301 merged pull requests**.
### Fixed
- **agents:** Loop-detection integer thresholds now reject YAML booleans instead
of coercing `true` to `1`. A configuration such as `warn_threshold: true`
and `hard_limit: true` previously made the first tool-call set meet the hard
limit and forced the agent to stop; booleans on the tracking-window,
per-tool-frequency, and per-tool override fields similarly collapsed their
limits to one. All integer threshold fields now fail configuration loading
with a field-specific error while valid integers and numeric strings retain
their existing behavior.([#6017])
- **uploads:** Converted Markdown ownership is now recorded when a document is
converted. `list_uploaded_files` hides only verified conversion outputs, and
document outlines use only the recorded companion; a user-uploaded Markdown

View File

@ -419,6 +419,12 @@
### 修复
- **智能体:** 循环检测的整数阈值现在会拒绝 YAML 布尔值,而不是把
`true` 静默转换为 `1`。此前若配置 `warn_threshold: true` 和
`hard_limit: true`,第一组工具调用就会达到硬上限并强制终止智能体;
跟踪窗口、工具频率和按工具覆盖项中的布尔值也会把对应限制缩小为
1。现在所有整数阈值字段都会在配置加载阶段按字段名报错,同时保持
有效整数和数字字符串的既有行为。([#6017])
- **上传:** 文档转换时现在会记录原文件与 Markdown 的归属关系。
`list_uploaded_files` 只隐藏归属已验证的转换文件,文档大纲也只读取
记录中指定的 Markdown;用户自行上传的同名文件会正常显示,不会被

View File

@ -1,6 +1,12 @@
"""Configuration for loop detection middleware."""
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
def _reject_boolean_threshold(value: object, info: ValidationInfo) -> object:
if isinstance(value, bool):
raise ValueError(f"{info.field_name} must be an integer, not a boolean")
return value
class ToolFreqOverride(BaseModel):
@ -14,6 +20,11 @@ class ToolFreqOverride(BaseModel):
warn: int = Field(ge=1)
hard_limit: int = Field(ge=1)
@field_validator("warn", "hard_limit", mode="before")
@classmethod
def reject_boolean_thresholds(cls, value: object, info: ValidationInfo) -> object:
return _reject_boolean_threshold(value, info)
@model_validator(mode="after")
def _validate(self) -> "ToolFreqOverride":
if self.hard_limit < self.warn:
@ -63,6 +74,19 @@ class LoopDetectionConfig(BaseModel):
description=("Per-tool overrides for tool_freq_warn / tool_freq_hard_limit, keyed by tool name. Values can be higher or lower than the global defaults. Commonly used to raise thresholds for high-frequency tools like bash."),
)
@field_validator(
"warn_threshold",
"hard_limit",
"window_size",
"max_tracked_threads",
"tool_freq_warn",
"tool_freq_hard_limit",
mode="before",
)
@classmethod
def reject_boolean_thresholds(cls, value: object, info: ValidationInfo) -> object:
return _reject_boolean_threshold(value, info)
@model_validator(mode="after")
def validate_thresholds(self) -> "LoopDetectionConfig":
"""Ensure hard stop cannot happen before the warning threshold."""

View File

@ -36,6 +36,28 @@ class TestLoopDetectionConfig:
assert config.tool_freq_warn == 60
assert config.tool_freq_hard_limit == 80
def test_accepts_numeric_string_thresholds(self):
config = LoopDetectionConfig.model_validate(
{
"warn_threshold": "10",
"hard_limit": "20",
"window_size": "50",
"max_tracked_threads": "200",
"tool_freq_warn": "60",
"tool_freq_hard_limit": "80",
"tool_freq_overrides": {"bash": {"warn": "150", "hard_limit": "300"}},
},
)
assert config.warn_threshold == 10
assert config.hard_limit == 20
assert config.window_size == 50
assert config.max_tracked_threads == 200
assert config.tool_freq_warn == 60
assert config.tool_freq_hard_limit == 80
assert config.tool_freq_overrides["bash"].warn == 150
assert config.tool_freq_overrides["bash"].hard_limit == 300
def test_rejects_zero_thresholds(self):
with pytest.raises(ValueError):
LoopDetectionConfig(warn_threshold=0)
@ -49,6 +71,23 @@ class TestLoopDetectionConfig:
with pytest.raises(ValueError):
LoopDetectionConfig(tool_freq_hard_limit=0)
@pytest.mark.parametrize(
("payload", "field"),
[
({"warn_threshold": True}, "warn_threshold"),
({"warn_threshold": 1, "hard_limit": True}, "hard_limit"),
({"window_size": True}, "window_size"),
({"max_tracked_threads": True}, "max_tracked_threads"),
({"tool_freq_warn": True}, "tool_freq_warn"),
({"tool_freq_warn": 1, "tool_freq_hard_limit": True}, "tool_freq_hard_limit"),
({"tool_freq_overrides": {"bash": {"warn": True, "hard_limit": 2}}}, "warn"),
({"tool_freq_overrides": {"bash": {"warn": 1, "hard_limit": True}}}, "hard_limit"),
],
)
def test_rejects_boolean_integer_thresholds(self, payload, field):
with pytest.raises(ValueError, match=field):
LoopDetectionConfig(**payload)
def test_rejects_hard_limit_below_warn_threshold(self):
with pytest.raises(ValueError, match="hard_limit"):
LoopDetectionConfig(warn_threshold=5, hard_limit=4)