Skip to content

feat(decisioning): built-in fields projection on get_products responses #492

@bokelley

Description

@bokelley

Motivation

GetProductsRequest.fields is a 29-value enum (product_id, name, description, publisher_properties, channels, format_ids, …, trusted_match) that lets buyers ask for a subset of Product fields for lightweight discovery — e.g. "give me just IDs and pricing for comparison". The wire schema requires product_id and name always be returned regardless of selection.

This is mechanical post-processing on the response. The seller's adapter already builds a full Product; honoring fields is model_dump(include=…)-shaped work that every adopter will write the same way (or, more likely, forget to implement, leaving the buyer's lightweight-discovery contract silently broken).

Parent tracker: #491.

Current state

Salesagent: does not honor fields at all — _get_products_impl (src/core/tools/products.py:145-470+) returns full Product objects unconditionally.

SDK: MediaBuyHandler.get_products (src/adcp/decisioning/handler.py:1015-1033) is a pass-through, no projection. The wire enum lives at src/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:1194-1225 (Field1).

Proposed API

Framework intercepts in MediaBuyHandler.get_products after the adapter returns, before envelope projection. No capability declaration needed — projection is a pure no-op when fields is absent.

# src/adcp/decisioning/handler.py — get_products

async def get_products(self, params, context=None):
    ...
    response = await _invoke_platform_method(...)
    if params.fields:
        response = _project_product_fields(response, params.fields)
    return response


# src/adcp/decisioning/_get_products_helpers.py (new)

_REQUIRED_FIELDS: frozenset[str] = frozenset({\"product_id\", \"name\"})

def _project_product_fields(
    response: GetProductsResponse,
    fields: list[Field1],
) -> GetProductsResponse:
    \"\"\"Drop product fields not in `fields`. `product_id` and `name`
    are always retained per the wire-schema description.\"\"\"
    keep = {f.value for f in fields} | _REQUIRED_FIELDS
    projected = [
        Product.model_validate(p.model_dump(include=keep, exclude_none=True))
        for p in response.products
    ]
    return response.model_copy(update={\"products\": projected})

Acceptance criteria

  • _project_product_fields drops every field not in fields ∪ {product_id, name}
  • When fields is None or empty, response is unchanged (no allocation)
  • product_id and name are always present, even when not in fields
  • Pydantic extra='allow' ext fields on Product are preserved (don't strip them)
  • Test: round-trip with all 29 enum values, assert exact field set on output Product
  • Test: fields=[product_id] returns Products with only product_id + name
  • Documented in handler docstring; linked from migration guide

Out of scope

  • Projecting fields on proposals[] — proposals carry product references, not full products (no wire-defined projection there)
  • Per-field cost/latency hints — wire spec doesn't define those
  • Capability declaration — projection is unconditional (no fields_projection_supported flag in spec)

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