Skip to content

Commit 09b5c1d

Browse files
authored
feat: display subagents on root allocator card (#447)
1 parent f60eb0e commit 09b5c1d

File tree

8 files changed

+275
-4
lines changed

8 files changed

+275
-4
lines changed

apps/torus-portal/src/app/(pages)/portal/_components/force-graph-2d/force-graph-2d.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ interface ForceGraph2DProps {
4141
onSelectionChange?: (nodeId: string | null) => void;
4242
selectedSwarmId?: string | null;
4343
swarmCenterNodeId?: string | null;
44+
/** When true, the graph fits within its parent container instead of being full-screen */
45+
contained?: boolean;
4446
}
4547

4648
export function ForceGraphCanvas2D(props: ForceGraph2DProps) {
@@ -166,7 +168,7 @@ export function ForceGraphCanvas2D(props: ForceGraph2DProps) {
166168
height,
167169
resolution: 1,
168170
antialias: true,
169-
resizeTo: window,
171+
resizeTo: props.contained ? container : window,
170172
backgroundColor: 0x111111,
171173
});
172174

@@ -487,7 +489,11 @@ export function ForceGraphCanvas2D(props: ForceGraph2DProps) {
487489
return (
488490
<div
489491
ref={containerRef}
490-
className="bg-background animate-fade animate-delay-1000 fixed inset-0 z-0"
492+
className={
493+
props.contained
494+
? "bg-background h-full w-full"
495+
: "bg-background animate-fade animate-delay-1000 fixed inset-0 z-0"
496+
}
491497
/>
492498
);
493499
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"use client";
2+
3+
import { Loading } from "@torus-ts/ui/components/loading";
4+
import { ForceGraphCanvas2D } from "~/app/(pages)/portal/_components/force-graph-2d/force-graph-2d";
5+
import { getConnectedNodesSwarm } from "~/app/(pages)/portal/_components/force-graph-2d/force-graph-2d-utils";
6+
import { useGraphData } from "~/app/(pages)/portal/_components/force-graph/use-graph-data";
7+
import { useMemo } from "react";
8+
9+
interface AgentConnectionsGraphProps {
10+
agentKey: string;
11+
}
12+
13+
export function AgentConnectionsGraph({
14+
agentKey,
15+
}: AgentConnectionsGraphProps) {
16+
const { graphData, isLoading, allocatorAddress } = useGraphData();
17+
18+
// Filter graph data to show only nodes connected to this agent
19+
const filteredGraphData = useMemo(() => {
20+
if (!graphData) return null;
21+
22+
// Find the agent node
23+
const agentNode = graphData.nodes.find((n) => n.id === agentKey);
24+
if (!agentNode) return null;
25+
26+
// Get all connected nodes using the swarm traversal logic
27+
const connectedNodeIds = getConnectedNodesSwarm(
28+
agentKey,
29+
graphData.nodes,
30+
graphData.links,
31+
allocatorAddress,
32+
);
33+
34+
// Filter nodes (exclude allocator from the filtered view)
35+
const filteredNodes = graphData.nodes.filter(
36+
(node) => connectedNodeIds.has(node.id) && node.id !== allocatorAddress,
37+
);
38+
39+
// Filter links to only include those between connected nodes (excluding allocator)
40+
const filteredLinks = graphData.links.filter((link) => {
41+
const sourceId =
42+
typeof link.source === "string"
43+
? link.source
44+
: (link.source as { id: string }).id;
45+
const targetId =
46+
typeof link.target === "string"
47+
? link.target
48+
: (link.target as { id: string }).id;
49+
return (
50+
connectedNodeIds.has(sourceId) &&
51+
connectedNodeIds.has(targetId) &&
52+
sourceId !== allocatorAddress &&
53+
targetId !== allocatorAddress
54+
);
55+
});
56+
57+
// Only show graph if there are connections (more than just the agent itself)
58+
if (filteredNodes.length <= 1) return null;
59+
60+
return {
61+
nodes: filteredNodes,
62+
links: filteredLinks,
63+
};
64+
}, [graphData, agentKey, allocatorAddress]);
65+
66+
if (isLoading) {
67+
return (
68+
<div className="flex h-[400px] items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900/50">
69+
<div className="flex items-center gap-2 text-sm text-zinc-400">
70+
<Loading /> Loading network connections...
71+
</div>
72+
</div>
73+
);
74+
}
75+
76+
if (!filteredGraphData) {
77+
return (
78+
<div className="flex h-[200px] items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900/50">
79+
<p className="text-sm text-zinc-400">No network connections found</p>
80+
</div>
81+
);
82+
}
83+
84+
return (
85+
<div className="relative h-[400px] overflow-hidden rounded-lg border border-zinc-800">
86+
<ForceGraphCanvas2D
87+
graphData={filteredGraphData}
88+
onNodeClick={() => {
89+
/* empty */
90+
}}
91+
allocatorAddress={allocatorAddress}
92+
swarmCenterNodeId={agentKey}
93+
contained
94+
/>
95+
</div>
96+
);
97+
}

apps/torus-portal/src/app/(pages)/root-allocator/(expanded-pages)/agent/[slug]/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import Link from "next/link";
1616
import { notFound } from "next/navigation";
1717
import { Suspense } from "react";
1818
import { PenaltyList } from "../../../_components/penalties-list";
19+
import { AgentConnectionsGraph } from "./components/agent-connections-graph";
1920
import { AgentInfoCard } from "./components/agent-info-card";
2021
import { ExpandedViewSocials } from "./components/expanded-view-socials";
2122

@@ -160,6 +161,11 @@ export default async function AgentPage({ params }: Readonly<AgentPageProps>) {
160161
</Card>
161162

162163
<MarkdownView source={metadata.description} />
164+
165+
<div className="mt-6">
166+
<h3 className="mb-4 text-lg font-semibold">Sub-Agents Graph</h3>
167+
<AgentConnectionsGraph agentKey={agentKey} />
168+
</div>
163169
</div>
164170

165171
<div className="flex flex-col gap-6 md:w-1/3">

apps/torus-portal/src/app/(pages)/root-allocator/_components/agent-card.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ interface AgentCardProps {
1818
percComputedWeight: number | null;
1919
weightFactor: number | null;
2020
isWhitelisted: boolean;
21+
/** For root agents: count of all connected agents in their swarm */
22+
subagentCount?: number;
2123
}
2224

2325
export function AgentCard(props: Readonly<AgentCardProps>) {
@@ -118,6 +120,7 @@ export function AgentCard(props: Readonly<AgentCardProps>) {
118120
isMetadataLoading={isMetadataLoading}
119121
userWeightPower={userWeightPower}
120122
isWhitelisted={props.isWhitelisted}
123+
subagentCount={props.subagentCount}
121124
/>
122125
);
123126
}

apps/torus-portal/src/app/(pages)/root-allocator/_components/infinite-agent-list.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { RouterOutputs } from "@torus-ts/api";
44
import { AgentItemSkeleton } from "@torus-ts/ui/components/agent-card/agent-card-skeleton-loader";
55
import { InfiniteList } from "@torus-ts/ui/components/infinite-list";
66
import { api } from "~/trpc/react";
7-
import { useEffect } from "react";
7+
import { useEffect, useMemo } from "react";
88
import { AgentCard } from "./agent-card";
99

1010
type InfiniteAgentData = RouterOutputs["agent"]["infinite"];
@@ -45,6 +45,9 @@ export function InfiniteAgentList({
4545
},
4646
);
4747

48+
// Fetch agent connection counts for displaying subagent/parent counts
49+
const { data: connectionCounts } = api.agent.agentConnectionCounts.useQuery();
50+
4851
// Log error to console and treat as empty list
4952
useEffect(() => {
5053
if (error) {
@@ -55,6 +58,12 @@ export function InfiniteAgentList({
5558
const agents: AgentType[] =
5659
data?.pages.flatMap((page: InfiniteAgentData) => page.agents) ?? [];
5760

61+
// Create lookup function for subagent counts
62+
const getSubagentCount = useMemo(() => {
63+
if (!connectionCounts) return (_key: string) => undefined;
64+
return (agentKey: string) => connectionCounts.subagentCounts[agentKey];
65+
}, [connectionCounts]);
66+
5867
return (
5968
<InfiniteList
6069
items={agents}
@@ -68,6 +77,7 @@ export function InfiniteAgentList({
6877
registrationBlock={agent.registrationBlock}
6978
percComputedWeight={agent.percComputedWeight}
7079
isWhitelisted={agent.isWhitelisted ?? false}
80+
subagentCount={getSubagentCount(agent.key)}
7181
/>
7282
)}
7383
getItemKey={(agent: AgentType, index: number) =>

packages/api/src/router/agent/agent.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { and, eq, gte, inArray, isNull, max, sql } from "@torus-ts/db";
22
import {
33
agentSchema,
44
computedAgentWeightSchema,
5+
emissionDistributionTargetsSchema,
6+
namespacePermissionsSchema,
57
penalizeAgentVotesSchema,
8+
permissionsSchema,
69
} from "@torus-ts/db/schema";
710
import type { TRPCRouterRecord } from "@trpc/server";
811
import { z } from "zod";
@@ -408,4 +411,134 @@ export const agentRouter = {
408411
penalties: penaltiesByAgent[agent.key] ?? [],
409412
}));
410413
}),
414+
415+
/**
416+
* Returns connection counts for agents:
417+
* - subagentCounts: For root agents, the count of all connected agents in their swarm
418+
* - parentCounts: For all agents, the count of root agents that grant permissions to them
419+
*/
420+
agentConnectionCounts: publicProcedure.query(async ({ ctx }) => {
421+
// 1. Fetch all relationships from multiple sources
422+
// Source 1: Base permissions (grantorAccountId -> granteeAccountId)
423+
const basePermissions = await ctx.db
424+
.select({
425+
grantor: permissionsSchema.grantorAccountId,
426+
grantee: permissionsSchema.granteeAccountId,
427+
})
428+
.from(permissionsSchema)
429+
.where(isNull(permissionsSchema.deletedAt));
430+
431+
// Source 2: Namespace permissions (grantor -> recipient)
432+
const namespaceRelations = await ctx.db
433+
.select({
434+
grantor: permissionsSchema.grantorAccountId,
435+
grantee: namespacePermissionsSchema.recipient,
436+
})
437+
.from(namespacePermissionsSchema)
438+
.innerJoin(
439+
permissionsSchema,
440+
eq(
441+
namespacePermissionsSchema.permissionId,
442+
permissionsSchema.permissionId,
443+
),
444+
)
445+
.where(isNull(permissionsSchema.deletedAt));
446+
447+
// Source 3: Emission distribution targets (grantor -> targetAccountId)
448+
const emissionTargets = await ctx.db
449+
.select({
450+
grantor: permissionsSchema.grantorAccountId,
451+
grantee: emissionDistributionTargetsSchema.targetAccountId,
452+
})
453+
.from(emissionDistributionTargetsSchema)
454+
.innerJoin(
455+
permissionsSchema,
456+
eq(
457+
emissionDistributionTargetsSchema.permissionId,
458+
permissionsSchema.permissionId,
459+
),
460+
)
461+
.where(isNull(permissionsSchema.deletedAt));
462+
463+
// 2. Fetch whitelisted (root) agents
464+
const whitelistedAgents = await ctx.db
465+
.select({ key: agentSchema.key })
466+
.from(agentSchema)
467+
.where(
468+
and(eq(agentSchema.isWhitelisted, true), isNull(agentSchema.deletedAt)),
469+
);
470+
471+
const whitelistedSet = new Set(whitelistedAgents.map((a) => a.key));
472+
473+
// 3. Build adjacency lists from all relationship sources
474+
const adjacencyList = new Map<string, Set<string>>();
475+
const reverseAdjacencyList = new Map<string, Set<string>>();
476+
477+
const addRelation = (grantor: string, grantee: string | null) => {
478+
if (!grantee) return;
479+
480+
// Forward: grantor -> grantee (for subagent traversal)
481+
const grantorNeighbors = adjacencyList.get(grantor);
482+
if (grantorNeighbors) {
483+
grantorNeighbors.add(grantee);
484+
} else {
485+
adjacencyList.set(grantor, new Set([grantee]));
486+
}
487+
488+
// Reverse: grantee -> grantor (for parent lookup)
489+
const granteeNeighbors = reverseAdjacencyList.get(grantee);
490+
if (granteeNeighbors) {
491+
granteeNeighbors.add(grantor);
492+
} else {
493+
reverseAdjacencyList.set(grantee, new Set([grantor]));
494+
}
495+
};
496+
497+
// Add relationships from all sources
498+
for (const p of basePermissions) {
499+
addRelation(p.grantor, p.grantee);
500+
}
501+
for (const p of namespaceRelations) {
502+
addRelation(p.grantor, p.grantee);
503+
}
504+
for (const p of emissionTargets) {
505+
addRelation(p.grantor, p.grantee);
506+
}
507+
508+
// 4. Calculate subagent counts for root agents (BFS traversal)
509+
const subagentCounts: Record<string, number> = {};
510+
for (const rootKey of whitelistedSet) {
511+
const visited = new Set<string>();
512+
const queue = [rootKey];
513+
514+
while (queue.length > 0) {
515+
const current = queue.shift();
516+
if (!current || visited.has(current)) continue;
517+
visited.add(current);
518+
519+
const neighbors = adjacencyList.get(current);
520+
if (neighbors) {
521+
for (const neighbor of neighbors) {
522+
if (!visited.has(neighbor)) {
523+
queue.push(neighbor);
524+
}
525+
}
526+
}
527+
}
528+
529+
visited.delete(rootKey); // Don't count self
530+
subagentCounts[rootKey] = visited.size;
531+
}
532+
533+
// 5. Calculate parent counts for all agents (only counting root agent parents)
534+
const parentCounts: Record<string, number> = {};
535+
for (const [grantee, grantors] of reverseAdjacencyList) {
536+
const rootParents = [...grantors].filter((g) => whitelistedSet.has(g));
537+
if (rootParents.length > 0) {
538+
parentCounts[grantee] = rootParents.length;
539+
}
540+
}
541+
542+
return { subagentCounts, parentCounts };
543+
}),
411544
} satisfies TRPCRouterRecord;

packages/ui/src/components/agent-card/agent-card-header.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface AgentCardHeaderProps {
1717
isAgentSelected?: boolean;
1818
isWhitelisted?: boolean;
1919
isMetadataLoading?: boolean;
20+
/** For root agents: count of all connected agents in their swarm */
21+
subagentCount?: number;
2022
}
2123

2224
function AgentBadge({
@@ -64,6 +66,7 @@ export function AgentCardHeader({
6466
isAgentSelected,
6567
isWhitelisted,
6668
isMetadataLoading = false,
69+
subagentCount,
6770
}: AgentCardHeaderProps) {
6871
const socialsList = buildSocials(socials, website);
6972

@@ -83,7 +86,16 @@ export function AgentCardHeader({
8386
{name}
8487
</h2>
8588
<div className="flex w-full items-center justify-between gap-1 md:absolute md:inset-x-0 md:top-0">
86-
<AgentCardSocialsInfo socials={socialsList} />
89+
<div className="flex items-center gap-1">
90+
<AgentCardSocialsInfo socials={socialsList} />
91+
{isWhitelisted &&
92+
subagentCount !== undefined &&
93+
subagentCount > 0 && (
94+
<Badge variant="secondary" className="whitespace-nowrap">
95+
{subagentCount} Subagent{subagentCount !== 1 ? "s" : ""}
96+
</Badge>
97+
)}
98+
</div>
8799
<AgentBadge
88100
isAgentSelected={isAgentSelected}
89101
isAgentDelegated={isAgentDelegated}

0 commit comments

Comments
 (0)