diff --git a/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md b/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md new file mode 100644 index 0000000000..2b7fece1ce --- /dev/null +++ b/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md @@ -0,0 +1,8 @@ +--- +--- + +Fix discovered sales agents being registered as `type: "buying"` instead of `type: "sales"`. + +The capability-discovery code was inferring `'buying'` from `SALES_TOOLS` (`get_products`, `create_media_buy`, `list_authorized_properties`) — semantically backwards: these tools are exposed by **sell-side** agents authorized to sell on behalf of publishers. Three inference sites are corrected (`server/src/capabilities.ts` × 2, `server/src/db/agent-context-db.ts`), and migration `454_fix_misclassified_sales_agents.sql` flips existing `'buying'` → `'sales'` rows in both `discovered_agents.agent_type` (the crawler only re-infers `'unknown'` rows, so the misclassification was sticky) AND `agent_capabilities_snapshot.inferred_type` (read by `registry-api.ts` and by PR #3498's prevention layer — stale rows there would silently override a correctly-supplied `'sales'` back to `'buying'`). + +Closes #3495. diff --git a/server/src/capabilities.ts b/server/src/capabilities.ts index 77b29119c5..1a031eb58c 100644 --- a/server/src/capabilities.ts +++ b/server/src/capabilities.ts @@ -213,16 +213,23 @@ export class CapabilityDiscovery { } /** - * Infer agent type from discovered tools. - * Buying agents have: get_products, create_media_buy, list_authorized_properties - * Creative agents have: list_creative_formats, build_creative, validate_creative - * Signals agents have: get_signals, match_audience, activate_signal + * Infer agent type from the tool list a remote agent advertises. + * + * The discovery vector is "what tools does this agent EXPOSE" — sell-side + * agents publish get_products / create_media_buy / list_authorized_ + * properties for buyers to call; buy-side agents typically do NOT + * advertise those (they call them). So advertising SALES_TOOLS maps to + * type 'sales'. Buy-side agents are not reliably typed from this signal + * and return 'unknown' until a stronger source (e.g. member self- + * registration) sets the type. */ - private inferAgentType(tools: ToolCapability[]): 'buying' | 'creative' | 'signals' | 'unknown' { + private inferAgentType(tools: ToolCapability[]): 'sales' | 'creative' | 'signals' | 'unknown' { const toolNames = new Set(tools.map((t) => t.name.toLowerCase())); - // Priority: buying > creative > signals (buying is the primary commerce type) - if (CapabilityDiscovery.SALES_TOOLS.some(t => toolNames.has(t))) return 'buying'; + // Priority: sales > creative > signals when an agent advertises tools + // from multiple buckets — sell-side wins because the rest of the + // registry UI treats it as the primary integration surface. + if (CapabilityDiscovery.SALES_TOOLS.some(t => toolNames.has(t))) return 'sales'; if (CapabilityDiscovery.CREATIVE_TOOLS.some(t => toolNames.has(t))) return 'creative'; if (CapabilityDiscovery.SIGNALS_TOOLS.some(t => toolNames.has(t))) return 'signals'; @@ -297,8 +304,8 @@ export class CapabilityDiscovery { * Infer agent type from a capability profile. * Use this to avoid duplicating the type inference logic. */ - inferTypeFromProfile(profile: AgentCapabilityProfile): 'buying' | 'creative' | 'signals' | 'unknown' { - if (profile.standard_operations) return 'buying'; + inferTypeFromProfile(profile: AgentCapabilityProfile): 'sales' | 'creative' | 'signals' | 'unknown' { + if (profile.standard_operations) return 'sales'; if (profile.creative_capabilities) return 'creative'; if (profile.signals_capabilities) return 'signals'; return 'unknown'; diff --git a/server/src/db/agent-context-db.ts b/server/src/db/agent-context-db.ts index 2ba2fdbd20..164fcda049 100644 --- a/server/src/db/agent-context-db.ts +++ b/server/src/db/agent-context-db.ts @@ -989,7 +989,7 @@ export class AgentContextDatabase { */ inferAgentType(tools: string[]): AgentType { if (tools.includes('get_products') || tools.includes('create_media_buy')) { - return 'buying'; + return 'sales'; } if (tools.includes('list_creative_formats') && !tools.includes('get_products')) { return 'creative'; diff --git a/server/src/db/migrations/454_fix_misclassified_sales_agents.sql b/server/src/db/migrations/454_fix_misclassified_sales_agents.sql new file mode 100644 index 0000000000..bf1ec61875 --- /dev/null +++ b/server/src/db/migrations/454_fix_misclassified_sales_agents.sql @@ -0,0 +1,33 @@ +-- Fix sales agents misclassified as 'buying' in the federated discovery index. +-- +-- Migration 387 renamed sales -> buying everywhere. Migration 392 restored +-- 'sales' as a valid agent_type but did not back-fill discovered_agents, and +-- the inference code in server/src/capabilities.ts continued to return +-- 'buying' for SALES_TOOLS (get_products / create_media_buy / +-- list_authorized_properties). Every row with the value 'buying' in either +-- table below was inferred from those sales tools and is therefore a sell- +-- side agent, not a buy-side agent. +-- +-- Two tables need fixing: +-- +-- 1. discovered_agents.agent_type +-- The crawler's refreshAgentSnapshots only re-infers when the existing +-- type is 'unknown', so without this back-fill the misclassification +-- is sticky even after the inference fix in capabilities.ts. +-- +-- 2. agent_capabilities_snapshot.inferred_type +-- Read by registry-api.ts:3465-3467 (to fill in agent type when the +-- registered type is 'unknown') and by the prevention layer in PR +-- #3498's resolveAgentTypes() (as the authoritative type source for +-- every member-profile write). Stale 'buying' values here would cause +-- the prevention layer to OVERRIDE a correctly client-supplied 'sales' +-- back to 'buying' for any agent probed before the inference fix +-- shipped. Same root cause, same fix. + +UPDATE discovered_agents + SET agent_type = 'sales' + WHERE agent_type = 'buying'; + +UPDATE agent_capabilities_snapshot + SET inferred_type = 'sales' + WHERE inferred_type = 'buying'; diff --git a/tests/capabilities.test.ts b/tests/capabilities.test.ts index 8c9c081c74..e25a5445a6 100644 --- a/tests/capabilities.test.ts +++ b/tests/capabilities.test.ts @@ -11,17 +11,17 @@ describe("CapabilityDiscovery", () => { tools.map((t) => ({ name: t.name, description: "", input_schema: {}, verified_at: "" })) ); - it("should infer buying type from buying-specific tools", () => { - expect(inferAgentType([{ name: "get_products" }])).toBe("buying"); - expect(inferAgentType([{ name: "create_media_buy" }])).toBe("buying"); - expect(inferAgentType([{ name: "list_authorized_properties" }])).toBe("buying"); + it("should infer sales type from sales-specific tools", () => { + expect(inferAgentType([{ name: "get_products" }])).toBe("sales"); + expect(inferAgentType([{ name: "create_media_buy" }])).toBe("sales"); + expect(inferAgentType([{ name: "list_authorized_properties" }])).toBe("sales"); expect( inferAgentType([ { name: "get_products" }, { name: "create_media_buy" }, { name: "list_authorized_properties" }, ]) - ).toBe("buying"); + ).toBe("sales"); }); it("should infer creative type from creative-specific tools", () => { @@ -45,27 +45,27 @@ describe("CapabilityDiscovery", () => { expect(inferAgentType([{ name: "ping" }, { name: "health_check" }])).toBe("unknown"); }); - it("should prioritize buying for multi-type agents", () => { - // Both buying and creative tools → buying (primary commerce type) + it("should prioritize sales for multi-type agents", () => { + // Both sales and creative tools → sales (primary commerce type) expect( inferAgentType([{ name: "get_products" }, { name: "list_creative_formats" }]) - ).toBe("buying"); - // All three types → buying + ).toBe("sales"); + // All three types → sales expect( inferAgentType([ { name: "get_products" }, { name: "build_creative" }, { name: "get_signals" }, ]) - ).toBe("buying"); - // Creative + signals (no buying) → creative + ).toBe("sales"); + // Creative + signals (no sales) → creative expect( inferAgentType([{ name: "build_creative" }, { name: "get_signals" }]) ).toBe("creative"); }); it("should handle tool names case-insensitively", () => { - expect(inferAgentType([{ name: "GET_PRODUCTS" }])).toBe("buying"); + expect(inferAgentType([{ name: "GET_PRODUCTS" }])).toBe("sales"); expect(inferAgentType([{ name: "List_Creative_Formats" }])).toBe("creative"); expect(inferAgentType([{ name: "MATCH_AUDIENCE" }])).toBe("signals"); }); @@ -79,7 +79,7 @@ describe("CapabilityDiscovery", () => { last_discovered: new Date().toISOString(), }; - it("should return buying when standard_operations is present", () => { + it("should return sales when standard_operations is present", () => { const profile: AgentCapabilityProfile = { ...baseProfile, standard_operations: { @@ -91,7 +91,7 @@ describe("CapabilityDiscovery", () => { can_list_properties: true, }, }; - expect(discovery.inferTypeFromProfile(profile)).toBe("buying"); + expect(discovery.inferTypeFromProfile(profile)).toBe("sales"); }); it("should return creative when creative_capabilities is present", () => { @@ -124,9 +124,9 @@ describe("CapabilityDiscovery", () => { expect(discovery.inferTypeFromProfile(baseProfile)).toBe("unknown"); }); - it("should prioritize buying over creative over signals when multiple present", () => { - // buying + creative -> buying - const buyingAndCreative: AgentCapabilityProfile = { + it("should prioritize sales over creative over signals when multiple present", () => { + // sales + creative -> sales + const salesAndCreative: AgentCapabilityProfile = { ...baseProfile, standard_operations: { can_search_inventory: true, @@ -143,7 +143,7 @@ describe("CapabilityDiscovery", () => { can_preview: false, }, }; - expect(discovery.inferTypeFromProfile(buyingAndCreative)).toBe("buying"); + expect(discovery.inferTypeFromProfile(salesAndCreative)).toBe("sales"); // creative + signals -> creative const creativeAndSignals: AgentCapabilityProfile = {