Skip to content

feat(payments): Add LangGraph integration for payment handling#546

Open
ragsu43 wants to merge 4 commits into
aws:mainfrom
ragsu43:ragsu/langgraph-integration
Open

feat(payments): Add LangGraph integration for payment handling#546
ragsu43 wants to merge 4 commits into
aws:mainfrom
ragsu43:ragsu/langgraph-integration

Conversation

@ragsu43

@ragsu43 ragsu43 commented Jun 23, 2026

Copy link
Copy Markdown

Description of changes:

Added LangGraph middleware and config files for developer integration with ACP.

@ragsu43 ragsu43 requested a review from a team June 23, 2026 18:03
Comment thread src/bedrock_agentcore/payments/integrations/langgraph/config.py Outdated


@dataclass
class PaymentErrorContext:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We already have error defined in the common package. Let's use the existing error class.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Given how the callback function would be implemented by developers without PaymentErrorContext, it seems to me that passing in a context object with the exception info and all necessary state/context fields for error handling gives the simplest dev experience. Based on what I have found, there are some big blockers against deprecating PaymentErrorContext in favor of just passing in the exception:

there are 4 main fields that langgraph devs cannot access from the callback unless it is passed into the callback:

  1. Retry state (number of retries on this tool call):
  • can't track it directly in the config class because dev does not have access to "request" (field from wrap_tool_call), so no way to know what tool-name the retry is for unless we pass that in, so either way we would need to pass something into the callback.
  • As mentioned above, middleware in langgraph doesn't receive graph state at all (which is the only equivalent of agent.state in strands), so even if we could add a custom field to the graph's State dict, the middleware can't read/write to it like "agent.state" in Strands.
  • Only option is developer tracks it in some closure dict, but again we would need to pass in tool_call_id, in which case just having retry_count in the context object is much easier for the dev.
  1. tool_name:
  • as mentioned above can't be accessed by dev if we deprecate PaymentErrorContext unless we pass it in anyway, since the developer has no access to the tool call "request" itself.
  1. tool_args: same problem as tool_name... lives in ToolCallRequest inside wrap_tool_call with no external visibility WITHOUT some arg passing into the callback

  2. payment_required_request (the parsed 402 body):

  • only exists as a local var in wrap_tool_call, developer doesn't see the 402 response from callback function so they can't parse it manually either so we would have to pass this in.

Based on these constraints, it seems to me that keeping the context object and passing it into the callback is the best option (developer just accesses everything needed for state in one object), but if anything seems off or something I haven't considered please let me know!

Comment thread src/bedrock_agentcore/payments/integrations/langgraph/middleware.py Outdated
  Merge AgentCorePaymentsConfig (LangGraph) and AgentCorePaymentsPluginConfig
  (Strands) into a single dataclass in integrations/config.py. Both names
  remain available as aliases for backward compatibility.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.65392% with 121 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@2359137). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...core/payments/integrations/langgraph/middleware.py 71.94% 60 Missing and 41 partials ⚠️
.../bedrock_agentcore/payments/integrations/config.py 70.21% 14 Missing ⚠️
...agentcore/payments/integrations/langgraph/tools.py 92.98% 3 Missing and 1 partial ⚠️
...gentcore/payments/integrations/langgraph/errors.py 89.47% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #546   +/-   ##
=======================================
  Coverage        ?   88.64%           
=======================================
  Files           ?      103           
  Lines           ?     9067           
  Branches        ?     1378           
=======================================
  Hits            ?     8037           
  Misses          ?      663           
  Partials        ?      367           
Flag Coverage Δ
unittests 88.64% <75.65%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.


if TYPE_CHECKING:
from .config import AgentCorePaymentsConfig

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wrong import path under TYPE_CHECKING

This imports from .config (i.e. langgraph/config.py) but the config class lives at integrations/config.py. Should be:

from ..config import AgentCorePaymentsConfig

Won't crash at runtime (guarded by TYPE_CHECKING), but will break mypy/pyright.

payment_tool_allowlist: Optional[List[str]] = None
provide_http_request: bool = True
post_payment_retry_delay_seconds: float = 3.0
max_interrupt_retries: int = 5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Type annotation contradicts validation

The field is typed Dict[str, Any] but validation at __post_init__ enforces isinstance(v, PaymentResponseHandler) for all values. Consider:

custom_handlers: Optional[Dict[str, "PaymentResponseHandler"]] = field(default=None)

This way the type signature matches the runtime contract and callers get proper type-checking.

def apply_payment_header(self, tool_input, payment_header):
self.inject_called = True
return super().apply_payment_header(tool_input, payment_header)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Duplicate test block (copy-paste leftover)

Lines below this point duplicate the test_custom_handler_full_flow test verbatim — the TrackingHandler class, middleware setup, and assertions are repeated inside this method body. Appears to be a copy-paste accident. The duplicate code still runs (appended to this method), but adds confusion and redundant test time. Safe to remove.

delay between signing and retry lets the chain advance one block so
the authorization is valid by the time the seller submits. Defaults
to 3.0 seconds (about one Base Sepolia block). Set to 0 to disable.
payment_connector_id: Payment connector ID (optional).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this a new attribute? A manager can have many connectiors. What use case this attribute is going to solve?

payment_connector_id=self.config.payment_connector_id,
)

def _create_auto_session(self) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we creating autoSession? Agent may not have permission to create session. Creating session in agent will be risky as they can bypass the payments limit (budget) check.

Comment on lines +693 to +714
if retry_prepared is not None:
from bedrock_agentcore.payments.integrations.handlers import GenericPaymentHandler as _GH
_retry_handler = _GH()
retry_status = _retry_handler.extract_status_code(retry_prepared)
if retry_status != 402:
retry_fallback = self._fallback_detect_402(retry_result.content)
if retry_fallback is not None:
retry_status = 402
_retry_handler = _FallbackHandler(retry_fallback)
if retry_status == 402:
retry_body = _retry_handler.extract_body(retry_prepared) or {}
error_detail = (
retry_body.get("error", "unknown error")
if isinstance(retry_body, dict)
else "unknown error"
)
return self._error_tool_message(
request,
PaymentError(
f"Payment was signed but rejected by the server ({error_detail})."
),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Duplicate code block. Can you make sure to remove duplicate code for the sake of maintainability?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants