From 632361d41bbe80eb069fdb291d9e51bcc5a76ee2 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Tue, 28 Apr 2026 22:27:19 -0400 Subject: [PATCH 1/4] fix(registry): infer discovered agents with sales tools as type 'sales' (#3495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability discovery code was inferring `type: '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. Migration 387 had renamed sales→buying everywhere, and 392 restored the 'sales' enum value in the DB constraint but did not revert the inference logic or back-fill misclassified rows. - capabilities.ts: inferAgentType + inferTypeFromProfile now return 'sales' - agent-context-db.ts: inferAgentType now returns 'sales' - migration 453: back-fill discovered_agents 'buying' -> 'sales' (the crawler only re-infers 'unknown' rows, so the misclassification was sticky) - tests/capabilities.test.ts: update expectations --- ...ed-sales-agents-misclassified-as-buying.md | 8 +++++ server/src/capabilities.ts | 12 +++---- server/src/db/agent-context-db.ts | 2 +- .../453_fix_misclassified_sales_agents.sql | 15 ++++++++ tests/capabilities.test.ts | 36 +++++++++---------- 5 files changed, 48 insertions(+), 25 deletions(-) create mode 100644 .changeset/fix-discovered-sales-agents-misclassified-as-buying.md create mode 100644 server/src/db/migrations/453_fix_misclassified_sales_agents.sql 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..45c964f65c --- /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 `453_fix_misclassified_sales_agents.sql` flips existing `discovered_agents` rows from `'buying'` → `'sales'` (the crawler only re-infers `'unknown'` rows, so the misclassification was sticky without a backfill). + +Closes #3495. diff --git a/server/src/capabilities.ts b/server/src/capabilities.ts index c681bca1b3..876f36567d 100644 --- a/server/src/capabilities.ts +++ b/server/src/capabilities.ts @@ -214,15 +214,15 @@ export class CapabilityDiscovery { /** * Infer agent type from discovered tools. - * Buying agents have: get_products, create_media_buy, list_authorized_properties + * Sales 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 */ - 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 (sales is the primary commerce type) + 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 +297,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 a607b3197e..a66c147d97 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/453_fix_misclassified_sales_agents.sql b/server/src/db/migrations/453_fix_misclassified_sales_agents.sql new file mode 100644 index 0000000000..fbfa19c792 --- /dev/null +++ b/server/src/db/migrations/453_fix_misclassified_sales_agents.sql @@ -0,0 +1,15 @@ +-- 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 in discovered_agents with +-- agent_type = 'buying' was inferred from those sales tools and is therefore +-- a sell-side agent, not a buy-side agent. +-- +-- 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. + +UPDATE discovered_agents SET agent_type = 'sales' WHERE agent_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 = { From b5d768616fb57a9a920f45278f55543c0084e441 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Tue, 28 Apr 2026 23:43:52 -0400 Subject: [PATCH 2/4] docs(capabilities): clarify inferAgentType discovery semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red-team reviewer flagged the prior docstring as sloppy: it said 'Sales agents have: get_products' which is true for the discovery vector this function uses (probing what an agent EXPOSES) but conflates 'agent has the capability' with 'agent advertises the tool'. Buy-side agents call these tools but do not advertise them, so this inference can only recognise sell-side agents from a probe — buy-side agents return 'unknown' and need a stronger signal (member self-registration) to be typed correctly. --- server/src/capabilities.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/server/src/capabilities.ts b/server/src/capabilities.ts index 876f36567d..be04f5509d 100644 --- a/server/src/capabilities.ts +++ b/server/src/capabilities.ts @@ -213,15 +213,22 @@ export class CapabilityDiscovery { } /** - * Infer agent type from discovered tools. - * Sales 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[]): 'sales' | 'creative' | 'signals' | 'unknown' { const toolNames = new Set(tools.map((t) => t.name.toLowerCase())); - // Priority: sales > creative > signals (sales is the primary commerce type) + // 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'; From e8c50c90cd523135664907d9472c1b230b594363 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Wed, 29 Apr 2026 11:43:17 -0400 Subject: [PATCH 3/4] fix(migration): also backfill agent_capabilities_snapshot.inferred_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per follow-up review: agent_capabilities_snapshot.inferred_type was populated by the same buggy inferTypeFromProfile call (crawler.ts:577), and is read in two places that would otherwise propagate the stale 'buying' value: 1. registry-api.ts:3465-3467 — uses inferred_type to fill in agent type when the registered type is 'unknown'. Sales agents would surface as 'buying' in the public registry until they were re-probed. 2. PR #3498's resolveAgentTypes() — uses inferred_type as the authoritative type source on every member-profile write. Stale rows there would silently override a correctly client-supplied 'sales' back to 'buying', defeating the prevention layer for any agent probed before this fix shipped. Cheapest place to land it is here in the existing discovery-side migration. Same root cause, same fix pattern, same migration. --- ...ed-sales-agents-misclassified-as-buying.md | 2 +- .../453_fix_misclassified_sales_agents.sql | 32 +++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md b/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md index 45c964f65c..1c3adf7c08 100644 --- a/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md +++ b/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md @@ -3,6 +3,6 @@ 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 `453_fix_misclassified_sales_agents.sql` flips existing `discovered_agents` rows from `'buying'` → `'sales'` (the crawler only re-infers `'unknown'` rows, so the misclassification was sticky without a backfill). +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 `453_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/db/migrations/453_fix_misclassified_sales_agents.sql b/server/src/db/migrations/453_fix_misclassified_sales_agents.sql index fbfa19c792..bf1ec61875 100644 --- a/server/src/db/migrations/453_fix_misclassified_sales_agents.sql +++ b/server/src/db/migrations/453_fix_misclassified_sales_agents.sql @@ -4,12 +4,30 @@ -- '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 in discovered_agents with --- agent_type = 'buying' was inferred from those sales tools and is therefore --- a sell-side agent, not a buy-side agent. +-- 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. -- --- 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. +-- 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 discovered_agents SET agent_type = 'sales' WHERE agent_type = 'buying'; +UPDATE agent_capabilities_snapshot + SET inferred_type = 'sales' + WHERE inferred_type = 'buying'; From a78da5abf650c3e88875cefe0d4011d93d16a6f3 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Wed, 29 Apr 2026 12:25:24 -0400 Subject: [PATCH 4/4] fix(migration): renumber 453 -> 454 to avoid collision with main Main merged migration 453 (PR #2153, AAO Verified agent badge system) while this stack was open, causing CI to fail with: Duplicate migration version 453: 453_agent_verification_badges.sql 453_fix_misclassified_sales_agents.sql Renumbered our migration to 454. PR #3497 and #3498 will need similar bumps to 455 and 456 respectively to keep the stack working. --- .../fix-discovered-sales-agents-misclassified-as-buying.md | 2 +- ..._sales_agents.sql => 454_fix_misclassified_sales_agents.sql} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename server/src/db/migrations/{453_fix_misclassified_sales_agents.sql => 454_fix_misclassified_sales_agents.sql} (100%) diff --git a/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md b/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md index 1c3adf7c08..2b7fece1ce 100644 --- a/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md +++ b/.changeset/fix-discovered-sales-agents-misclassified-as-buying.md @@ -3,6 +3,6 @@ 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 `453_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'`). +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/db/migrations/453_fix_misclassified_sales_agents.sql b/server/src/db/migrations/454_fix_misclassified_sales_agents.sql similarity index 100% rename from server/src/db/migrations/453_fix_misclassified_sales_agents.sql rename to server/src/db/migrations/454_fix_misclassified_sales_agents.sql