Skip to content

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

Open
rshriharripriya wants to merge 4 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 4 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
self.agent_id = _identifier.validate(agent_id or _DEFAULT_AGENT_ID, _identifier.Identifier.AGENT)
self.name = name or _DEFAULT_AGENT_NAME
self.description = description
self.max_turns = max_turns
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Add validation for invalid values. Negative values produce silent misbehavior — e.g. max_turns=-1 blocks all execution because the check 0 >= -1 is True on the first cycle. Same for max_token_budget.

if max_turns is not None and max_turns < 1:                                                                                           
    raise ValueError("max_turns must be a positive integer")                                                                          
if max_token_budget is not None and max_token_budget < 1:                                                                             
    raise ValueError("max_token_budget must be a positive integer")                                                                   
                                                                                                                                    
self.max_turns = max_turns                                                                                                            
self.max_token_budget = max_token_budget                                                          

This also resolves the max_token_budget=0 edge case where the >= check prevents any model call from ever executing (see comment on event_loop.py).

Comment thread src/strands/agent/agent.py Outdated
merged_state = invocation_state

# Reset per-invocation execution limit counters.
self._invocation_turn_count: int = 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These instance variables don't exist on the agent until the first call to stream_async. The event_loop_cycle code works around this with getattr(agent, "_invocation_turn_count", 0), but every other agent attribute (_interrupt_state, _cancel_signal, messages, etc.) is initialized in init and accessed directly.

Please also initialize them in init alongside self.max_turns / self.max_token_budget:

self.max_turns = max_turns
self.max_token_budget = max_token_budget                                                                                              
self._invocation_turn_count: int = 0                                                                                                  
self._invocation_token_count: int = 0    

Keep the reset here in stream_async, but having them in init makes the getattr calls in event_loop.py unnecessary (they can become direct attribute access).

Comment thread src/strands/event_loop/event_loop.py Outdated
with trace_api.use_span(cycle_span, end_on_exit=False):
try:
# Check max_turns execution limit before each cycle.
_max_turns = getattr(agent, "max_turns", None)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Every other agent attribute in this function is accessed directly (agent.messages, agent.event_loop_metrics, agent._interrupt_state).
Using getattr with defaults implies these attributes might not exist, which is inconsistent and makes the contract unclear.

If the counters are initialized in init (see comment on agent.py), these can be simplified to:
if agent.max_turns is not None and agent._invocation_turn_count >= agent.max_turns:
Same applies to the max_token_budget check and the accumulation line further down (getattr(agent, "_invocation_token_count", 0)).

Comment thread src/strands/event_loop/event_loop.py Outdated
# Check max_token_budget execution limit before each cycle.
_max_token_budget = getattr(agent, "max_token_budget", None)
_invocation_tokens = getattr(agent, "_invocation_token_count", 0)
if _max_token_budget is not None and _invocation_tokens >= _max_token_budget:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The >= comparison creates an asymmetry with max_turns. With max_turns=1, the first cycle executes (0 >= 1 → False, counter increments, model runs). With max_token_budget=0, the first cycle is blocked (0 >= 0 → True, model never runs).

This is because the token counter starts at 0 and only accumulates after a model call, so the initial state 0 >= 0 fires immediately.
Two options:

  1. Use > instead of >= — guarantees at least one model call, matches max_turns semantics
  2. Validate max_token_budget >= 1 in init (my preference — see that comment)



@pytest.mark.asyncio
async def test_max_token_budget_stops_when_exceeded():
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The docstring says "MockedModelProvider attaches usage metadata" but it doesn't — MockedModelProvider never emits a {"metadata": ...} chunk, so message.get("metadata", {}).get("usage", {}) always returns {} and accumulated tokens are always 0. This test only passes because budget=0 triggers on the initial 0 >= 0 check.

There's no test that verifies a positive token budget actually stops the agent when real token counts accumulate. Consider either:

  1. Enhancing MockedModelProvider to emit usage metadata events, then testing with e.g. max_token_budget=100 where the mock reports 150 tokens
  2. Adding a unit test that sets agent._invocation_token_count = 500 directly and verifies the check fires with max_token_budget=500

Also, the inline comment says "First cycle executes" but then explains 0 >= 0 fires immediately — meaning the first cycle does not execute (no model call happens). The comment contradicts itself.

mock._model_state = {}
mock.trace_attributes = {}
mock.retry_strategy = ModelRetryStrategy()
mock.max_turns = None
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: These 4 lines are duplicated in 4 places across the event_loop test files (test_event_loop.py x2, test_event_loop_metadata.py, test_event_loop_structured_output.py). If the counters are initialized in Agent.init (see earlier comment), only mock agents that don't go through init need these. Consider extracting a shared fixture helper or adding these to an existing base fixture to reduce the boilerplate that would grow with future agent attributes.

- 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.
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