Skip to content

Commit 8165cda

Browse files
Merge branch 'aws:main' into feat/agentcore-tool-search
2 parents 88a5c24 + 0b2b34f commit 8165cda

3 files changed

Lines changed: 467 additions & 168 deletions

File tree

src/bedrock_agentcore/payments/integrations/strands/plugin.py

Lines changed: 38 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,6 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
163163
)
164164
return
165165

166-
# Check if payment retry limit has been reached
167-
if self._check_payment_retry_limit(event):
168-
logger.warning("Payment processing retry limit has been reached. Processing skipped.")
169-
return
170-
171166
# Check if response is a 402 Payment Required
172167
if not hasattr(event, "result") or event.result is None:
173168
return
@@ -192,9 +187,6 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
192187

193188
logger.info("Detected 402 Payment Required response from tool: %s", event.tool_use.get("name", "unknown"))
194189

195-
# Increment retry count in invocation state
196-
self._increment_payment_retry_count(event)
197-
198190
# Build payment_required_request dict using handler methods
199191
headers = handler.extract_headers(event.result)
200192
body = handler.extract_body(event.result)
@@ -204,20 +196,28 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
204196
"body": body or {},
205197
}
206198

207-
# If we already retried with payment credentials and still got a 402,
208-
# this is a post-payment failure (e.g., insufficient balance, invalid signature).
209-
# Propagate as an interrupt instead of retrying again to avoid infinite loops.
210-
if self._is_post_payment_failure(event, body):
199+
# If we previously signed successfully and still got a 402, the server
200+
# rejected the payment for a non-retryable reason (e.g., insufficient balance).
201+
# Do not retry — store failure state so the agent is notified via interrupt.
202+
if self._has_successful_signing(event):
203+
error_msg = body.get("error", "unknown error") if body and isinstance(body, dict) else "unknown error"
211204
logger.warning(
212-
"Received 402 after payment retry for tool %s — treating as payment failure",
205+
"Received 402 after successful signing for tool %s — post-payment failure: %s",
213206
event.tool_use.get("name", "unknown"),
207+
error_msg,
214208
)
215-
error_msg = self._extract_payment_error_message(body)
216-
self._store_payment_failure_state(event, PaymentError(f"Payment failed after retry: {error_msg}"))
209+
self._store_payment_failure_state(event, PaymentError(f"Payment rejected after signing: {error_msg}"))
210+
return
211+
212+
# Check if signing retry limit has been reached
213+
if self._check_payment_retry_limit(event):
214+
logger.warning("Payment signing retry limit reached. Processing skipped.")
217215
return
218216

217+
# Increment before attempt so limit is enforced even on exception
218+
self._increment_payment_retry_count(event)
219+
219220
# Validate tool input before processing payment
220-
tool_input = event.tool_use.get("input", {})
221221
if not handler.validate_tool_input(tool_input):
222222
logger.error("Tool input validation failed, cannot apply payment header")
223223
self._store_payment_failure_state(event, Exception("Tool input validation failed"))
@@ -232,10 +232,11 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
232232
self._store_payment_failure_state(event, Exception("Failed to apply payment header"))
233233
return
234234

235+
# Mark that signing succeeded for this tool use — if we get another 402
236+
# after this retry, we know it's a server-side rejection, not a signing failure.
237+
self._mark_successful_signing(event)
238+
235239
# Set retry flag to re-execute the tool with payment credentials.
236-
# Do NOT reset the payment retry counter here — it must persist across
237-
# retries so that _is_post_payment_failure and _check_payment_retry_limit
238-
# can detect repeated 402s and break the loop.
239240
event.retry = True
240241
self._reset_interrupt_retry_count(event)
241242
logger.info("Set retry flag to re-execute tool with payment credentials")
@@ -295,63 +296,37 @@ def _increment_payment_retry_count(self, event: AfterToolCallEvent) -> None:
295296
"Payment retry attempt %d/%d for tool use %s", retry_count + 1, self.MAX_PAYMENT_RETRIES, tool_use_id
296297
)
297298

298-
def _is_post_payment_failure(self, event: AfterToolCallEvent, body: Optional[Dict[str, Any]]) -> bool:
299-
"""Check if this 402 response is a failure after we already retried with payment credentials.
300-
301-
A post-payment failure occurs when:
302-
1. We already sent a payment header (retry count > 0 before this increment), AND
303-
2. The 402 response body contains an error that is NOT the initial "payment required"
304-
(e.g., "invalid_exact_evm_insufficient_balance", "payment_rejected", etc.)
305-
306-
This prevents infinite loops where the plugin keeps signing and retrying
307-
against a server that keeps rejecting the payment for non-retryable reasons.
299+
def _has_successful_signing(self, event: AfterToolCallEvent) -> bool:
300+
"""Check if we previously signed a payment successfully for this tool use.
308301
309302
Args:
310303
event: The after tool call event
311-
body: The extracted response body (may be None)
312304
313305
Returns:
314-
True if this is a post-payment failure that should be propagated as an interrupt
306+
True if signing was previously successful (meaning this 402 is a server-side rejection)
315307
"""
316308
tool_use_id = event.tool_use.get("toolUseId", "unknown")
317-
payment_retry_key = f"payment_retry_count_{tool_use_id}"
318-
# retry count was already incremented before this check, so > 1 means
319-
# we already attempted at least one payment retry
320-
retry_count = event.invocation_state.get(payment_retry_key, 0)
321-
322-
if retry_count <= 1:
323-
return False
309+
signed_key = f"payment_signed_{tool_use_id}"
310+
return event.invocation_state.get(signed_key, False)
324311

325-
# If the body contains an error field that is NOT the initial "payment required",
326-
# this is a post-payment failure
327-
if body and isinstance(body, dict):
328-
error = body.get("error", "")
329-
if isinstance(error, str) and error.lower() not in ("", "payment required"):
330-
logger.info(
331-
"Post-payment failure detected for tool %s: error=%s (retry_count=%d)",
332-
tool_use_id,
333-
error,
334-
retry_count,
335-
)
336-
return True
312+
def _mark_successful_signing(self, event: AfterToolCallEvent) -> None:
313+
"""Mark that signing succeeded for this tool use.
337314
338-
return False
315+
Called after generate_payment_header and apply_payment_header both succeed,
316+
right before setting event.retry. If a subsequent 402 is received,
317+
_has_successful_signing will return True indicating the failure is server-side.
339318
340-
@staticmethod
341-
def _extract_payment_error_message(body: Optional[Dict[str, Any]]) -> str:
342-
"""Extract a human-readable error message from a 402 response body.
319+
Note: payment_signed_*, payment_retry_count_*, and payment_failure_* keys are
320+
intentionally not cleared. invocation_state is scoped to a single agent
321+
invocation and is discarded by Strands when the invocation ends, so these
322+
per-tool-use markers do not accumulate across invocations.
343323
344324
Args:
345-
body: The extracted response body (may be None)
346-
347-
Returns:
348-
Error message string, or "unknown error" if not extractable
325+
event: The after tool call event
349326
"""
350-
if body and isinstance(body, dict):
351-
error = body.get("error")
352-
if isinstance(error, str) and error:
353-
return error
354-
return "unknown error"
327+
tool_use_id = event.tool_use.get("toolUseId", "unknown")
328+
signed_key = f"payment_signed_{tool_use_id}"
329+
event.invocation_state[signed_key] = True
355330

356331
def _store_payment_failure_state(self, event: AfterToolCallEvent, exception: Exception) -> None:
357332
"""Store payment failure information in invocation state for agent to handle.

0 commit comments

Comments
 (0)