Skip to content

fix(sdk-python): validate max_tool_calls and max_subagent_depth as integers - #7548

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
chinesepowered:fix/py-int-option-validation
Jul 23, 2026
Merged

fix(sdk-python): validate max_tool_calls and max_subagent_depth as integers#7548
wenshao merged 3 commits into
QwenLM:mainfrom
chinesepowered:fix/py-int-option-validation

Conversation

@chinesepowered

Copy link
Copy Markdown
Contributor

What this PR does

max_tool_calls and max_subagent_depth only range-checked their input, while the sibling max_session_turns also rejects bools and non-integers. This applies the same integer check to all three by extracting the one max_session_turns already performed.

Why it's needed

Each of these options states its contract in its own error message — "must be -1 or a non-negative integer", "must be between 1 and 100" — but only max_session_turns enforced the integer part. The value is then stringified straight onto the CLI in transport.py, so an accepted non-integer becomes a malformed argument rather than a clear ValidationError at the SDK boundary.

Verified against validate_query_options on main:

input main forwarded as
max_tool_calls=True accepted --max-tool-calls True
max_tool_calls=2.5 accepted --max-tool-calls 2.5
max_subagent_depth=True accepted --max-subagent-depth True
max_subagent_depth=2.5 accepted --max-subagent-depth 2.5
max_session_turns=True rejected
max_session_turns=2.5 rejected

bool is the sharp edge: it subclasses int, so True satisfies isinstance(x, int) and passes every range comparison as 1. max_subagent_depth=True therefore slips through 1 <= x <= 100 as a valid depth of 1. (False is caught there by accident, since 0 fails the lower bound — but only by accident.)

The bool-aware check already existed inline in the max_session_turns branch; this names it _is_int and reuses it. max_session_turns keeps its exact previous behavior — same expression, just extracted.

Reviewer Test Plan

How to verify

  • From the repo root, exactly as CI runs it:
    • python -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q → 123 passed, 29 skipped.
    • python -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src → success, 9 source files.
    • python -m ruff check / ruff format --check → clean on both changed files.
  • Reverting only validation.py fails 5 of the new cases: max_tool_calls[True], [False], [0.5] and max_subagent_depth[True], [2.5]. max_subagent_depth[False] passes before and after, because False == 0 already failed the range check — the parametrization is deliberately precise about which cases are actually new.
  • test_accepts_valid_integer_limits pins the other direction: -1, 0, 1 and 100 still validate across all three options, so this cannot become an over-strict regression.
  • The existing test_rejects_invalid_max_tool_calls (-2), test_rejects_invalid_max_subagent_depth (0) and the three max_session_turns tests are untouched and still pass.

Evidence (Before & After)

  • Before: QueryOptions(max_tool_calls=True) validates, and the CLI is invoked with --max-tool-calls True.
  • After: ValidationError: max_tool_calls must be -1 or a non-negative integer — the message the option already promised.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

macOS, CPython 3.12/3.13: full sdk-python suite, mypy, ruff check and ruff format all pass locally, with proven fail-before/pass-after. Type checking with no platform-dependent behavior, so no manual QA is required; the sdk-python workflow covers the CI matrix.

Environment (optional)

CPython 3.12.10 / 3.13.3; packages/sdk-python; pytest via the repo's own pyproject.toml config.

Risk & Scope

  • Main risk or tradeoff: a caller passing True or a float for these options now gets a ValidationError instead of a confusing downstream CLI failure. Callers passing integers — the documented type — are unaffected, which test_accepts_valid_integer_limits pins.
  • Not validated / out of scope: this is scoped to the three integer limits in validate_query_options that share the same contract. It is one gap (integer options not validated as integers), not three separate ones, so they are fixed together rather than split across PRs. Other validators are untouched.
  • Breaking changes / migration notes: none for integer inputs.

Linked Issues

None — found by comparing max_tool_calls against the stricter max_session_turns check a few lines above it.

中文说明

本 PR 的作用

max_tool_callsmax_subagent_depth 只做了范围检查,而同类的 max_session_turns 还会拒绝布尔值与非整数。本 PR 把 max_session_turns 已有的那段整数判断提取出来,并应用于全部三者。

为什么需要

这些选项各自在错误信息中声明了自己的契约——「must be -1 or a non-negative integer」「must be between 1 and 100」——但只有 max_session_turns 真正执行了其中的整数部分。该值随后在 transport.py 中被直接字符串化拼到 CLI 上,于是被放行的非整数会变成一个畸形参数,而不是在 SDK 边界抛出清晰的 ValidationError

main 上针对 validate_query_options 验证:

输入 main 转发为
max_tool_calls=True 接受 --max-tool-calls True
max_tool_calls=2.5 接受 --max-tool-calls 2.5
max_subagent_depth=True 接受 --max-subagent-depth True
max_subagent_depth=2.5 接受 --max-subagent-depth 2.5
max_session_turns=True 拒绝
max_session_turns=2.5 拒绝

bool 是最尖锐的一处:它是 int 的子类,因此 True 既满足 isinstance(x, int),又以 1 的身份通过所有范围比较。于是 max_subagent_depth=True 会作为「深度 1」溜过 1 <= x <= 100。(False 在那里被拦下纯属偶然——0 未过下界而已。)

这段能识别布尔的判断本就以内联形式存在于 max_session_turns 分支中;本 PR 将其命名为 _is_int 并复用。max_session_turns 的行为与此前完全一致——表达式相同,只是被提取出来。

复核测试计划

如何验证

  • 在仓库根目录,与 CI 完全一致地运行:
    • python -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q → 123 passed、29 skipped。
    • python -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src → 成功,9 个源文件。
    • python -m ruff check / ruff format --check → 两个改动文件均干净。
  • 仅还原 validation.py,新增用例中有 5 个失败:max_tool_calls[True][False][0.5] 以及 max_subagent_depth[True][2.5]max_subagent_depth[False] 在修复前后均通过,因为 False == 0 本就未过范围检查——参数化刻意精确区分了哪些用例才是真正新增覆盖的。
  • test_accepts_valid_integer_limits 固定了反方向:-101100 在三个选项上仍然通过校验,因此本改动不会演变成过度严格的回归。
  • 既有的 test_rejects_invalid_max_tool_calls-2)、test_rejects_invalid_max_subagent_depth0)以及三个 max_session_turns 测试未作改动且仍然通过。

证据(修复前后对比)

  • 修复前:QueryOptions(max_tool_calls=True) 校验通过,CLI 被以 --max-tool-calls True 调用。
  • 修复后:ValidationError: max_tool_calls must be -1 or a non-negative integer——正是该选项早已承诺的那句提示。

测试环境

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

macOS,CPython 3.12/3.13:完整 sdk-python 套件、mypy、ruff check 与 ruff format 本地全部通过,并验证了 fail-before/pass-after。纯类型检查,无平台相关行为,因此无需人工 QA;sdk-python workflow 覆盖 CI 矩阵。

运行环境(可选)

CPython 3.12.10 / 3.13.3;packages/sdk-python;pytest 使用仓库自带的 pyproject.toml 配置。

风险与影响范围

  • 主要风险或权衡:为这些选项传入 True 或浮点数的调用方,现在会收到 ValidationError,而不再是下游一个令人困惑的 CLI 失败。传入整数(即文档所载类型)的调用方不受影响,这一点由 test_accepts_valid_integer_limits 固定。
  • 未验证 / 范围之外:本改动限定于 validate_query_options 中共享同一契约的三个整数上限。这是一个缺口(整数选项未按整数校验),而非三个彼此独立的缺口,因此一并修复而不拆成多个 PR。其他校验函数未作改动。
  • 破坏性变更 / 迁移说明:对整数输入无影响。

关联 Issue

无——通过将 max_tool_calls 与其上方几行更严格的 max_session_turns 判断对照发现。

…tegers

max_session_turns rejects bools and non-ints before its range check, but
the two sibling limits only range-checked, so values their own error
messages rule out were accepted and stringified onto the CLI:

  max_tool_calls=True      -> --max-tool-calls True
  max_tool_calls=2.5       -> --max-tool-calls 2.5
  max_subagent_depth=True  -> --max-subagent-depth True

bool is the sharp edge: it subclasses int, so True passes both isinstance
and the range comparison as 1.

Extract the bool-aware check the max_session_turns branch already
performed into _is_int and apply it to all three. max_session_turns keeps
its exact previous behavior; the expression is the same, only named.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed and concretely demonstrated. The table in the PR body shows max_tool_calls=True and max_tool_calls=2.5 accepted on main and forwarded as malformed CLI arguments, while the sibling max_session_turns correctly rejects the same inputs. The inconsistency is real and reproducible — not theoretical.

Direction: aligned. The SDK's error messages already promise integer validation ("must be -1 or a non-negative integer", "must be between 1 and 100") but only one of the three options enforced it. Making the validation match the documented contract is straightforward correctness work.

Size: not applicable (sdk-python, not core infrastructure).

Approach: minimal and well-scoped. Extracting the existing inline bool-aware check from max_session_turns into _is_int and reusing it for the two siblings is exactly the right move — no new abstractions, no behavior change for valid inputs. The diff carries nothing unrelated.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测且有具体演示。PR 正文中的表格展示了 max_tool_calls=Truemax_tool_calls=2.5main 上被接受并作为畸形 CLI 参数转发,而同类选项 max_session_turns 正确拒绝了相同输入。不一致是真实且可复现的——不是理论性的。

方向:对齐。SDK 的错误信息已承诺整数校验("must be -1 or a non-negative integer"、"must be between 1 and 100"),但三个选项中只有一个执行了该契约。让校验与文档承诺一致是直接的修复。

规模:不适用(sdk-python,非核心基础设施)。

方案:最小且范围恰当。将 max_session_turns 中已有的内联布尔感知检查提取为 _is_int 并复用于另外两个选项,正是正确做法——无新抽象,对合法输入无行为变化。diff 不夹带无关改动。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 9ee71cda9eacf19006d4c0c85d0d40ff8d40f02a · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: I'd extract the inline bool-aware check from max_session_turns into a small helper and apply it to the two siblings. Add parametrized tests for the newly-rejected types (bool, float) and a positive test pinning valid integers.

What the PR does: exactly that. _is_int is isinstance(value, int) and not isinstance(value, bool) — correct handling of Python's bool-subclasses-int gotcha. The max_session_turns refactor is semantics-preserving (same expression, just named). The new guards on max_tool_calls and max_subagent_depth compose _is_int with the existing range checks cleanly.

No critical blockers. No convention violations. The docstring on _is_int explains the why (bool subclasses int), which is the right place for a comment. Tests are precise — the parametrization deliberately distinguishes which cases are newly covered vs. already caught by accident (max_subagent_depth=False fails the range check regardless).

Before (main branch)

=== BEFORE (main branch) ===
============================================================
Validation test for PR #7548
============================================================
  max_tool_calls=True                      -> ACCEPTED
  max_tool_calls=2.5                       -> ACCEPTED
  max_tool_calls=False                     -> ACCEPTED
  max_subagent_depth=True                  -> ACCEPTED
  max_subagent_depth=2.5                   -> ACCEPTED
  max_session_turns=True                   -> REJECTED: max_session_turns must be -1 or a non-negative integer
  max_session_turns=2.5                    -> REJECTED: max_session_turns must be -1 or a non-negative integer
  max_tool_calls=-1 (valid)                -> ACCEPTED
  max_tool_calls=0 (valid)                 -> ACCEPTED
  max_subagent_depth=1 (valid)             -> ACCEPTED
  max_subagent_depth=100 (valid)           -> ACCEPTED
============================================================

After (PR #7548 applied)

=== AFTER (PR #7548 applied) ===
============================================================
Validation test for PR #7548
============================================================
  max_tool_calls=True                      -> REJECTED: max_tool_calls must be -1 or a non-negative integer
  max_tool_calls=2.5                       -> REJECTED: max_tool_calls must be -1 or a non-negative integer
  max_tool_calls=False                     -> REJECTED: max_tool_calls must be -1 or a non-negative integer
  max_subagent_depth=True                  -> REJECTED: max_subagent_depth must be between 1 and 100
  max_subagent_depth=2.5                   -> REJECTED: max_subagent_depth must be between 1 and 100
  max_session_turns=True                   -> REJECTED: max_session_turns must be -1 or a non-negative integer
  max_session_turns=2.5                    -> REJECTED: max_session_turns must be -1 or a non-negative integer
  max_tool_calls=-1 (valid)                -> ACCEPTED
  max_tool_calls=0 (valid)                 -> ACCEPTED
  max_subagent_depth=1 (valid)             -> ACCEPTED
  max_subagent_depth=100 (valid)           -> ACCEPTED
============================================================

Test case verification (all PR test cases exercised manually)

=== Running PR test cases manually ===
  PASS: max_tool_calls=True -> rejected
  PASS: max_tool_calls=False -> rejected
  PASS: max_tool_calls=0.5 -> rejected
  PASS: max_subagent_depth=True -> rejected
  PASS: max_subagent_depth=False -> rejected
  PASS: max_subagent_depth=2.5 -> rejected
  PASS: valid integer limits accepted
  PASS: max_tool_calls=-2 -> rejected (existing)
  PASS: max_subagent_depth=0 -> rejected (existing)
  PASS: max_session_turns=True -> rejected (existing)

=== Results: ALL PASSED ===

Note: tmux was unavailable on this CI runner; tests were executed directly against the SDK source with the PR patch applied, then reverted. The behavioral evidence is equivalent.

中文说明

代码审查

独立方案:max_session_turns 中内联的布尔感知检查提取为一个小辅助函数,并应用于另外两个选项。为新增拒绝的类型(布尔、浮点)添加参数化测试,并添加正向测试固定合法整数。

PR 实际做法: 完全一致。_is_intisinstance(value, int) and not isinstance(value, bool)——正确处理了 Python 中 bool 是 int 子类的问题。max_session_turns 的重构保持语义不变(表达式相同,只是命名)。max_tool_callsmax_subagent_depth 的新守卫将 _is_int 与已有范围检查干净地组合。

无关键阻塞项。无规范违反。_is_int 的文档字符串解释了为什么(bool 是 int 的子类),这是注释的恰当位置。测试精确——参数化刻意区分了哪些用例是新增覆盖的,哪些是偶然已被捕获的(max_subagent_depth=False 无论如何都会未过范围检查)。

修复前(main 分支)

max_tool_calls=True2.5False 以及 max_subagent_depth=True2.5 均被接受(不应被接受)。

修复后(PR #7548 应用后)

上述输入均被正确拒绝,并返回清晰的 ValidationError。合法整数(-1、0、1、100)在修复前后均被接受。

测试用例验证

PR 中所有新增及既有测试用例均手动执行通过。

注:本 CI 运行器无 tmux;测试直接对 SDK 源码执行(应用 PR 补丁后),随后还原。行为证据等价。

Qwen Code · qwen3.8-max-preview

Reviewed at 9ee71cda9eacf19006d4c0c85d0d40ff8d40f02a · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean, minimal fix for a real validation gap; before/after confirms it works exactly as promised.

This is what a good SDK fix looks like: the error messages already promised integer validation, one sibling enforced it, two didn't. The PR extracts the existing check, names it, and applies it uniformly. No new abstractions, no scope creep, no behavior change for valid inputs. The tests are precise about which cases are newly covered vs. already caught by accident — that kind of care tells me the author actually thought about the boundary conditions rather than just throwing a type check over the fence.

The before/after output speaks for itself: max_tool_calls=True goes from silently accepted (and forwarded as --max-tool-calls True to the CLI) to a clear ValidationError at the SDK boundary. That's the fix doing its job.

Ships it. ✅

中文说明

置信度:5/5 — 干净、最小的修复,针对真实的校验缺口;修复前后对比确认其行为完全如承诺。

这是一个好的 SDK 修复的样子:错误信息已承诺整数校验,一个同类选项执行了,另外两个没有。PR 提取已有检查、命名、统一应用。无新抽象、无范围蔓延、对合法输入无行为变化。测试精确区分了新增覆盖与偶然已捕获的用例——这种细致说明作者确实思考了边界条件,而非随手丢一个类型检查。

修复前后输出说明一切:max_tool_calls=True 从被静默接受(并以 --max-tool-calls True 转发给 CLI)变为在 SDK 边界抛出清晰的 ValidationError。这正是修复的职责。

可以合并 ✅

Qwen Code · qwen3.8-max-preview

Reviewed at 9ee71cda9eacf19006d4c0c85d0d40ff8d40f02a · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

not _is_int(options.max_subagent_depth)
or not (1 <= options.max_subagent_depth <= 100)
):
raise ValidationError("max_subagent_depth must be between 1 and 100")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The error message says "must be between 1 and 100", but 2.5 is between 1 and 100. A user who passes a float gets a misleading message that doesn't indicate the actual problem (non-integer type). The same applies to True/False — they satisfy the range check numerically.

Consider updating the message to mention the integer requirement:

Suggested change
raise ValidationError("max_subagent_depth must be between 1 and 100")
raise ValidationError("max_subagent_depth must be an integer between 1 and 100")

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, and applied — max_subagent_depth must be an integer between 1 and 100. You're right that the old wording was actively unhelpful for 2.5: it named a constraint the value already satisfies, so the message pointed away from the real problem.

Left max_tool_calls alone since it already says "must be -1 or a non-negative integer", which names the type.

Test matchers assert on the option name rather than the full string, so nothing else moved: 123 passed / 29 skipped, ruff check + format clean, mypy clean on 9 files.

Comment on lines +209 to +215
def _is_int(value: object) -> bool:
"""True for a real integer.

``bool`` subclasses ``int``, so ``isinstance(True, int)`` is True and a
bare isinstance check would let ``max_tool_calls=True`` through as 1.
"""
return isinstance(value, int) and not isinstance(value, bool)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This predicate duplicates logic already present in _as_optional_int in types.py (line 258: isinstance(raw, bool) or not isinstance(raw, int)), which guards the same three fields during JSON deserialization. If a future change adjusts what counts as a valid integer (e.g., accepting numpy.int64), both sites must be updated — missing one creates inconsistent behavior between the programmatic and deserialization paths.

Consider consolidating: move _is_int to types.py (or import it there) and refactor _as_optional_int to use it.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch that the two sites exist — I'd rather not consolidate them here, though, because they aren't quite the same check and merging them would change behavior beyond this fix.

_as_optional_int is a deserialization coercion: it runs on untrusted Mapping data in QueryOptions.from_dict and raises TypeError. _is_int is a predicate used by validate_query_options, which raises ValidationError and composes with a range test in the same condition. Having _as_optional_int call _is_int would mean types.py importing from validation.py, which imports QueryOptions from types.py — a cycle. The other direction (moving _is_int into types.py and importing it back) works, but it makes a private helper of the type module part of the validation module's contract for a two-line predicate.

The divergence risk you describe is real but bounded: if numpy.int64 ever needed accepting, the deserialization path would have to change anyway, since _as_optional_int also calls int(raw) on the result and the two paths have different error types. I'd rather leave both explicit and obvious than share a helper across that boundary — and if the project would prefer them unified, that's a refactor worth its own PR rather than folding into a validation fix.

wenshao and others added 2 commits July 23, 2026 09:04
A float like 2.5 is between 1 and 100, so the old message described a
constraint the value already satisfied and gave no hint that the type
was the problem.
@gwinthis

Copy link
Copy Markdown
Collaborator

Review & Local Verification Report

代码审查

设计评价:Python 类型系统的经典陷阱修复。

问题: Python 中 boolint 的子类,因此 isinstance(True, int) 返回 True。这导致:

  • max_tool_calls=True 通过验证 → CLI 收到 --max-tool-calls True(字符串 "True")
  • max_subagent_depth=False 通过验证 → 语义错误

修复:

def _is_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)

覆盖范围扩展:

  • max_tool_calls:之前只检查范围(< -1),现在也检查类型
  • max_subagent_depth:之前只检查范围(1-100),现在也检查类型
  • max_session_turns:已有 bool 排除,重构为使用 _is_int()

测试: 参数化测试覆盖 TrueFalse0.5/2.5(float)三种非整数类型,以及合法整数值的通过验证。

结论

LGTM。 _is_int() 是 Python SDK 中处理 bool-is-int 陷阱的标准模式,docstring 解释了 why

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.7-max via Qwen Code /review

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@ZijianZhang989 ZijianZhang989 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit c5dddb5 Jul 23, 2026
96 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants