diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.test.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.test.ts
index a6508d28..407a4215 100644
--- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.test.ts
+++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.test.ts
@@ -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,
@@ -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)
+ })
+ })
})
diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.ts
index 282655aa..f4890e79 100644
--- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.ts
+++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.ts
@@ -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).
diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx
new file mode 100644
index 00000000..71a96e6b
--- /dev/null
+++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx
@@ -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,
+ TopologyNodeIconKind,
+ type TopologyNodeIconInput,
+} 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: TopologyNodeIconKind.Supervisor,
+ },
+ {
+ caseName: "logistics supervisor slug -> supervisor",
+ input: { directoryAgentSlug: "logistics-supervisor-agent" },
+ expected: TopologyNodeIconKind.Supervisor,
+ },
+ {
+ caseName: "recruiter slug -> recruiter",
+ input: { directoryAgentSlug: "recruiter" },
+ expected: TopologyNodeIconKind.Recruiter,
+ },
+ {
+ caseName: "coffee farm slug -> farm",
+ input: { directoryAgentSlug: "colombia-coffee-farm" },
+ expected: TopologyNodeIconKind.Farm,
+ },
+ {
+ caseName: "tatooine farm slug -> farm",
+ input: { directoryAgentSlug: "tatooine-farm-agent" },
+ expected: TopologyNodeIconKind.Farm,
+ },
+ {
+ caseName: "weather mcp slug -> weatherMcp",
+ input: { directoryAgentSlug: "weather-mcp-server" },
+ expected: TopologyNodeIconKind.WeatherMcp,
+ },
+ {
+ caseName: "payment mcp slug -> paymentMcp",
+ input: { directoryAgentSlug: "payment-mcp-server" },
+ expected: TopologyNodeIconKind.PaymentMcp,
+ },
+ {
+ caseName: "shipping slug -> shipping",
+ input: { directoryAgentSlug: "shipping-agent" },
+ expected: TopologyNodeIconKind.Shipping,
+ },
+ {
+ caseName: "accountant slug -> accountant",
+ input: { directoryAgentSlug: "accountant-agent" },
+ expected: TopologyNodeIconKind.Accountant,
+ },
+ {
+ caseName: "unknown slug falls back to labels",
+ input: { directoryAgentSlug: "mystery", label1: "Shipper" },
+ expected: TopologyNodeIconKind.Shipping,
+ },
+ {
+ caseName: "weather mcp by labels",
+ input: { label1: "Weather", label2: "MCP Server" },
+ expected: TopologyNodeIconKind.WeatherMcp,
+ },
+ {
+ caseName: "payment mcp by labels",
+ input: { label1: "Payment", label2: "MCP Server" },
+ expected: TopologyNodeIconKind.PaymentMcp,
+ },
+ {
+ caseName: "generic mcp by labels -> default",
+ input: { label1: "Inventory", label2: "MCP Server" },
+ expected: TopologyNodeIconKind.Default,
+ },
+ {
+ caseName: "agentic recruiter by labels",
+ input: {
+ label1: "Agentic Recruiter",
+ label2: "Discovery and delegation",
+ },
+ expected: TopologyNodeIconKind.Recruiter,
+ },
+ {
+ caseName: "agntcy agent directory by labels",
+ input: { label1: "Directory", label2: "AGNTCY Agent Directory" },
+ expected: TopologyNodeIconKind.Directory,
+ },
+ {
+ caseName: "coffee farm by labels",
+ input: { label1: "Brazil", label2: "Coffee Farm Agent" },
+ expected: TopologyNodeIconKind.Farm,
+ },
+ {
+ caseName: "auction agent by labels",
+ input: { label1: "Auction Agent", label2: "Buyer" },
+ expected: TopologyNodeIconKind.Supervisor,
+ },
+ {
+ caseName: "logistics buyer by labels",
+ input: { label1: "Buyer", label2: "Logistics Agent" },
+ expected: TopologyNodeIconKind.Supervisor,
+ },
+ {
+ caseName: "shipper by labels",
+ input: { label1: "Shipper", label2: "Shipper Agent" },
+ expected: TopologyNodeIconKind.Shipping,
+ },
+ {
+ caseName: "accountant by labels",
+ input: { label1: "Accountant", label2: "Accountant Agent" },
+ expected: TopologyNodeIconKind.Accountant,
+ },
+ {
+ caseName: "unknown -> default",
+ input: { label1: "Mystery", label2: "Thing" },
+ expected: TopologyNodeIconKind.Default,
+ },
+ {
+ caseName: "empty input -> default",
+ input: {},
+ expected: TopologyNodeIconKind.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)
+ })
+})
diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx
new file mode 100644
index 00000000..e2ab0eda
--- /dev/null
+++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx
@@ -0,0 +1,152 @@
+/**
+ * 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 enum TopologyNodeIconKind {
+ Supervisor = "supervisor",
+ Recruiter = "recruiter",
+ Directory = "directory",
+ Farm = "farm",
+ WeatherMcp = "weatherMcp",
+ PaymentMcp = "paymentMcp",
+ Shipping = "shipping",
+ Accountant = "accountant",
+ Default = "default",
+}
+
+function normalize(value: string | undefined): string {
+ return typeof value === "string" ? value.trim().toLowerCase() : ""
+}
+
+function iconKindFromSlug(slug: string): TopologyNodeIconKind | null {
+ if (slug.includes(TopologyNodeIconKind.Supervisor)) {
+ return TopologyNodeIconKind.Supervisor
+ }
+ if (slug.includes(TopologyNodeIconKind.Recruiter)) {
+ return TopologyNodeIconKind.Recruiter
+ }
+ if (slug.includes(TopologyNodeIconKind.Farm)) {
+ return TopologyNodeIconKind.Farm
+ }
+ if (slug.includes("weather")) {
+ return TopologyNodeIconKind.WeatherMcp
+ }
+ if (slug.includes("payment")) {
+ return TopologyNodeIconKind.PaymentMcp
+ }
+ if (slug.includes(TopologyNodeIconKind.Shipping)) {
+ return TopologyNodeIconKind.Shipping
+ }
+ if (slug.includes(TopologyNodeIconKind.Accountant)) {
+ return TopologyNodeIconKind.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 TopologyNodeIconKind.WeatherMcp
+ if (label1.includes("payment")) return TopologyNodeIconKind.PaymentMcp
+ return TopologyNodeIconKind.Default
+ }
+
+ if (isRecruiterLabel(combined)) {
+ return TopologyNodeIconKind.Recruiter
+ }
+
+ if (label1 === "directory" || isDirectoryLabel(combined)) {
+ return TopologyNodeIconKind.Directory
+ }
+
+ if (label2.includes("coffee farm")) return TopologyNodeIconKind.Farm
+ if (label1 === "shipper") return TopologyNodeIconKind.Shipping
+ if (label1 === "accountant") return TopologyNodeIconKind.Accountant
+
+ if (
+ label1 === "auction agent" ||
+ (label1 === "auction" && label2.includes("agent")) ||
+ label1 === "buyer" ||
+ label2.includes("buyer") ||
+ label2.includes("logistics agent") ||
+ combined === "logistics group"
+ ) {
+ return TopologyNodeIconKind.Supervisor
+ }
+
+ return TopologyNodeIconKind.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
+}
+
+export function resolveTopologyNodeIcon(
+ input: TopologyNodeIconInput,
+): React.ReactNode {
+ switch (topologyNodeIconKind(input)) {
+ case TopologyNodeIconKind.Supervisor:
+ return brandedImg(supervisorIcon, "Supervisor Icon")
+ case TopologyNodeIconKind.Recruiter:
+ return brandedImg(supervisorIcon, "Recruiter Icon")
+ case TopologyNodeIconKind.Directory:
+ return brandedImg(supervisorIcon, "Directory Icon")
+ case TopologyNodeIconKind.Farm:
+ return brandedImg(farmAgentIcon, "Farm Agent Icon")
+ case TopologyNodeIconKind.WeatherMcp:
+ return
+ case TopologyNodeIconKind.PaymentMcp:
+ return
+ case TopologyNodeIconKind.Shipping:
+ return
+ case TopologyNodeIconKind.Accountant:
+ return
+ default:
+ return
+ }
+}
diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.test.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.test.tsx
index dacac8e9..b29b19f0 100644
--- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.test.tsx
+++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.test.tsx
@@ -4,10 +4,13 @@
**/
import { describe, expect, it } from "vitest"
-import { NODE_TYPES, EDGE_TYPES } from "@/utils/const"
+import { NODE_TYPES, EDGE_TYPES, EDGE_LABELS } from "@/utils/const"
import { topologyWireToReactFlow } from "@/utils/topologyToReactFlow"
import { stableAgentUuidForRecordName } from "@/utils/agenticTopologyIdentityUiMap"
+function wireNode(id: string, type: string, label: string, layerIndex: number) {
+ return { id, type, label, layer_index: layerIndex }
+}
describe("topologyWireToReactFlow", () => {
it.each([
{
@@ -177,30 +180,99 @@ describe("topologyWireToReactFlow", () => {
expect(data?.label2).toBe("Farm Agent")
})
- it("enrich: logistics group node gets transport github link", () => {
+ it("renders a group container with children parented and compact transport", () => {
+ const groupId = "node://aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
const { nodes } = topologyWireToReactFlow(
{
nodes: [
+ wireNode(groupId, "group", "Logistics Group", 0),
+ wireNode(
+ "node://bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+ "customNode",
+ "Buyer Logistics Agent",
+ 1,
+ ),
+ wireNode(
+ "node://cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ "transportNode",
+ "Transport",
+ 2,
+ ),
+ ],
+ edges: [],
+ },
+ { validateUrls: false },
+ )
+ const group = nodes.find((node) => node.id === groupId)
+ const transport = nodes.find((node) => node.type === "transportNode")
+ const child = nodes.find(
+ (node) => node.type === "customNode" && node.parentId === groupId,
+ )
+ expect(group?.type).toBe("group")
+ expect(group?.style).toBeDefined()
+ expect(transport?.parentId).toBe(groupId)
+ expect((transport?.data as { compact?: boolean })?.compact).toBe(true)
+ expect(child).toBeDefined()
+ expect(child?.extent).toBe("parent")
+ })
+
+ it("labels edges into MCP servers as MCP and keeps them branching", () => {
+ const colombiaId = "node://10101010-1010-4101-8101-101010101010"
+ const weatherId = "node://20202020-2020-4202-8202-202020202020"
+ const { edges } = topologyWireToReactFlow(
+ {
+ nodes: [
+ wireNode(colombiaId, "customNode", "Colombia Coffee Farm Agent", 0),
+ wireNode(weatherId, "customNode", "Weather MCP Server", 1),
+ ],
+ edges: [
{
- id: "node://aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
- type: "group",
- label: "Logistics Group",
- layer_index: 0,
+ id: "edge://30303030-3030-4303-8303-303030303030",
+ type: "branching",
+ source: colombiaId,
+ target: weatherId,
},
],
- edges: [],
},
{ validateUrls: false },
)
- const data = nodes[0]?.data as {
- githubLink?: string
- label1?: string
- directoryAgentSlug?: string
- }
- expect(data?.label1).toBe("Logistics")
- expect(data?.githubLink).toBeDefined()
- expect(data?.githubLink).toContain("transport")
- expect(data?.directoryAgentSlug).toBe("logistics-supervisor-agent")
+ expect(edges[0]?.type).toBe(EDGE_TYPES.BRANCHING)
+ expect((edges[0]?.data as { label?: string })?.label).toBe(EDGE_LABELS.MCP)
+ })
+
+ it("adds A2A directory/recruiter handles and stdio edge label", () => {
+ const recruiterId = "node://40404040-4040-4404-8404-404040404040"
+ const directoryId = "node://50505050-5050-4505-8505-505050505050"
+ const { nodes, edges } = topologyWireToReactFlow(
+ {
+ nodes: [
+ wireNode(recruiterId, "customNode", "Agentic Recruiter", 0),
+ wireNode(directoryId, "customNode", "AGNTCY Agent Directory", 1),
+ ],
+ edges: [
+ {
+ id: "edge://60606060-6060-4606-8606-606060606060",
+ type: "custom",
+ source: directoryId,
+ target: recruiterId,
+ },
+ ],
+ },
+ { validateUrls: false },
+ )
+ const recruiter = nodes.find((node) => node.id === recruiterId)
+ const directory = nodes.find((node) => node.id === directoryId)
+ const recruiterHandles = (recruiter?.data as { extraHandles?: unknown[] })
+ ?.extraHandles
+ const directoryHandles = (directory?.data as { extraHandles?: unknown[] })
+ ?.extraHandles
+ expect(recruiterHandles).toHaveLength(1)
+ expect(directoryHandles).toHaveLength(1)
+ expect(edges[0]?.sourceHandle).toBe("source-left")
+ expect(edges[0]?.targetHandle).toBe("target-right")
+ expect((edges[0]?.data as { label?: string })?.label).toBe(
+ EDGE_LABELS.MCP_WITH_STDIO,
+ )
})
it("enrich: AGNTCY Agent Directory gets directory github link", () => {
diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx
index ed00d30b..fe4e7d2d 100644
--- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx
+++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx
@@ -3,9 +3,6 @@
* SPDX-License-Identifier: Apache-2.0
**/
-import React from "react"
-import LocalShipping from "@mui/icons-material/LocalShipping"
-import SmartToy from "@mui/icons-material/SmartToy"
import type { Edge, Node } from "@xyflow/react"
import type {
TopologyEdgeWire,
@@ -21,6 +18,7 @@ import {
} from "@/utils/const"
import type {
CustomNodeData,
+ ExtraHandle,
TransportNodeData,
} from "@/components/MainArea/Graph/Elements/types"
import {
@@ -29,11 +27,15 @@ import {
} from "@/utils/topologyLayout"
import {
enrichAgenticTopologyWellKnownUi,
+ isDirectoryLabel,
+ isMcpServerLabel,
+ isRecruiterLabel,
mergeAgenticTopologyIdentityUi,
resolveGithubFromAgentRecordUri,
splitTopologyNodeLabel,
} from "@/utils/agenticTopologyIdentityUiMap"
import type { IdentityUiGithubVariant } from "@/utils/agenticTopologyIdentityUiMap"
+import { resolveTopologyNodeIcon } from "@/utils/topologyNodeIcons"
// Transport label -> canonical synonym. Seed emits "transport"; runtime emits
// "slim"/"nats"/"jsonrpc" for the same logical transport. Distinct logical
@@ -68,12 +70,19 @@ export function extractStableAgentId(n: TopologyNodeWire): string {
return ""
}
-function defaultCustomIcon(label: string): React.ReactNode {
- const lower = label.toLowerCase()
- if (lower.includes("transport")) {
- return
+// Group container defaults; the compact-group layout recomputes width/height
+// from content, these are only the pre-layout box.
+const GROUP_DEFAULT_WIDTH = 900
+const GROUP_DEFAULT_HEIGHT = 650
+
+function a2aExtraHandlesForLabel(label: string): ExtraHandle[] | undefined {
+ if (isRecruiterLabel(label)) {
+ return [{ id: "target-right", type: "target", position: "right" }]
+ }
+ if (isDirectoryLabel(label)) {
+ return [{ id: "source-left", type: "source", position: "left" }]
}
- return
+ return undefined
}
export interface TopologyToFlowOptions {
@@ -193,34 +202,65 @@ export function topologyWireToReactFlow(
positions.set(rfId, position)
}
+ // A single group node turns the workflow into a contained graph: members
+ // become children (parentId/extent) and the transport renders compact.
+ const groupRfIds = dedupedNodesIn
+ .filter((n) => n.type === NODE_TYPES.GROUP)
+ .map(rfIdOf)
+ const groupRfId = groupRfIds.length === 1 ? groupRfIds[0] : null
+
+ const labelByRfId = new Map()
+ for (const n of dedupedNodesIn) {
+ labelByRfId.set(rfIdOf(n), typeof n.label === "string" ? n.label : "")
+ }
+
const nodes: Node[] = dedupedNodesIn.map((n): Node => {
const rfId = rfIdOf(n)
- const nodeType =
- n.type === NODE_TYPES.TRANSPORT ? NODE_TYPES.TRANSPORT : NODE_TYPES.CUSTOM
const position = positions.get(rfId) ?? { x: 0, y: 0 }
+ const labelStr = labelByRfId.get(rfId) ?? ""
const gh = resolveGithubFromAgentRecordUri(
n.agent_record_uri as string | undefined,
{ validateUrls },
)
- if (nodeType === NODE_TYPES.TRANSPORT) {
+ if (n.type === NODE_TYPES.GROUP) {
+ return {
+ id: rfId,
+ type: NODE_TYPES.GROUP,
+ position,
+ data: { label: labelStr },
+ style: {
+ width: GROUP_DEFAULT_WIDTH,
+ height: GROUP_DEFAULT_HEIGHT,
+ backgroundColor: "var(--group-background)",
+ border: "none",
+ borderRadius: "8px",
+ },
+ }
+ }
+
+ const childProps = groupRfId
+ ? { parentId: groupRfId, extent: "parent" as const }
+ : {}
+
+ if (n.type === NODE_TYPES.TRANSPORT) {
const data: TransportNodeData = {
- label: typeof n.label === "string" ? n.label : "Transport",
+ label: labelStr || "Transport",
githubLink: gh,
- compact: false,
+ compact: groupRfId != null,
}
return {
id: rfId,
type: NODE_TYPES.TRANSPORT,
position,
data: data as unknown as Record,
+ ...childProps,
}
}
- const labelStr = typeof n.label === "string" ? n.label : ""
const { label1, label2 } = splitTopologyNodeLabel(labelStr)
let data: CustomNodeData = {
- icon: defaultCustomIcon(labelStr),
+ icon: resolveTopologyNodeIcon({ label1, label2 }),
label1,
label2,
handles: HANDLE_TYPES.ALL,
@@ -232,12 +272,27 @@ export function topologyWireToReactFlow(
identityUiVariant: options.identityUiVariant,
})
data = enrichAgenticTopologyWellKnownUi(data, n, { validateUrls })
+ if (data.directoryAgentSlug) {
+ data = {
+ ...data,
+ icon: resolveTopologyNodeIcon({
+ label1: data.label1,
+ label2: data.label2,
+ directoryAgentSlug: data.directoryAgentSlug,
+ }),
+ }
+ }
+ const extraHandles = a2aExtraHandlesForLabel(labelStr)
+ if (extraHandles) {
+ data = { ...data, extraHandles }
+ }
return {
id: rfId,
type: NODE_TYPES.CUSTOM,
position,
data: data as unknown as Record,
+ ...childProps,
}
})
@@ -253,18 +308,29 @@ export function topologyWireToReactFlow(
seenEdgePairs.add(pairKey)
const edgeType =
e.type === EDGE_TYPES.BRANCHING ? EDGE_TYPES.BRANCHING : EDGE_TYPES.CUSTOM
+ const sourceLabel = labelByRfId.get(source) ?? ""
+ const targetLabel = labelByRfId.get(target) ?? ""
+
+ let label: string = EDGE_LABELS.A2A
+ let sourceHandle: string | undefined
+ let targetHandle: string | undefined
+ if (isMcpServerLabel(targetLabel)) {
+ label = EDGE_LABELS.MCP
+ } else if (isDirectoryLabel(sourceLabel) && isRecruiterLabel(targetLabel)) {
+ label = EDGE_LABELS.MCP_WITH_STDIO
+ sourceHandle = "source-left"
+ targetHandle = "target-right"
+ }
+
const base: Edge = {
id: e.id,
source,
target,
type: edgeType,
- data: {
- label:
- typeof e.type === "string" && e.type.toLowerCase().includes("mcp")
- ? EDGE_LABELS.MCP
- : EDGE_LABELS.A2A,
- },
+ data: { label },
}
+ if (sourceHandle) base.sourceHandle = sourceHandle
+ if (targetHandle) base.targetHandle = targetHandle
const branches = (e as { branches?: string[] }).branches
if (edgeType === EDGE_TYPES.BRANCHING && Array.isArray(branches)) {
base.data = {