Motivation
GetProductsRequest.refine[] is a discriminated-union list (scope: request | product | proposal) used when buying_mode='refine' to iterate on a previous response. The seller MUST return refinement_applied[] in the response, same length, same order, echoing scope and the matching id field (per the wire description). That echo contract is mechanical — adopters that get it wrong silently break orchestrators that cross-validate alignment.
The framework can scaffold the position-matched echo, validate the request, and call the adopter once per refine entry with a typed handler. The narrowing logic itself is adopter business logic, but every other piece is protocol plumbing.
Parent tracker: #491.
Current state
Salesagent: does not implement refine. Grep of src/core/tools/products.py shows zero references to refine or buying_mode. A buyer sending buying_mode='refine' to salesagent today gets undefined behavior.
SDK: MediaBuyHandler.get_products (src/adcp/decisioning/handler.py:1015-1033) is a pass-through; nothing validates the refine shape or builds refinement_applied[]. Wire shapes:
- Request:
src/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:21-107 (Refine / Refine6 / Refine7 / discriminated Refine4)
- Request mode:
src/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:15-18 (BuyingMode.refine)
- Response: lines 4550-4554 of
get_products_response.py (refinement_applied)
Proposed API
Adopter-facing protocol the framework calls per refine entry; framework handles everything else (validation, position alignment, refinement_applied[] construction, buying_mode cross-validation):
# src/adcp/decisioning/specialisms/sales.py — extend SalesPlatform
class SalesPlatform(Protocol):
...
async def refine_get_products(
self,
previous_response_state: GetProductsResponseState, # opaque, framework-managed
refines: list[Refine4],
ctx: RequestContext,
) -> RefineResult:
\"\"\"Optional. When buying_mode='refine', framework calls this
instead of get_products. Adopter applies the narrowing logic per
refine entry; framework projects to the wire response with
position-matched refinement_applied[].\"\"\"
# Adopter returns a typed shape that mirrors per-entry decisions:
@dataclass
class RefineResult:
products: list[Product]
proposals: list[Proposal] | None
per_refine_outcome: list[RefinementOutcome] # exactly len(refines)
@dataclass
class RefinementOutcome:
status: Literal['applied', 'partial', 'unable']
note: str | None
# framework infers scope + product_id/proposal_id from the request entry
The framework:
- Rejects
buying_mode='refine' when adopter doesn't implement refine_get_products (with a clear AdcpError).
- Validates
len(refinement_outcome) == len(refines) and raises a developer-facing error if not.
- Echoes
scope, product_id, proposal_id from request entries by position (adopter doesn't have to remember).
- Rejects
buying_mode='refine' + brief and buying_mode='wholesale' + brief per wire spec.
Acceptance criteria
Out of scope
- The narrowing logic itself (adopter business logic)
- Storing previous-response state — framework passes
previous_response_state opaquely; adopter chooses whether to load anything from it (probably yes via product_id lookup, but not framework's job)
- The
request_proposal task (separate verb per SalesPlatform.get_products docstring at sales.py:115)
action='finalize' proposal-scoped refines that trigger HITL — that's a SalesResult hybrid concern, may need its own follow-up
Cross-references
Open question (for the implementer)
Spec-side: is refine_get_products gated by a specialism, or is it implicit in sales-*? If it's implicit, drop the validate_platform warning. If gated, name the gate. Check adcontextprotocol/adcp schemas before implementing.
Motivation
GetProductsRequest.refine[]is a discriminated-union list (scope:request|product|proposal) used whenbuying_mode='refine'to iterate on a previous response. The seller MUST returnrefinement_applied[]in the response, same length, same order, echoing scope and the matching id field (per the wire description). That echo contract is mechanical — adopters that get it wrong silently break orchestrators that cross-validate alignment.The framework can scaffold the position-matched echo, validate the request, and call the adopter once per refine entry with a typed handler. The narrowing logic itself is adopter business logic, but every other piece is protocol plumbing.
Parent tracker: #491.
Current state
Salesagent: does not implement
refine. Grep ofsrc/core/tools/products.pyshows zero references torefineorbuying_mode. A buyer sendingbuying_mode='refine'to salesagent today gets undefined behavior.SDK:
MediaBuyHandler.get_products(src/adcp/decisioning/handler.py:1015-1033) is a pass-through; nothing validates the refine shape or buildsrefinement_applied[]. Wire shapes:src/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:21-107(Refine/Refine6/Refine7/ discriminatedRefine4)src/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:15-18(BuyingMode.refine)get_products_response.py(refinement_applied)Proposed API
Adopter-facing protocol the framework calls per refine entry; framework handles everything else (validation, position alignment,
refinement_applied[]construction,buying_modecross-validation):The framework:
buying_mode='refine'when adopter doesn't implementrefine_get_products(with a clearAdcpError).len(refinement_outcome) == len(refines)and raises a developer-facing error if not.scope,product_id,proposal_idfrom request entries by position (adopter doesn't have to remember).buying_mode='refine' + briefandbuying_mode='wholesale' + briefper wire spec.Acceptance criteria
refine_get_productsis optional onSalesPlatform; when absent,buying_mode='refine'returnsAdcpError(REFINE_NOT_SUPPORTED)refinement_applied[i]fromrefines[i]+per_refine_outcome[i]— adopter doesn't echo scope/id manuallybuying_mode='refine'+ non-emptybriefper wire specbuying_mode='wholesale'+ non-emptybriefper wire specvalidate_platformwarns when adopter implementsrefine_get_productsbut doesn't listsales-proposal-mode(or whichever specialism gates refine — confirm via spec)applied→ response has 3-entryrefinement_applied, scope+ids echoed correctlyapplied/unableoutcomesproposalscope echoesproposal_idrequestscope (no id) echoes scope onlyOut of scope
previous_response_stateopaquely; adopter chooses whether to load anything from it (probably yes viaproduct_idlookup, but not framework's job)request_proposaltask (separate verb perSalesPlatform.get_productsdocstring at sales.py:115)action='finalize'proposal-scoped refines that trigger HITL — that's aSalesResulthybrid concern, may need its own follow-upCross-references
src/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:21-107,1413-1431src/adcp/types/generated_poc/bundled/media_buy/get_products_response.py:4550-4554SalesPlatformprotocol:src/adcp/decisioning/specialisms/sales.py:105-119Open question (for the implementer)
Spec-side: is
refine_get_productsgated by a specialism, or is it implicit insales-*? If it's implicit, drop thevalidate_platformwarning. If gated, name the gate. Checkadcontextprotocol/adcpschemas before implementing.