Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions lib/crewai/src/crewai/tools/idempotency/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""
Fix for CrewAI #5802: Tool re-execution on task retry has no idempotency guard

Root cause:
_check_tool_repeated_usage() only checks the last used tool in memory.
When a task fails and retries, tools_handler is reset, so last_used_tool becomes None.
The duplicate tool call executes again — causing duplicate payments/emails/trades.

Fix:
Add a durable idempotency layer that persists across task retries.
Each tool call gets a stable hash (tool_name + arguments).
If the hash exists in durable storage, return cached result instead of re-executing.

Usage:
from crewai.tools.idempotency import IdempotencyGuard, idempotent

@tool
@idempotent(storage_backend="file")
def send_payment(amount: float, recipient: str) -> str:
result = stripe.charge(amount, recipient)
return result
"""

from __future__ import annotations
import hashlib
import json
from typing import Any, Dict, Optional, Tuple
from pathlib import Path


class IdempotencyGuard:
"""
Durable idempotency guard for CrewAI tools.

Survives task retries, worker re-dispatch, and process restarts.
Based on CCS (Conformance Checking Standard) Identity dimension.
"""

# Class-level cache (shared within a process)
_storage: Dict[str, Any] = {}
# File path for durable backend (shared across all instances using file backend)
_storage_path: Optional[Path] = None

def __init__(self, tool_name: str, storage_backend: str = "memory"):
"""
Args:
tool_name: Name of the tool (used in hash computation)
storage_backend: "memory" (default) or "file" (durable)
"""
self.tool_name = tool_name
self.storage_backend = storage_backend

if storage_backend == "file":
IdempotencyGuard._storage_path = Path(".ccs_idempotency.json")
self._load()

def _load(self):
"""Load storage from file (if using file backend)."""
if (
self.storage_backend == "file"
and IdempotencyGuard._storage_path
and IdempotencyGuard._storage_path.exists()
):
with open(IdempotencyGuard._storage_path, "r") as f:
IdempotencyGuard._storage = json.load(f)

def _compute_hash(self, call_key: Dict[str, Any]) -> str:
"""Compute stable hash from tool_name + call key (args + kwargs)"""
key = json.dumps({
"tool": self.tool_name,
"call_key": call_key
}, sort_keys=True, default=str)
return hashlib.sha256(key.encode()).hexdigest()[:16]

def _persist(self):
"""Persist storage to disk (if using file backend)"""
if self.storage_backend == "file" and IdempotencyGuard._storage_path:
with open(IdempotencyGuard._storage_path, "w") as f:
json.dump(IdempotencyGuard._storage, f)

def is_duplicate(self, call_key: Dict[str, Any]) -> bool:
"""Check if this tool call has already been executed"""
h = self._compute_hash(call_key)
return h in IdempotencyGuard._storage

def get_cached_result(self, call_key: Dict[str, Any]) -> Any:
"""Get cached result for duplicate call"""
h = self._compute_hash(call_key)
return IdempotencyGuard._storage.get(h)

def record(self, call_key: Dict[str, Any], result: Any):
"""Record tool execution result"""
h = self._compute_hash(call_key)
IdempotencyGuard._storage[h] = result
self._persist()

@classmethod
def reset(cls):
"""Reset storage (for testing)"""
cls._storage = {}
if cls._storage_path and cls._storage_path.exists():
cls._storage_path.unlink()


def idempotent(storage_backend: str = "memory"):
"""
Decorator to make a tool idempotent.

Usage:
@tool
@idempotent(storage_backend="file")
def send_payment(amount: float, recipient: str) -> str:
stripe.charge(amount, recipient)
return "payment sent"
"""
def decorator(func):
def wrapper(*args, **kwargs):
Comment on lines +116 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Missing functools.wraps breaks downstream @tool decorator.

The decorator returns a bare wrapper that loses func.__name__, __doc__, and __module__. Since the usage example stacks @tool on top of @idempotent, the @tool decorator will see wrapper.__name__ == "wrapper" instead of the original function name, likely causing incorrect tool registration.

🔧 Proposed fix
+import functools

 def decorator(func):
+    `@functools.wraps`(func)
     def wrapper(*args, **kwargs):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def decorator(func):
def wrapper(*args, **kwargs):
import functools
def decorator(func):
`@functools.wraps`(func)
def wrapper(*args, **kwargs):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/tools/idempotency/__init__.py` around lines 107 - 108,
The idempotency decorator in decorator/wrapper loses the wrapped function’s
metadata, which breaks downstream `@tool` registration. Update the
`decorator`/`wrapper` path to preserve the original function identity by
applying `functools.wraps` to `wrapper` inside `idempotent`, so `__name__`,
`__doc__`, and `__module__` remain tied to `func`. This should be done where
`decorator(func)` defines `wrapper(*args, **kwargs)` so stacked decorators like
`@tool` continue to see the correct function name.

guard = IdempotencyGuard(
tool_name=func.__name__, storage_backend=storage_backend
)

# Build call key from BOTH positional and keyword arguments
call_key = {
"args": args,
"kwargs": {k: v for k, v in sorted(kwargs.items())},
}

if guard.is_duplicate(call_key):
cached = guard.get_cached_result(call_key)
print(
f"[CCS] Blocked duplicate: {func.__name__}"
f", returning cached result"
)
return cached

# Execute
result = func(*args, **kwargs)
guard.record(call_key, result)
return result
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return wrapper

return decorator
173 changes: 173 additions & 0 deletions lib/crewai/src/crewai/tools/idempotency/test_idempotency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""
Tests for CrewAI #5802 fix: Idempotency guard for tool retries
"""
import pytest
from pathlib import Path
from idempotency import IdempotencyGuard, idempotent

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Incorrect import path will fail pytest at repo root.

from idempotency import ... only resolves if this directory is on sys.path. Under normal repository-root collection the module is crewai.tools.idempotency, so this import raises ModuleNotFoundError and the whole test file fails to collect.

🐛 Proposed fix
-from idempotency import IdempotencyGuard, idempotent
+from crewai.tools.idempotency import IdempotencyGuard, idempotent
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from idempotency import IdempotencyGuard, idempotent
from crewai.tools.idempotency import IdempotencyGuard, idempotent
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/tools/idempotency/test_idempotency.py` at line 6,
Update the import in the idempotency test to use the package-qualified module
path, importing IdempotencyGuard and idempotent from crewai.tools.idempotency so
pytest collection from the repository root succeeds.



class TestIdempotencyGuard:
"""Test IdempotencyGuard core functionality"""

def setup_method(self):
IdempotencyGuard.reset()

def test_first_execution_not_duplicate(self):
"""First tool call should not be detected as duplicate"""
guard = IdempotencyGuard("send_payment")
call_key = {"args": (100, "alice"), "kwargs": {}}

assert not guard.is_duplicate(call_key)

def test_second_execution_is_duplicate(self):
"""Second tool call with same args should be duplicate"""
guard = IdempotencyGuard("send_payment")
call_key = {"args": (), "kwargs": {"amount": 100, "recipient": "alice"}}

guard.record(call_key, "payment_sent")

assert guard.is_duplicate(call_key)
assert guard.get_cached_result(call_key) == "payment_sent"

def test_different_args_not_duplicate(self):
"""Different arguments should not be duplicate"""
guard = IdempotencyGuard("send_payment")

guard.record({"args": (), "kwargs": {"amount": 100, "recipient": "alice"}}, "payment_1")

assert not guard.is_duplicate({"args": (), "kwargs": {"amount": 200, "recipient": "bob"}})

def test_positional_args_distinguished(self):
"""Positional args must produce different keys (fix for CodeRabbit Critical)"""
guard = IdempotencyGuard("send_payment")

key1 = {"args": (100, "alice"), "kwargs": {}}
key2 = {"args": (200, "bob"), "kwargs": {}}

guard.record(key1, "payment_1")

assert guard.is_duplicate(key1)
assert not guard.is_duplicate(key2)

def test_mixed_args_and_kwargs(self):
"""Mix of positional and keyword args should be handled correctly"""
guard = IdempotencyGuard("send_payment")

key1 = {"args": (100,), "kwargs": {"recipient": "alice"}}
key2 = {"args": (200,), "kwargs": {"recipient": "bob"}}
key3 = {"args": (100,), "kwargs": {"recipient": "alice"}}

guard.record(key1, "payment_1")

assert guard.is_duplicate(key3) # Same args -> duplicate
assert not guard.is_duplicate(key2) # Different args -> not duplicate

def test_different_tools_not_duplicate(self):
"""Different tools with same args should not be duplicate"""
guard1 = IdempotencyGuard("send_payment")
guard2 = IdempotencyGuard("send_email")

call_key = {"args": (123,), "kwargs": {}}
guard1.record(call_key, "payment_sent")

assert not guard2.is_duplicate(call_key)

def test_file_backend_persists(self, tmp_path):
"""File backend should persist across instances"""
storage_path = tmp_path / ".ccs_idempotency.json"

# First instance records
guard1 = IdempotencyGuard("send_payment", storage_backend="file")
guard1._storage_path = storage_path
call_key = {"args": (100, "alice"), "kwargs": {}}
guard1.record(call_key, "payment_sent")

# Second instance should see the record
guard2 = IdempotencyGuard("send_payment", storage_backend="file")
guard2._storage_path = storage_path
guard2._storage = {} # simulate fresh load
import json
if storage_path.exists():
with open(storage_path, "r") as f:
guard2._storage = json.load(f)

assert guard2.is_duplicate(call_key)
assert guard2.get_cached_result(call_key) == "payment_sent"
Comment on lines +75 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

File backend persistence test doesn't actually test persistence.

is_duplicate and get_cached_result read the class attribute IdempotencyGuard._storage, not the instance attribute guard2._storage. Setting guard2._storage = {} and manually loading JSON into it has no effect on the assertions — they pass solely because IdempotencyGuard._storage (class-level) still holds the entry from guard1.record().

To prove this, clearing IdempotencyGuard._storage after guard1.record() would cause guard2.is_duplicate(call_key) to return False, even though the file exists and was loaded into guard2._storage.

The test needs to either:

  1. Clear IdempotencyGuard._storage (class) after recording, so the only path to the cached result is through the file, or
  2. Have the implementation support a _load() method that reads the file into IdempotencyGuard._storage on initialization, then test that path.

This was previously flagged and marked as addressed, but the same instance-attribute pattern persists in the current code.

🧪 Proposed fix: clear class storage to force file-based lookup
     guard1.record(call_key, "payment_sent")
 
+    # Clear class-level storage to force file-based lookup
+    IdempotencyGuard._storage = {}
+
     # Second instance should see the record
     guard2 = IdempotencyGuard("send_payment", storage_backend="file")
     guard2._storage_path = storage_path
-    guard2._storage = {}  # simulate fresh load
-    import json
-    if storage_path.exists():
-        with open(storage_path, "r") as f:
-            guard2._storage = json.load(f)
+    # guard2 should load from file automatically (requires _load() in implementation)
+    # OR manually load into class storage:
+    import json
+    if storage_path.exists():
+        with open(storage_path, "r") as f:
+            IdempotencyGuard._storage = json.load(f)
 
     assert guard2.is_duplicate(call_key)
     assert guard2.get_cached_result(call_key) == "payment_sent"
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 90-90: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(storage_path, "r")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/tools/idempotency/test_idempotency.py` around lines 75
- 95, The persistence test for IdempotencyGuard is still relying on class-level
state instead of the file backend, so it does not prove disk persistence. Update
test_file_backend_persists to clear IdempotencyGuard._storage after
guard1.record(), then verify guard2.is_duplicate and guard2.get_cached_result
only succeed via the file at _storage_path; use the IdempotencyGuard and
record/is_duplicate/get_cached_result symbols to keep the test focused on true
file-backed loading.



class TestIdempotentDecorator:
"""Test @idempotent decorator"""

def setup_method(self):
IdempotencyGuard.reset()

def test_decorator_blocks_duplicate(self):
"""Decorator should block duplicate calls with same args"""
call_count = 0

@idempotent()
def send_payment(amount, recipient):
nonlocal call_count
call_count += 1
return f"sent {amount} to {recipient}"

# First call executes
r1 = send_payment(100, "alice")
assert r1 == "sent 100 to alice"
assert call_count == 1

# Second call with same args returns cached
r2 = send_payment(100, "alice")
assert r2 == "sent 100 to alice"
assert call_count == 1 # Not executed again

def test_decorator_allows_different_args(self):
"""Decorator should allow calls with different args"""
call_count = 0

@idempotent()
def send_payment(amount, recipient):
nonlocal call_count
call_count += 1
return f"sent {amount} to {recipient}"

r1 = send_payment(100, "alice")
r2 = send_payment(200, "bob")

assert r1 == "sent 100 to alice"
assert r2 == "sent 200 to bob"
assert call_count == 2 # Both executed

def test_decorator_with_kwargs_only(self):
"""Decorator should work with keyword-only calls"""
call_count = 0

@idempotent()
def send_payment(amount=0, recipient=""):
nonlocal call_count
call_count += 1
return f"sent {amount} to {recipient}"

r1 = send_payment(amount=100, recipient="alice")
r2 = send_payment(amount=100, recipient="alice")

assert call_count == 1 # Second call blocked
assert r2 == r1

def test_decorator_positional_vs_kwargs(self):
"""Decorator should distinguish positional from keyword calls"""
call_count = 0

@idempotent()
def send_payment(amount, recipient):
nonlocal call_count
call_count += 1
return f"sent {amount} to {recipient}"

# Positional
send_payment(100, "alice")
assert call_count == 1

# Same values as kwargs — should still be different call key
send_payment(amount=100, recipient="alice")
assert call_count == 2 # Different call key format