Skip to content

feat(agent): add max_turns and max_token_budget execution limits#2139

Open
rshriharripriya wants to merge 10 commits intostrands-agents:mainfrom
rshriharripriya:feat/agent-execution-limits
Open

feat(agent): add max_turns and max_token_budget execution limits#2139
rshriharripriya wants to merge 10 commits intostrands-agents:mainfrom
rshriharripriya:feat/agent-execution-limits

Conversation

@rshriharripriya
Copy link
Copy Markdown

@rshriharripriya rshriharripriya commented Apr 16, 2026

Summary

Closes #2124

  • Add max_turns parameter to Agent.__init__ — caps the number of event loop cycles per invocation; stops with stop_reason="max_turns" when reached
  • Add max_token_budget parameter to Agent.__init__ — caps cumulative input+output tokens per invocation; stops with stop_reason="max_token_budget" when reached
  • Both default to None (no limit), fully backwards compatible
  • Add "max_turns" and "max_token_budget" to the StopReason Literal in types/event_loop.py
  • Counters are stored on the agent instance and reset at the start of each invocation in stream_async; in-flight tool calls always complete before a limit is enforced

Test plan

  • test_max_turns_stops_loopmax_turns=1 stops before entering a second cycle triggered by a tool call
  • test_max_turns_allows_exactly_n_cyclesmax_turns=2 allows exactly 2 cycles
  • test_max_turns_none_is_unboundedmax_turns=None (default) runs to natural completion
  • test_max_turns_resets_between_invocations — counter resets on each new invocation
  • test_max_token_budget_stops_when_exceeded — budget of 0 triggers immediately after first model call
  • test_max_token_budget_none_is_unboundedmax_token_budget=None (default) runs to natural completion
  • test_max_token_budget_resets_between_invocations — token counter resets on each new invocation
  • All 666 existing unit tests pass with no regressions
  • ruff check and ruff format --check pass on all changed files
  • mypy reports no issues on all changed source files

@harishvadali
Copy link
Copy Markdown

Thank you for the MR. Looking forward to this as we are currently using hooks to configure the max turns.

Comment thread src/strands/agent/agent.py Outdated
Comment thread src/strands/agent/agent.py Outdated
Comment thread src/strands/event_loop/event_loop.py Outdated
Comment thread src/strands/event_loop/event_loop.py Outdated
Comment thread tests/strands/agent/test_agent_execution_limits.py Outdated
Comment thread tests/strands/event_loop/test_event_loop.py Outdated
- Introduced `max_turns` and `max_token_budget` with validation and tracking.
- Added exceptions for limit breaches: `MaxTurnsReachedException` and `MaxTokenBudgetReachedException`.
- Enhanced tests for validation, runtime behavior, and async execution.
- Created helper `apply_execution_limit_defaults` for streamlined test setup.
- Update docstrings for `max_turns` and `max_token_budget` with clearer descriptions and warnings for structured output retries.
- Export `MaxTurnsReachedException` and `MaxTokenBudgetReachedException` for easier user access.
- Add a comment to clarify ordering invariant for token accumulation in async generators.
- Fix test mock to reference `_retry_strategy` correctly.
- Introduce `test_max_token_budget_accumulates_from_message_metadata` to verify token accumulation logic.
@rshriharripriya
Copy link
Copy Markdown
Author

@srbhsrkr thank you so much for your feedback

Copy link
Copy Markdown

@srbhsrkr srbhsrkr left a comment

Choose a reason for hiding this comment

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

PTAL.

Comment thread src/strands/event_loop/event_loop.py Outdated
…x_token_budget`

- Removed `MaxTurnsReachedException` and `MaxTokenBudgetReachedException` in favor of `EventLoopStopEvent` with `stop_reason`.
- Updated event loop logic to handle limits gracefully without raising exceptions.
- Revised related docstrings, exports, and tests to align with the changes.
yield model_event

stop_reason, message, *_ = model_event["stop"]
agent._invocation_turn_count += 1
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One minor correctnss concern -> the early-exit guard fires before checking agent._interrupt_state.activated and _has_tool_use_in_latest_message. Those 2 paths execute the model at stop_reason = "tool_use" without incrementing _invocation_turn_count. This means a sequence like:

cycle 1: tool_use (no model call) → counter stays 0
cycle 2: model call → counter becomes 1

this could burn an extra cycle past max_turns=1 in interrupt/existing-tool-use scenarios. This is an edge case and may be acceptable, but it's worth a comment acknowledging the behavior or a test covering it.
invocation_state["request_state"] at early exit, by line 123–124 of event_loop.py, request_state is initialized before the try block where the limit checks now live, so invocation_state["request_state"] is always present when the EventLoopStopEvent is yielded. Safe.

The _retry_strategy attribute name inconsistency, the existing mock fixture in test_event_loop.py sets mock.retry_strategy (no underscore prefix) while agent.py stores it as self._retry_strategy. This is a pre-existing issue, not introduced by this PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added a comment at the branch point making it explicit that neither the interrupt nor existing-tool-use path increments _invocation_turn_count (no model call = no turn consumed), and added test_max_turns_skipped_model_cycle_does_not_consume_turn to pin that behaviour.

Also caught two small things on the final pass: stale stop_reason examples in the stream_async/call docstrings weren't listing the two new stop reasons, and test_max_token_budget_stops_when_counter_already_at_limit wasn't using apply_execution_limit_defaults like the rest of the file. Both fixed.

I learned a lot from this one, Thanks for the review, really appreciated!

Copy link
Copy Markdown

@srbhsrkr srbhsrkr left a comment

Choose a reason for hiding this comment

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

LGTM. Please address that 1 minor correctness comment shared.

…and update docstrings

- Added a test to verify that skipped model cycles do not increment `_invocation_turn_count`, ensuring `max_turns` only limits model invocations.
- Updated docstrings to include `max_turns` and refined descriptions of stop reasons.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add agent execution limits (max_turns, max_token_budget)

3 participants