Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Expand Up @@ -10,6 +10,9 @@ import type { CustomNodeData } from "@/components/MainArea/Graph/Elements/types"
import {
getOasfSlugFromNodeData,
IDENTITY_UI_BY_STABLE_AGENT_UUID,
isDirectoryLabel,
isMcpServerLabel,
isRecruiterLabel,
normalizeAgentRecordUriToRepoPath,
parseStableAgentUuid,
resolveGithubFromAgentRecordUri,
Expand Down Expand Up @@ -162,4 +165,54 @@ describe("agenticTopologyIdentityUiMap", () => {
).toBe("recruiter")
})
})

describe("label predicates", () => {
it.each([
{ caseName: "weather mcp server", label: "Weather MCP Server", v: true },
{
caseName: "lowercased mcp server",
label: "payment mcp server",
v: true,
},
{
caseName: "trailing whitespace",
label: "Weather MCP Server ",
v: true,
},
{ caseName: "not at end", label: "MCP Server Gateway", v: false },
{ caseName: "plain agent", label: "Brazil Coffee Farm Agent", v: false },
])("isMcpServerLabel: $caseName", ({ label, v }) => {
expect(isMcpServerLabel(label)).toBe(v)
})

it.each([
{ caseName: "agentic recruiter", label: "Agentic Recruiter", v: true },
{
caseName: "split labels combined",
label: "agentic recruiter delegation",
v: true,
},
{ caseName: "recruiter alone", label: "Recruiter", v: false },
{ caseName: "directory", label: "AGNTCY Agent Directory", v: false },
])("isRecruiterLabel: $caseName", ({ label, v }) => {
expect(isRecruiterLabel(label)).toBe(v)
})

it.each([
{
caseName: "agntcy agent directory",
label: "AGNTCY Agent Directory",
v: true,
},
{
caseName: "combined labels",
label: "directory agntcy agent directory",
v: true,
},
{ caseName: "missing agntcy", label: "Agent Directory", v: false },
{ caseName: "recruiter", label: "Agentic Recruiter", v: false },
])("isDirectoryLabel: $caseName", ({ label, v }) => {
expect(isDirectoryLabel(label)).toBe(v)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,23 @@ export function splitTopologyNodeLabel(label: string): {
return { label1: t.slice(0, sp), label2: t.slice(sp + 1).trim() }
}

/** True when a node label denotes an MCP server (e.g. "Weather MCP Server"). */
export function isMcpServerLabel(label: string): boolean {
return /mcp\s+server$/i.test(label.trim())
}

/** True when a node label denotes the agentic recruiter. */
export function isRecruiterLabel(label: string): boolean {
const lower = label.toLowerCase()
return /\bagentic\b/.test(lower) && /\brecruiter\b/.test(lower)
}

/** True when a node label denotes the AGNTCY agent directory. */
export function isDirectoryLabel(label: string): boolean {
const lower = label.toLowerCase()
return lower.includes("agntcy") && lower.includes("agent directory")
}

/**
* Normalize catalog `agent_record_uri` relative paths to a repo path segment
* under `coffeeAGNTCY/coffee_agents` (for GitHub blob URLs).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Copyright AGNTCY Contributors (https://github.com/agntcy)
* SPDX-License-Identifier: Apache-2.0
**/

import { isValidElement } from "react"
import { describe, expect, it } from "vitest"
import {
resolveTopologyNodeIcon,
topologyNodeIconKind,
type TopologyNodeIconInput,
type TopologyNodeIconKind,
} from "@/utils/topologyNodeIcons"

describe("topologyNodeIconKind", () => {
it.each<{
caseName: string
input: TopologyNodeIconInput
expected: TopologyNodeIconKind
}>([
{
caseName: "slug wins over labels (auction supervisor)",
input: {
directoryAgentSlug: "auction-supervisor-agent",
label1: "Weather",
label2: "MCP Server",
},
expected: "supervisor",
},
{
caseName: "logistics supervisor slug -> supervisor",
input: { directoryAgentSlug: "logistics-supervisor-agent" },
expected: "supervisor",
},
{
caseName: "recruiter slug -> recruiter",
input: { directoryAgentSlug: "recruiter" },
expected: "recruiter",
},
{
caseName: "coffee farm slug -> farm",
input: { directoryAgentSlug: "colombia-coffee-farm" },
expected: "farm",
},
{
caseName: "tatooine farm slug -> farm",
input: { directoryAgentSlug: "tatooine-farm-agent" },
expected: "farm",
},
{
caseName: "weather mcp slug -> weatherMcp",
input: { directoryAgentSlug: "weather-mcp-server" },
expected: "weatherMcp",
},
{
caseName: "payment mcp slug -> paymentMcp",
input: { directoryAgentSlug: "payment-mcp-server" },
expected: "paymentMcp",
},
{
caseName: "shipping slug -> shipping",
input: { directoryAgentSlug: "shipping-agent" },
expected: "shipping",
},
{
caseName: "accountant slug -> accountant",
input: { directoryAgentSlug: "accountant-agent" },
expected: "accountant",
},
{
caseName: "unknown slug falls back to labels",
input: { directoryAgentSlug: "mystery", label1: "Shipper" },
expected: "shipping",
},
{
caseName: "weather mcp by labels",
input: { label1: "Weather", label2: "MCP Server" },
expected: "weatherMcp",
},
{
caseName: "payment mcp by labels",
input: { label1: "Payment", label2: "MCP Server" },
expected: "paymentMcp",
},
{
caseName: "generic mcp by labels -> default",
input: { label1: "Inventory", label2: "MCP Server" },
expected: "default",
},
{
caseName: "agentic recruiter by labels",
input: {
label1: "Agentic Recruiter",
label2: "Discovery and delegation",
},
expected: "recruiter",
},
{
caseName: "agntcy agent directory by labels",
input: { label1: "Directory", label2: "AGNTCY Agent Directory" },
expected: "directory",
},
{
caseName: "coffee farm by labels",
input: { label1: "Brazil", label2: "Coffee Farm Agent" },
expected: "farm",
},
{
caseName: "auction agent by labels",
input: { label1: "Auction Agent", label2: "Buyer" },
expected: "supervisor",
},
{
caseName: "logistics buyer by labels",
input: { label1: "Buyer", label2: "Logistics Agent" },
expected: "supervisor",
},
{
caseName: "shipper by labels",
input: { label1: "Shipper", label2: "Shipper Agent" },
expected: "shipping",
},
{
caseName: "accountant by labels",
input: { label1: "Accountant", label2: "Accountant Agent" },
expected: "accountant",
},
{
caseName: "unknown -> default",
input: { label1: "Mystery", label2: "Thing" },
expected: "default",
},
{
caseName: "empty input -> default",
input: {},
expected: "default",
},
])("$caseName", ({ input, expected }) => {
expect(topologyNodeIconKind(input)).toBe(expected)
})
})

describe("resolveTopologyNodeIcon", () => {
it("returns a renderable element for every input", () => {
expect(isValidElement(resolveTopologyNodeIcon({ label1: "Shipper" }))).toBe(
true,
)
expect(isValidElement(resolveTopologyNodeIcon({}))).toBe(true)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Copyright AGNTCY Contributors (https://github.com/agntcy)
* SPDX-License-Identifier: Apache-2.0
*
* -----------------------------------------------------------------------------
* Presentation-only icon resolver for dynamically rendered topology nodes.
*
* Backend topology carries no icon field, so the dynamic renderer derives a
* branded icon from a node's directory slug (preferred) or its split label,
* using loose substring matching rather than an exhaustive agent map. This is
* presentation, not data: if the backend later supplies an icon/type hint, key
* off that.
* -----------------------------------------------------------------------------
*/

import React from "react"
import Air from "@mui/icons-material/Air"
import Calculate from "@mui/icons-material/Calculate"
import LocalShipping from "@mui/icons-material/LocalShipping"
import SmartToy from "@mui/icons-material/SmartToy"
import { GraphDiscoveryAssetImg } from "@/utils/GraphDiscoveryAssetImg"
import {
isDirectoryLabel,
isRecruiterLabel,
} from "@/utils/agenticTopologyIdentityUiMap"
import supervisorIcon from "@/assets/supervisor.png"
import farmAgentIcon from "@/assets/Grader-Agent.png"

export interface TopologyNodeIconInput {
label1?: string
label2?: string
directoryAgentSlug?: string
}

export type TopologyNodeIconKind =
Comment thread
delthazor marked this conversation as resolved.
Outdated
| "supervisor"
| "recruiter"
| "directory"
| "farm"
| "weatherMcp"
| "paymentMcp"
| "shipping"
| "accountant"
| "default"

function normalize(value: string | undefined): string {
return typeof value === "string" ? value.trim().toLowerCase() : ""
}

function iconKindFromSlug(slug: string): TopologyNodeIconKind | null {
if (slug.includes("supervisor")) return "supervisor"
if (slug.includes("recruiter")) return "recruiter"
if (slug.includes("farm")) return "farm"
if (slug.includes("weather")) return "weatherMcp"
if (slug.includes("payment")) return "paymentMcp"
if (slug.includes("shipping")) return "shipping"
if (slug.includes("accountant")) return "accountant"
return null
}

function iconKindFromLabels(
label1: string,
label2: string,
): TopologyNodeIconKind {
const combined = `${label1} ${label2}`.trim()

if (label2 === "mcp server" || label1.endsWith("mcp server")) {
if (label1.includes("weather")) return "weatherMcp"
Comment thread
delthazor marked this conversation as resolved.
Outdated
if (label1.includes("payment")) return "paymentMcp"
return "default"
}

if (isRecruiterLabel(combined)) {
return "recruiter"
}

if (label1 === "directory" || isDirectoryLabel(combined)) {
return "directory"
}

if (label2.includes("coffee farm")) return "farm"
if (label1 === "shipper") return "shipping"
if (label1 === "accountant") return "accountant"

if (
label1 === "auction agent" ||
(label1 === "auction" && label2.includes("agent")) ||
label1 === "buyer" ||
label2.includes("buyer") ||
label2.includes("logistics agent") ||
combined === "logistics group"
) {
return "supervisor"
}

return "default"
}

export function topologyNodeIconKind(
input: TopologyNodeIconInput,
): TopologyNodeIconKind {
const slug = normalize(input.directoryAgentSlug)
if (slug) {
const bySlug = iconKindFromSlug(slug)
if (bySlug) return bySlug
}
return iconKindFromLabels(normalize(input.label1), normalize(input.label2))
}

function brandedImg(src: string, alt: string): React.ReactNode {
return <GraphDiscoveryAssetImg src={src} alt={alt} invertInDarkMode />
}

export function resolveTopologyNodeIcon(
input: TopologyNodeIconInput,
): React.ReactNode {
switch (topologyNodeIconKind(input)) {
case "supervisor":
return brandedImg(supervisorIcon, "Supervisor Icon")
case "recruiter":
return brandedImg(supervisorIcon, "Recruiter Icon")
case "directory":
return brandedImg(supervisorIcon, "Directory Icon")
case "farm":
return brandedImg(farmAgentIcon, "Farm Agent Icon")
case "weatherMcp":
return <Air aria-hidden />
case "paymentMcp":
return <Calculate aria-hidden />
case "shipping":
return <LocalShipping aria-hidden />
case "accountant":
return <Calculate aria-hidden />
default:
return <SmartToy aria-hidden />
}
}
Loading
Loading