Motivation
GetProductsRequest.pagination is {max_results: int (1..100, default 50), cursor: str | None}. The response carries pagination: {has_more, cursor, total_count}. The same shape recurs on get_media_buys, list_creatives, list_creative_formats, list_property_lists — anywhere AdCP returns a list.
Every adopter who paginates will write the same opaque-cursor encode/decode logic (typically base64-JSON of (offset, query_hash) or (last_id, query_hash)) and the same page-slice. Today no adopter does it correctly out of the box.
Parent tracker: #491.
Current state
Salesagent: does not honor pagination on get_products — _get_products_impl returns the full filtered list every time. Grep confirms no pagination, cursor, or max_results references in src/core/tools/products.py.
SDK: no pagination helper. MediaBuyHandler.get_products (src/adcp/decisioning/handler.py:1015-1033) passes through. Wire request Pagination is at src/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:1247-1257; wire response Pagination at src/adcp/types/generated_poc/bundled/media_buy/get_products_response.py:2807-2826.
Proposed API
Two pieces, both reusable across list tools:
1. Cursor codec (framework-internal, hash-anchored to detect query drift):
# src/adcp/decisioning/pagination.py (new)
def encode_cursor(offset: int, query_hash: str) -> str:
\"\"\"Opaque base64-JSON cursor. query_hash anchors the cursor to the
request shape — if the buyer changes filters, the framework rejects
the stale cursor with INVALID_CURSOR.\"\"\"
def decode_cursor(cursor: str, expected_query_hash: str) -> int:
\"\"\"Returns offset. Raises AdcpError(INVALID_CURSOR) on hash mismatch
or malformed input.\"\"\"
2. Page-slice helper (used by handler post-adapter, when adopter returns the full list and asks the framework to slice):
# Adopter opt-in via SalesPlatform protocol — adopter returns full list,
# framework slices. Mirrors the auto-emit pattern: declare via capability,
# framework does the work.
class DecisioningCapabilities:
framework_pagination: bool = False # default off — adopter handles natively
# In handler.get_products:
response = await _invoke_platform_method(...)
if caps.framework_pagination and params.pagination:
response = _apply_framework_pagination(response, params.pagination, request_hash)
return response
Acceptance criteria
Out of scope
- Adopter-native cursor formats (when
framework_pagination=False, adopter is on its own — explicitly)
- Reverse pagination (no wire shape)
- Total-count computation — left to adopter; spec marks
total_count optional
Cross-references
Motivation
GetProductsRequest.paginationis{max_results: int (1..100, default 50), cursor: str | None}. The response carriespagination: {has_more, cursor, total_count}. The same shape recurs onget_media_buys,list_creatives,list_creative_formats,list_property_lists— anywhere AdCP returns a list.Every adopter who paginates will write the same opaque-cursor encode/decode logic (typically base64-JSON of
(offset, query_hash)or(last_id, query_hash)) and the same page-slice. Today no adopter does it correctly out of the box.Parent tracker: #491.
Current state
Salesagent: does not honor
paginationonget_products—_get_products_implreturns the full filtered list every time. Grep confirms nopagination,cursor, ormax_resultsreferences insrc/core/tools/products.py.SDK: no pagination helper.
MediaBuyHandler.get_products(src/adcp/decisioning/handler.py:1015-1033) passes through. Wire requestPaginationis atsrc/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:1247-1257; wire responsePaginationatsrc/adcp/types/generated_poc/bundled/media_buy/get_products_response.py:2807-2826.Proposed API
Two pieces, both reusable across list tools:
1. Cursor codec (framework-internal, hash-anchored to detect query drift):
2. Page-slice helper (used by handler post-adapter, when adopter returns the full list and asks the framework to slice):
Acceptance criteria
encode_cursor/decode_cursorround-trip;decode_cursorraisesAdcpError(INVALID_CURSOR)on hash mismatchframework_paginationcapability defaults False; existing adopters unaffectedframework_pagination=Trueandparams.paginationis set, response carriespagination.has_morecorrectlycursoris invalid for current request, framework returns structuredINVALID_CURSORerror per error catalogmax_resultsclamped to wire schema bounds (1..100)max_results=50, paginate to end — exactly 5 pages, last hashas_more=Falseget_media_buys,list_creatives, etc.Out of scope
framework_pagination=False, adopter is on its own — explicitly)total_countoptionalCross-references
src/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:1247-1257src/adcp/types/generated_poc/bundled/media_buy/get_products_response.py:2807-2826INVALID_CURSOR): adcontextprotocol/adcpstatic/schemas/source/enums/error-codes.json