feat(payments): Add LangGraph integration for payment handling#546
feat(payments): Add LangGraph integration for payment handling#546ragsu43 wants to merge 12 commits into
Conversation
Merge AgentCorePaymentsConfig (LangGraph) and AgentCorePaymentsPluginConfig (Strands) into a single dataclass in integrations/config.py. Both names remain available as aliases for backward compatibility.
… of in langgraph-specific package
…ersion 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.
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
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.
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.
…cation 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.
17b79dc to
b44cf37
Compare
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.
aidandaly24
left a comment
There was a problem hiding this comment.
Nice work on this. The overall flow (detect, sign, inject, retry, across sync and async, plus the recovery callback and auto-session) reads well and the unit coverage is thorough. Requesting changes on a couple of correctness issues before this goes in, plus some cleanup.
Blocking:
- Sync path silently drops async
on_payment_errorcallbacks (middleware.py:555). _check_post_recovery_rejectionsurfaces the wrong error detail because it never swaps in the fallback handler (middleware.py:373).
Should fix:
- Detection ignores the name-based handler registry, so legacy text-format 402s get missed (middleware.py:267).
- The exported
MCPRequestPaymentHandlercan't actually be used as acustom_handlersentry because of a shape mismatch (middleware.py:269).
Cleanup:
- The sync/async and the two rejection-check methods are near-duplicates and have already drifted, worth collapsing (middleware.py:353).
- Move the inline imports up to the top of the file per our style (middleware.py, several spots).
One process note: the header on test_functional.py says any change to this middleware has to be validated against a live Base Sepolia testnet before merge. Please confirm that run has happened.
| ) | ||
|
|
||
| try: | ||
| resolution = self.config.on_payment_error(ctx) |
There was a problem hiding this comment.
This drops async on_payment_error callbacks on the sync path. The async version at line 727 checks inspect.iscoroutinefunction before awaiting, but here we call the callback directly.
If someone registers an async def callback and runs the agent with agent.invoke(), this returns an un-awaited coroutine. It isn't a str and it isn't ErrorResolution.RETRY, so line 568 returns None and we fall through to PROPAGATE. The callback body never runs, the retry/fix is silently discarded, the LLM gets the default error message, and you leak a coroutine was never awaited RuntimeWarning.
Either run it to completion here (e.g. via an event loop), or detect a coroutine function up front and raise a clear error so it fails loudly instead of silently.
| if fallback: | ||
| retry_status = 402 | ||
| if retry_status == 402: | ||
| retry_body = _rh.extract_body(retry_prepared) or {} |
There was a problem hiding this comment.
Copy-paste drift from _check_post_payment_rejection. When the fallback detects the 402 (lines 369-371) this never reassigns _rh to _FallbackHandler(fallback), unlike the sibling method at line 343.
So _rh stays the GenericPaymentHandler, extract_body here looks for the PAYMENT_REQUIRED: marker, doesn't find it in raw JSON, and returns None which becomes {}. For a raw-JSON or MCP tool that returns {"statusCode":402,"body":{"error":"budget exceeded"}} on the recovery retry, the LLM sees rejected after recovery (unknown) instead of the real budget exceeded.
Reassign _rh = _FallbackHandler(fallback) inside the fallback branch, matching _check_post_payment_rejection.
| else: | ||
| from bedrock_agentcore.payments.integrations.handlers import GenericPaymentHandler | ||
|
|
||
| detection_handler = GenericPaymentHandler() |
There was a problem hiding this comment.
Detection only ever uses GenericPaymentHandler on the non-custom path (marker only), plus the JSON-only _fallback_detect_402. The name-based registry (http_request maps to HttpRequestPaymentHandler) is only consulted on the injection side through _get_handler.
That means a tool returning 402 in the legacy Status Code: / Headers: / Body: text-block format, which is exactly what HttpRequestPaymentHandler.extract_status_code was written to parse (handlers.py:479), never gets detected here. Generic finds no marker and returns None, and _fallback_detect_402 can't json.loads Status Code: 402. The 402 passes through as a normal result and payment never fires. This bites anyone who brings their own http_request-style tool with provide_http_request=False.
Consider routing detection through get_payment_handler(tool_name, tool_args) as well, not just injection.
|
|
||
| detection_handler = GenericPaymentHandler() | ||
|
|
||
| status_code = detection_handler.extract_status_code(prepared) |
There was a problem hiding this comment.
Shape-contract gotcha for custom handlers. We hand the detection handler the _prepare_for_handler output, which is {"content": [{"text": ...}]}. That works for GenericPaymentHandler, but the exported MCPRequestPaymentHandler expects the raw result with top-level structuredContent/httpStatus.
Since config accepts any PaymentResponseHandler, custom_handlers={"my_mcp_tool": MCPRequestPaymentHandler()} silently never detects a 402: extract_status_code reads keys that aren't in the prepared shape and returns None, and because has_custom_handler is True the lenient fallback at line 272 is skipped.
Either feed custom handlers the raw content, or document and enforce that a custom handler must consume the prepared shape.
| ) | ||
| return None | ||
|
|
||
| def _check_post_recovery_rejection( |
There was a problem hiding this comment.
Three chunks of near-duplicated logic in this file, and the drift called out at line 373 is a direct result. _check_post_recovery_rejection is basically _check_post_payment_rejection, _ainvoke_error_handler mirrors _invoke_error_handler (~90 lines each, and their inner retry block re-implements _inject_payment_header), and the GenericPaymentHandler plus _fallback_detect_402 detect ladder shows up in three methods.
A single _resolve_402(content) helper plus a shared retry/inject body (the async version just wraps it with to_thread) would collapse all of this and stop the two rejection checks from diverging again.
| if has_custom_handler: | ||
| detection_handler = self.config.custom_handlers[tool_name] | ||
| else: | ||
| from bedrock_agentcore.payments.integrations.handlers import GenericPaymentHandler |
There was a problem hiding this comment.
Imports belong at the top of the file, not inside methods. This one (from ...handlers import GenericPaymentHandler) is repeated inside _detect_402, _check_post_payment_rejection (335), and _check_post_recovery_rejection (364). Same story for import json as _json (187), from .errors import ... (537, 709), import asyncio (635, 706), and import inspect (707).
None of these have a cycle risk (.errors doesn't import middleware), so hoist them to the module top. The repeated handler import is also part of what let the two rejection checks drift.
Description of changes:
Added LangGraph middleware and config files for developer integration with ACP.