diff --git a/.changeset/fix-client-validation.md b/.changeset/fix-client-validation.md new file mode 100644 index 000000000..81497e866 --- /dev/null +++ b/.changeset/fix-client-validation.md @@ -0,0 +1,4 @@ +--- +--- + +Training agent fixes: add creative/account/brand blocks to get_adcp_capabilities (#1990), increase rate limit 60→300 req/min (#1991, #1994), fix creative handler schema compliance (assets object map, generative build mode, preview response fields, list_creatives dates/snapshots). diff --git a/server/src/training-agent/index.ts b/server/src/training-agent/index.ts index 880518cef..e9c131d40 100644 --- a/server/src/training-agent/index.ts +++ b/server/src/training-agent/index.ts @@ -175,10 +175,12 @@ export function createTrainingAgentRouter(): Router { res.status(204).end(); }); - // Rate limiting: 60 requests/minute per IP (in-memory, no DB dependency) + // Rate limiting: 300 requests/minute per IP (in-memory, no DB dependency). + // The training agent is a sandbox — bulk storyboard evaluation runs ~10 MCP + // calls per storyboard, so 21 storyboards need ~210 calls within a short window. const mcpRateLimiter = rateLimit({ windowMs: 60 * 1000, - max: 60, + max: 300, standardHeaders: true, legacyHeaders: false, validate: { xForwardedForHeader: false, ip: false }, diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 8f040d96b..2943e51f9 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -32,6 +32,10 @@ import type { ActivateSignalRequest, GetCreativeDeliveryRequest, GetAdCPCapabilitiesRequest, + BuildCreativeResponse, + ListCreativesResponse, + PreviewCreativeResponse, + CreativeManifest as AdcpCreativeManifest, } from '@adcp/client'; /** Escape HTML special characters to prevent injection in generated HTML responses. */ function escapeHtmlAttr(s: string): string { @@ -538,6 +542,7 @@ const TOOLS = [ account: ACCOUNT_REF_SCHEMA, creative_ids: { type: 'array', items: { type: 'string' } }, media_buy_id: { type: 'string' }, + include_snapshot: { type: 'boolean', description: 'Include delivery snapshot per creative' }, }, }, }, @@ -1568,7 +1573,7 @@ function handleSyncCreatives(args: ToolArgs, ctx: TrainingContext) { } function handleListCreatives(args: ToolArgs, ctx: TrainingContext) { - const req = args as unknown as ListCreativesRequest & ToolArgs & { creative_ids?: string[] }; + const req = args as unknown as ListCreativesRequest & ToolArgs & { creative_ids?: string[]; include_snapshot?: boolean }; const session = getSession(sessionKeyFromArgs(req, ctx.mode, ctx.userId, ctx.moduleId)); const filterIds = req.creative_ids || req.filters?.creative_ids; @@ -1591,7 +1596,11 @@ function handleListCreatives(args: ToolArgs, ctx: TrainingContext) { format_id: c.formatId, name: c.name, status: c.status, - synced_at: c.syncedAt, + created_date: c.syncedAt, + updated_date: c.syncedAt, + ...(req.include_snapshot && { + snapshot_unavailable_reason: 'SNAPSHOT_UNSUPPORTED', + }), })), sandbox: true, }; @@ -1783,14 +1792,14 @@ function handleUpdateMediaBuy(args: ToolArgs, ctx: TrainingContext) { return result; } -function handleGetAdcpCapabilities(_args: ToolArgs, _ctx: TrainingContext): { adcp: { major_versions: number[] }; supported_protocols: string[]; protocol_version: string; tasks: string[]; media_buy: unknown; agent: { name: string; description: string } } { +function handleGetAdcpCapabilities(_args: ToolArgs, _ctx: TrainingContext): Record { const tasks = TOOLS .map(t => t.name) .filter(name => name !== 'get_adcp_capabilities'); const channels = [...new Set(PUBLISHERS.flatMap(p => p.channels))].sort(); return { adcp: { major_versions: [3] }, - supported_protocols: ['media_buy', 'creative', 'governance', 'signals'], + supported_protocols: ['media_buy', 'creative', 'governance', 'signals', 'brand'], protocol_version: '3.0', tasks, media_buy: { @@ -1801,6 +1810,18 @@ function handleGetAdcpCapabilities(_args: ToolArgs, _ctx: TrainingContext): { ad channels, }, }, + creative: { + supports_generation: true, + supports_transformation: true, + supports_compliance: false, + has_creative_library: true, + }, + account: { + require_operator_auth: false, + required_for_products: false, + sandbox: true, + supported_billing: [], + }, agent: { name: 'AdCP Training Agent', description: 'Training agent for AdCP protocol testing and certification', @@ -2188,7 +2209,7 @@ function handleGetCreativeDelivery(args: ToolArgs, ctx: TrainingContext) { interface BuildCreativeArgs { account?: unknown; creative_id?: string; - creative_manifest?: { format_id?: FormatID; assets?: Array> }; + creative_manifest?: { format_id?: FormatID; assets?: Record | Array> }; target_format_id?: FormatID; target_format_ids?: FormatID[]; brand?: { domain?: string }; @@ -2203,16 +2224,21 @@ function getDimensions(format: { renders: Array> } | und return { w: dims?.width || 300, h: dims?.height || 250 }; } -function handleBuildCreative(args: ToolArgs, ctx: TrainingContext) { +function buildHtmlAssets(html: string): AdcpCreativeManifest['assets'] { + return { serving_tag: { content: html } }; +} + +function handleBuildCreative(args: ToolArgs, ctx: TrainingContext): BuildCreativeResponse & { sandbox?: boolean } { const req = args as unknown as BuildCreativeArgs; const session = getSession(sessionKeyFromArgs(req as unknown as ToolArgs, ctx.mode, ctx.userId, ctx.moduleId)); const agentUrl = getAgentUrl(); const formats = getFormats(); const validFormatIds = new Map(formats.map(f => [f.format_id.id, f])); - // Determine target formats + // Determine target formats (cap at 50 to prevent response amplification) + const MAX_TARGET_FORMATS = 50; const targetIds: FormatID[] = req.target_format_ids?.length - ? req.target_format_ids + ? req.target_format_ids.slice(0, MAX_TARGET_FORMATS) : req.target_format_id ? [req.target_format_id] : []; @@ -2232,15 +2258,8 @@ function handleBuildCreative(args: ToolArgs, ctx: TrainingContext) { return { creative_manifest: { - creative_id: req.creative_id, format_id: { agent_url: agentUrl, id: formatId.id }, - assets: [ - { - asset_id: 'serving_tag', - asset_type: 'html', - html: `\n
Ad: ${escapeHtmlAttr(creative.name || req.creative_id!)}
`, - }, - ], + assets: buildHtmlAssets(`\n
Ad: ${escapeHtmlAttr(creative.name || req.creative_id!)}
`), }, sandbox: true, }; @@ -2248,7 +2267,8 @@ function handleBuildCreative(args: ToolArgs, ctx: TrainingContext) { // Mode 2: Stateless transformation (creative_manifest + target_format_id) if (req.creative_manifest) { - const inputAssets = req.creative_manifest.assets || []; + const rawAssets = req.creative_manifest.assets; + const inputAssetCount = Array.isArray(rawAssets) ? rawAssets.length : Object.keys(rawAssets || {}).length; if (targetIds.length === 0) { // Use the manifest's own format_id if no target specified @@ -2259,25 +2279,16 @@ function handleBuildCreative(args: ToolArgs, ctx: TrainingContext) { // Generate output for each target format if (targetIds.length > 1) { // Multi-format response - const results = targetIds.map(fmtId => { + const creative_manifests = targetIds.map(fmtId => { const format = validFormatIds.get(fmtId.id); const { w, h } = getDimensions(format); - return { - creative_manifest: { - format_id: { agent_url: agentUrl, id: fmtId.id }, - assets: [ - { - asset_id: 'serving_tag', - asset_type: 'html', - html: `\n
Built: ${escapeHtmlAttr(fmtId.id)} (${w}x${h})
`, - }, - ], - }, + format_id: { agent_url: agentUrl, id: fmtId.id }, + assets: buildHtmlAssets(`\n
Built: ${escapeHtmlAttr(fmtId.id)} (${w}x${h})
`), }; }); - return { results, sandbox: true }; + return { creative_manifests, sandbox: true }; } // Single format response @@ -2288,20 +2299,41 @@ function handleBuildCreative(args: ToolArgs, ctx: TrainingContext) { return { creative_manifest: { format_id: { agent_url: agentUrl, id: fmtId.id }, - assets: [ - { - asset_id: 'serving_tag', - asset_type: 'html', - html: `\n
Built: ${escapeHtmlAttr(fmtId.id)} (${w}x${h})
`, - }, - ], + assets: buildHtmlAssets(`\n
Built: ${escapeHtmlAttr(fmtId.id)} (${w}x${h})
`), + }, + sandbox: true, + }; + } + + // Mode 3: Generative build (target_format_id + message, no manifest or library creative) + if (targetIds.length > 0) { + if (targetIds.length > 1) { + const creative_manifests = targetIds.map(fmtId => { + const format = validFormatIds.get(fmtId.id); + const { w, h } = getDimensions(format); + return { + format_id: { agent_url: agentUrl, id: fmtId.id }, + assets: buildHtmlAssets(`\n
Generated: ${escapeHtmlAttr(fmtId.id)} (${w}x${h})
`), + }; + }); + return { creative_manifests, sandbox: true }; + } + + const fmtId = targetIds[0]; + const format = validFormatIds.get(fmtId.id); + const { w, h } = getDimensions(format); + + return { + creative_manifest: { + format_id: { agent_url: agentUrl, id: fmtId.id }, + assets: buildHtmlAssets(`\n
Generated: ${escapeHtmlAttr(fmtId.id)} (${w}x${h})
`), }, sandbox: true, }; } return { - errors: [{ code: 'INVALID_REQUEST', message: 'Provide either creative_id (library mode) or creative_manifest (transformation mode).' }], + errors: [{ code: 'INVALID_REQUEST', message: 'Provide creative_id (library mode), creative_manifest (transformation mode), or target_format_id (generative mode).' }], }; } @@ -2310,9 +2342,9 @@ function handleBuildCreative(args: ToolArgs, ctx: TrainingContext) { interface PreviewCreativeArgs { account?: unknown; request_type?: 'single' | 'batch'; - creative_manifest?: { format_id?: FormatID; creative_id?: string; assets?: Array> }; + creative_manifest?: { format_id?: FormatID; creative_id?: string; assets?: Record }; creative_id?: string; - creatives?: Array<{ format_id?: FormatID; creative_id?: string; assets?: Array> }>; + creatives?: Array<{ format_id?: FormatID; creative_id?: string; assets?: Record }>; output_format?: 'url' | 'html' | 'both'; quality?: 'draft' | 'production'; } @@ -2326,7 +2358,7 @@ function handlePreviewCreative(args: ToolArgs, ctx: TrainingContext) { const outputFormat = req.output_format || 'url'; const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); - function buildPreview(manifest: { format_id?: FormatID; creative_id?: string; assets?: Array> }) { + function buildPreview(manifest: { format_id?: FormatID; creative_id?: string; assets?: Record }) { // Resolve format let formatId = manifest.format_id; let creativeName = 'Preview'; @@ -2342,33 +2374,46 @@ function handlePreviewCreative(args: ToolArgs, ctx: TrainingContext) { const fmtId = formatId?.id || 'display_300x250'; const format = validFormatIds.get(fmtId); + if (!format && formatId?.id) { + return null; // Signal invalid format to caller + } const { w, h } = getDimensions(format); const previewHtml = `Preview: ${escapeHtmlAttr(fmtId)}
${escapeHtmlAttr(creativeName)}
${escapeHtmlAttr(fmtId)} (${w}x${h})
AdCP Training Agent Preview
`; const render: Record = { + render_id: `preview_${fmtId}`, + output_format: outputFormat, + role: 'primary', dimensions: { width: w, height: h }, }; if (outputFormat === 'url' || outputFormat === 'both') { - // Generate a data URI as the preview URL (the training agent doesn't host files) - render.url = `data:text/html;base64,${Buffer.from(previewHtml).toString('base64')}`; + render.preview_url = `data:text/html;base64,${Buffer.from(previewHtml).toString('base64')}`; } if (outputFormat === 'html' || outputFormat === 'both') { - render.html = previewHtml; + render.preview_html = previewHtml; } return { - format_id: { agent_url: agentUrl, id: fmtId }, + preview_id: `preview_${fmtId}`, renders: [render], - expires_at: expiresAt, + input: { name: creativeName }, }; } // Batch mode if (req.request_type === 'batch' && req.creatives?.length) { return { - previews: req.creatives.map(buildPreview), + response_type: 'batch', + results: req.creatives.map(c => ({ + success: true, + creative_id: c.creative_id || 'unknown', + response: { + previews: [buildPreview(c)], + expires_at: expiresAt, + }, + })), sandbox: true, }; } @@ -2381,8 +2426,18 @@ function handlePreviewCreative(args: ToolArgs, ctx: TrainingContext) { }; } + const preview = buildPreview(manifest); + if (!preview) { + const fmtId = manifest.format_id?.id || 'unknown'; + return { + errors: [{ code: 'INVALID_FORMAT', message: `Format "${fmtId}" is not supported. Use list_creative_formats to discover available formats.` }], + }; + } + return { - previews: [buildPreview(manifest)], + response_type: 'single', + previews: [preview], + expires_at: expiresAt, sandbox: true, }; } @@ -2527,7 +2582,7 @@ export function createTrainingAgentServer(ctx: TrainingContext): Server { // Training agent tasks resolve immediately, so moderate TTLs suffice. // 15 minutes gives developers time to inspect tasks while debugging. - // With the rate limiter (60 req/min) this caps live tasks at ~900. + // With the rate limiter (300 req/min) this caps live tasks at ~4,500. const MAX_TASK_TTL = 15 * 60 * 1000; // 15 minutes const DEFAULT_TASK_TTL = 15 * 60 * 1000; // 15 minutes const clampedTtl = Math.min(taskField?.ttl ?? DEFAULT_TASK_TTL, MAX_TASK_TTL); diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 3cbb0a9e1..24a3aab3a 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -3464,7 +3464,7 @@ describe('get_adcp_capabilities handler', () => { expect(result.adcp).toEqual({ major_versions: [3] }); expect(result.protocol_version).toBe('3.0'); - expect(result.supported_protocols).toEqual(['media_buy', 'creative', 'governance', 'signals']); + expect(result.supported_protocols).toEqual(['media_buy', 'creative', 'governance', 'signals', 'brand']); }); it('lists protocol tasks without get_adcp_capabilities itself', async () => {