Skip to content

Commit 3b66dcf

Browse files
committed
improvement(nested): subagents
1 parent 3f82429 commit 3b66dcf

17 files changed

Lines changed: 703 additions & 23 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,24 @@ import type { ToolCallData } from '../../../../types'
77
import { getAgentIcon } from '../../utils'
88
import { ToolCallItem } from './tool-call-item'
99

10+
/**
11+
* A subagent group nested inside another agent's output. Carries the same shape
12+
* as a top-level group so {@link AgentGroup} can render it recursively, which is
13+
* how deterministic parent/child nesting (e.g. Deploy inside Workflow) is drawn.
14+
*/
15+
export interface NestedAgentGroup {
16+
id: string
17+
agentName: string
18+
agentLabel: string
19+
items: AgentGroupItem[]
20+
isDelegating: boolean
21+
isOpen: boolean
22+
}
23+
1024
export type AgentGroupItem =
1125
| { type: 'text'; content: string }
1226
| { type: 'tool'; data: ToolCallData }
27+
| { type: 'agent_group'; group: NestedAgentGroup }
1328

1429
interface AgentGroupProps {
1530
agentName: string
@@ -21,6 +36,10 @@ interface AgentGroupProps {
2136
defaultExpanded?: boolean
2237
}
2338

39+
function isToolItemDone(item: AgentGroupItem): boolean {
40+
return item.type === 'tool' && isToolDone(item.data.status)
41+
}
42+
2443
function isToolDone(status: ToolCallData['status']): boolean {
2544
return (
2645
status === 'success' ||
@@ -126,6 +145,20 @@ export function AgentGroup({
126145
/>
127146
)
128147
}
148+
if (item.type === 'agent_group') {
149+
return (
150+
<div key={item.group.id} className='pl-6'>
151+
<AgentGroup
152+
agentName={item.group.agentName}
153+
agentLabel={item.group.agentLabel}
154+
items={item.group.items}
155+
isDelegating={item.group.isDelegating}
156+
isStreaming={isStreaming}
157+
defaultExpanded={item.group.isOpen}
158+
/>
159+
</div>
160+
)
161+
}
129162
return (
130163
<span
131164
key={`text-${idx}`}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type { AgentGroupItem } from './agent-group'
1+
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
22
export { AgentGroup, CircleStop } from './agent-group'
33
export { ChatContent } from './chat-content'
44
export { Options } from './options'
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { ContentBlock } from '../../types'
6+
import { parseBlocks } from './message-content'
7+
8+
function subagentStart(name: string, spanId: string, parentSpanId: string): ContentBlock {
9+
return { type: 'subagent', content: name, spanId, parentSpanId, timestamp: 1 }
10+
}
11+
12+
function subagentToolCall(
13+
id: string,
14+
name: string,
15+
spanId: string,
16+
calledBy: string
17+
): ContentBlock {
18+
return {
19+
type: 'tool_call',
20+
toolCall: { id, name, status: 'success', calledBy },
21+
spanId,
22+
timestamp: 1,
23+
}
24+
}
25+
26+
describe('parseBlocks span-identity tree', () => {
27+
it('nests a deploy subagent inside the workflow subagent that spawned it', () => {
28+
const blocks: ContentBlock[] = [
29+
subagentStart('workflow', 'S1', 'main'),
30+
subagentToolCall('t1', 'create_workflow', 'S1', 'workflow'),
31+
subagentStart('deploy', 'S2', 'S1'),
32+
subagentToolCall('t2', 'check_deployment_status', 'S2', 'deploy'),
33+
]
34+
35+
const segments = parseBlocks(blocks)
36+
37+
expect(segments).toHaveLength(1)
38+
const workflow = segments[0]
39+
expect(workflow.type).toBe('agent_group')
40+
if (workflow.type !== 'agent_group') throw new Error('expected workflow group')
41+
expect(workflow.agentName).toBe('workflow')
42+
43+
const nested = workflow.items.find((item) => item.type === 'agent_group')
44+
expect(nested).toBeDefined()
45+
if (!nested || nested.type !== 'agent_group') throw new Error('expected nested deploy group')
46+
expect(nested.group.agentName).toBe('deploy')
47+
// Deploy's own tool nests under deploy, not under workflow.
48+
expect(nested.group.items.some((item) => item.type === 'tool')).toBe(true)
49+
})
50+
51+
it('keeps two top-level subagents as siblings', () => {
52+
const blocks: ContentBlock[] = [
53+
subagentStart('workflow', 'S1', 'main'),
54+
subagentStart('research', 'S3', 'main'),
55+
]
56+
57+
const segments = parseBlocks(blocks)
58+
const groups = segments.filter((s) => s.type === 'agent_group')
59+
expect(groups).toHaveLength(2)
60+
})
61+
62+
it('creates distinct groups for repeated deploy invocations (no collision)', () => {
63+
const blocks: ContentBlock[] = [
64+
subagentStart('deploy', 'S2', 'main'),
65+
subagentToolCall('t1', 'deploy_api', 'S2', 'deploy'),
66+
subagentStart('deploy', 'S4', 'main'),
67+
subagentToolCall('t2', 'deploy_api', 'S4', 'deploy'),
68+
]
69+
70+
const segments = parseBlocks(blocks)
71+
const groups = segments.filter((s) => s.type === 'agent_group')
72+
expect(groups).toHaveLength(2)
73+
})
74+
75+
it('shows the delegating spinner while a span subagent is open with no output, and clears it once content arrives', () => {
76+
const openOnly = parseBlocks([subagentStart('deploy', 'S2', 'main')])
77+
expect(openOnly).toHaveLength(1)
78+
if (openOnly[0].type !== 'agent_group') throw new Error('expected group')
79+
expect(openOnly[0].isDelegating).toBe(true)
80+
81+
const withContent = parseBlocks([
82+
subagentStart('deploy', 'S2', 'main'),
83+
{ type: 'subagent_text', content: 'working on it', spanId: 'S2', timestamp: 2 },
84+
])
85+
if (withContent[0].type !== 'agent_group') throw new Error('expected group')
86+
expect(withContent[0].isDelegating).toBe(false)
87+
})
88+
89+
it('prunes an empty nested subagent that started and ended without output', () => {
90+
const blocks: ContentBlock[] = [
91+
subagentStart('workflow', 'S1', 'main'),
92+
subagentToolCall('t1', 'create_workflow', 'S1', 'workflow'),
93+
subagentStart('deploy', 'S2', 'S1'),
94+
{ type: 'subagent_end', spanId: 'S2', parentSpanId: 'S1', timestamp: 3 },
95+
]
96+
const segments = parseBlocks(blocks)
97+
expect(segments).toHaveLength(1)
98+
if (segments[0].type !== 'agent_group') throw new Error('expected workflow group')
99+
// The empty, ended deploy group is pruned; only the workflow tool remains.
100+
expect(segments[0].items.some((item) => item.type === 'agent_group')).toBe(false)
101+
expect(segments[0].items.some((item) => item.type === 'tool')).toBe(true)
102+
})
103+
104+
it('falls back to legacy flat grouping when blocks have no span identity', () => {
105+
const blocks: ContentBlock[] = [
106+
{ type: 'subagent', content: 'workflow', parentToolCallId: 'tc-1', timestamp: 1 },
107+
{
108+
type: 'tool_call',
109+
toolCall: { id: 't1', name: 'create_workflow', status: 'success', calledBy: 'workflow' },
110+
parentToolCallId: 'tc-1',
111+
timestamp: 1,
112+
},
113+
]
114+
115+
const segments = parseBlocks(blocks)
116+
const groups = segments.filter((s) => s.type === 'agent_group')
117+
expect(groups).toHaveLength(1)
118+
if (groups[0].type !== 'agent_group') throw new Error('expected group')
119+
expect(groups[0].agentName).toBe('workflow')
120+
})
121+
})

0 commit comments

Comments
 (0)