From 37cfb63dc112bc1041e35181cf4f034548ac5a99 Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Thu, 6 Nov 2025 14:48:15 -0800 Subject: [PATCH 1/2] feat: Replace SimpleMediaAgent with OutcomeAgent implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement OutcomeAgent with MCP protocol integration - Add get_proposals tool for proposal generation with product filtering and budget optimization - Add accept_proposal tool for assignment acceptance with validation - Replace simple-media-agent binary with outcome-agent binary - Add comprehensive test coverage (32 tests: 15 for get-proposals, 17 for accept-proposal) - Add npm script: start:outcome-agent - Update exports to include OutcomeAgent and related types BREAKING CHANGE: SimpleMediaAgent has been removed. Use OutcomeAgent instead. The binary name has changed from simple-media-agent to outcome-agent. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/hungry-lines-accept.md | 13 + package-lock.json | 6 +- package.json | 3 +- src/index.ts | 11 +- src/outcome-agent-server.ts | 29 ++ src/outcome-agent.ts | 112 +++++ .../__tests__/accept-proposal.test.ts | 425 +++++++++++++++++ .../__tests__/get-proposals.test.ts | 431 ++++++++++++++++++ src/outcome-agent/accept-proposal.ts | 56 +++ src/outcome-agent/get-proposals.ts | 187 ++++++++ src/outcome-agent/types.ts | 128 ++++++ src/simple-media-agent-server.ts | 38 -- src/simple-media-agent.ts | 98 ---- .../get-proposed-tactics.ts | 84 ---- src/simple-media-agent/manage-tactic.ts | 145 ------ src/simple-media-agent/types.ts | 25 - 16 files changed, 1396 insertions(+), 395 deletions(-) create mode 100644 .changeset/hungry-lines-accept.md create mode 100644 src/outcome-agent-server.ts create mode 100644 src/outcome-agent.ts create mode 100644 src/outcome-agent/__tests__/accept-proposal.test.ts create mode 100644 src/outcome-agent/__tests__/get-proposals.test.ts create mode 100644 src/outcome-agent/accept-proposal.ts create mode 100644 src/outcome-agent/get-proposals.ts create mode 100644 src/outcome-agent/types.ts delete mode 100644 src/simple-media-agent-server.ts delete mode 100644 src/simple-media-agent.ts delete mode 100644 src/simple-media-agent/get-proposed-tactics.ts delete mode 100644 src/simple-media-agent/manage-tactic.ts delete mode 100644 src/simple-media-agent/types.ts diff --git a/.changeset/hungry-lines-accept.md b/.changeset/hungry-lines-accept.md new file mode 100644 index 0000000..c5e9388 --- /dev/null +++ b/.changeset/hungry-lines-accept.md @@ -0,0 +1,13 @@ +--- +"@scope3/agentic-client": minor +--- + +Add OutcomeAgent implementation with get_proposals and accept_proposal MCP tools + +BREAKING CHANGE: Removed SimpleMediaAgent in favor of OutcomeAgent +- Deleted simple-media-agent files and replaced with outcome-agent implementation +- New binary: `outcome-agent` (previously `simple-media-agent`) +- New script: `npm run start:outcome-agent` to run the outcome agent server +- Implemented get-proposals handler with product filtering and budget optimization +- Implemented accept-proposal handler with validation and default acceptance +- Added comprehensive test coverage (32 tests total) diff --git a/package-lock.json b/package-lock.json index 84bdfe0..dc5ebe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@scope3/agentic-client", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@scope3/agentic-client", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", @@ -15,7 +15,7 @@ "zod": "^3.25.76" }, "bin": { - "simple-media-agent": "dist/simple-media-agent-server.js" + "outcome-agent": "dist/outcome-agent-server.js" }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", diff --git a/package.json b/package.json index bffee49..904c7e3 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "access": "public" }, "bin": { - "simple-media-agent": "dist/simple-media-agent-server.js" + "outcome-agent": "dist/outcome-agent-server.js" }, "scripts": { "build": "npm run type-check && tsc", @@ -17,6 +17,7 @@ "lint": "eslint src --ext .ts", "format": "prettier --write \"src/**/*.ts\"", "type-check": "tsc --noEmit", + "start:outcome-agent": "npm run build && node dist/outcome-agent-server.js", "generate-outcome-agent-types": "openapi-typescript outcome-agent-openapi.yaml -o src/types/outcome-agent-api.ts", "generate-partner-api-types": "openapi-typescript partner-api.yaml -o src/types/partner-api.ts", "generate-platform-api-types": "openapi-typescript platform-api.yaml -o src/types/platform-api.ts", diff --git a/src/index.ts b/src/index.ts index 2a547bf..12d8379 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,18 @@ export { Scope3AgenticClient } from './sdk'; // Legacy export for backwards compatibility export { Scope3AgenticClient as Scope3SDK } from './sdk'; export { WebhookServer } from './webhook-server'; -export { SimpleMediaAgent } from './simple-media-agent'; +export { OutcomeAgent } from './outcome-agent'; export type { ClientConfig, ToolResponse, Environment } from './types'; export type { WebhookEvent, WebhookHandler, WebhookServerConfig } from './webhook-server'; +export type { + OutcomeAgentConfig, + GetProposalsRequest, + GetProposalsResponse, + AcceptProposalRequest, + AcceptProposalResponse, + Proposal, + CampaignContext, +} from './outcome-agent/types'; export * from './resources/agents'; export * from './resources/assets'; diff --git a/src/outcome-agent-server.ts b/src/outcome-agent-server.ts new file mode 100644 index 0000000..f998b23 --- /dev/null +++ b/src/outcome-agent-server.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { OutcomeAgent } from './outcome-agent.js'; + +const scope3ApiKey = process.env.SCOPE3_API_KEY; +const scope3BaseUrl = process.env.SCOPE3_BASE_URL; + +if (!scope3ApiKey) { + console.error('Error: SCOPE3_API_KEY environment variable is required'); + process.exit(1); +} + +const agent = new OutcomeAgent({ + scope3ApiKey, + scope3BaseUrl, + name: 'outcome-agent', + version: '1.0.0', +}); + +agent.start().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); + +console.error(` +Outcome Agent +- Scope3 Base URL: ${scope3BaseUrl || 'https://api.agentic.scope3.com'} +- Protocol: MCP (stdio) +- Tools: get_proposals, accept_proposal +`); diff --git a/src/outcome-agent.ts b/src/outcome-agent.ts new file mode 100644 index 0000000..b242351 --- /dev/null +++ b/src/outcome-agent.ts @@ -0,0 +1,112 @@ +import { FastMCP } from 'fastmcp'; +import { z } from 'zod'; +import { Scope3AgenticClient } from './sdk'; +import type { OutcomeAgentConfig } from './outcome-agent/types'; +import { getProposals } from './outcome-agent/get-proposals'; +import { acceptProposal } from './outcome-agent/accept-proposal'; + +/** + * Outcome Agent that exposes MCP tools for proposal generation and management + * Called by Scope3 platform via MCP protocol + */ +export class OutcomeAgent { + private server: FastMCP; + private scope3: Scope3AgenticClient; + private config: Required< + Omit & { name: string; version: string } + >; + + constructor(config: OutcomeAgentConfig) { + this.config = { + scope3ApiKey: config.scope3ApiKey, + scope3BaseUrl: config.scope3BaseUrl || 'https://api.agentic.scope3.com', + name: config.name || 'outcome-agent', + version: (config.version as `${number}.${number}.${number}`) || '1.0.0', + }; + + this.server = new FastMCP({ + name: this.config.name, + version: this.config.version as `${number}.${number}.${number}`, + }); + + this.scope3 = new Scope3AgenticClient({ + apiKey: this.config.scope3ApiKey, + baseUrl: this.config.scope3BaseUrl, + }); + + this.setupTools(); + } + + private setupTools(): void { + // get_proposals tool + this.server.addTool({ + name: 'get_proposals', + description: + 'Get proposal recommendations from this outcome agent. Returns proposals with execution strategies, budget capacity, and pricing.', + parameters: z.object({ + campaignId: z.string().describe('Campaign ID'), + seatId: z.string().describe('Seat/account ID'), + budgetRange: z + .object({ + min: z.number().optional(), + max: z.number().optional(), + currency: z.string().optional(), + }) + .optional() + .describe('Budget range with min/max and currency'), + startDate: z.string().optional().describe('Campaign start date (ISO 8601 UTC)'), + endDate: z.string().optional().describe('Campaign end date (ISO 8601 UTC)'), + channels: z + .array(z.enum(['display', 'video', 'native', 'audio', 'connected_tv'])) + .optional() + .describe('Ad channels'), + countries: z.array(z.string()).optional().describe('ISO 3166-1 alpha-2 country codes'), + brief: z.string().optional().describe('Campaign description'), + products: z.array(z.object({}).passthrough()).optional().describe('Product references'), + propertyListIds: z.array(z.number()).optional().describe('Property list IDs'), + }), + execute: async (args) => { + const result = await getProposals(this.scope3, args); + return JSON.stringify(result, null, 2); + }, + }); + + // accept_proposal tool + this.server.addTool({ + name: 'accept_proposal', + description: + 'Accept or decline a proposal assignment. Called when a proposal is accepted by users.', + parameters: z.object({ + tacticId: z.string().describe('Tactic ID'), + proposalId: z.string().optional().describe('Proposal ID from get_proposals response'), + campaignContext: z + .object({ + budget: z.number(), + budgetCurrency: z.string().optional(), + startDate: z.string(), + endDate: z.string(), + channel: z.enum(['display', 'video', 'native', 'audio', 'connected_tv']), + countries: z.array(z.string()).optional(), + creatives: z.array(z.object({}).passthrough()).optional(), + brandStandards: z.array(z.object({}).passthrough()).optional(), + }) + .passthrough() + .describe('Campaign details including budget, schedule, targeting, and creatives'), + brandAgentId: z.string().describe('Brand agent ID'), + seatId: z.string().describe('Seat/account ID'), + customFields: z.record(z.unknown()).optional().describe('Custom advertiser fields'), + additional_info: z.record(z.unknown()).optional().describe('Additional metadata'), + }), + execute: async (args) => { + const result = await acceptProposal(this.scope3, args); + return JSON.stringify(result, null, 2); + }, + }); + } + + async start(): Promise { + await this.server.start({ + transportType: 'stdio', + }); + } +} diff --git a/src/outcome-agent/__tests__/accept-proposal.test.ts b/src/outcome-agent/__tests__/accept-proposal.test.ts new file mode 100644 index 0000000..4f6248b --- /dev/null +++ b/src/outcome-agent/__tests__/accept-proposal.test.ts @@ -0,0 +1,425 @@ +import { acceptProposal } from '../accept-proposal'; +import type { AcceptProposalRequest, CampaignContext } from '../types'; +import { Scope3AgenticClient } from '../../sdk'; + +describe('acceptProposal', () => { + let mockScope3: Scope3AgenticClient; + + beforeEach(() => { + mockScope3 = new Scope3AgenticClient({ + apiKey: 'test-api-key', + }); + }); + + describe('successful acceptance', () => { + it('should accept valid proposal assignments', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + budgetCurrency: 'USD', + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'display', + countries: ['US', 'CA'], + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + proposalId: 'prop-456', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it('should accept proposals with creatives', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + budgetCurrency: 'USD', + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'video', + creatives: [ + { + creativeId: 'creative-001', + assetUrl: 'https://example.com/video.mp4', + format: 'video/mp4', + dimensions: { + width: 1920, + height: 1080, + }, + }, + ], + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept proposals with brand standards', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'display', + brandStandards: [ + { + type: 'viewability', + value: 0.7, + }, + { + type: 'brand_safety', + value: 'strict', + }, + ], + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept proposals with custom fields', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'audio', + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + customFields: { + targetAudience: 'millennials', + preferredDayparts: ['morning', 'evening'], + customTargeting: { + interests: ['sports', 'technology'], + }, + }, + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept proposals with additional_info', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'connected_tv', + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + proposalId: 'prop-456', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + additional_info: { + products: [ + { + product_ref: 'prod-001', + sales_agent_url: 'https://sales-agent.example.com', + pricing_option_id: 'pricing-001', + }, + ], + }, + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept proposals for all channel types', async () => { + const channels: Array<'display' | 'video' | 'native' | 'audio' | 'connected_tv'> = [ + 'display', + 'video', + 'native', + 'audio', + 'connected_tv', + ]; + + for (const channel of channels) { + const campaignContext: CampaignContext = { + budget: 25000, + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-12-31T23:59:59Z', + channel, + }; + + const request: AcceptProposalRequest = { + tacticId: `tactic-${channel}`, + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + } + }); + }); + + describe('validation and rejection', () => { + it('should reject when tacticId is missing', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'display', + }; + + const request: AcceptProposalRequest = { + tacticId: '', // Empty tacticId + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(false); + expect(result.reason).toBeDefined(); + expect(result.reason).toContain('Missing required fields'); + }); + + it('should reject when budget is zero', async () => { + const campaignContext: CampaignContext = { + budget: 0, // Invalid budget + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'display', + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(false); + expect(result.reason).toBeDefined(); + expect(result.reason).toContain('Budget must be greater than 0'); + }); + + it('should reject when budget is negative', async () => { + const campaignContext: CampaignContext = { + budget: -1000, // Negative budget + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'video', + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(false); + expect(result.reason).toBeDefined(); + expect(result.reason).toContain('Budget must be greater than 0'); + }); + }); + + describe('optional fields', () => { + it('should accept proposals without proposalId', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'display', + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + // proposalId is optional + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept proposals without budgetCurrency (defaults to USD)', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + // budgetCurrency is optional + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'display', + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept proposals without countries', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'native', + // countries is optional + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept proposals without creatives', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'display', + // creatives is optional + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept proposals without brand standards', async () => { + const campaignContext: CampaignContext = { + budget: 50000, + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-03-31T23:59:59Z', + channel: 'video', + // brandStandards is optional + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + }); + + describe('various budget scenarios', () => { + it('should accept small budgets', async () => { + const campaignContext: CampaignContext = { + budget: 100, // Small budget + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-01-31T23:59:59Z', + channel: 'display', + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept large budgets', async () => { + const campaignContext: CampaignContext = { + budget: 10000000, // Large budget + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-12-31T23:59:59Z', + channel: 'video', + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + + it('should accept decimal budgets', async () => { + const campaignContext: CampaignContext = { + budget: 1234.56, // Decimal budget + startDate: '2024-01-01T00:00:00Z', + endDate: '2024-01-31T23:59:59Z', + channel: 'native', + }; + + const request: AcceptProposalRequest = { + tacticId: 'tactic-123', + campaignContext, + brandAgentId: 'brand-789', + seatId: 'seat-012', + }; + + const result = await acceptProposal(mockScope3, request); + + expect(result.acknowledged).toBe(true); + }); + }); +}); diff --git a/src/outcome-agent/__tests__/get-proposals.test.ts b/src/outcome-agent/__tests__/get-proposals.test.ts new file mode 100644 index 0000000..0272899 --- /dev/null +++ b/src/outcome-agent/__tests__/get-proposals.test.ts @@ -0,0 +1,431 @@ +import { getProposals } from '../get-proposals'; +import type { GetProposalsRequest, Product } from '../types'; +import { Scope3AgenticClient } from '../../sdk'; + +describe('getProposals', () => { + let mockScope3: Scope3AgenticClient; + + beforeEach(() => { + mockScope3 = new Scope3AgenticClient({ + apiKey: 'test-api-key', + }); + }); + + describe('when no products are provided', () => { + it('should return empty proposals array', async () => { + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + }; + + const result = await getProposals(mockScope3, request); + + expect(result.proposals).toEqual([]); + }); + + it('should return empty proposals when products array is empty', async () => { + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products: [], + }; + + const result = await getProposals(mockScope3, request); + + expect(result.proposals).toEqual([]); + }); + }); + + describe('when products are provided', () => { + const mockProducts: Product[] = [ + { + sales_agent_url: 'https://sales-agent-1.example.com', + product_ref: 'prod-display-001', + pricing_option_id: 'pricing-001', + floor_price: 2.5, + floor_price_currency: 'USD', + name: 'Premium Display Ads', + targeting: { + channels: ['display'], + countries: ['US', 'CA'], + }, + }, + { + sales_agent_url: 'https://sales-agent-2.example.com', + product_ref: 'prod-video-001', + pricing_option_id: 'pricing-002', + floor_price: 5.0, + floor_price_currency: 'USD', + name: 'Video Ads', + targeting: { + channels: ['video'], + countries: ['US', 'GB'], + }, + }, + ]; + + it('should generate proposals for eligible products', async () => { + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products: mockProducts, + budgetRange: { + min: 10000, + max: 50000, + currency: 'USD', + }, + }; + + const result = await getProposals(mockScope3, request); + + expect(result.proposals.length).toBeGreaterThan(0); + }); + + it('should include correct proposal structure', async () => { + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products: mockProducts, + budgetRange: { + min: 10000, + max: 50000, + currency: 'USD', + }, + }; + + const result = await getProposals(mockScope3, request); + const proposal = result.proposals[0]; + + expect(proposal).toHaveProperty('proposalId'); + expect(proposal).toHaveProperty('execution'); + expect(proposal).toHaveProperty('budgetCapacity'); + expect(proposal).toHaveProperty('pricing'); + expect(proposal).toHaveProperty('sku'); + expect(proposal).toHaveProperty('additional_info'); + }); + + it('should use revshare pricing method with 15% rate', async () => { + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products: mockProducts, + budgetRange: { + max: 50000, + currency: 'USD', + }, + }; + + const result = await getProposals(mockScope3, request); + const proposal = result.proposals[0]; + + expect(proposal.pricing.method).toBe('revshare'); + expect(proposal.pricing.rate).toBe(0.15); + expect(proposal.pricing.currency).toBe('USD'); + }); + + it('should group products by channel', async () => { + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products: mockProducts, + budgetRange: { + max: 50000, + }, + }; + + const result = await getProposals(mockScope3, request); + + // Should have proposals for display and video channels + expect(result.proposals.length).toBe(2); + + const channelsInProposals = result.proposals.map( + (p) => p.additional_info?.channel + ); + expect(channelsInProposals).toContain('display'); + expect(channelsInProposals).toContain('video'); + }); + + it('should include product references in additional_info', async () => { + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products: [mockProducts[0]], + budgetRange: { + max: 50000, + }, + }; + + const result = await getProposals(mockScope3, request); + const proposal = result.proposals[0]; + + expect(proposal.additional_info).toHaveProperty('products'); + expect(Array.isArray(proposal.additional_info?.products)).toBe(true); + expect(proposal.additional_info?.products).toHaveLength(1); + + const products = proposal.additional_info?.products as Array<{ + product_ref: string; + sales_agent_url: string; + pricing_option_id: string; + }>; + + expect(products[0]).toEqual({ + product_ref: 'prod-display-001', + sales_agent_url: 'https://sales-agent-1.example.com', + pricing_option_id: 'pricing-001', + }); + }); + }); + + describe('product filtering', () => { + it('should filter products by channels', async () => { + const products: Product[] = [ + { + sales_agent_url: 'https://sales-agent-1.example.com', + product_ref: 'prod-display-001', + pricing_option_id: 'pricing-001', + targeting: { + channels: ['display'], + }, + }, + { + sales_agent_url: 'https://sales-agent-2.example.com', + product_ref: 'prod-video-001', + pricing_option_id: 'pricing-002', + targeting: { + channels: ['video'], + }, + }, + ]; + + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products, + channels: ['display'], + budgetRange: { + max: 50000, + }, + }; + + const result = await getProposals(mockScope3, request); + + // Should only have display channel proposal + expect(result.proposals.length).toBe(1); + expect(result.proposals[0].additional_info?.channel).toBe('display'); + }); + + it('should filter products by countries', async () => { + const products: Product[] = [ + { + sales_agent_url: 'https://sales-agent-1.example.com', + product_ref: 'prod-us-001', + pricing_option_id: 'pricing-001', + targeting: { + channels: ['display'], + countries: ['US'], + }, + }, + { + sales_agent_url: 'https://sales-agent-2.example.com', + product_ref: 'prod-uk-001', + pricing_option_id: 'pricing-002', + targeting: { + channels: ['display'], + countries: ['GB'], + }, + }, + ]; + + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products, + countries: ['US'], + budgetRange: { + max: 50000, + }, + }; + + const result = await getProposals(mockScope3, request); + + // Should only have US product + expect(result.proposals.length).toBe(1); + expect(result.proposals[0].additional_info?.productCount).toBe(1); + }); + + it('should filter out products with floor prices > 10% of max budget', async () => { + const products: Product[] = [ + { + sales_agent_url: 'https://sales-agent-1.example.com', + product_ref: 'prod-cheap-001', + pricing_option_id: 'pricing-001', + floor_price: 100, // Within budget + targeting: { + channels: ['display'], + }, + }, + { + sales_agent_url: 'https://sales-agent-2.example.com', + product_ref: 'prod-expensive-001', + pricing_option_id: 'pricing-002', + floor_price: 10000, // 100% of budget - too high + targeting: { + channels: ['display'], + }, + }, + ]; + + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products, + budgetRange: { + max: 10000, + }, + }; + + const result = await getProposals(mockScope3, request); + + // Should only have the cheap product + expect(result.proposals[0].additional_info?.productCount).toBe(1); + }); + + it('should return empty proposals when no products match filters', async () => { + const products: Product[] = [ + { + sales_agent_url: 'https://sales-agent-1.example.com', + product_ref: 'prod-video-001', + pricing_option_id: 'pricing-001', + targeting: { + channels: ['video'], + }, + }, + ]; + + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products, + channels: ['display'], // Requesting display, but only video available + budgetRange: { + max: 50000, + }, + }; + + const result = await getProposals(mockScope3, request); + + expect(result.proposals).toEqual([]); + }); + }); + + describe('budget capacity calculation', () => { + it('should use budget range max as capacity when provided', async () => { + const products: Product[] = [ + { + sales_agent_url: 'https://sales-agent-1.example.com', + product_ref: 'prod-001', + pricing_option_id: 'pricing-001', + floor_price: 2.5, + targeting: { + channels: ['display'], + }, + }, + ]; + + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products, + budgetRange: { + min: 10000, + max: 75000, + currency: 'USD', + }, + }; + + const result = await getProposals(mockScope3, request); + + expect(result.proposals[0].budgetCapacity).toBe(75000); + }); + + it('should estimate capacity from floor prices when no budget range', async () => { + const products: Product[] = [ + { + sales_agent_url: 'https://sales-agent-1.example.com', + product_ref: 'prod-001', + pricing_option_id: 'pricing-001', + floor_price: 100, + targeting: { + channels: ['display'], + }, + }, + ]; + + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products, + }; + + const result = await getProposals(mockScope3, request); + + // Capacity should be 100x floor price = 10000 + expect(result.proposals[0].budgetCapacity).toBe(10000); + }); + }); + + describe('proposal ID generation', () => { + it('should generate unique proposal IDs', async () => { + const products: Product[] = [ + { + sales_agent_url: 'https://sales-agent-1.example.com', + product_ref: 'prod-display-001', + pricing_option_id: 'pricing-001', + targeting: { channels: ['display'] }, + }, + { + sales_agent_url: 'https://sales-agent-2.example.com', + product_ref: 'prod-video-001', + pricing_option_id: 'pricing-002', + targeting: { channels: ['video'] }, + }, + ]; + + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products, + budgetRange: { max: 50000 }, + }; + + const result = await getProposals(mockScope3, request); + + const proposalIds = result.proposals.map((p) => p.proposalId); + const uniqueIds = new Set(proposalIds); + + expect(proposalIds.length).toBe(uniqueIds.size); + }); + + it('should include campaign ID in proposal ID', async () => { + const products: Product[] = [ + { + sales_agent_url: 'https://sales-agent-1.example.com', + product_ref: 'prod-001', + pricing_option_id: 'pricing-001', + targeting: { channels: ['display'] }, + }, + ]; + + const request: GetProposalsRequest = { + campaignId: 'camp-123', + seatId: 'seat-456', + products, + budgetRange: { max: 50000 }, + }; + + const result = await getProposals(mockScope3, request); + + expect(result.proposals[0].proposalId).toContain('camp-123'); + }); + }); +}); diff --git a/src/outcome-agent/accept-proposal.ts b/src/outcome-agent/accept-proposal.ts new file mode 100644 index 0000000..8de111b --- /dev/null +++ b/src/outcome-agent/accept-proposal.ts @@ -0,0 +1,56 @@ +import type { Scope3AgenticClient } from '../sdk'; +import type { AcceptProposalRequest, AcceptProposalResponse } from './types'; + +/** + * Handler for /accept-proposal endpoint + * Called when a proposal is accepted by users and assigned to this agent + * + * Business Logic: + * - Default to accepting all assignments (acknowledged: true) + * - Log the assignment details for tracking + * - Future: Could add validation logic to decline if unable to fulfill + */ +export async function acceptProposal( + scope3: Scope3AgenticClient, + request: AcceptProposalRequest +): Promise { + // Note: Using console.error for logging because stdout is reserved for MCP protocol communication + console.error('[accept-proposal] Received assignment:', { + tacticId: request.tacticId, + proposalId: request.proposalId, + brandAgentId: request.brandAgentId, + seatId: request.seatId, + budget: request.campaignContext.budget, + budgetCurrency: request.campaignContext.budgetCurrency, + startDate: request.campaignContext.startDate, + endDate: request.campaignContext.endDate, + channel: request.campaignContext.channel, + countries: request.campaignContext.countries, + creativesCount: request.campaignContext.creatives?.length || 0, + customFields: request.customFields, + }); + + // Basic validation: check if we have required information + if (!request.tacticId || !request.campaignContext) { + console.error('[accept-proposal] Missing required fields, declining assignment'); + return { + acknowledged: false, + reason: 'Missing required fields: tacticId or campaignContext', + }; + } + + // Check if budget is reasonable (simple validation) + if (request.campaignContext.budget <= 0) { + console.error('[accept-proposal] Invalid budget, declining assignment'); + return { + acknowledged: false, + reason: 'Budget must be greater than 0', + }; + } + + // Default: Accept the assignment + console.error('[accept-proposal] Assignment accepted successfully'); + return { + acknowledged: true, + }; +} diff --git a/src/outcome-agent/get-proposals.ts b/src/outcome-agent/get-proposals.ts new file mode 100644 index 0000000..afe8ea1 --- /dev/null +++ b/src/outcome-agent/get-proposals.ts @@ -0,0 +1,187 @@ +import type { Scope3AgenticClient } from '../sdk'; +import type { GetProposalsRequest, GetProposalsResponse, Proposal, Product } from './types'; + +/** + * Handler for /get-proposals endpoint + * Called when campaigns are created to get proposal recommendations from the outcome agent + * + * Business Logic: + * 1. Receive products from the request (from sales agents) + * 2. Filter products that fit our criteria (budget, channels, countries) + * 3. Generate proposals with budget optimization strategy + * 4. Use revshare pricing model (default 15%) + */ +export async function getProposals( + scope3: Scope3AgenticClient, + request: GetProposalsRequest +): Promise { + // Note: Using console.error for logging because stdout is reserved for MCP protocol communication + console.error('[get-proposals] Received request:', { + campaignId: request.campaignId, + seatId: request.seatId, + budgetRange: request.budgetRange, + channels: request.channels, + countries: request.countries, + productsCount: request.products?.length || 0, + }); + + // TEMPORARY: For now, returning empty proposals if no products provided. + // TODO: In the future, this will be a "simple mirror agent" that can discover + // products from sales agents directly, rather than requiring them in the request. + if (!request.products || request.products.length === 0) { + console.error('[get-proposals] No products provided, returning empty proposals'); + return { proposals: [] }; + } + + // Filter products based on campaign criteria + const eligibleProducts = filterProducts(request.products, request); + + if (eligibleProducts.length === 0) { + console.error('[get-proposals] No eligible products found after filtering'); + return { proposals: [] }; + } + + // Generate proposals using simple budget optimization + const proposals = generateProposals(eligibleProducts, request); + + console.error(`[get-proposals] Generated ${proposals.length} proposals`); + + return { proposals }; +} + +/** + * Filter products based on campaign requirements + */ +function filterProducts(products: Product[], request: GetProposalsRequest): Product[] { + return products.filter((product) => { + // Filter by channels if specified + if (request.channels && request.channels.length > 0) { + const productChannels = product.targeting?.channels || []; + const hasMatchingChannel = request.channels.some((channel) => + productChannels.includes(channel) + ); + if (!hasMatchingChannel && productChannels.length > 0) { + return false; + } + } + + // Filter by countries if specified + if (request.countries && request.countries.length > 0) { + const productCountries = product.targeting?.countries || []; + const hasMatchingCountry = request.countries.some((country) => + productCountries.includes(country) + ); + if (!hasMatchingCountry && productCountries.length > 0) { + return false; + } + } + + // Filter by budget (basic check - floor price should be reasonable) + if (request.budgetRange?.max && product.floor_price) { + // Simple heuristic: floor price shouldn't be more than 10% of max budget + const maxAcceptableFloorPrice = request.budgetRange.max * 0.1; + if (product.floor_price > maxAcceptableFloorPrice) { + return false; + } + } + + return true; + }); +} + +/** + * Generate proposals with simple budget optimization + * Strategy: Allocate budget across products to maximize reach + */ +function generateProposals( + products: Product[], + request: GetProposalsRequest +): Proposal[] { + const proposals: Proposal[] = []; + + // Group products by channel for diversification + const productsByChannel = groupByChannel(products); + + // Generate one proposal per channel group (simple strategy) + for (const [channel, channelProducts] of Object.entries(productsByChannel)) { + // Calculate total budget capacity (sum of all product potential) + const budgetCapacity = calculateBudgetCapacity(channelProducts, request); + + if (budgetCapacity <= 0) continue; + + const proposal: Proposal = { + proposalId: generateProposalId(request.campaignId, channel), + execution: `Optimized ${channel} campaign across ${channelProducts.length} products. Budget allocation strategy: maximize reach while maintaining quality thresholds.`, + budgetCapacity, + pricing: { + method: 'revshare', + rate: 0.15, // 15% revenue share + currency: request.budgetRange?.currency || 'USD', + }, + sku: `outcome-agent-${channel.toLowerCase().replace(/[^a-z0-9]/g, '-')}`, + additional_info: { + channel, + productCount: channelProducts.length, + products: channelProducts.map((p) => ({ + product_ref: p.product_ref, + sales_agent_url: p.sales_agent_url, + pricing_option_id: p.pricing_option_id, + })), + }, + }; + + proposals.push(proposal); + } + + return proposals; +} + +/** + * Group products by channel + */ +function groupByChannel(products: Product[]): Record { + const grouped: Record = {}; + + for (const product of products) { + const channels = product.targeting?.channels || ['unknown']; + for (const channel of channels) { + if (!grouped[channel]) { + grouped[channel] = []; + } + grouped[channel].push(product); + } + } + + return grouped; +} + +/** + * Calculate budget capacity for a set of products + * Simple heuristic: use budget range max, or sum of floor prices * 100 + */ +function calculateBudgetCapacity( + products: Product[], + request: GetProposalsRequest +): number { + // If budget range provided, use max as capacity + if (request.budgetRange?.max) { + return request.budgetRange.max; + } + + // Otherwise, estimate based on floor prices + const totalFloorPrice = products.reduce((sum, p) => { + return sum + (p.floor_price || 0); + }, 0); + + // Simple heuristic: capacity is 100x the floor price sum + return totalFloorPrice * 100; +} + +/** + * Generate unique proposal ID + */ +function generateProposalId(campaignId: string, channel: string): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `prop-${campaignId}-${channel}-${timestamp}-${random}`; +} diff --git a/src/outcome-agent/types.ts b/src/outcome-agent/types.ts new file mode 100644 index 0000000..2c7340b --- /dev/null +++ b/src/outcome-agent/types.ts @@ -0,0 +1,128 @@ +/** + * Types for Outcome Agent API + * Based on Scope3 Outcome Agent Protocol Specification + */ + +// Request/Response types for /get-proposals endpoint + +export interface GetProposalsRequest { + campaignId: string; + seatId: string; + budgetRange?: BudgetRange; + startDate?: string; // ISO 8601 UTC + endDate?: string; // ISO 8601 UTC + channels?: Channel[]; + countries?: string[]; // ISO 3166-1 alpha-2 + brief?: string; + products?: Product[]; + propertyListIds?: number[]; +} + +export interface BudgetRange { + min?: number; + max?: number; + currency?: string; // ISO 4217, default USD +} + +export type Channel = 'display' | 'video' | 'native' | 'audio' | 'connected_tv'; + +export interface Product { + sales_agent_url?: string; + product_ref?: string; + pricing_option_id?: string; + // Additional fields from the sales agent response + floor_price?: number; + floor_price_currency?: string; + name?: string; + description?: string; + targeting?: { + countries?: string[]; + channels?: Channel[]; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface GetProposalsResponse { + proposals: Proposal[]; +} + +export interface Proposal { + proposalId: string; + execution: string; + budgetCapacity: number; + pricing: ProposalPricing; + sku: string; + customFieldsRequired?: CustomFieldDefinition[]; + additional_info?: Record; +} + +export interface ProposalPricing { + method: 'revshare' | 'cost_per_unit'; + rate: number; + unit?: 'cpm' | 'cpc' | 'cpa' | 'cpv' | 'cpcv'; + currency?: string; // ISO 4217, default USD +} + +export interface CustomFieldDefinition { + name: string; + type: 'string' | 'number' | 'boolean' | 'date'; + required?: boolean; + description?: string; + [key: string]: unknown; +} + +// Request/Response types for /accept-proposal endpoint + +export interface AcceptProposalRequest { + tacticId: string; + proposalId?: string; + campaignContext: CampaignContext; + brandAgentId: string; + seatId: string; + customFields?: Record; + additional_info?: Record; +} + +export interface CampaignContext { + budget: number; + budgetCurrency?: string; // ISO 4217, default USD + startDate: string; // ISO 8601 UTC + endDate: string; // ISO 8601 UTC + channel: Channel; + countries?: string[]; // ISO 3166-1 alpha-2 + creatives?: Creative[]; + brandStandards?: BrandStandard[]; + [key: string]: unknown; +} + +export interface Creative { + creativeId?: string; + assetUrl?: string; + format?: string; + dimensions?: { + width: number; + height: number; + }; + [key: string]: unknown; +} + +export interface BrandStandard { + type?: string; + value?: unknown; + [key: string]: unknown; +} + +export interface AcceptProposalResponse { + acknowledged: boolean; + reason?: string; // Required if acknowledged is false +} + +// Configuration for OutcomeAgent + +export interface OutcomeAgentConfig { + scope3ApiKey: string; + scope3BaseUrl?: string; + name?: string; + version?: string; +} diff --git a/src/simple-media-agent-server.ts b/src/simple-media-agent-server.ts deleted file mode 100644 index 3c987e2..0000000 --- a/src/simple-media-agent-server.ts +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -import { SimpleMediaAgent } from './simple-media-agent.js'; - -const scope3ApiKey = process.env.SCOPE3_API_KEY; -const scope3BaseUrl = process.env.SCOPE3_BASE_URL; -const minDailyBudget = process.env.MIN_DAILY_BUDGET - ? parseFloat(process.env.MIN_DAILY_BUDGET) - : 100; -const overallocationPercent = process.env.OVERALLOCATION_PERCENT - ? parseFloat(process.env.OVERALLOCATION_PERCENT) - : 40; - -if (!scope3ApiKey) { - console.error('Error: SCOPE3_API_KEY environment variable is required'); - process.exit(1); -} - -const agent = new SimpleMediaAgent({ - scope3ApiKey, - scope3BaseUrl, - minDailyBudget, - overallocationPercent, - name: 'simple-media-agent', - version: '1.0.0', -}); - -agent.start().catch((error) => { - console.error('Fatal error:', error); - process.exit(1); -}); - -console.error(` -Simple Media Agent -- Scope3 Base URL: ${scope3BaseUrl || 'https://api.agentic.scope3.com'} -- Min Daily Budget: $${minDailyBudget} -- Overallocation: ${overallocationPercent}% (sum of all media buy budgets) -- Protocol: MCP (stdio) -`); diff --git a/src/simple-media-agent.ts b/src/simple-media-agent.ts deleted file mode 100644 index 4dc6811..0000000 --- a/src/simple-media-agent.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { FastMCP } from 'fastmcp'; -import { z } from 'zod'; -import { Scope3AgenticClient } from './sdk'; -import type { SimpleMediaAgentConfig, MediaBuyAllocation } from './simple-media-agent/types'; -import { getProposedTactics } from './simple-media-agent/get-proposed-tactics'; -import { manageTactic } from './simple-media-agent/manage-tactic'; - -/** - * Simple Media Agent that exposes MCP tools - * Called by Scope3 platform via MCP protocol - */ -export class SimpleMediaAgent { - private server: FastMCP; - private scope3: Scope3AgenticClient; - private config: Required< - Omit & { name: string; version: string } - >; - private activeTactics: Map; - - constructor(config: SimpleMediaAgentConfig) { - this.config = { - scope3ApiKey: config.scope3ApiKey, - scope3BaseUrl: config.scope3BaseUrl || 'https://api.agentic.scope3.com', - minDailyBudget: config.minDailyBudget || 100, - overallocationPercent: config.overallocationPercent || 40, - name: config.name || 'simple-media-agent', - version: (config.version as `${number}.${number}.${number}`) || '1.0.0', - }; - - this.server = new FastMCP({ - name: this.config.name, - version: this.config.version as `${number}.${number}.${number}`, - }); - - this.scope3 = new Scope3AgenticClient({ - apiKey: this.config.scope3ApiKey, - baseUrl: this.config.scope3BaseUrl, - }); - - this.activeTactics = new Map(); - this.setupTools(); - } - - private setupTools(): void { - // get_proposed_tactics tool - this.server.addTool({ - name: 'get_proposed_tactics', - description: - 'Get tactic proposals from this media agent. Returns budget allocation based on floor prices from sales agents.', - parameters: z.object({ - campaignId: z.string().describe('Campaign ID'), - budgetRange: z - .object({ - min: z.number(), - max: z.number(), - currency: z.string(), - }) - .optional(), - channels: z.array(z.string()).optional().describe('Media channels'), - countries: z.array(z.string()).optional().describe('ISO country codes'), - seatId: z.string().describe('Seat/account ID'), - }), - execute: async (args) => { - const result = await getProposedTactics(this.scope3, args); - return JSON.stringify(result, null, 2); - }, - }); - - // manage_tactic tool - this.server.addTool({ - name: 'manage_tactic', - description: - 'Assign this media agent to manage a tactic. Creates media buys with overallocated budgets.', - parameters: z.object({ - tacticId: z.string().describe('Tactic ID'), - tacticContext: z.object({}).passthrough().describe('Tactic details including budget'), - brandAgentId: z.string().describe('Brand agent ID'), - seatId: z.string().describe('Seat/account ID'), - }), - execute: async (args) => { - const result = await manageTactic( - this.scope3, - this.config.minDailyBudget, - this.config.overallocationPercent, - this.activeTactics, - args - ); - return JSON.stringify(result, null, 2); - }, - }); - } - - async start(): Promise { - await this.server.start({ - transportType: 'stdio', - }); - } -} diff --git a/src/simple-media-agent/get-proposed-tactics.ts b/src/simple-media-agent/get-proposed-tactics.ts deleted file mode 100644 index 2845081..0000000 --- a/src/simple-media-agent/get-proposed-tactics.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Scope3AgenticClient } from '../sdk'; -import type { ProposedTactic } from './types'; - -export async function getProposedTactics( - scope3: Scope3AgenticClient, - args: { - campaignId: string; - budgetRange?: { min: number; max: number; currency: string }; - seatId: string; - } -): Promise<{ proposedTactics: ProposedTactic[] }> { - const { campaignId, budgetRange } = args; - - // Get all registered agents (SALES type) - const agentsResponse = await scope3.agents.list({ type: 'SALES' }); - const agents = agentsResponse.data || []; - - if (!Array.isArray(agents)) { - throw new Error('Expected agents to be an array'); - } - - // Call getProducts for each agent - const allProducts: Array<{ - id: string; - salesAgentId: string; - floorPrice?: number; - recommendedPrice?: number; - name?: string; - }> = []; - - for (const agent of agents) { - try { - const productsResponse = await scope3.products.discover({ - salesAgentId: agent.id, - }); - - const products = productsResponse.data; - if (!Array.isArray(products)) continue; - - const mappedProducts = products.map((p) => ({ - id: p.id, - salesAgentId: agent.id, - floorPrice: p.floorPrice, - recommendedPrice: p.recommendedPrice, - name: p.name, - })); - - allProducts.push(...mappedProducts); - } catch (error) { - console.error(`Error fetching products from agent ${agent.id}:`, error); - } - } - - // Fail if no products found - if (allProducts.length === 0) { - throw new Error( - `No products available from ${agents.length} agents. ` + - 'Cannot propose tactics without available inventory.' - ); - } - - // Sort by floor price (cheapest first) - allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); - - // Calculate average floor price for proposal - const avgFloorPrice = - allProducts.reduce((sum, p) => sum + (p.floorPrice || 0), 0) / allProducts.length; - - return { - proposedTactics: [ - { - tacticId: `simple-passthrough-${campaignId}`, - execution: `Passthrough strategy: distribute budget across ${allProducts.length} products based on floor prices with 40% overallocation.`, - budgetCapacity: budgetRange?.max || 0, - pricing: { - method: 'passthrough', - estimatedCpm: avgFloorPrice, - currency: 'USD', - }, - sku: 'simple-passthrough', - }, - ], - }; -} diff --git a/src/simple-media-agent/manage-tactic.ts b/src/simple-media-agent/manage-tactic.ts deleted file mode 100644 index b35252e..0000000 --- a/src/simple-media-agent/manage-tactic.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Scope3AgenticClient } from '../sdk'; -import type { MediaBuyAllocation } from './types'; - -function calculateBudgetAllocation( - products: Array<{ - id: string; - salesAgentId: string; - floorPrice?: number; - }>, - totalBudget: number, - minDailyBudget: number, - overallocationPercent: number -): MediaBuyAllocation[] { - if (products.length === 0) return []; - - // Apply overallocation to total budget - // The SUM of all media buy budgets = totalBudget * (1 + overallocationPercent/100) - const overallocationMultiplier = 1 + overallocationPercent / 100; - const allocatedTotalBudget = totalBudget * overallocationMultiplier; - - // Calculate N = number of products where daily budget >= minDailyBudget - const assumedDays = 30; - const maxProducts = Math.floor(allocatedTotalBudget / assumedDays / minDailyBudget); - const n = Math.min(maxProducts, products.length); - - if (n === 0) return []; - - // Take N cheapest products - const selectedProducts = products.slice(0, n); - - // Divide overallocated budget equally - // Each media buy gets: allocatedTotalBudget / N - const budgetPerProduct = allocatedTotalBudget / n; - - return selectedProducts.map((product) => ({ - mediaProductId: product.id, - salesAgentId: product.salesAgentId, - budgetAmount: budgetPerProduct, - budgetCurrency: 'USD', - pricingCpm: product.floorPrice || 0, - })); -} - -export async function manageTactic( - scope3: Scope3AgenticClient, - minDailyBudget: number, - overallocationPercent: number, - tacticAllocations: Map, - args: { - tacticId: string; - tacticContext: { budget?: number }; - brandAgentId: string; - seatId: string; - } -): Promise<{ - acknowledged: boolean; - reason?: string; -}> { - const { tacticId, tacticContext } = args; - - console.log(`Managing tactic ${tacticId}`); - - // Get all registered agents (SALES type) - const agentsResponse = await scope3.agents.list({ type: 'SALES' }); - const agents = agentsResponse.data || []; - - if (!Array.isArray(agents)) { - throw new Error('Expected agents to be an array'); - } - - // Get products from all agents - const allProducts: Array<{ - id: string; - salesAgentId: string; - floorPrice?: number; - recommendedPrice?: number; - name?: string; - }> = []; - - for (const agent of agents) { - try { - const productsResponse = await scope3.products.discover({ - salesAgentId: agent.id, - }); - - const products = productsResponse.data; - if (!Array.isArray(products)) continue; - - const mappedProducts = products.map((p) => ({ - id: p.id, - salesAgentId: agent.id, - floorPrice: p.floorPrice, - recommendedPrice: p.recommendedPrice, - name: p.name, - })); - - allProducts.push(...mappedProducts); - } catch (error) { - console.error(`Error fetching products from agent ${agent.id}:`, error); - } - } - - // Sort by floor price (cheapest first) - allProducts.sort((a, b) => (a.floorPrice || 0) - (b.floorPrice || 0)); - - // Calculate budget allocation with overallocation - const totalBudget = tacticContext.budget || 0; - const allocations = calculateBudgetAllocation( - allProducts, - totalBudget, - minDailyBudget, - overallocationPercent - ); - - // Store tactic info for later reallocation - tacticAllocations.set(tacticId, { - allocations, - }); - - // Create media buys for each allocation - for (const allocation of allocations) { - try { - await scope3.mediaBuys.create({ - tacticId, - name: `Media Buy - ${allocation.mediaProductId}`, - products: [allocation], - budget: { - amount: allocation.budgetAmount || 0, - currency: allocation.budgetCurrency || 'USD', - }, - }); - } catch (error) { - console.error(`Error creating media buy for product ${allocation.mediaProductId}:`, error); - // Return failure if we can't create media buys - return { - acknowledged: false, - reason: `Failed to create media buy for product ${allocation.mediaProductId}`, - }; - } - } - - return { - acknowledged: true, - }; -} diff --git a/src/simple-media-agent/types.ts b/src/simple-media-agent/types.ts deleted file mode 100644 index e6b23ca..0000000 --- a/src/simple-media-agent/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { MediaBuyProduct } from '../resources/media-buys'; - -export interface SimpleMediaAgentConfig { - scope3ApiKey: string; - scope3BaseUrl?: string; - minDailyBudget?: number; - overallocationPercent?: number; - name?: string; - version?: string; -} - -// MediaBuyAllocation uses MediaBuyProduct from the ADCP client library -export type MediaBuyAllocation = MediaBuyProduct; - -export interface ProposedTactic { - tacticId: string; - execution: string; - budgetCapacity: number; - pricing: { - method: string; - estimatedCpm: number; - currency: string; - }; - sku: string; -} From 0299ffc0c81e1bfe510b3de90a444fb7523e8c72 Mon Sep 17 00:00:00 2001 From: Nastassia Fulconis Date: Thu, 6 Nov 2025 14:54:07 -0800 Subject: [PATCH 2/2] style: Fix prettier formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/outcome-agent/__tests__/get-proposals.test.ts | 4 +--- src/outcome-agent/get-proposals.ts | 10 ++-------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/outcome-agent/__tests__/get-proposals.test.ts b/src/outcome-agent/__tests__/get-proposals.test.ts index 0272899..e0f63d9 100644 --- a/src/outcome-agent/__tests__/get-proposals.test.ts +++ b/src/outcome-agent/__tests__/get-proposals.test.ts @@ -138,9 +138,7 @@ describe('getProposals', () => { // Should have proposals for display and video channels expect(result.proposals.length).toBe(2); - const channelsInProposals = result.proposals.map( - (p) => p.additional_info?.channel - ); + const channelsInProposals = result.proposals.map((p) => p.additional_info?.channel); expect(channelsInProposals).toContain('display'); expect(channelsInProposals).toContain('video'); }); diff --git a/src/outcome-agent/get-proposals.ts b/src/outcome-agent/get-proposals.ts index afe8ea1..0ef778c 100644 --- a/src/outcome-agent/get-proposals.ts +++ b/src/outcome-agent/get-proposals.ts @@ -93,10 +93,7 @@ function filterProducts(products: Product[], request: GetProposalsRequest): Prod * Generate proposals with simple budget optimization * Strategy: Allocate budget across products to maximize reach */ -function generateProposals( - products: Product[], - request: GetProposalsRequest -): Proposal[] { +function generateProposals(products: Product[], request: GetProposalsRequest): Proposal[] { const proposals: Proposal[] = []; // Group products by channel for diversification @@ -159,10 +156,7 @@ function groupByChannel(products: Product[]): Record { * Calculate budget capacity for a set of products * Simple heuristic: use budget range max, or sum of floor prices * 100 */ -function calculateBudgetCapacity( - products: Product[], - request: GetProposalsRequest -): number { +function calculateBudgetCapacity(products: Product[], request: GetProposalsRequest): number { // If budget range provided, use max as capacity if (request.budgetRange?.max) { return request.budgetRange.max;