-
Notifications
You must be signed in to change notification settings - Fork 112
fix(payments): drop unsupported paymentConnectorId + add http_request plugin tool + EIP-3009 timing fix #493
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2a95334
4c771a2
de290ab
b130f9a
bbac3d8
a41e853
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,12 @@ | ||
| """AgentCorePaymentsPlugin for Strands Agents framework.""" | ||
|
|
||
| import json | ||
| import logging | ||
| import time | ||
| import uuid | ||
| from typing import Any, Dict, Optional | ||
| from typing import Any, Dict, Optional, Union | ||
|
|
||
| import httpx | ||
| from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent | ||
| from strands.plugins import Plugin, hook | ||
| from strands.tools import tool | ||
|
|
@@ -25,7 +28,8 @@ | |
| class AgentCorePaymentsPlugin(Plugin): | ||
| """Plugin for handling X402 payment requirements and providing payment tools in Strands Agents. | ||
|
|
||
| This plugin provides three tools for querying payment information: | ||
| This plugin provides tools for querying payment information and making paid HTTP calls: | ||
| - http_request: Call a (paid) HTTP endpoint; 402 responses are settled automatically | ||
| - getPaymentInstrument: Retrieve details about a specific payment instrument | ||
| - listPaymentInstruments: List all payment instruments for a user | ||
| - getPaymentSession: Retrieve details about a specific payment session | ||
|
|
@@ -55,6 +59,18 @@ def __init__(self, config: AgentCorePaymentsPluginConfig): | |
| super().__init__() | ||
| self.config = config | ||
| self.payment_manager: Optional[PaymentManager] = None | ||
|
|
||
| # Honor the provide_http_request opt-out: Strands' Plugin base auto-discovers | ||
| # every @tool method into self._tools at super().__init__(). If the caller | ||
| # 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"] | ||
| logger.info( | ||
| "provide_http_request=False — plugin's http_request tool will not be registered. " | ||
| "Auto-payment still triggers on any tool emitting a PAYMENT_REQUIRED: marker." | ||
| ) | ||
|
|
||
| logger.info("Initialized AgentCorePaymentsPlugin") | ||
|
|
||
| def init_agent(self, agent) -> None: | ||
|
|
@@ -236,6 +252,20 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None: | |
| # after this retry, we know it's a server-side rejection, not a signing failure. | ||
| self._mark_successful_signing(event) | ||
|
|
||
| # Wait one chain-block before letting the tool retry, so the merchant's | ||
| # facilitator has time to see block.timestamp > validAfter when it submits | ||
| # transferWithAuthorization to USDC. Without this delay, fast facilitators | ||
| # can submit in the same second the signature was minted, hitting the | ||
| # contract's strict ``block.timestamp > validAfter`` check and producing | ||
| # a misleading "invalid_payload" 402 from the seller. | ||
| delay = self.config.post_payment_retry_delay_seconds | ||
| if delay > 0: | ||
| logger.info( | ||
| "Waiting %.1fs before retry to allow chain to advance past validAfter", | ||
| delay, | ||
| ) | ||
| time.sleep(delay) | ||
|
|
||
| # Set retry flag to re-execute the tool with payment credentials. | ||
| event.retry = True | ||
| self._reset_interrupt_retry_count(event) | ||
|
|
@@ -726,3 +756,87 @@ def get_payment_session( | |
| str(e), | ||
| ) | ||
| raise | ||
|
|
||
| @tool | ||
| def http_request( | ||
| self, | ||
| url: str, | ||
| method: str = "GET", | ||
| headers: Optional[Dict[str, str]] = None, | ||
| body: Optional[Union[Dict[str, Any], str]] = None, | ||
| ) -> Dict[str, Any]: | ||
| """Call an HTTP endpoint. 402 Payment Required responses are settled automatically. | ||
|
|
||
| When the endpoint responds with HTTP 402, this plugin's after_tool_call hook | ||
| intercepts the result, generates an x402 payment header via the configured | ||
| PaymentManager, mutates ``headers`` with the X-PAYMENT (v1) or | ||
| PAYMENT-SIGNATURE (v2) header, and Strands re-invokes this tool — yielding | ||
| the final 200 response and (when applicable) a settle hash in the | ||
| PAYMENT-RESPONSE header. | ||
|
|
||
| Returns a Strands ToolResult dict: ``status`` is always ``success`` (HTTP | ||
| errors are returned in the body, not raised), and ``content`` is a single | ||
| text block. On 402 the text is prefixed with ``PAYMENT_REQUIRED:`` so the | ||
| SDK's payment handlers can extract the x402 payload. | ||
|
|
||
| Args: | ||
| url: The full URL to request. | ||
| method: HTTP method. Defaults to ``GET``. | ||
| headers: Optional request headers. The plugin mutates this dict to add | ||
| the payment header on retry. | ||
| body: Optional request body. ``dict`` is sent as JSON; ``str`` is sent | ||
| as-is. Ignored for ``GET``/``HEAD``. | ||
|
|
||
| Returns: | ||
| Strands ToolResult dict with ``status`` and ``content``. | ||
| """ | ||
| request_headers = dict(headers) if headers else {} | ||
| method_upper = method.upper() | ||
|
|
||
| try: | ||
| with httpx.Client(timeout=30.0, follow_redirects=True) as client: | ||
| if body is None or method_upper in ("GET", "HEAD"): | ||
| resp = client.request(method_upper, url, headers=request_headers) | ||
| elif isinstance(body, str): | ||
| resp = client.request(method_upper, url, headers=request_headers, content=body) | ||
| else: | ||
| resp = client.request(method_upper, url, headers=request_headers, json=body) | ||
| except httpx.RequestError as exc: | ||
| logger.error("http_request failed for %s: %s", url, exc) | ||
| return { | ||
| "status": "error", | ||
| "content": [ | ||
| { | ||
| "text": json.dumps( | ||
| { | ||
| "statusCode": 0, | ||
| "error": f"Request failed: {exc}", | ||
| "url": url, | ||
| } | ||
| ) | ||
| } | ||
| ], | ||
| } | ||
|
|
||
| response_headers = dict(resp.headers) | ||
| try: | ||
| response_body: Any = resp.json() | ||
| except Exception: | ||
| response_body = {"text": resp.text} | ||
|
|
||
| payload = { | ||
| "statusCode": resp.status_code, | ||
| "headers": response_headers, | ||
| "body": response_body, | ||
| } | ||
|
|
||
| if resp.status_code == 402: | ||
| return { | ||
| "status": "success", | ||
| "content": [{"text": f"PAYMENT_REQUIRED: {json.dumps(payload)}"}], | ||
| } | ||
|
|
||
| return { | ||
| "status": "success", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: non-402 HTTP errors (500, 503, 429) come back as |
||
| "content": [{"text": json.dumps(payload)}], | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
self._toolsis 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 whenprovide_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.