Skip to content

feat(decisioning): time_budget deadline wrapper + incomplete projection on get_products #495

@bokelley

Description

@bokelley

Motivation

GetProductsRequest.time_budget is {interval: int, unit: 'seconds'|'minutes'|'hours'|'days'|'campaign'}. The wire description: "the seller returns the best results achievable within this budget and does not start processes (human approvals, expensive external queries) that cannot complete in time." The response carries incomplete[] with per-scope estimated_wait so the buyer can decide whether to retry with a larger budget.

Every adopter that takes time_budget seriously needs to (1) install a deadline, (2) cancel in-flight work when the deadline expires, (3) project the unfinished scopes to the wire incomplete[] shape. None of that is business logic — it's protocol plumbing that's identical for every seller.

Parent tracker: #491.

Current state

Salesagent: does not honor time_budget at all. Grep of src/core/tools/products.py shows zero references. The seller can run unbounded.

SDK: no deadline wrapper. MediaBuyHandler.get_products runs the adopter to completion regardless. Wire shape lives at src/adcp/types/generated_poc/bundled/media_buy/get_products_request.py:1235-1244 (TimeBudget); response incomplete at src/adcp/types/generated_poc/bundled/media_buy/get_products_response.py:2786-2804 (IncompleteItem).

Proposed API

Framework wraps the adapter call in asyncio.wait_for, catches TimeoutError, projects whatever the adopter has produced so far via a checkpoint protocol:

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

class IncrementalGetProducts(Protocol):
    \"\"\"Optional protocolwhen the adopter implements it, the framework
    can project partial results on time-budget exhaustion. When the
    adopter only implements plain get_products (returns the full
    response), framework cancels and returns a single
    incomplete[{scope: 'products', description: 'time_budget exhausted'}]
    with no products.\"\"\"

    async def get_products_incremental(
        self, req: GetProductsRequest, ctx: RequestContext, checkpoint: Checkpoint
    ) -> AsyncIterator[ProductsBatch]:
        ...


# In handler.get_products:
deadline = _resolve_time_budget(params.time_budget)  # → seconds float
try:
    response = await asyncio.wait_for(
        _invoke_platform_method(...), timeout=deadline,
    )
except asyncio.TimeoutError:
    response = _project_partial_or_empty(checkpoint, deadline)
return response

Acceptance criteria

  • _resolve_time_budget converts every wire unit (seconds/minutes/hours/days/campaign) to a deadline; 'campaign' → no deadline (matches wire description)
  • When deadline exhausted and adopter is plain (non-incremental), response has products: [], incomplete: [{scope: 'products', description: '...', estimated_wait: ...}]
  • When adopter implements IncrementalGetProducts, framework returns whatever batches landed before timeout, plus incomplete[] for the rest
  • estimated_wait populated from a hint protocol the adopter supplies (or omitted if no hint)
  • Test: time_budget={interval: 1, unit: 'seconds'} against a 10s adapter returns the timeout shape
  • Test: adopter that returns within budget passes through unchanged (no incomplete)
  • Test: time_budget absent → no deadline, no projection
  • Wire-validates: incomplete[].scope uses spec enum (products, pricing, forecast, proposals)

Out of scope

  • Per-scope deadlines (wire spec is request-level only)
  • HITL/approval-aware deadline (refine with action='finalize' explicitly says buyer should NOT set time_budget)
  • Cancellation propagation to upstream adapter calls — adopter responsibility unless framework owns the executor
  • Applying the same wrapper to other tools (open question — see below)

Cross-references

Open question (for the implementer, not blocking)

time_budget is a request-shape parameter on more than just get_products — it also appears on request_proposal and likely others. Implement first against get_products; generalize when a second tool adopts the same wire shape.

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