Skip to content

Commit 0a8a486

Browse files
ragsu43aidandaly24
andauthored
feat(payments): Add LangGraph integration for payment handling (#546)
* feat(payments): Add LangGraph integration for payment handling * refactor(payments): Unify LangGraph and Strands config into single class Merge AgentCorePaymentsConfig (LangGraph) and AgentCorePaymentsPluginConfig (Strands) into a single dataclass in integrations/config.py. Both names remain available as aliases for backward compatibility. * define _ERROR_MESSAGES dict at the root level of payments SDK instead of in langgraph-specific package * added langgraph dependencies to pyproject.toml for CI * fix: linter check failing due to long lines, wrapped and fixed length of all * style: apply ruff formatting locally for push and rerun lint check * fix(payments): Fix broken type import and raise langchain/langgraph version floor Fix TYPE_CHECKING import in errors.py to use ..config (parent package) instead of .config (non-existent sibling module). This resolves mypy/pyright failures for PaymentErrorContext.config type resolution. Raise langchain and langgraph minimum versions from >=0.2.0 to >=1.0.0 in both dev dependencies and the [langgraph] optional group. AgentMiddleware, create_agent, and the langchain.agents.middleware namespace are langchain 1.0 APIs — the 0.2.0 floor allowed installations that would fail at import time. * test(payments): Add unit tests for auto_session lazy session creation Cover the auto_session feature path that was previously only validated live against testnet. Tests verify: - Session created on first 402 when auto_session=True - Config mutated with new session ID for subsequent calls - Session reused across multiple tool calls (no duplicate creation) - auto_session=False still raises PAYMENT ERROR without session - Budget and expiry config values passed correctly - Pre-existing session_id skips auto-creation * docs(payments): Document pre-merge testnet validation requirement Add PRE-MERGE REQUIREMENT note to test_functional.py docstring clarifying that any middleware changes must be validated against live testnet before merge, since these tests are skipped in CI. * docs(payments): Document middleware instance lifecycle and thread-safety Add note that one middleware instance should be created per agent invocation/request. The middleware is not thread-safe due to config mutations in auto_session and on_payment_error callbacks. * refactor(payments): Extract shared helpers to reduce sync/async duplication Extract guard checks, 402 detection, payment request extraction, header injection, and post-payment rejection detection into shared private methods. The sync and async paths now only differ at await/sleep boundaries. Future bug fixes to detection or injection logic only need to be applied in one place instead of four. No behavioral changes — 128 tests pass identically before and after. * docs(payments): Add Sync vs Async section to langgraph middleware README Document when each path is used (.invoke vs .ainvoke), what the async path does differently (non-blocking sleep, to_thread for signing, async callbacks), and provide FastAPI and script examples. * fix(payments): Detect async callback on sync path and fail loudly If an async def on_payment_error callback is registered but the agent runs via .invoke() (sync path), the callback would silently return an unawaited coroutine, bypass RETRY logic, and leak a RuntimeWarning. Now we detect this with inspect.iscoroutinefunction() and raise a clear TypeError that the existing try/except catches and logs — the agent continues with the default error message and the developer sees exactly what to fix in their logs. * test(payments): Cover post-recovery rejection with raw JSON fallback path Verify that when a raw-JSON tool returns 402 after error handler recovery, the FallbackHandler is used to extract the real error detail (e.g. 'budget exceeded') instead of falling through to 'unknown'. * fix(payments): Reassign handler in post-recovery fallback detection When fallback detects a 402 in the recovery retry path, reassign _rh to _FallbackHandler(fallback) so extract_body returns the actual parsed body. Without this, GenericPaymentHandler looks for the PAYMENT_REQUIRED marker, finds nothing in raw JSON, and the LLM sees 'unknown' instead of the real error detail. * fix(payments): Add name-based handler as third 402 detection fallback When both GenericPaymentHandler (marker) and _fallback_detect_402 (JSON) fail to detect a 402, try the name-based handler from get_payment_handler (e.g. HttpRequestPaymentHandler for tools named http_request). This covers the legacy 'Status Code: 402' text-block format used by Strands tools ported to LangGraph without format adaptation. * fix(payments): Feed custom handlers raw content instead of prepared shape Custom handlers now receive result.content (the raw ToolMessage content) for extract_status_code, extract_headers, and extract_body — not the internal {'content': [{'text': ...}]} prepared shape. This makes the custom handler contract intuitive: handlers parse the tool's actual output format, not a middleware-internal wrapper. Fixes silent detection failures when custom handlers expected raw JSON or other native formats. * test(payments): Fill async path and custom handler test coverage gaps Add tests for: - Async auto_session creation and reuse - Async post-payment rejection with raw JSON fallback - Async name-based handler fallback (legacy text-block format) - Async custom handler receiving raw content - Async error handler callback (retry and propagate paths) - Sync custom handler raw content contract verification Suite now at 141 passed, providing async path parity with sync tests. * refactor(payments): Collapse remaining sync/async duplication - Merge _check_post_payment_rejection and _check_post_recovery_rejection into single _check_retry_rejection with context parameter - Extract _inject_for_error_retry for shared injection in error handlers - Extract _build_error_context for shared PaymentErrorContext construction - Extract _handle_callback_resolution for shared resolution dispatch Reduces middleware.py from 805 to 704 lines. The sync/async error handlers now only differ at await/sleep/to_thread boundaries. 141 tests pass identically. * refactor(payments): Hoist all inline imports to module top Move asyncio, json, GenericPaymentHandler, ErrorResolution, and PaymentErrorContext imports to the top of the file. None have circular dependency risks. Eliminates repeated imports that contributed to drift between duplicated code blocks. * fix(payments): Update functional test custom handlers for raw content contract Update TrackingHandler and RawJsonHandler in test_functional.py to handle raw content (str/list) instead of the prepared shape dict. Required after the custom handler contract change in 076d6ed. --------- Co-authored-by: Aidan Daly <99039782+aidandaly24@users.noreply.github.com>
1 parent ba755aa commit 0a8a486

18 files changed

Lines changed: 5053 additions & 58 deletions

File tree

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ dev = [
151151
"wheel>=0.45.1",
152152
"strands-agents>=1.20.0",
153153
"strands-agents-evals>=0.1.0",
154+
"langchain>=1.0.0",
155+
"langgraph>=1.0.0",
156+
"langchain-mcp-adapters>=0.1.0",
154157
"a2a-sdk[http-server]>=0.3,<1.0",
155158
"ag-ui-protocol>=0.1.10",
156159
"mcp-proxy-for-aws>=0.1.0",
@@ -163,6 +166,12 @@ strands-agents = [
163166
"strands-agents>=1.20.0",
164167
"mcp>=1.23.0,<2.0.0",
165168
]
169+
langgraph = [
170+
"langchain>=1.0.0",
171+
"langgraph>=1.0.0",
172+
"langchain-mcp-adapters>=0.1.0",
173+
"httpx>=0.27.0",
174+
]
166175
strands-agents-evals = [
167176
"strands-agents-evals>=0.1.0"
168177
]
Lines changed: 101 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,49 @@
1-
"""Configuration for AgentCorePaymentsPlugin."""
1+
"""Configuration for AgentCore Payments integrations (Strands and LangGraph)."""
22

3-
from dataclasses import dataclass
4-
from typing import Callable, List, Optional
3+
from dataclasses import dataclass, field
4+
from typing import Any, Callable, Dict, List, Optional
5+
6+
from .handlers import PaymentResponseHandler
57

68

79
@dataclass
810
class AgentCorePaymentsPluginConfig:
9-
"""Configuration for AgentCorePaymentsPlugin.
11+
"""Configuration for AgentCore Payments integrations.
12+
13+
This unified config is used by both the Strands plugin and LangGraph middleware.
1014
1115
Attributes:
12-
payment_manager_arn: ARN of the payment manager service
13-
region: AWS region for the payment manager
16+
payment_manager_arn: ARN of the payment manager service.
1417
user_id: User ID for payment processing. Required for SigV4 auth.
1518
Optional for bearer token auth (JWT identifies the user).
1619
When set with bearer auth, propagated via X-Amzn-Bedrock-AgentCore-Payments-User-Id header.
1720
payment_instrument_id: Optional payment instrument ID for the user.
1821
Can be set later via update_payment_instrument_id().
1922
payment_session_id: Optional payment session ID for the transaction.
2023
Can be set later via update_payment_session_id().
21-
network_preferences_config: Optional list of network CAIP2 identifiers
22-
in order of preference. If not provided, defaults to the system default.
23-
auto_payment: Whether to automatically process 402 payment requirements.
24-
Defaults to True to maintain existing behavior.
25-
max_interrupt_retries: Maximum number of interrupt retries per tool use.
26-
Defaults to 5. Set to 0 to disable interrupt retries entirely (no interrupts will be raised).
27-
agent_name: Optional agent name to propagate via the
28-
X-Amzn-Bedrock-AgentCore-Payments-Agent-Name HTTP header on every
29-
AgentCore payments data-plane API call. When set, the header is automatically injected
30-
by PaymentManager and propagated for Payments.
31-
bearer_token: Optional static JWT bearer token for OAuth/CUSTOM_JWT authentication.
32-
When set, PaymentManager uses Bearer token auth instead of SigV4.
33-
Mutually exclusive with token_provider.
34-
token_provider: Optional callable that returns a fresh JWT bearer token string.
35-
Called before each request to support token refresh.
36-
Mutually exclusive with bearer_token.
37-
payment_tool_allowlist: Optional list of tool names that are eligible for
38-
automatic X402 payment processing. When None (default), all tools are
39-
eligible (preserving existing behavior). When set, only tool calls whose
40-
name appears in this list will trigger payment processing; all others are
41-
skipped.
42-
provide_http_request: Whether the plugin should register its built-in
43-
``http_request`` ``@tool`` on the agent. Defaults to True so adding the
44-
plugin gives a turnkey paid-HTTP experience. Set to False if you want
45-
to ship your own ``http_request`` tool — Strands raises a ValueError
46-
on duplicate tool names, so you must opt out of the plugin's version
47-
before passing your own. Auto-payment of 402 responses still works
48-
against any tool whose output carries the ``PAYMENT_REQUIRED:``
49-
content marker, so disabling this flag does not disable interception.
50-
post_payment_retry_delay_seconds: Seconds to wait after generating a
51-
payment header before allowing the tool to be retried. The x402
52-
EIP-3009 ``transferWithAuthorization`` contract requires
53-
``block.timestamp > validAfter`` (strict greater-than). Some signing
54-
services set ``validAfter`` close to the current time, which can
55-
cause the merchant facilitator to submit before ``validAfter``
56-
elapses, producing a misleading "invalid_payload" response. A small
57-
delay between signing and retry lets the chain advance one block so
58-
the authorization is valid by the time the seller submits. Defaults
59-
to 3.0 seconds (about one Base Sepolia block). Set to 0 to disable.
24+
payment_connector_id: Payment connector ID (optional).
25+
region: AWS region for the payment manager.
26+
network_preferences_config: Ordered list of network CAIP2 identifiers.
27+
auto_payment: Whether to automatically process 402 responses. Default True.
28+
agent_name: Agent name propagated via HTTP header on data-plane calls.
29+
bearer_token: Static JWT for OAuth/CUSTOM_JWT auth. Mutually exclusive with token_provider.
30+
token_provider: Callable returning a fresh JWT. Mutually exclusive with bearer_token.
31+
payment_tool_allowlist: Tool names eligible for payment processing. None = all tools.
32+
provide_http_request: Whether the integration registers its built-in http_request tool.
33+
post_payment_retry_delay_seconds: Delay after signing before retry. Default 3.0s.
34+
max_interrupt_retries: Maximum number of interrupt retries per tool use (Strands only).
35+
Defaults to 5. Set to 0 to disable interrupt retries entirely.
36+
custom_handlers: Custom PaymentResponseHandler instances keyed by tool name.
37+
Takes precedence over the built-in handler registry during resolution.
38+
auto_session: Whether to auto-create a payment session on first 402 if
39+
payment_session_id is not set. Default False.
40+
auto_session_budget: Budget for auto-created sessions (USD). Default "1.00".
41+
auto_session_expiry_minutes: Expiry time for auto-created sessions. Default 60.
42+
on_payment_error: Optional callback invoked when a payment exception occurs.
43+
Receives PaymentErrorContext, returns ErrorResolution.RETRY or .PROPAGATE.
44+
When None (default), errors produce deterministic ToolMessages directly.
45+
max_error_retries: Maximum times the error callback can return RETRY per tool call.
46+
Default 3. Set to 0 to disable the callback entirely.
6047
"""
6148

6249
payment_manager_arn: str
@@ -65,51 +52,52 @@ class AgentCorePaymentsPluginConfig:
6552
payment_session_id: Optional[str] = None
6653
payment_connector_id: Optional[str] = None
6754
region: Optional[str] = None
68-
network_preferences_config: Optional[list[str]] = None
55+
network_preferences_config: Optional[List[str]] = None
6956
auto_payment: bool = True
70-
max_interrupt_retries: int = 5
7157
agent_name: Optional[str] = None
7258
bearer_token: Optional[str] = None
7359
token_provider: Optional[Callable[[], str]] = None
7460
payment_tool_allowlist: Optional[List[str]] = None
7561
provide_http_request: bool = True
7662
post_payment_retry_delay_seconds: float = 3.0
63+
max_interrupt_retries: int = 5
64+
custom_handlers: Optional[Dict[str, Any]] = field(default=None)
65+
auto_session: bool = False
66+
auto_session_budget: str = "1.00"
67+
auto_session_expiry_minutes: int = 60
68+
on_payment_error: Optional[Callable] = None
69+
max_error_retries: int = 3
7770

7871
def __post_init__(self) -> None:
7972
"""Validate configuration after initialization."""
8073
if not self.payment_manager_arn:
8174
raise ValueError("payment_manager_arn is required")
82-
8375
if not self.payment_manager_arn.startswith("arn:"):
8476
raise ValueError(f"Invalid ARN format: {self.payment_manager_arn}")
8577

78+
if self.bearer_token is not None and self.token_provider is not None:
79+
raise ValueError("bearer_token and token_provider are mutually exclusive")
8680
if self.bearer_token is not None and not isinstance(self.bearer_token, str):
8781
raise ValueError(f"bearer_token must be a string, got {type(self.bearer_token).__name__}")
88-
8982
if self.token_provider is not None and not callable(self.token_provider):
9083
raise ValueError(f"token_provider must be callable, got {type(self.token_provider).__name__}")
9184

92-
if self.user_id is not None and self.user_id and not self.user_id.strip():
93-
raise ValueError("user_id cannot be whitespace-only")
94-
9585
if not self.user_id and self.bearer_token is None and self.token_provider is None:
9686
raise ValueError("user_id is required for SigV4 auth (when bearer_token/token_provider not set)")
87+
if self.user_id is not None and self.user_id and not self.user_id.strip():
88+
raise ValueError("user_id cannot be whitespace-only")
9789

9890
if not isinstance(self.auto_payment, bool):
9991
raise ValueError(f"auto_payment must be a boolean, got {type(self.auto_payment).__name__}")
100-
101-
if self.bearer_token is not None and self.token_provider is not None:
102-
raise ValueError("bearer_token and token_provider are mutually exclusive. Provide only one.")
92+
if not isinstance(self.provide_http_request, bool):
93+
raise ValueError(f"provide_http_request must be a boolean, got {type(self.provide_http_request).__name__}")
10394

10495
if self.payment_tool_allowlist is not None:
10596
if not isinstance(self.payment_tool_allowlist, list):
10697
raise ValueError("payment_tool_allowlist must be a list of tool name strings")
10798
if not all(isinstance(t, str) for t in self.payment_tool_allowlist):
10899
raise ValueError("All entries in payment_tool_allowlist must be strings")
109100

110-
if not isinstance(self.provide_http_request, bool):
111-
raise ValueError(f"provide_http_request must be a boolean, got {type(self.provide_http_request).__name__}")
112-
113101
if not isinstance(self.post_payment_retry_delay_seconds, (int, float)) or isinstance(
114102
self.post_payment_retry_delay_seconds, bool
115103
):
@@ -122,6 +110,24 @@ def __post_init__(self) -> None:
122110
f"post_payment_retry_delay_seconds must be >= 0, got {self.post_payment_retry_delay_seconds}"
123111
)
124112

113+
if self.custom_handlers is not None:
114+
if not isinstance(self.custom_handlers, dict):
115+
raise ValueError(
116+
"custom_handlers must be a dict mapping tool names to PaymentResponseHandler instances"
117+
)
118+
if not all(isinstance(k, str) for k in self.custom_handlers):
119+
raise ValueError("All keys in custom_handlers must be strings")
120+
if not all(isinstance(v, PaymentResponseHandler) for v in self.custom_handlers.values()):
121+
raise ValueError("All values in custom_handlers must be PaymentResponseHandler instances")
122+
123+
if self.on_payment_error is not None and not callable(self.on_payment_error):
124+
raise ValueError(f"on_payment_error must be callable, got {type(self.on_payment_error).__name__}")
125+
126+
if not isinstance(self.max_error_retries, int) or isinstance(self.max_error_retries, bool):
127+
raise ValueError(f"max_error_retries must be an int, got {type(self.max_error_retries).__name__}")
128+
if self.max_error_retries < 0:
129+
raise ValueError(f"max_error_retries must be >= 0, got {self.max_error_retries}")
130+
125131
def update_payment_session_id(self, payment_session_id: str) -> None:
126132
"""Update the payment session ID.
127133
@@ -141,3 +147,40 @@ def update_payment_instrument_id(self, payment_instrument_id: str) -> None:
141147
if not payment_instrument_id:
142148
raise ValueError("payment_instrument_id cannot be empty")
143149
self.payment_instrument_id = payment_instrument_id
150+
151+
def add_to_allowlist(self, *tool_names: str) -> None:
152+
"""Add tool names to the payment allowlist.
153+
154+
Creates the allowlist if it doesn't exist yet (switching from "all tools"
155+
to explicit allowlist mode).
156+
157+
Args:
158+
tool_names: One or more tool names to add.
159+
"""
160+
if self.payment_tool_allowlist is None:
161+
self.payment_tool_allowlist = []
162+
for name in tool_names:
163+
if not isinstance(name, str):
164+
raise ValueError(f"Tool name must be a string, got {type(name).__name__}")
165+
if name not in self.payment_tool_allowlist:
166+
self.payment_tool_allowlist.append(name)
167+
168+
def remove_from_allowlist(self, *tool_names: str) -> None:
169+
"""Remove tool names from the payment allowlist.
170+
171+
If the allowlist becomes empty, sets it to None (all tools eligible).
172+
173+
Args:
174+
tool_names: One or more tool names to remove.
175+
"""
176+
if self.payment_tool_allowlist is None:
177+
return
178+
for name in tool_names:
179+
if name in self.payment_tool_allowlist:
180+
self.payment_tool_allowlist.remove(name)
181+
if not self.payment_tool_allowlist:
182+
self.payment_tool_allowlist = None
183+
184+
185+
# Backward-compatible alias for LangGraph imports
186+
AgentCorePaymentsConfig = AgentCorePaymentsPluginConfig
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Shared deterministic error messages for payment exceptions.
2+
3+
These messages are designed to be shown to LLMs via tool results. They instruct
4+
the model not to retry and to inform the user of the specific issue.
5+
6+
Used by: LangGraph middleware, and available for any future plugin integration.
7+
"""
8+
9+
from typing import Dict, Type
10+
11+
from bedrock_agentcore.payments.manager import (
12+
InsufficientBudget,
13+
PaymentError,
14+
PaymentInstrumentConfigurationRequired,
15+
PaymentInstrumentNotFound,
16+
PaymentSessionConfigurationRequired,
17+
PaymentSessionExpired,
18+
PaymentSessionNotFound,
19+
)
20+
21+
# Maps exception types to deterministic, LLM-instructive messages.
22+
PAYMENT_ERROR_MESSAGES: Dict[Type[Exception], str] = {
23+
PaymentInstrumentConfigurationRequired: (
24+
"No payment instrument configured. Do not retry this call. "
25+
"Inform the user they need to configure a payment instrument before making paid requests."
26+
),
27+
PaymentSessionConfigurationRequired: (
28+
"No payment session configured. Do not retry this call. "
29+
"Inform the user they need to create a payment session before making paid requests."
30+
),
31+
PaymentInstrumentNotFound: (
32+
"Payment instrument not found. Do not retry this call. "
33+
"Inform the user their payment instrument ID is invalid or has been deleted."
34+
),
35+
PaymentSessionNotFound: (
36+
"Payment session not found. Do not retry this call. "
37+
"Inform the user their payment session ID is invalid or has expired."
38+
),
39+
PaymentSessionExpired: (
40+
"Payment session has expired. Do not retry this call. "
41+
"Inform the user they need to create a new payment session."
42+
),
43+
InsufficientBudget: (
44+
"Insufficient budget. The payment amount exceeds the remaining session limit. "
45+
"Do not retry this call. Inform the user they need to increase their session budget "
46+
"or create a new session with higher limits."
47+
),
48+
}
49+
50+
51+
def get_payment_error_message(exception: Exception) -> str:
52+
"""Get the deterministic error message for a payment exception.
53+
54+
Looks up the exception type in the message map. Falls back to a generic
55+
message that includes the exception string for unrecognized types.
56+
57+
Args:
58+
exception: The payment exception.
59+
60+
Returns:
61+
Human/LLM-readable error message string.
62+
"""
63+
msg = PAYMENT_ERROR_MESSAGES.get(type(exception))
64+
if msg is not None:
65+
return msg
66+
if isinstance(exception, PaymentError):
67+
return (
68+
f"Payment processing failed ({exception}). "
69+
"Do not retry this call. Inform the user that payment could not be completed."
70+
)
71+
return (
72+
f"An unexpected error occurred during payment processing ({exception}). "
73+
"Do not retry this call. Inform the user that payment could not be completed."
74+
)

0 commit comments

Comments
 (0)