Skip to content

Commit 1a4518a

Browse files
committed
feat(subagents): add support for parallel subagents
1 parent 73c73ff commit 1a4518a

30 files changed

Lines changed: 834 additions & 172 deletions

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,56 @@ describe('parseBlocks span-identity tree', () => {
9494
expect(withContent[0].isDelegating).toBe(false)
9595
})
9696

97+
it('keeps two concurrently-open subagent lanes separate with interleaved text', () => {
98+
const blocks: ContentBlock[] = [
99+
subagentStart('research', 'A', 'main'),
100+
subagentStart('research', 'B', 'main'),
101+
{ type: 'subagent_text', content: 'A1 ', spanId: 'A', subagent: 'research', timestamp: 2 },
102+
{ type: 'subagent_text', content: 'B1 ', spanId: 'B', subagent: 'research', timestamp: 2 },
103+
{ type: 'subagent_text', content: 'A2', spanId: 'A', subagent: 'research', timestamp: 3 },
104+
]
105+
106+
const segments = parseBlocks(blocks)
107+
const groups = segments.filter((s) => s.type === 'agent_group')
108+
expect(groups).toHaveLength(2)
109+
110+
const textOf = (g: (typeof groups)[number]): string => {
111+
if (g.type !== 'agent_group') return ''
112+
return g.items
113+
.filter((i) => i.type === 'text')
114+
.map((i) => (i.type === 'text' ? i.content : ''))
115+
.join('')
116+
}
117+
// Group A (spanId A) created first, group B second. Interleaved chunks stay
118+
// in their own lane and in order — no cross-contamination.
119+
expect(textOf(groups[0])).toBe('A1 A2')
120+
expect(textOf(groups[1])).toBe('B1 ')
121+
})
122+
123+
it('renders a persisted subagent lane as closed when only endedAt is set (no subagent_end)', () => {
124+
// The Sim backend stamps endedAt on the subagent block but does not emit a
125+
// separate subagent_end block; a reloaded transcript must still show the
126+
// lane closed (no stuck delegating spinner).
127+
const blocks: ContentBlock[] = [
128+
{
129+
type: 'subagent',
130+
content: 'research',
131+
spanId: 'S1',
132+
parentSpanId: 'main',
133+
timestamp: 1,
134+
endedAt: 5,
135+
},
136+
{ type: 'subagent_text', content: 'done', spanId: 'S1', subagent: 'research', timestamp: 2 },
137+
]
138+
139+
const segments = parseBlocks(blocks)
140+
const group = segments.find((s) => s.type === 'agent_group')
141+
expect(group).toBeDefined()
142+
if (!group || group.type !== 'agent_group') throw new Error('expected research group')
143+
expect(group.isOpen).toBe(false)
144+
expect(group.isDelegating).toBe(false)
145+
})
146+
97147
it('prunes an empty nested subagent that started and ended without output', () => {
98148
const blocks: ContentBlock[] = [
99149
subagentStart('workflow', 'S1', 'main'),

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,16 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
315315
const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[block.content]
316316
if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId)
317317
const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId, i)
318+
if (block.endedAt !== undefined) {
319+
// Persisted backend path: the lane was stamped closed (endedAt) without
320+
// a separate subagent_end block (the Sim backend stamps endedAt only;
321+
// only the live browser path pushes subagent_end). Honor endedAt so a
322+
// reloaded transcript shows the subagent closed instead of a stuck
323+
// delegating spinner.
324+
g.isOpen = false
325+
g.isDelegating = false
326+
continue
327+
}
318328
// Show the working/delegating spinner from span open until the agent
319329
// emits its first content or tool (or ends). The legacy path derived this
320330
// from the dispatch tool_call, which the span path absorbs, so we set it

apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,12 @@ export function handleSpanEvent(
106106
deps.setActiveResourceId(lastFileResource.id)
107107
}
108108
}
109-
if (
110-
!parentToolCallId ||
111-
parentToolCallId === state.activeSubagentParentToolCallId ||
112-
name === state.activeSubagent
113-
) {
109+
// Clear the legacy single pointer only when THIS ending lane is the active
110+
// one (matched by parent tool call id, or an unscoped end). Never clear by
111+
// agent name alone — a concurrent same-name subagent that is still open must
112+
// not be torn down by a sibling's end. Per-lane state lives in the
113+
// subagentBySpanId / subagentByParentToolCallId maps cleared above.
114+
if (!parentToolCallId || parentToolCallId === state.activeSubagentParentToolCallId) {
114115
state.activeSubagent = undefined
115116
state.activeSubagentParentToolCallId = undefined
116117
}

apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,15 +223,52 @@ describe('createStreamLoopContext', () => {
223223
expect(Number.isFinite(ms)).toBe(true)
224224
})
225225

226-
it('resolveScopedSubagent prefers agentId, then spanId, then parentToolCallId, then active', () => {
226+
it('stampBlockEnd never closes a subagent header (prevents concurrent-lane flicker)', () => {
227+
const ctx = createStreamLoopContext(makeStreamLoopDeps())
228+
229+
// A subagent header must stay open when a generic block boundary fires
230+
// (e.g. the next sibling subagent starts, or this lane's first content
231+
// arrives). endedAt is set only by the real span-end handler.
232+
const header: ContentBlock = { type: 'subagent', content: 'research', spanId: 's1' }
233+
ctx.ops.stampBlockEnd(header)
234+
expect(header.endedAt).toBeUndefined()
235+
236+
// Other block types still get their endedAt stamped as before.
237+
const text: ContentBlock = { type: 'text', content: 'hi' }
238+
ctx.ops.stampBlockEnd(text)
239+
expect(text.endedAt).toBeTypeOf('number')
240+
})
241+
242+
it('resolveScopedSubagent prefers agentId, then spanId, then parentToolCallId (scope-only, no active fallback)', () => {
227243
const ctx = createStreamLoopContext(makeStreamLoopDeps())
228244
ctx.state.subagentBySpanId.set('s1', 'spanAgent')
229245
ctx.state.subagentByParentToolCallId.set('p1', 'parentAgent')
230246
ctx.state.activeSubagent = 'activeAgent'
231247
expect(ctx.ops.resolveScopedSubagent('explicit', 'p1', 's1')).toBe('explicit')
232248
expect(ctx.ops.resolveScopedSubagent(undefined, 'p1', 's1')).toBe('spanAgent')
233249
expect(ctx.ops.resolveScopedSubagent(undefined, 'p1', undefined)).toBe('parentAgent')
234-
expect(ctx.ops.resolveScopedSubagent(undefined, undefined, undefined)).toBe('activeAgent')
250+
// No scope match → undefined (the legacy activeSubagent fallback was
251+
// removed so a concurrent sibling can never be mis-attributed).
252+
expect(ctx.ops.resolveScopedSubagent(undefined, undefined, undefined)).toBeUndefined()
253+
})
254+
255+
it('rebuilds every open subagent lane on reconnect, skipping closed ones', () => {
256+
const blocks: ContentBlock[] = [
257+
{ type: 'subagent', content: 'research', spanId: 'span-a', parentToolCallId: 'tc-a' },
258+
{ type: 'subagent', content: 'deploy', spanId: 'span-b', parentToolCallId: 'tc-b' },
259+
// span-b closed via marker; span-a stays open.
260+
{ type: 'subagent_end', spanId: 'span-b', parentToolCallId: 'tc-b' },
261+
]
262+
const ctx = createStreamLoopContext(
263+
makeStreamLoopDeps({
264+
options: { preserveExistingState: true },
265+
streamingBlocksRef: ref<ContentBlock[]>(blocks),
266+
})
267+
)
268+
expect(ctx.state.subagentBySpanId.get('span-a')).toBe('research')
269+
expect(ctx.state.subagentBySpanId.has('span-b')).toBe(false)
270+
expect(ctx.state.subagentByParentToolCallId.get('tc-a')).toBe('research')
271+
expect(ctx.state.subagentByParentToolCallId.has('tc-b')).toBe(false)
235272
})
236273

237274
it('buildInlineErrorTag includes the message, code and provider', () => {

apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -218,20 +218,31 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
218218
if (tc.params) state.toolArgsMap.set(tc.id, tc.params)
219219
}
220220
}
221+
// Rebuild ALL open subagent lanes (not just the most recent one) so that a
222+
// reconnect mid-flight with multiple concurrent subagents rehydrates every
223+
// lane. A lane is closed when its `subagent` start block has an endedAt OR a
224+
// matching `subagent_end` marker exists (the live path stamps endedAt and
225+
// pushes subagent_end; the persisted backend path stamps endedAt only).
226+
const endedSpanIds = new Set<string>()
227+
const endedParents = new Set<string>()
221228
for (const block of state.blocks) {
222-
if (block.type === 'subagent' && block.spanId && block.content) {
223-
state.subagentBySpanId.set(block.spanId, block.content)
229+
if (block.type === 'subagent_end') {
230+
if (block.spanId) endedSpanIds.add(block.spanId)
231+
if (block.parentToolCallId) endedParents.add(block.parentToolCallId)
224232
}
225233
}
226-
for (let i = state.blocks.length - 1; i >= 0; i--) {
227-
if (state.blocks[i].type === 'subagent' && state.blocks[i].content) {
228-
state.activeSubagent = state.blocks[i].content
229-
state.activeSubagentParentToolCallId = state.blocks[i].parentToolCallId
230-
break
231-
}
232-
if (state.blocks[i].type === 'subagent_end') {
233-
break
234+
for (const block of state.blocks) {
235+
if (block.type !== 'subagent' || !block.content || block.endedAt !== undefined) continue
236+
if (block.spanId && endedSpanIds.has(block.spanId)) continue
237+
if (block.parentToolCallId && endedParents.has(block.parentToolCallId)) continue
238+
if (block.spanId) state.subagentBySpanId.set(block.spanId, block.content)
239+
if (block.parentToolCallId) {
240+
state.subagentByParentToolCallId.set(block.parentToolCallId, block.content)
234241
}
242+
// Keep a best-effort single pointer for legacy (no-spanId) dedup only;
243+
// routing no longer depends on it.
244+
state.activeSubagent = block.content
245+
state.activeSubagentParentToolCallId = block.parentToolCallId
235246
}
236247
} else if (!isStale()) {
237248
deps.streamingContentRef.current = ''
@@ -247,7 +258,15 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
247258
}
248259

249260
const stampBlockEnd = (block: ContentBlock | undefined, ts?: string) => {
250-
if (block && block.endedAt === undefined) block.endedAt = toEventMs(ts)
261+
// Never stamp a subagent header here. Its endedAt is the renderer's
262+
// "group closed" signal (parseBlocksWithSpanTree), set explicitly only when
263+
// the subagent's span actually ends (the span-end handler and the backend
264+
// both set it directly). Stamping it as a generic block boundary — when the
265+
// next sibling subagent starts, or when this lane's first content arrives —
266+
// would close + prune concurrent subagents mid-stream, making them all flash
267+
// in, vanish to one, then reappear one-by-one as content trickles in.
268+
if (!block || block.type === 'subagent') return
269+
if (block.endedAt === undefined) block.endedAt = toEventMs(ts)
251270
}
252271

253272
const ensureTextBlock = (
@@ -306,6 +325,12 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
306325
parentToolCallId: string | undefined,
307326
spanId?: string
308327
): string | undefined => {
328+
// Scope-only: resolve by the event's own identity. The legacy
329+
// `state.activeSubagent` fallback was removed — with concurrent subagents it
330+
// points at whichever started most recently and would mis-attribute an
331+
// interleaved event from a different lane. Well-formed subagent events carry
332+
// agentId (and spanId), so this resolves deterministically; anything else is
333+
// treated as main-lane rather than guessed.
309334
if (agentId) return agentId
310335
if (spanId) {
311336
const scoped = state.subagentBySpanId.get(spanId)
@@ -315,20 +340,18 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
315340
const scoped = state.subagentByParentToolCallId.get(parentToolCallId)
316341
if (scoped) return scoped
317342
}
318-
return state.activeSubagent
343+
return undefined
319344
}
320345

321346
const resolveParentForSubagentBlock = (
322347
subagent: string | undefined,
323348
scopedParent: string | undefined
324349
): string | undefined => {
350+
// Scope-only: a subagent block's parent comes from the event's own scope.
351+
// The previous "first parent whose name matches" scan was ambiguous when two
352+
// concurrent subagents share an agent name, so it was removed.
325353
if (!subagent) return undefined
326-
if (scopedParent) return scopedParent
327-
if (state.activeSubagent === subagent) return state.activeSubagentParentToolCallId
328-
for (const [parent, name] of state.subagentByParentToolCallId) {
329-
if (name === subagent) return parent
330-
}
331-
return undefined
354+
return scopedParent
332355
}
333356

334357
const flush = () => {

apps/sim/lib/copilot/chat/effective-transcript.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ function buildLiveAssistantMessage(params: {
119119
let requestId: string | undefined
120120
let lastTimestamp: string | undefined
121121

122+
// Scope-only resolution (mirrors the live browser stream loop): with
123+
// concurrent subagents the legacy activeSubagent fallback / name-match scan
124+
// would mis-attribute interleaved replayed events to the wrong lane.
122125
const resolveScopedSubagent = (
123126
agentId: string | undefined,
124127
parentToolCallId: string | undefined,
@@ -133,20 +136,15 @@ function buildLiveAssistantMessage(params: {
133136
const scoped = subagentByParentToolCallId.get(parentToolCallId)
134137
if (scoped) return scoped
135138
}
136-
return activeSubagent
139+
return undefined
137140
}
138141

139142
const resolveParentForSubagentBlock = (
140143
subagent: string | undefined,
141144
scopedParent: string | undefined
142145
): string | undefined => {
143146
if (!subagent) return undefined
144-
if (scopedParent) return scopedParent
145-
if (activeSubagent === subagent) return activeSubagentParentToolCallId
146-
for (const [parent, name] of subagentByParentToolCallId) {
147-
if (name === subagent) return parent
148-
}
149-
return undefined
147+
return scopedParent
150148
}
151149

152150
const ensureToolBlock = (input: {
@@ -364,11 +362,10 @@ function buildLiveAssistantMessage(params: {
364362
if (parentToolCallId) {
365363
subagentByParentToolCallId.delete(parentToolCallId)
366364
}
367-
if (
368-
!parentToolCallId ||
369-
parentToolCallId === activeSubagentParentToolCallId ||
370-
name === activeSubagent
371-
) {
365+
// Clear the legacy pointer only for THIS lane (by parent tool call id)
366+
// or an unscoped end — never by agent name, which would tear down a
367+
// concurrent same-name sibling that is still open.
368+
if (!parentToolCallId || parentToolCallId === activeSubagentParentToolCallId) {
372369
activeSubagent = undefined
373370
activeSubagentParentToolCallId = undefined
374371
}

apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = {
5050
MothershipStreamV1CheckpointPauseFrame: {
5151
additionalProperties: false,
5252
properties: {
53+
checkpointId: {
54+
type: 'string',
55+
},
5356
parentToolCallId: {
5457
type: 'string',
5558
},

apps/sim/lib/copilot/generated/mothership-stream-v1.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ export interface MothershipStreamV1CheckpointPausePayload {
319319
runId: string
320320
}
321321
export interface MothershipStreamV1CheckpointPauseFrame {
322+
checkpointId?: string
322323
parentToolCallId: string
323324
parentToolName: string
324325
pendingToolIds: string[]

apps/sim/lib/copilot/request/context/request-context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function createStreamingContext(overrides?: Partial<StreamingContext>): S
1818
toolCalls: new Map(),
1919
pendingToolPromises: new Map(),
2020
currentThinkingBlock: null,
21-
currentSubagentThinkingBlock: null,
21+
subagentThinkingBlocks: new Map(),
2222
isInThinkingBlock: false,
2323
subAgentParentToolCallId: undefined,
2424
subAgentParentStack: [],
@@ -28,7 +28,7 @@ export function createStreamingContext(overrides?: Partial<StreamingContext>): S
2828
streamComplete: false,
2929
wasAborted: false,
3030
errors: [],
31-
activeFileIntent: null,
31+
activeFileIntents: new Map(),
3232
trace: new TraceCollector(),
3333
...overrides,
3434
}

apps/sim/lib/copilot/request/context/result.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ function makeContext(): StreamingContext {
2323
pendingToolPromises: new Map(),
2424
awaitingAsyncContinuation: undefined,
2525
currentThinkingBlock: null,
26+
subagentThinkingBlocks: new Map(),
2627
isInThinkingBlock: false,
2728
subAgentParentToolCallId: undefined,
2829
subAgentParentStack: [],

0 commit comments

Comments
 (0)