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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 16 additions & 9 deletions server/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion server/src/db/agent-context-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
33 changes: 33 additions & 0 deletions server/src/db/migrations/454_fix_misclassified_sales_agents.sql
Original file line number Diff line number Diff line change
@@ -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';
36 changes: 18 additions & 18 deletions tests/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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");
});
Expand All @@ -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: {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down
Loading