fix(payments): drop unsupported paymentConnectorId + add http_request plugin tool + EIP-3009 timing fix#493
Conversation
…t call ProcessPayment's public API contract (bedrock-agentcore Data Plane) does not accept a paymentConnectorId field — the connector is resolved server side from the payment instrument. The SDK was forwarding the param, causing every real ProcessPayment call to fail at the boto3 layer with: Parameter validation failed: Unknown parameter in input: "paymentConnectorId", must be one of: userId, agentName, paymentManagerArn, paymentSessionId, paymentInstrumentId, paymentType, paymentInput, clientToken The bug surfaced end-to-end when AgentCorePaymentsPlugin auto-paid a 402 response: get_payment_instrument succeeded, then generate_payment_header forwarded payment_connector_id into process_payment, which appended it to the API call and was rejected. Unit tests passed because they mock the boto3 client and don't validate the param schema. Fix: stop including paymentConnectorId in the ProcessPayment request body, and stop forwarding it from generate_payment_header. The kwarg is kept on both function signatures for backward compatibility, with docstrings updated to note that it is accepted but no longer forwarded. Adds a regression test asserting paymentConnectorId is absent from the ProcessPayment kwargs even when callers pass it explicitly.
✅ No Breaking Changes DetectedNo public API breaking changes found in this PR. |
Ships a Strands @tool method on AgentCorePaymentsPlugin that performs HTTP requests and emits Strands ToolResult content blocks the SDK's payment handlers can parse. When the endpoint returns 402 Payment Required, the tool prefixes the content block with the spec-compliant PAYMENT_REQUIRED: marker, so the plugin's existing after_tool_call hook intercepts the result, generates an x402 payment header via PaymentManager, and Strands re-invokes the tool with the payment header injected. Why on the plugin: aligns with how get_payment_instrument / list_payment_instruments / get_payment_session are already exposed — adding the plugin to a Strands agent now produces a turnkey paid-HTTP experience without requiring strands-agents-tools or hand-written httpx code in customer agents. httpx is already a hard SDK dep; no new dependencies are introduced. The 402 envelope (statusCode + headers + body) matches what GenericPaymentHandler / HttpRequestPaymentHandler already parse, so the existing handler dispatch lights up automatically. Tests cover: - 200 envelope shape (Strands ToolResult dict, not a raw list) - 402 wrapped with PAYMENT_REQUIRED: marker - End-to-end contract: tool output -> get_payment_handler -> 402 - GET ignores body, POST sends dict as JSON, str body sent verbatim - Caller header dict isolated (mutations don't bleed back) - Non-JSON body falls back to {"text": ...} - httpx.RequestError returned as status='error' (not raised) - Method casing normalization - Default headers when caller omits All 490 payments tests pass.
…prompt update The deployed agent runtime role was missing every Get/List/Create payment instrument+session action and ProcessPayment. The L3 stack only granted sts:AssumeRole on the ProcessPaymentRole, which carries only ProcessPayment — and the SDK plugin's auto-pay path calls GetPaymentInstrument on the runtime's own credentials before any role assumption. Without this grant the plugin fails with AccessDeniedException on the very first 402 it tries to settle. Add the seven required actions to the runtime role's inline policy in the vended CDK stack template (src/assets/cdk/lib/cdk-stack.ts). Scoped to the manager ARN. Also update PAYMENT_SYSTEM_PROMPT in the Strands payments capability to mention the http_request tool, which the AgentCorePaymentsPlugin now provides automatically (see SDK PR aws/bedrock-agentcore-sdk-python#493). The other prompt lines for get_payment_session / get_payment_instrument_balance / list_payment_instruments are unchanged — those tools were already provided by the plugin. Snapshot updated to reflect the cdk-stack.ts addition.
…timing race The x402 EIP-3009 transferWithAuthorization contract requires block.timestamp > validAfter — a strict greater-than check, not greater-or-equal. When the signing service mints a signature with validAfter set to ~current Unix time, fast merchant facilitators submit the transaction within the same second the signature was minted, hitting the contract's strict check before block.timestamp advances. The seller returns a misleading 402 with reason "invalid_payload" or "invalid_exact_evm_transaction_simulation_failed" — but the signature is structurally valid; it's a race between minting and submission. Verified empirically: same signed payload reverts via eth_call within the validAfter second, then succeeds 5+ seconds later. Once the chain advances one block past validAfter, the seller's facilitator submits and the on-chain transferWithAuthorization settles. Real Base Sepolia transaction proving the round-trip: 0x0e542e2d5c3c97521bd47231d299c6d56841fe0865f63ebfaa92d6f47e28b6b6 Add post_payment_retry_delay_seconds: float = 3.0 to AgentCorePaymentsPluginConfig. Sleeps that many seconds in after_tool_call right before setting event.retry = True, so by the time Strands re-invokes the tool with the PAYMENT-SIGNATURE header, the chain has advanced one block past validAfter. 3.0s default chosen as ~one Base Sepolia block (~2s) plus margin. Set to 0 to disable for tests or chains with sub-second blocks. Adds 7 tests: - default delay is 3.0 - custom positive delay is honored - zero delay skips the sleep entirely (event.retry still set) - no sleep when signing fails (we never reach the retry path) - validator rejects negative values - validator rejects non-numeric values (str) - validator rejects bool (which would otherwise sneak through isinstance(int)) All 502 payments tests pass.
| } | ||
|
|
||
| return { | ||
| "status": "success", |
There was a problem hiding this comment.
nit: non-402 HTTP errors (500, 503, 429) come back as status: "success" which is kinda misleading for the LLM — it sees "success" and may not dig into the nested JSON to find the actual status code. could be a follow up to either return "error" for 4xx/5xx or at least surface the status code more prominently in the result shape.
| # wants to ship their own http_request, drop ours so Strands' tool registry | ||
| # doesn't raise ValueError on duplicate tool name. | ||
| if not self.config.provide_http_request: | ||
| self._tools = [t for t in self._tools if t.tool_name != "http_request"] |
There was a problem hiding this comment.
nit: self._tools is a private attr on the Strands Plugin base class — if they rename or restructure it in a future version, this opt-out breaks silently (http_request gets registered even when provide_http_request=False). might be worth a follow up to see if strands exposes a public API for deregistering tools, or at least pin a comment here noting the coupling so we catch it if strands changes.
jariy17
left a comment
There was a problem hiding this comment.
Just a few follow ups no blockers
Summary
Four related changes that get the AgentCorePaymentsPlugin's 402 auto-pay path working end-to-end and verified on chain:
ProcessPaymentwas failing at the boto3 layer with anUnknown parameter: paymentConnectorIdvalidation error. Drop the field — public Data Plane API does not accept it.http_request@toolonAgentCorePaymentsPluginso adding the plugin to a Strands agent gives a turnkey paid-HTTP experience withoutstrands-agents-toolsor hand-rolled httpx.provide_http_request: bool = Trueconfig flag so callers shipping their ownhttp_requestcan opt out without hitting Strands' duplicate-tool-nameValueError.post_payment_retry_delay_seconds: float = 3.0so the plugin sleeps one chain block after signing before retrying. Without this, the EIP-3009 strict timestamp check on USDC fires before the chain advances and the seller returns a misleading 402.End-to-end verified on Base Sepolia. Real settle transaction:
0x0e542e2d5c3c97521bd47231d299c6d56841fe0865f63ebfaa92d6f47e28b6b6— 0.001 USDC moved from a CDP-managed wallet to the merchant via SDK-generatedPAYMENT-SIGNATURE.Commit 1 — Drop unsupported
paymentConnectorIdfrom ProcessPaymentWhat broke
Hitting a 402 with
AgentCorePaymentsPlugintriggered the auto-pay path:generate_payment_headercalledget_payment_instrument(succeeded)generate_payment_headerthen calledprocess_payment, forwardingpayment_connector_idprocess_paymentappendedpaymentConnectorIdto the request paramsConfirmed against public docs: https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_ProcessPayment.html — the request body has no
paymentConnectorIdfield.Why unit tests didn't catch it
Existing tests mock
_payment_client.process_paymentwithMagicMock, which accepts any kwargs without validating against the service model. Bug only surfaces against a real (or botocore-validated) client.Fix
process_payment: stop addingpaymentConnectorIdto the request params. The connector is resolved server-side from the payment instrument.generate_payment_header: stop forwardingpayment_connector_idtoprocess_payment.payment_connector_idin their signatures for backward compatibility. Docstrings flag that the value is accepted but no longer forwarded.paymentConnectorIdis absent fromprocess_paymentkwargs even when callers pass it explicitly.paymentConnectorIdis still forwarded for the API operations where it IS valid (CreatePaymentInstrument,GetPaymentInstrument,ListPaymentInstruments,DeletePaymentInstrument, etc.) — those code paths are unchanged.Commit 2 —
http_request@toolon AgentCorePaymentsPluginWhy on the plugin
Aligns with how
getPaymentInstrument/listPaymentInstruments/getPaymentSession/getPaymentInstrumentBalanceare already exposed. Adding the plugin to a Strands agent now produces a turnkey paid-HTTP experience without requiringstrands-agents-tools.httpxis already a hard SDK dep; no new dependencies.Wire format
The 402 envelope (statusCode + headers + body) matches what
GenericPaymentHandler/HttpRequestPaymentHandleralready parse via thePAYMENT_REQUIRED:content-block marker. Tool returns a StrandsToolResultdict ({status, content}) — the right shape for Strands' tool-result decorator path.Tests (11 new)
PAYMENT_REQUIRED:markerget_payment_handler-> 402{"text": ...}httpx.RequestErrorreturned asstatus='error'(not raised)Commit 3 —
provide_http_requestopt-out config flagWhy
Strands'
ToolRegistry.register_toolraisesValueErroron duplicate tool names. Without an opt-out, a caller who passes their ownhttp_requesttoAgent(tools=[...], plugins=[plugin])crashes at agent construction.Behavior
AgentCorePaymentsPluginConfig.provide_http_request: bool = True(default preserves current behavior)False, the plugin filtershttp_requestout ofself._toolsimmediately aftersuper().__init__(). Other plugin tools unaffected.auto_paymentinterception is independent — theafter_tool_callhook still triggers on any tool whose output carries thePAYMENT_REQUIRED:marker.Tests (5 new)
http_requesthttp_request; other plugin tools remainafter_tool_call/before_tool_callhooksinit_agentworks with the flag offCommit 4 —
post_payment_retry_delay_secondsfor EIP-3009 timing raceWhat broke
USDC v2's
transferWithAuthorizationenforces:Strict greater-than. When the signing service sets
validAfter ≈ current_unix_time, fast merchant facilitators submittransferWithAuthorizationin the same second the signature was minted, and the contract reverts becauseblock.timestamp == validAfter. The seller decodes the on-chain failure and returns 402 with reasoninvalid_payloadorinvalid_exact_evm_transaction_simulation_failed.I confirmed this empirically: simulated the SAME signed payload via
eth_callwithin the validAfter second → revert. Waited 5 seconds, simulated again → success. Submitted to merchant after waiting → real on-chain settle (tx above).Fix
Add
post_payment_retry_delay_seconds: float = 3.0config knob. Plugin sleeps that many seconds inafter_tool_callbetween callinggenerate_payment_headerand settingevent.retry = True, so by the time Strands re-invokes the tool with thePAYMENT-SIGNATUREheader, the chain has advanced one block pastvalidAfter.3.0s default chosen as ~one Base Sepolia block (2s) plus margin. Set to 0 to disable for tests or chains with sub-second blocks.
Tests (7 new)
isinstance(int))Test plan
ruff checkpasses;ruff format --checkpasses0x0e542e2d5c3c97521bd47231d299c6d56841fe0865f63ebfaa92d6f47e28b6b60x7139d0a9fEf181FBc06A4c48e43143622466fAE4(CDP-managed wallet) to the merchantPAYMENT-RESPONSEshowssuccess: true, payer: 0x7139..., transaction: 0x0e54...https://x402.bitcoinsapi.com/weatherwith the paid bodyBackward compatibility
payment_connector_idstill work, the value is now ignored on the ProcessPayment path specifically.http_requestis purely additive and gated byprovide_http_request=True(default).