Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/proposal-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"adcontextprotocol": minor
---

Add proposal lifecycle with draft/committed status, finalization via refine action, insertion order signing, and expiry enforcement on create_media_buy. Proposals containing guaranteed products now start as draft (indicative pricing) and must be finalized before purchase. Committed proposals include hold windows and optional insertion orders for formal agreements.
22 changes: 21 additions & 1 deletion server/src/addie/mcp/adcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,16 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [
refine: {
type: 'array',
description: 'Change requests for iterating on a previous get_products response. Each entry has scope (request/product/proposal), an action, and an ask.',
items: { type: 'object' },
items: {
type: 'object',
properties: {
scope: { type: 'string', enum: ['request', 'product', 'proposal'] },
action: { type: 'string', enum: ['include', 'omit', 'adjust', 'finalize'] },
id: { type: 'string', description: 'Product or proposal ID to act on' },
ask: { type: 'string', description: 'Free-text instruction for the refinement' },
},
required: ['scope', 'action'],
},
},
catalog: {
type: 'object',
Expand Down Expand Up @@ -237,6 +246,17 @@ export const ADCP_MEDIA_BUY_TOOLS: AddieTool[] = [
},
required: ['legal_name'],
},
io_acceptance: {
type: 'object',
description: 'Acceptance of an insertion order from a committed proposal. Required when the proposal has requires_signature: true.',
properties: {
io_id: { type: 'string', description: 'The io_id from the proposal insertion_order' },
accepted_at: { type: 'string', description: 'ISO 8601 timestamp when the IO was accepted' },
signatory: { type: 'string', description: 'Who accepted — agent identifier or human name' },
signature_id: { type: 'string', description: 'Reference to electronic signature from signing service' },
},
required: ['io_id', 'accepted_at', 'signatory'],
},
po_number: {
type: 'string',
description: 'Purchase order number for tracking.',
Expand Down
12 changes: 11 additions & 1 deletion server/src/training-agent/product-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,14 +669,24 @@ export function buildProposals(catalog: CatalogProduct[]): Proposal[] {

if (!valid) continue;

// Proposals containing guaranteed products are drafts (indicative pricing, needs finalization)
const hasGuaranteed = allocations.some(alloc => {
const cp = catalog.find(c => c.product.product_id === alloc.product_id);
return cp?.product.delivery_type === 'guaranteed';
});

proposals.push({
proposal_id: def.proposalId,
name: def.name,
description: def.description,
brief_alignment: def.briefAlignment,
total_budget_guidance: def.budgetGuidance,
allocations,
});
...(hasGuaranteed && {
proposal_status: 'draft' as const,
expires_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // 2 hours indicative window
}),
} as Proposal);
}

return proposals;
Expand Down
144 changes: 137 additions & 7 deletions server/src/training-agent/task-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createLogger } from '../logger.js';
import type { TrainingContext, CatalogProduct, MediaBuyState, PackageState, SignalActivationState, CreativeState, CreativeManifest, ToolArgs } from './types.js';
import type {
Product,
Proposal,
FormatID,
CreateMediaBuyRequest,
UpdateMediaBuyRequest,
Expand Down Expand Up @@ -80,6 +81,15 @@ interface CreativeAssignmentInput {
media_buy_id: string;
}

// Proposal lifecycle fields not yet in @adcp/client — remove after client update
interface ProposalLifecycle {
proposal_status?: 'draft' | 'committed';
insertion_order?: { io_id: string; requires_signature: boolean; terms?: Record<string, unknown> };
}
function proposalLifecycle(proposal: Proposal): ProposalLifecycle {
return proposal as unknown as ProposalLifecycle;
}

import { buildCatalog, buildShowsForProducts, buildProposals } from './product-factory.js';
import { buildFormats, FORMAT_CHANNEL_MAP } from './formats.js';
import { getAllSignals, SIGNAL_PROVIDERS } from './signal-providers.js';
Expand Down Expand Up @@ -656,16 +666,19 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) {
}
}

// Refine mode: apply include/omit/more_like_this
// Refine mode: apply include/omit/more_like_this/finalize
const refinementApplied: Array<{ status: string; notes?: string }> = [];
const proposalOmitIds = new Set<string>();
if (buyingMode === 'refine' && req.refine) {
const previousProducts = session.lastGetProductsContext?.products || products;
const previousProposals = session.lastGetProductsContext?.proposals || getProposals();
const omitIds = new Set<string>();
const includeIds = new Set<string>();

for (const op of req.refine) {
if (op.scope === 'product') {
if (op.action === 'omit') omitIds.add(op.id);
else if (op.action === 'include') includeIds.add(op.id);
if (op.action === 'omit') { omitIds.add(op.id); refinementApplied.push({ status: 'applied' }); }
else if (op.action === 'include') { includeIds.add(op.id); refinementApplied.push({ status: 'applied' }); }
// more_like_this: include the product plus similar channel products
else if (op.action === 'more_like_this') {
includeIds.add(op.id);
Expand All @@ -678,11 +691,83 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) {
}
}
}
refinementApplied.push({ status: 'applied' });
}
} else if (op.scope === 'proposal') {
const proposalOp = op as { scope: 'proposal'; id: string; action: string; ask?: string };
const proposal = previousProposals.find(p => p.proposal_id === proposalOp.id);
if (!proposal) {
refinementApplied.push({ status: 'unable', notes: `Proposal not found: ${proposalOp.id}` });
continue;
}
if (proposalOp.action === 'omit') {
proposalOmitIds.add(proposalOp.id);
refinementApplied.push({ status: 'applied' });
} else if (proposalOp.action === 'include') {
// Include is a no-op for proposals already in the response
refinementApplied.push({ status: 'applied' });
} else if (proposalOp.action === 'finalize') {
const status = proposalLifecycle(proposal).proposal_status;
if (status === 'committed') {
refinementApplied.push({ status: 'applied', notes: 'Proposal already committed' });
} else if (status === 'draft') {
// Transition draft → committed: firm pricing, inventory hold, IO
const committed = { ...proposal } as Record<string, unknown> & ProposalLifecycle;
committed.proposal_status = 'committed';
(committed as Record<string, unknown>).expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24h hold

// Attach insertion order for proposals with guaranteed products
const hasGuaranteed = proposal.allocations.some(alloc => {
const cp = getCatalog().find(c => c.product.product_id === alloc.product_id);
return cp?.product.delivery_type === 'guaranteed';
});
if (hasGuaranteed) {
const publisherCp = getCatalog().find(c => c.product.product_id === proposal.allocations[0].product_id);
const accountBrand = (req as unknown as Record<string, unknown>).account as Record<string, unknown> | undefined;
const brandDomain = ((accountBrand?.brand as Record<string, unknown>)?.domain as string) || 'advertiser.example';
committed.insertion_order = {
io_id: `io_${randomUUID().replace(/-/g, '')}`,
terms: {
advertiser: brandDomain,
publisher: publisherCp?.publisherId || 'unknown',
total_budget: {
amount: proposal.total_budget_guidance?.recommended ?? 0,
currency: proposal.total_budget_guidance?.currency ?? 'USD',
},
flight_start: new Date().toISOString(),
flight_end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
payment_terms: 'net_30',
},
requires_signature: true,
};
}

// Update proposal in session context
if (!session.lastGetProductsContext) {
session.lastGetProductsContext = { products: [...products], proposals: [] };
}
const sessionProposals = session.lastGetProductsContext.proposals || [];
const idx = sessionProposals.findIndex(p => p.proposal_id === proposalOp.id);
const updatedProposal = committed as unknown as import('@adcp/client').Proposal;
if (idx >= 0) {
sessionProposals[idx] = updatedProposal;
} else {
sessionProposals.push(updatedProposal);
}
session.lastGetProductsContext.proposals = sessionProposals;

refinementApplied.push({ status: 'applied', notes: 'Proposal finalized — pricing committed, inventory held for 24 hours' });
} else {
// No proposal_status means already ready to buy — finalize is a no-op
refinementApplied.push({ status: 'applied', notes: 'Proposal is already ready to buy (no finalization needed)' });
}
}
} else if (op.scope === 'request') {
refinementApplied.push({ status: 'partial', notes: 'Request-level refinement acknowledged but not applied by training agent' });
}
}

// Apply includes first (expand), then omits (filter)
// Apply includes first (expand), then omits (filter) for products
if (includeIds.size > 0) {
products = getCatalog()
.filter(cp => includeIds.has(cp.product.product_id))
Expand Down Expand Up @@ -713,8 +798,14 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) {
}
}

const proposals = getProposals().filter(proposal =>
proposal.allocations.every(a => productIds.has(a.product_id)),
// In refine mode, use session proposals (which may include finalized versions)
const sourceProposals = (buyingMode === 'refine' && session.lastGetProductsContext?.proposals)
? session.lastGetProductsContext.proposals
: getProposals();

const proposals = sourceProposals.filter(proposal =>
proposal.allocations.every(a => productIds.has(a.product_id)) &&
!proposalOmitIds.has(proposal.proposal_id),
);

// Store context for refine
Expand All @@ -723,6 +814,7 @@ function handleGetProducts(args: ToolArgs, ctx: TrainingContext) {
return {
products,
...(proposals.length > 0 && { proposals }),
...(refinementApplied.length > 0 && { refinement_applied: refinementApplied }),
sandbox: true,
};
}
Expand Down Expand Up @@ -759,12 +851,44 @@ function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) {

// Proposal-based creation: expand proposal allocations into packages
if (req.proposal_id && !req.packages?.length) {
const proposal = getProposals().find(p => p.proposal_id === req.proposal_id);
// Check session proposals first (may have finalized versions), then global catalog
const proposal = session.lastGetProductsContext?.proposals?.find(p => p.proposal_id === req.proposal_id)
|| getProposals().find(p => p.proposal_id === req.proposal_id);
if (!proposal) {
return {
errors: [{ code: 'INVALID_REQUEST', message: `Proposal not found: ${req.proposal_id}` }] as TaskError[],
};
}

// Enforce proposal lifecycle: draft proposals cannot be purchased directly
const proposalStatus = proposalLifecycle(proposal).proposal_status;
if (proposalStatus === 'draft') {
return {
errors: [{ code: 'PROPOSAL_NOT_COMMITTED', message: `Proposal "${req.proposal_id}" has draft status — finalize it first using get_products with buying_mode "refine" and action "finalize".` }] as TaskError[],
};
}

// Enforce proposal expiry
if (proposal.expires_at && new Date(proposal.expires_at) < new Date()) {
return {
errors: [{ code: 'PROPOSAL_EXPIRED', message: `Proposal "${req.proposal_id}" expired at ${proposal.expires_at}. Re-discover with get_products to get a fresh proposal.` }] as TaskError[],
};
}

// Enforce IO acceptance when required
const insertionOrder = proposalLifecycle(proposal).insertion_order;
const ioAcceptance = (req as unknown as Record<string, unknown>).io_acceptance as { io_id: string; accepted_at: string; signatory: string } | undefined;
if (insertionOrder?.requires_signature && !ioAcceptance) {
return {
errors: [{ code: 'IO_REQUIRED', message: `Proposal "${req.proposal_id}" requires a signed insertion order. Include io_acceptance with io_id "${insertionOrder.io_id}" on create_media_buy.` }] as TaskError[],
};
}
if (ioAcceptance && insertionOrder && ioAcceptance.io_id !== insertionOrder.io_id) {
return {
errors: [{ code: 'INVALID_REQUEST', message: `io_acceptance.io_id "${ioAcceptance.io_id}" does not match proposal insertion order io_id "${insertionOrder.io_id}".` }] as TaskError[],
};
}

const totalBudget = req.total_budget?.amount;
if (!totalBudget) {
return {
Expand Down Expand Up @@ -840,6 +964,12 @@ function handleCreateMediaBuy(args: ToolArgs, ctx: TrainingContext) {
continue;
}

// Enforce product expiry
if (product.expires_at && new Date(product.expires_at) < new Date()) {
errors.push({ code: 'PRODUCT_EXPIRED', message: `${pkgLabel}: Product "${pkg.product_id}" expired at ${product.expires_at}. Re-discover with get_products.` });
continue;
}

const pricingOptions = product.pricing_options;
const pricing = pricingOptions?.find(po => po.pricing_option_id === pkg.pricing_option_id);
if (!pricing) {
Expand Down
Loading
Loading