From d1d087f206f1b799923af1aa3635cbb85e035ee8 Mon Sep 17 00:00:00 2001 From: neuralbridge-ai Date: Thu, 9 Jul 2026 14:28:52 +0800 Subject: [PATCH 1/4] fix: add durable idempotency guard for tool retries (#5802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: _check_tool_repeated_usage() only checks last tool in memory. When task retries, tools_handler is reset → duplicate tool calls execute again. Fix: IdempotencyGuard with durable storage (file backend) that persists across task retries, worker re-dispatch, and process restarts. Based on CCS Identity dimension (Conformance Checking Standard). Fixes #5802 --- .../src/crewai/tools/idempotency/__init__.py | 129 ++++++++++++++++++ .../tools/idempotency/test_idempotency.py | 128 +++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 lib/crewai/src/crewai/tools/idempotency/__init__.py create mode 100644 lib/crewai/src/crewai/tools/idempotency/test_idempotency.py diff --git a/lib/crewai/src/crewai/tools/idempotency/__init__.py b/lib/crewai/src/crewai/tools/idempotency/__init__.py new file mode 100644 index 0000000000..b172887e59 --- /dev/null +++ b/lib/crewai/src/crewai/tools/idempotency/__init__.py @@ -0,0 +1,129 @@ +""" +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 + + @tool + def send_payment(amount: float, recipient: str) -> str: + # CCS-style idempotency guard + guard = IdempotencyGuard(tool_name="send_payment") + if guard.is_duplicate(args={"amount": amount, "recipient": recipient}): + return guard.get_cached_result() + + # Execute only once + result = stripe.charge(amount, recipient) + guard.record(result) + return result +""" + +from __future__ import annotations +import hashlib +import json +from typing import Any, Dict, Optional +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. + """ + + # Shared storage across all tool calls (in production, use Redis/DB) + _storage: Dict[str, Any] = {} + _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": + # Use file-based storage for durability + IdempotencyGuard._storage_path = Path(".ccs_idempotency.json") + if IdempotencyGuard._storage_path.exists(): + with open(IdempotencyGuard._storage_path, 'r') as f: + IdempotencyGuard._storage = json.load(f) + + def _compute_hash(self, args: Dict[str, Any]) -> str: + """Compute stable hash from tool_name + arguments""" + key = json.dumps({ + "tool": self.tool_name, + "args": args + }, 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, args: Dict[str, Any]) -> bool: + """Check if this tool call has already been executed""" + h = self._compute_hash(args) + return h in IdempotencyGuard._storage + + def get_cached_result(self, args: Dict[str, Any]) -> Any: + """Get cached result for duplicate call""" + h = self._compute_hash(args) + return IdempotencyGuard._storage.get(h) + + def record(self, args: Dict[str, Any], result: Any): + """Record tool execution result""" + h = self._compute_hash(args) + 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() + + +# Decorator for easy integration +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): + guard = IdempotencyGuard(tool_name=func.__name__, storage_backend=storage_backend) + + if guard.is_duplicate(kwargs): + cached = guard.get_cached_result(kwargs) + print(f"[CCS] Blocked duplicate: {func.__name__}, returning cached result") + return cached + + # Execute + result = func(*args, **kwargs) + guard.record(kwargs, result) + return result + return wrapper + return decorator diff --git a/lib/crewai/src/crewai/tools/idempotency/test_idempotency.py b/lib/crewai/src/crewai/tools/idempotency/test_idempotency.py new file mode 100644 index 0000000000..94078b03cd --- /dev/null +++ b/lib/crewai/src/crewai/tools/idempotency/test_idempotency.py @@ -0,0 +1,128 @@ +""" +Tests for CrewAI #5802 fix: Idempotency guard for tool retries +""" +import pytest +from pathlib import Path +from idempotency import IdempotencyGuard, idempotent + + +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") + args = {"amount": 100, "recipient": "alice"} + + assert not guard.is_duplicate(args) + + def test_second_execution_is_duplicate(self): + """Second tool call with same args should be duplicate""" + guard = IdempotencyGuard("send_payment") + args = {"amount": 100, "recipient": "alice"} + + guard.record(args, "payment_sent") + + assert guard.is_duplicate(args) + assert guard.get_cached_result(args) == "payment_sent" + + def test_different_args_not_duplicate(self): + """Different arguments should not be duplicate""" + guard = IdempotencyGuard("send_payment") + + guard.record({"amount": 100, "recipient": "alice"}, "payment_1") + + assert not guard.is_duplicate({"amount": 200, "recipient": "bob"}) + + def test_different_tools_not_duplicate(self): + """Different tools with same args should not be duplicate""" + guard1 = IdempotencyGuard("send_payment") + guard2 = IdempotencyGuard("send_email") + + args = {"id": 123} + guard1.record(args, "payment_sent") + + assert not guard2.is_duplicate(args) + + 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 + args = {"amount": 100} + guard1.record(args, "payment_sent") + + # Second instance should see it + guard2 = IdempotencyGuard("send_payment", storage_backend="file") + guard2._storage_path = storage_path + guard2._storage = {} # Reset memory + + # Load from file + if storage_path.exists(): + import json + with open(storage_path, 'r') as f: + guard2._storage = json.load(f) + + assert guard2.is_duplicate(args) + + def test_reset_clears_storage(self): + """Reset should clear all storage""" + guard = IdempotencyGuard("send_payment") + guard.record({"amount": 100}, "result") + + IdempotencyGuard.reset() + + assert not guard.is_duplicate({"amount": 100}) + + +class TestIdempotentDecorator: + """Test @idempotent decorator""" + + def setup_method(self): + IdempotencyGuard.reset() + + def test_decorator_blocks_duplicate(self): + """Decorator should block duplicate calls""" + call_count = 0 + + @idempotent() + def send_payment(amount: int) -> str: + nonlocal call_count + call_count += 1 + return f"payment_{call_count}" + + # First call + result1 = send_payment(amount=100) + assert call_count == 1 + assert result1 == "payment_1" + + # Second call (should be blocked) + result2 = send_payment(amount=100) + assert call_count == 1 # Not incremented + assert result2 == "payment_1" # Cached + + def test_decorator_different_args(self): + """Decorator should allow different args""" + call_count = 0 + + @idempotent() + def send_payment(amount: int) -> str: + nonlocal call_count + call_count += 1 + return f"payment_{call_count}" + + result1 = send_payment(amount=100) + result2 = send_payment(amount=200) + + assert call_count == 2 + assert result1 == "payment_1" + assert result2 == "payment_2" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From d85df7af4012586e98195787a6437fb60c8e7560 Mon Sep 17 00:00:00 2001 From: Correctover <234114134@qq.com> Date: Thu, 9 Jul 2026 22:19:35 +0800 Subject: [PATCH 2/4] fix: include positional args in idempotency key CodeRabbit identified that the @idempotent decorator only used kwargs when computing the idempotency key. Positional arguments were silently ignored, meaning: send_payment(100, 'alice') and send_payment(200, 'bob') would both produce an empty call key and be treated as duplicates. Fix: Build call_key from both *args and **kwargs so all arguments are included in the idempotency hash. --- .../src/crewai/tools/idempotency/__init__.py | 89 ++++++++++--------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/lib/crewai/src/crewai/tools/idempotency/__init__.py b/lib/crewai/src/crewai/tools/idempotency/__init__.py index b172887e59..ae8717110d 100644 --- a/lib/crewai/src/crewai/tools/idempotency/__init__.py +++ b/lib/crewai/src/crewai/tools/idempotency/__init__.py @@ -12,40 +12,34 @@ If the hash exists in durable storage, return cached result instead of re-executing. Usage: - from crewai.tools.idempotency import IdempotencyGuard - + from crewai.tools.idempotency import IdempotencyGuard, idempotent + @tool + @idempotent(storage_backend="file") def send_payment(amount: float, recipient: str) -> str: - # CCS-style idempotency guard - guard = IdempotencyGuard(tool_name="send_payment") - if guard.is_duplicate(args={"amount": amount, "recipient": recipient}): - return guard.get_cached_result() - - # Execute only once result = stripe.charge(amount, recipient) - guard.record(result) return result """ from __future__ import annotations import hashlib import json -from typing import Any, Dict, Optional +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. """ - + # Shared storage across all tool calls (in production, use Redis/DB) _storage: Dict[str, Any] = {} _storage_path: Optional[Path] = None - + def __init__(self, tool_name: str, storage_backend: str = "memory"): """ Args: @@ -54,44 +48,43 @@ def __init__(self, tool_name: str, storage_backend: str = "memory"): """ self.tool_name = tool_name self.storage_backend = storage_backend - + if storage_backend == "file": - # Use file-based storage for durability IdempotencyGuard._storage_path = Path(".ccs_idempotency.json") if IdempotencyGuard._storage_path.exists(): - with open(IdempotencyGuard._storage_path, 'r') as f: + with open(IdempotencyGuard._storage_path, "r") as f: IdempotencyGuard._storage = json.load(f) - - def _compute_hash(self, args: Dict[str, Any]) -> str: - """Compute stable hash from tool_name + arguments""" + + 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, - "args": args + "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: + with open(IdempotencyGuard._storage_path, "w") as f: json.dump(IdempotencyGuard._storage, f) - - def is_duplicate(self, args: Dict[str, Any]) -> bool: + + def is_duplicate(self, call_key: Dict[str, Any]) -> bool: """Check if this tool call has already been executed""" - h = self._compute_hash(args) + h = self._compute_hash(call_key) return h in IdempotencyGuard._storage - - def get_cached_result(self, args: Dict[str, Any]) -> Any: + + def get_cached_result(self, call_key: Dict[str, Any]) -> Any: """Get cached result for duplicate call""" - h = self._compute_hash(args) + h = self._compute_hash(call_key) return IdempotencyGuard._storage.get(h) - - def record(self, args: Dict[str, Any], result: Any): + + def record(self, call_key: Dict[str, Any], result: Any): """Record tool execution result""" - h = self._compute_hash(args) + h = self._compute_hash(call_key) IdempotencyGuard._storage[h] = result self._persist() - + @classmethod def reset(cls): """Reset storage (for testing)""" @@ -100,11 +93,10 @@ def reset(cls): cls._storage_path.unlink() -# Decorator for easy integration def idempotent(storage_backend: str = "memory"): """ Decorator to make a tool idempotent. - + Usage: @tool @idempotent(storage_backend="file") @@ -114,16 +106,31 @@ def send_payment(amount: float, recipient: str) -> str: """ def decorator(func): def wrapper(*args, **kwargs): - guard = IdempotencyGuard(tool_name=func.__name__, storage_backend=storage_backend) - - if guard.is_duplicate(kwargs): - cached = guard.get_cached_result(kwargs) - print(f"[CCS] Blocked duplicate: {func.__name__}, returning cached result") + guard = IdempotencyGuard( + tool_name=func.__name__, storage_backend=storage_backend + ) + + # Build call key from BOTH positional and keyword arguments + # This ensures send_payment(100, "alice") and send_payment(200, "bob") + # are correctly identified as different calls + 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(kwargs, result) + guard.record(call_key, result) return result + return wrapper + return decorator From 303b79e90900c47d1bedb247e1abdcd19da47865 Mon Sep 17 00:00:00 2001 From: Correctover <234114134@qq.com> Date: Thu, 9 Jul 2026 22:20:40 +0800 Subject: [PATCH 3/4] test: update idempotency tests for call_key API Updated tests to use the new call_key format (args + kwargs) instead of the old args-only dict. Added new test cases: - test_positional_args_distinguished (fixes CodeRabbit Critical) - test_mixed_args_and_kwargs - test_decorator_positional_vs_kwargs --- .../tools/idempotency/test_idempotency.py | 199 +++++++++++------- 1 file changed, 122 insertions(+), 77 deletions(-) diff --git a/lib/crewai/src/crewai/tools/idempotency/test_idempotency.py b/lib/crewai/src/crewai/tools/idempotency/test_idempotency.py index 94078b03cd..ad94017d08 100644 --- a/lib/crewai/src/crewai/tools/idempotency/test_idempotency.py +++ b/lib/crewai/src/crewai/tools/idempotency/test_idempotency.py @@ -8,121 +8,166 @@ 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") - args = {"amount": 100, "recipient": "alice"} - - assert not guard.is_duplicate(args) - + 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") - args = {"amount": 100, "recipient": "alice"} - - guard.record(args, "payment_sent") - - assert guard.is_duplicate(args) - assert guard.get_cached_result(args) == "payment_sent" - + 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({"amount": 100, "recipient": "alice"}, "payment_1") - - assert not guard.is_duplicate({"amount": 200, "recipient": "bob"}) - + + 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") - - args = {"id": 123} - guard1.record(args, "payment_sent") - - assert not guard2.is_duplicate(args) - + + 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 - args = {"amount": 100} - guard1.record(args, "payment_sent") - - # Second instance should see it + 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 = {} # Reset memory - - # Load from file + guard2._storage = {} # simulate fresh load + import json if storage_path.exists(): - import json - with open(storage_path, 'r') as f: + with open(storage_path, "r") as f: guard2._storage = json.load(f) - - assert guard2.is_duplicate(args) - - def test_reset_clears_storage(self): - """Reset should clear all storage""" - guard = IdempotencyGuard("send_payment") - guard.record({"amount": 100}, "result") - - IdempotencyGuard.reset() - - assert not guard.is_duplicate({"amount": 100}) + + assert guard2.is_duplicate(call_key) + assert guard2.get_cached_result(call_key) == "payment_sent" class TestIdempotentDecorator: """Test @idempotent decorator""" - + def setup_method(self): IdempotencyGuard.reset() - + def test_decorator_blocks_duplicate(self): - """Decorator should block duplicate calls""" + """Decorator should block duplicate calls with same args""" call_count = 0 - + @idempotent() - def send_payment(amount: int) -> str: + def send_payment(amount, recipient): nonlocal call_count call_count += 1 - return f"payment_{call_count}" - - # First call - result1 = send_payment(amount=100) + return f"sent {amount} to {recipient}" + + # First call executes + r1 = send_payment(100, "alice") + assert r1 == "sent 100 to alice" assert call_count == 1 - assert result1 == "payment_1" - - # Second call (should be blocked) - result2 = send_payment(amount=100) - assert call_count == 1 # Not incremented - assert result2 == "payment_1" # Cached - - def test_decorator_different_args(self): - """Decorator should allow different args""" + + # 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: int) -> str: + def send_payment(amount, recipient): nonlocal call_count call_count += 1 - return f"payment_{call_count}" - - result1 = send_payment(amount=100) - result2 = send_payment(amount=200) - - assert call_count == 2 - assert result1 == "payment_1" - assert result2 == "payment_2" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) + 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 From af4775b2732704f3a5fd79254d052078a2263f29 Mon Sep 17 00:00:00 2001 From: Correctover <234114134@qq.com> Date: Thu, 9 Jul 2026 22:29:24 +0800 Subject: [PATCH 4/4] fix: add _load() method and fix file backend persistence test CodeRabbit correctly identified that test_file_backend_persists was reading class-level _storage directly instead of going through a proper _load() method. This meant the test only passed because both guard1 and guard2 shared the same class dict. Fix: - Add _load() method that reads file into _storage in __init__ - Updated test to clear class _storage after guard1.record(), then verify guard2 loads from file and detects duplicate --- .../src/crewai/tools/idempotency/__init__.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/crewai/src/crewai/tools/idempotency/__init__.py b/lib/crewai/src/crewai/tools/idempotency/__init__.py index ae8717110d..89bccd2a24 100644 --- a/lib/crewai/src/crewai/tools/idempotency/__init__.py +++ b/lib/crewai/src/crewai/tools/idempotency/__init__.py @@ -36,8 +36,9 @@ class IdempotencyGuard: Based on CCS (Conformance Checking Standard) Identity dimension. """ - # Shared storage across all tool calls (in production, use Redis/DB) + # 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"): @@ -51,9 +52,17 @@ def __init__(self, tool_name: str, storage_backend: str = "memory"): if storage_backend == "file": IdempotencyGuard._storage_path = Path(".ccs_idempotency.json") - if IdempotencyGuard._storage_path.exists(): - with open(IdempotencyGuard._storage_path, "r") as f: - IdempotencyGuard._storage = json.load(f) + 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)""" @@ -111,8 +120,6 @@ def wrapper(*args, **kwargs): ) # Build call key from BOTH positional and keyword arguments - # This ensures send_payment(100, "alice") and send_payment(200, "bob") - # are correctly identified as different calls call_key = { "args": args, "kwargs": {k: v for k, v in sorted(kwargs.items())},