Skip to content

feat(decisioning): cursor-based pagination helper for list responses #493

@bokelley

Description

@bokelley

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 shapeif 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

  • encode_cursor / decode_cursor round-trip; decode_cursor raises AdcpError(INVALID_CURSOR) on hash mismatch
  • framework_pagination capability defaults False; existing adopters unaffected
  • When framework_pagination=True and params.pagination is set, response carries pagination.has_more correctly
  • When cursor is invalid for current request, framework returns structured INVALID_CURSOR error per error catalog
  • max_results clamped to wire schema bounds (1..100)
  • Test: 250 products, max_results=50, paginate to end — exactly 5 pages, last has has_more=False
  • Test: cursor rejects when filters change between calls
  • Helpers documented as reusable for get_media_buys, list_creatives, etc.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions