Skip to content

fix: output_validator ctx.retry reflects global retry counter, not per-tool count#4527

Closed
adityasingh2400 wants to merge 1 commit intopydantic:mainfrom
adityasingh2400:fix/output-validator-global-retry
Closed

fix: output_validator ctx.retry reflects global retry counter, not per-tool count#4527
adityasingh2400 wants to merge 1 commit intopydantic:mainfrom
adityasingh2400:fix/output-validator-global-retry

Conversation

@adityasingh2400
Copy link
Copy Markdown

Problem

Fixes #4385.

@agent.output_validator functions received a ctx.retry that was set by the ToolManager's _build_tool_execution_context:

retry=self.ctx.retries.get(call.tool_name, 0)  # per-tool retry count

This is correct for function tools (each tool has its own retry budget) but wrong for output validators, which share the global output-validation budget (output_retries / max_result_retries).

The discrepancy means: if any retry-consuming event occurred before the output tool is called — empty model response, unknown tool call, text validation failure — the global counter is already > 0 but the validator still sees ctx.retry = 0. Validators that use ctx.retry to adjust behavior on the last attempt are blind to the already-consumed retry budget.

The text-output path already does the right thing (line 821 of _agent_graph.py):

run_context = replace(run_context, retry=ctx.state.retries, max_retries=ctx.deps.max_result_retries)

The tool-output path was missing this step.

Fix

Immediately before calling tool_manager.execute_tool_call for an output tool, replace validated.ctx with a copy whose retry/max_retries reflect the global state:

validated = replace(
    validated,
    ctx=replace(validated.ctx, retry=ctx.state.retries, max_retries=ctx.deps.max_result_retries),
)

Test

Added test_output_validator_sees_global_retry_count which:

  1. First model call returns an empty ModelResponse(parts=[])) — this consumes 1 global retry
  2. Second call returns the output tool call
  3. Asserts the output validator sees ctx.retry == 1 (global), not ctx.retry == 0 (per-tool)

…t per-tool count

When an @agent.output_validator fails, the RunContext passed to it had
ctx.retry set to the per-tool retry counter via ToolManager's
_build_tool_execution_context:

    retry=self.ctx.retries.get(call.tool_name, 0)

This is correct for function tools (each tool has its own budget), but
wrong for output validators, which share the global output-validation
budget (max_result_retries / output_retries).

Consequence: if any retry-consuming event occurs *before* the output
tool is called (empty model response, unknown tool call, text validation
failure), the global counter is already > 0 but the validator sees
ctx.retry = 0.  Validators that use ctx.retry to adjust behaviour on the
last attempt (e.g. 'be lenient on final try') are blind to these
already-consumed retries.

Fix: immediately before calling tool_manager.execute_tool_call for an
output tool, replace validated.ctx with a copy whose retry/max_retries
reflect the global state (ctx.state.retries / ctx.deps.max_result_retries).

Add regression test test_output_validator_sees_global_retry_count that
fires an empty-response before the output tool call and asserts the
validator receives ctx.retry=1, not ctx.retry=0.

Fixes pydantic#4385
@github-actions github-actions Bot added the size: S Small PR (≤100 weighted lines) label Mar 4, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 4, 2026

Thanks for your interest in this issue! However, there are already open PRs addressing issue #4385: #4325.

To avoid duplicate efforts, this PR has been closed. If you'd like to contribute, you can review the existing PRs or share your thoughts on issue #4385.

If you believe the existing PRs are inactive, please comment on the issue and a maintainer can reassess.

@github-actions github-actions Bot closed this Mar 4, 2026
@github-actions github-actions Bot added the bug Report that something isn't working, or PR implementing a fix label Mar 4, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +1013 to +1016
validated = replace(
validated,
ctx=replace(validated.ctx, retry=ctx.state.retries, max_retries=ctx.deps.max_result_retries),
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Dual retry budget for output tools: global counter and per-tool counter both track failures

Output tool retries are tracked by two independent mechanisms:

  1. Global counter (ctx.state.retries / ctx.state.increment_retries): Managed in _agent_graph.py:1034 when ToolRetryError is caught. This enforces the max_result_retries budget.
  2. Per-tool counter (ToolManager.failed_tools + ToolManager.ctx.retries dict): Managed in _tool_manager.py:359-360 when ModelRetry is caught inside _execute_tool_call_impl. This enforces validated.tool.max_retries via _check_max_retries at _tool_manager.py:147-151.

Both counters use the same cap value (output_retries / max_result_retries), so they effectively race to the same limit. This PR's fix only modifies what validated.ctx.retry (scalar) exposes to output validators — it does NOT change either retry-counting mechanism. The dual tracking is pre-existing and works because both counters increment in lockstep for output-only retry scenarios. However, when non-output retry events (empty responses, unknown tools) increment the global counter without affecting the per-tool counter, the per-tool _check_max_retries could allow more attempts than max_result_retries intends. This is mitigated by the global increment_retries check in _agent_graph.py, which fires first.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Report that something isn't working, or PR implementing a fix size: S Small PR (≤100 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@agent.output_validator receives inconsistent ctx.retry depending on text vs tool output path

1 participant