diff --git a/python/coinbase-agentkit/changelog.d/fix-x402-v2-discovery-description.bugfix.md b/python/coinbase-agentkit/changelog.d/fix-x402-v2-discovery-description.bugfix.md new file mode 100644 index 000000000..03dc1a39a --- /dev/null +++ b/python/coinbase-agentkit/changelog.d/fix-x402-v2-discovery-description.bugfix.md @@ -0,0 +1 @@ +Fixed x402 discovery filtering to read the description of v2 resources from the discovery API's top-level `description` field, falling back to `metadata.description` and then `accepts[].description` diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/constants.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/constants.py index f1e40fdc8..2c587ca38 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/constants.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/constants.py @@ -58,6 +58,8 @@ class DiscoveryResource(TypedDict, total=False): url: str resource: str type: str + # v2: the discovery API returns description as a top-level field on the resource. + description: str metadata: dict accepts: list[PaymentOption] x402_version: int diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/utils.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/utils.py index da85989c2..38a907efe 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/utils.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/utils.py @@ -196,7 +196,10 @@ def _get_resource_description(resource: DiscoveryResource) -> str: """Extract description from a resource based on its x402 version. - v1: description is in accepts[].description - - v2: description is in metadata.description + - v2: the discovery API returns description as a top-level field on the + resource; metadata.description and accepts[].description are checked + as fallbacks for resources published under an older or non-standard + v2 shape. Args: resource: The discovery resource @@ -207,9 +210,21 @@ def _get_resource_description(resource: DiscoveryResource) -> str: """ x402_version = resource.get("x402_version", resource.get("x402Version")) if x402_version == 2: + top_level_desc = resource.get("description") + if isinstance(top_level_desc, str) and top_level_desc.strip(): + return top_level_desc + metadata = resource.get("metadata", {}) metadata_desc = metadata.get("description") if metadata else None - return metadata_desc if isinstance(metadata_desc, str) else "" + if isinstance(metadata_desc, str) and metadata_desc.strip(): + return metadata_desc + + accepts = resource.get("accepts", []) + for option in accepts: + desc = option.get("description", "") + if desc and desc.strip(): + return desc + return "" # v1: look in accepts[].description accepts = resource.get("accepts", []) @@ -224,7 +239,8 @@ def filter_by_description(resources: list[DiscoveryResource]) -> list[DiscoveryR """Filter resources by having a valid description. Removes resources with empty or default descriptions. - Supports both v1 (accepts[].description) and v2 (metadata.description) formats. + Supports both v1 (accepts[].description) and v2 (description, with + metadata.description and accepts[].description as fallbacks) formats. Args: resources: Array of discovery resources @@ -278,7 +294,8 @@ def filter_by_keyword( """Filter resources by keyword appearing in description or URL. Case-insensitive search. - Supports both v1 (accepts[].description) and v2 (metadata.description) formats. + Supports both v1 (accepts[].description) and v2 (description, with + metadata.description and accepts[].description as fallbacks) formats. Args: resources: Array of discovery resources diff --git a/python/coinbase-agentkit/tests/action_providers/x402/test_utils.py b/python/coinbase-agentkit/tests/action_providers/x402/test_utils.py new file mode 100644 index 000000000..9c08adc02 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/x402/test_utils.py @@ -0,0 +1,89 @@ +"""Tests for x402 action provider utility functions.""" + +from coinbase_agentkit.action_providers.x402.utils import filter_by_description + + +def test_filter_by_description_keeps_v2_top_level_description(): + """A v2 resource with a top-level description is kept.""" + resources = [ + { + "resource": "https://example.com/feed", + "x402_version": 2, + "description": "Live agent-index data", + } + ] + + assert filter_by_description(resources) == resources + + +def test_filter_by_description_keeps_v2_metadata_only_description(): + """A v2 resource that only has metadata.description is kept.""" + resources = [ + { + "resource": "https://example.com/feed", + "x402_version": 2, + "metadata": {"description": "Legacy metadata-only description"}, + } + ] + + assert filter_by_description(resources) == resources + + +def test_filter_by_description_prefers_top_level_over_metadata(): + """The top-level description wins when both are present.""" + resources = [ + { + "resource": "https://example.com/feed", + "x402_version": 2, + "description": "Top-level wins", + "metadata": {"description": "Should be ignored"}, + } + ] + + assert len(filter_by_description(resources)) == 1 + + +def test_filter_by_description_drops_v2_with_no_description(): + """A v2 resource with no description anywhere is dropped.""" + resources = [ + { + "resource": "https://example.com/feed", + "x402_version": 2, + } + ] + + assert filter_by_description(resources) == [] + + +def test_filter_by_description_leaves_v1_accepts_path_unchanged(): + """The v1 accepts[].description path is unaffected by the v2 fix.""" + resources = [ + { + "resource": "https://example.com/v1-feed", + "x402_version": 1, + "accepts": [ + { + "scheme": "exact", + "network": "base", + "asset": "0xusdc", + "max_amount_required": "1000", + "description": "v1 accepts description", + } + ], + } + ] + + assert filter_by_description(resources) == resources + + +def test_filter_by_description_drops_default_placeholder(): + """Resources whose description is the discovery API's default placeholder are dropped.""" + resources = [ + { + "resource": "https://example.com/placeholder", + "x402_version": 2, + "description": "Access to protected content", + } + ] + + assert filter_by_description(resources) == [] diff --git a/typescript/.changeset/fix-x402-v2-discovery-description.md b/typescript/.changeset/fix-x402-v2-discovery-description.md new file mode 100644 index 000000000..91a2892c9 --- /dev/null +++ b/typescript/.changeset/fix-x402-v2-discovery-description.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Fixed x402 discovery filtering to read the description of v2 resources from the discovery API's top-level `description` field, falling back to `metadata.description` and then `accepts[].description`. Previously only `metadata.description` was checked, so `filterByDescription` and `filterByKeyword` silently dropped every v2 resource that used the top-level field, which is the shape the live Bazaar discovery API returns. diff --git a/typescript/agentkit/src/action-providers/x402/constants.ts b/typescript/agentkit/src/action-providers/x402/constants.ts index 961491dd3..606a87169 100644 --- a/typescript/agentkit/src/action-providers/x402/constants.ts +++ b/typescript/agentkit/src/action-providers/x402/constants.ts @@ -67,6 +67,8 @@ export interface DiscoveryResource { url?: string; resource?: string; type?: string; + // v2: the discovery API returns description as a top-level field on the resource. + description?: string; metadata?: { [key: string]: unknown; description?: string; diff --git a/typescript/agentkit/src/action-providers/x402/utils.test.ts b/typescript/agentkit/src/action-providers/x402/utils.test.ts new file mode 100644 index 000000000..e7ee28eea --- /dev/null +++ b/typescript/agentkit/src/action-providers/x402/utils.test.ts @@ -0,0 +1,87 @@ +import { filterByDescription } from "./utils"; +import { DiscoveryResource } from "./constants"; + +describe("x402 Utilities", () => { + describe("filterByDescription", () => { + it("keeps a v2 resource with a top-level description", () => { + const resources: DiscoveryResource[] = [ + { + resource: "https://example.com/feed", + x402Version: 2, + description: "Live agent-index data", + }, + ]; + + expect(filterByDescription(resources)).toEqual(resources); + }); + + it("keeps a v2 resource that only has metadata.description", () => { + const resources: DiscoveryResource[] = [ + { + resource: "https://example.com/feed", + x402Version: 2, + metadata: { description: "Legacy metadata-only description" }, + }, + ]; + + expect(filterByDescription(resources)).toEqual(resources); + }); + + it("prefers the top-level description over metadata.description when both are present", () => { + const resources: DiscoveryResource[] = [ + { + resource: "https://example.com/feed", + x402Version: 2, + description: "Top-level wins", + metadata: { description: "Should be ignored" }, + }, + ]; + + const result = filterByDescription(resources); + expect(result).toHaveLength(1); + }); + + it("drops a v2 resource with no description anywhere", () => { + const resources: DiscoveryResource[] = [ + { + resource: "https://example.com/feed", + x402Version: 2, + }, + ]; + + expect(filterByDescription(resources)).toEqual([]); + }); + + it("leaves the v1 accepts[].description path unchanged", () => { + const resources: DiscoveryResource[] = [ + { + resource: "https://example.com/v1-feed", + x402Version: 1, + accepts: [ + { + scheme: "exact", + network: "base", + asset: "0xusdc", + maxAmountRequired: "1000", + description: "v1 accepts description", + }, + ], + }, + ]; + + expect(filterByDescription(resources)).toEqual(resources); + }); + + it("drops resources whose description is the discovery API's default placeholder", () => { + const resources: DiscoveryResource[] = [ + { + resource: "https://example.com/placeholder", + x402Version: 2, + description: "Access to protected content", + }, + ]; + + expect(filterByDescription(resources)).toEqual([]); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/x402/utils.ts b/typescript/agentkit/src/action-providers/x402/utils.ts index 356316687..2006b95ee 100644 --- a/typescript/agentkit/src/action-providers/x402/utils.ts +++ b/typescript/agentkit/src/action-providers/x402/utils.ts @@ -180,15 +180,31 @@ export function filterByNetwork( /** * Extracts description from a resource based on its x402 version. * - v1: description is in accepts[].description - * - v2: description is in metadata.description + * - v2: the discovery API returns description as a top-level field on the resource; + * metadata.description and accepts[].description are checked as fallbacks for + * resources published under an older or non-standard v2 shape. * * @param resource - The discovery resource * @returns The description string or empty string if not found */ function getResourceDescription(resource: DiscoveryResource): string { if (resource.x402Version === 2) { + if (typeof resource.description === "string" && resource.description.trim()) { + return resource.description; + } + const metadataDesc = resource.metadata?.description; - return typeof metadataDesc === "string" ? metadataDesc : ""; + if (typeof metadataDesc === "string" && metadataDesc.trim()) { + return metadataDesc; + } + + const accepts = resource.accepts ?? []; + for (const option of accepts) { + if (option.description?.trim()) { + return option.description; + } + } + return ""; } // v1: look in accepts[].description @@ -204,7 +220,8 @@ function getResourceDescription(resource: DiscoveryResource): string { /** * Filters resources by having a valid description. * Removes resources with empty or default descriptions. - * Supports both v1 (accepts[].description) and v2 (metadata.description) formats. + * Supports both v1 (accepts[].description) and v2 (description, with metadata.description + * and accepts[].description as fallbacks) formats. * * @param resources - Array of discovery resources * @returns Filtered array of resources with valid descriptions @@ -240,7 +257,8 @@ export function filterByX402Version( /** * Filters resources by keyword appearing in description or URL. * Case-insensitive search. - * Supports both v1 (accepts[].description) and v2 (metadata.description) formats. + * Supports both v1 (accepts[].description) and v2 (description, with metadata.description + * and accepts[].description as fallbacks) formats. * * @param resources - Array of discovery resources * @param keyword - The keyword to search for in descriptions and URLs