Skip to content

feat(decisioning): refine[] flow scaffold for get_products (position-matched echo) #496

@bokelley

Description

@bokelley

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:

  1. Rejects buying_mode='refine' when adopter doesn't implement refine_get_products (with a clear AdcpError).
  2. Validates len(refinement_outcome) == len(refines) and raises a developer-facing error if not.
  3. Echoes scope, product_id, proposal_id from request entries by position (adopter doesn't have to remember).
  4. Rejects buying_mode='refine' + brief and buying_mode='wholesale' + brief per wire spec.

Acceptance criteria

  • refine_get_products is optional on SalesPlatform; when absent, buying_mode='refine' returns AdcpError(REFINE_NOT_SUPPORTED)
  • Framework constructs refinement_applied[i] from refines[i] + per_refine_outcome[i] — adopter doesn't echo scope/id manually
  • Framework rejects mismatched lengths with a developer-facing error (not a wire error — this is an adopter bug)
  • Framework rejects buying_mode='refine' + non-empty brief per wire spec
  • Framework rejects buying_mode='wholesale' + non-empty brief per wire spec
  • validate_platform warns when adopter implements refine_get_products but doesn't list sales-proposal-mode (or whichever specialism gates refine — confirm via spec)
  • Test: 3-entry refine, all applied → response has 3-entry refinement_applied, scope+ids echoed correctly
  • Test: mixed applied/unable outcomes
  • Test: proposal scope echoes proposal_id
  • Test: request scope (no id) echoes scope only

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.

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