Skip to content

Commit 9e77555

Browse files
feat: comprehensive tool retry policy improvements and testing
- Fix RetryPolicy import in TYPE_CHECKING block - Fix clone_for_channel to propagate tool_retry_policy - Fix async hook emission to use executor instead of blocking event loop - Add exception chaining (raise ... from e) for better error tracebacks - Fix iterable guard in _get_tool_retry_policy for MCP tools - Wire @tool(retry_policy=...) support on FunctionTool decorator - Add comprehensive unit tests for sync/async retry functionality - Add real agentic tests with flaky tools (satisfies AGENTS.md §9.4) - Add CLI support with --tool-retry-* flags - Add YAML configuration support for tool_retry_policy - Test tool-level policy precedence and error classification 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f36efb commit 9e77555

9 files changed

Lines changed: 765 additions & 11 deletions

File tree

src/praisonai-agents/praisonaiagents/agent/agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ def _get_default_server_registry() -> ServerRegistry:
252252
from .handoff import Handoff, HandoffConfig, HandoffResult
253253
from ..rag.models import RAGResult, ContextPack
254254
from ..eval.results import EvaluationLoopResult
255+
from ..tools.retry import RetryPolicy
255256

256257
# Import structured error from central errors module
257258
from ..errors import BudgetExceededError
@@ -2076,6 +2077,7 @@ def clone_for_channel(self) -> "Agent":
20762077

20772078
# Tool configuration
20782079
'tool_timeout': getattr(self, '_tool_timeout', None),
2080+
'tool_retry_policy': getattr(self, '_tool_retry_policy', None),
20792081
'parallel_tool_calls': getattr(self, 'parallel_tool_calls', False),
20802082

20812083
# CLI backend

src/praisonai-agents/praisonaiagents/agent/execution_mixin.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,8 +1397,12 @@ async def _emit_retry_hook_async(self, tool_name, attempt, delay_ms, error, max_
13971397
if hasattr(hook_runner, 'execute_async'):
13981398
await hook_runner.execute_async(HookEvent.ON_RETRY, retry_input, target=tool_name)
13991399
else:
1400-
# Fallback to sync execution
1401-
hook_runner.execute_sync(HookEvent.ON_RETRY, retry_input, target=tool_name)
1400+
# Fallback to sync execution in executor to avoid blocking event loop
1401+
loop = asyncio.get_running_loop()
1402+
await loop.run_in_executor(
1403+
None,
1404+
lambda: hook_runner.execute_sync(HookEvent.ON_RETRY, retry_input, target=tool_name)
1405+
)
14021406

14031407
except Exception as e:
14041408
# Don't let hook failures break retry logic

src/praisonai-agents/praisonaiagents/agent/tool_execution.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -726,13 +726,13 @@ def _execute_tool_with_circuit_breaker(self, function_name, arguments):
726726

727727
# Check if retryable and not last attempt
728728
if not is_retryable or attempt == retry_policy.max_attempts - 1:
729-
raise wrapped_error
729+
raise wrapped_error from e
730730

731731
# Determine error type
732732
error_type = self._classify_error_type(None, wrapped_error)
733733

734734
if not retry_policy.should_retry(error_type, attempt):
735-
raise wrapped_error
735+
raise wrapped_error from e
736736

737737
# Emit retry hook event
738738
delay_ms = retry_policy.get_delay_ms(attempt)
@@ -1171,7 +1171,11 @@ def _get_tool_retry_policy(self, tool_name):
11711171
from ..tools.retry import RetryPolicy
11721172

11731173
# Check for tool-level retry policy first
1174-
for tool in getattr(self, 'tools', []):
1174+
tools = getattr(self, 'tools', [])
1175+
# Handle non-iterable tools (e.g., single MCP instance)
1176+
if not isinstance(tools, (list, tuple)):
1177+
tools = [] # MCP or single tool instance - no tool-level policy lookup
1178+
for tool in tools:
11751179
if (callable(tool) and
11761180
getattr(tool, '__name__', '') == tool_name and
11771181
hasattr(tool, 'retry_policy')):

src/praisonai-agents/praisonaiagents/tools/decorator.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,15 @@ def __init__(
6868
name: Optional[str] = None,
6969
description: Optional[str] = None,
7070
version: str = "1.0.0",
71-
availability: Optional[Callable[[], tuple[bool, str]]] = None
71+
availability: Optional[Callable[[], tuple[bool, str]]] = None,
72+
retry_policy: Optional[Any] = None
7273
):
7374
self._func = func
7475
self.name = name or func.__name__
7576
self.description = description or func.__doc__ or f"Tool: {self.name}"
7677
self.version = version
7778
self._availability = availability
79+
self.retry_policy = retry_policy
7880

7981
# Detect injected parameters
8082
self._injected_params = get_injected_params(func)
@@ -176,7 +178,8 @@ def tool(
176178
name: Optional[str] = None,
177179
description: Optional[str] = None,
178180
version: str = "1.0.0",
179-
availability: Optional[Callable[[], tuple[bool, str]]] = None
181+
availability: Optional[Callable[[], tuple[bool, str]]] = None,
182+
retry_policy: Optional[Any] = None
180183
) -> Union[FunctionTool, Callable[[Callable], FunctionTool]]:
181184
"""Decorator to convert a function into a tool.
182185
@@ -194,13 +197,18 @@ def my_func(x: str) -> str:
194197
@tool(availability=lambda: (bool(os.getenv("API_KEY")), "API_KEY missing"))
195198
def my_func(x: str) -> str:
196199
return x
200+
201+
@tool(retry_policy=RetryPolicy(max_attempts=5))
202+
def my_func(x: str) -> str:
203+
return x
197204
198205
Args:
199206
func: The function to wrap (when used without parentheses)
200207
name: Override the tool name (default: function name)
201208
description: Override description (default: function docstring)
202209
version: Tool version (default: "1.0.0")
203210
availability: Function that returns (is_available, reason) tuple
211+
retry_policy: RetryPolicy for tool execution with exponential backoff
204212
205213
Returns:
206214
FunctionTool instance that wraps the function
@@ -211,7 +219,8 @@ def decorator(fn: Callable) -> FunctionTool:
211219
name=name,
212220
description=description,
213221
version=version,
214-
availability=availability
222+
availability=availability,
223+
retry_policy=retry_policy
215224
)
216225

217226
# Validate the tool at creation time for early error detection
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""
2+
Tests for @tool(retry_policy=...) decorator functionality.
3+
"""
4+
from praisonaiagents import tool, Agent
5+
from praisonaiagents.tools.retry import RetryPolicy
6+
7+
8+
class TestToolDecoratorRetryPolicy:
9+
"""Test @tool decorator with retry_policy parameter."""
10+
11+
def test_tool_decorator_with_retry_policy(self):
12+
"""Test @tool decorator accepts retry_policy parameter."""
13+
retry_policy = RetryPolicy(max_attempts=5, initial_delay_ms=500)
14+
15+
@tool(retry_policy=retry_policy)
16+
def test_tool(query: str) -> str:
17+
"""A test tool with retry policy."""
18+
return f"Result for {query}"
19+
20+
assert hasattr(test_tool, 'retry_policy')
21+
assert test_tool.retry_policy == retry_policy
22+
assert test_tool.retry_policy.max_attempts == 5
23+
assert test_tool.retry_policy.initial_delay_ms == 500
24+
25+
def test_tool_decorator_without_retry_policy(self):
26+
"""Test @tool decorator works without retry_policy (default None)."""
27+
@tool
28+
def test_tool(query: str) -> str:
29+
"""A test tool without retry policy."""
30+
return f"Result for {query}"
31+
32+
assert hasattr(test_tool, 'retry_policy')
33+
assert test_tool.retry_policy is None
34+
35+
def test_tool_with_retry_policy_used_by_agent(self):
36+
"""Test that agent respects tool-level retry policy."""
37+
tool_retry_policy = RetryPolicy(max_attempts=7, initial_delay_ms=300)
38+
39+
@tool(retry_policy=tool_retry_policy)
40+
def special_tool(query: str) -> str:
41+
"""Special tool with custom retry policy."""
42+
return f"Special result for {query}"
43+
44+
# Agent with different retry policy
45+
agent_retry_policy = RetryPolicy(max_attempts=2, initial_delay_ms=1000)
46+
agent = Agent(
47+
name="test_agent",
48+
instructions="Test agent",
49+
tools=[special_tool],
50+
tool_retry_policy=agent_retry_policy
51+
)
52+
53+
# Should get tool-level policy (higher precedence)
54+
resolved_policy = agent._get_tool_retry_policy("special_tool")
55+
assert resolved_policy == tool_retry_policy
56+
assert resolved_policy.max_attempts == 7
57+
assert resolved_policy.initial_delay_ms == 300
58+
59+
def test_mixed_tools_retry_policies(self):
60+
"""Test agent with mix of tools - some with retry policy, some without."""
61+
policy_a = RetryPolicy(max_attempts=3)
62+
policy_b = RetryPolicy(max_attempts=6)
63+
64+
@tool(retry_policy=policy_a)
65+
def tool_a(query: str) -> str:
66+
return f"A: {query}"
67+
68+
@tool(retry_policy=policy_b)
69+
def tool_b(query: str) -> str:
70+
return f"B: {query}"
71+
72+
@tool # No retry policy
73+
def tool_c(query: str) -> str:
74+
return f"C: {query}"
75+
76+
agent_policy = RetryPolicy(max_attempts=4)
77+
agent = Agent(
78+
name="test_agent",
79+
instructions="Test agent",
80+
tools=[tool_a, tool_b, tool_c],
81+
tool_retry_policy=agent_policy
82+
)
83+
84+
# Tool A should use its own policy
85+
policy_a_resolved = agent._get_tool_retry_policy("tool_a")
86+
assert policy_a_resolved == policy_a
87+
assert policy_a_resolved.max_attempts == 3
88+
89+
# Tool B should use its own policy
90+
policy_b_resolved = agent._get_tool_retry_policy("tool_b")
91+
assert policy_b_resolved == policy_b
92+
assert policy_b_resolved.max_attempts == 6
93+
94+
# Tool C should use agent policy (fallback)
95+
policy_c_resolved = agent._get_tool_retry_policy("tool_c")
96+
assert policy_c_resolved == agent_policy
97+
assert policy_c_resolved.max_attempts == 4

0 commit comments

Comments
 (0)