Skip to content

Commit e5f3965

Browse files
Sg312icecrasher321
andauthored
feat(mship): add parallel subagents, improve streaming performance (#5122)
* feat(subagents): add support for parallel subagents * fix(subagents): address parallel-subagent bugs * progress on streaming refactor * improvement(subagents): update comment to reflect new go feature flag * debug mode progress * remove debug logs * fix(validation): add escape annotation * improvement(code): remove dead fallbacks * fix subagent lane fallback issue * fix(mothership): increase default redis event limit to 100k from 5k * fix(mothership): streaming invariant projection enforcement --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent 597d7ea commit e5f3965

127 files changed

Lines changed: 12197 additions & 1121 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
# bun specific
1010
bun-debug.log*
1111

12+
# cursor debug logs
13+
.cursor/debug-*.log
14+
1215
# this repo uses bun.lock; package-lock.json files are accidental
1316
package-lock.json
1417

@@ -44,6 +47,11 @@ dump.rdb
4447
.env.test
4548
.env.production
4649

50+
# editor swap files
51+
*.swp
52+
*.swo
53+
*.swn
54+
4755
# vercel
4856
.vercel
4957

apps/docs/components/icons.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1958,6 +1958,24 @@ export function WhatsAppIcon(props: SVGProps<SVGSVGElement>) {
19581958
)
19591959
}
19601960

1961+
export function SportmonksIcon(props: SVGProps<SVGSVGElement>) {
1962+
return (
1963+
<svg
1964+
{...props}
1965+
viewBox='0 0 25 24'
1966+
fill='none'
1967+
fillRule='evenodd'
1968+
xmlns='http://www.w3.org/2000/svg'
1969+
>
1970+
<path
1971+
d='M11.857 8.546c1.893 0 3.517.678 4.872 2.033 1.355 1.336 2.032 2.96 2.032 4.872 0 1.91-.677 3.535-2.032 4.871-1.355 1.355-2.979 2.032-4.872 2.032H1V17.093h10.857c.446 0 .825-.157 1.142-.473.334-.334.5-.724.5-1.17 0-.445-.166-.835-.5-1.17a1.558 1.558 0 00-1.142-.472H7.905c-1.912 0-3.537-.677-4.873-2.032C1.678 10.421 1 8.796 1 6.903 1 4.993 1.678 3.368 3.033 2.032 4.368.678 5.992 0 7.905 0h10.188V5.263H7.904a1.65 1.65 0 00-1.17.473 1.586 1.586 0 00-.472 1.169c0 .445.157.835.473 1.17.334.315.724.472 1.17.472h3.952z'
1972+
fill='currentColor'
1973+
/>
1974+
<circle cx='21.27' cy='20.123' r='2.732' fill='#FF0F50' />
1975+
</svg>
1976+
)
1977+
}
1978+
19611979
export function SquareIcon(props: SVGProps<SVGSVGElement>) {
19621980
return (
19631981
<svg {...props} viewBox='0 0 501.42 501.42' xmlns='http://www.w3.org/2000/svg'>

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ import {
195195
SixtyfourIcon,
196196
SlackIcon,
197197
SmtpIcon,
198+
SportmonksIcon,
198199
SQSIcon,
199200
SquareIcon,
200201
SshIcon,
@@ -449,6 +450,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
449450
sixtyfour: SixtyfourIcon,
450451
slack: SlackIcon,
451452
smtp: SmtpIcon,
453+
sportmonks: SportmonksIcon,
452454
sqs: SQSIcon,
453455
square: SquareIcon,
454456
ssh: SshIcon,

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
"sixtyfour",
197197
"slack",
198198
"smtp",
199+
"sportmonks",
199200
"sqs",
200201
"square",
201202
"ssh",

apps/docs/content/docs/en/integrations/sportmonks.mdx

Lines changed: 1517 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { ToolCallData, ToolCallStatus } from '../../../../types'
6+
import type { AgentGroupItem } from './agent-group'
7+
import { isAgentGroupResolved } from './agent-group'
8+
9+
let toolSeq = 0
10+
11+
function tool(status: ToolCallStatus): AgentGroupItem {
12+
toolSeq += 1
13+
const data: ToolCallData = {
14+
id: `tool-${toolSeq}`,
15+
toolName: 'grep',
16+
displayTitle: 'Searching',
17+
status,
18+
}
19+
return { type: 'tool', data }
20+
}
21+
22+
function text(content: string): AgentGroupItem {
23+
return { type: 'text', content }
24+
}
25+
26+
function group(items: AgentGroupItem[], isDelegating = false): AgentGroupItem {
27+
return {
28+
type: 'agent_group',
29+
group: {
30+
id: `group-${toolSeq}`,
31+
agentName: 'deploy',
32+
agentLabel: 'Deploy',
33+
items,
34+
isDelegating,
35+
isOpen: true,
36+
},
37+
}
38+
}
39+
40+
describe('isAgentGroupResolved', () => {
41+
it('is unresolved when there is no work yet', () => {
42+
expect(isAgentGroupResolved([])).toBe(false)
43+
expect(isAgentGroupResolved([text('thinking...')])).toBe(false)
44+
})
45+
46+
it('resolves once every own tool is terminal', () => {
47+
expect(isAgentGroupResolved([tool('success')])).toBe(true)
48+
expect(isAgentGroupResolved([tool('success'), tool('error')])).toBe(true)
49+
})
50+
51+
it('stays unresolved while any own tool is still executing', () => {
52+
expect(isAgentGroupResolved([tool('success'), tool('executing')])).toBe(false)
53+
})
54+
55+
it('resolves a parent whose only work is a finished child group', () => {
56+
expect(isAgentGroupResolved([group([tool('success')])])).toBe(true)
57+
})
58+
59+
it('stays unresolved while a nested child is still delegating', () => {
60+
expect(isAgentGroupResolved([group([], true)])).toBe(false)
61+
})
62+
63+
it('stays unresolved while a nested child has an executing tool', () => {
64+
expect(isAgentGroupResolved([group([tool('executing')])])).toBe(false)
65+
})
66+
67+
it('resolves deep nesting only when every descendant is terminal', () => {
68+
expect(isAgentGroupResolved([group([group([tool('success')])])])).toBe(true)
69+
expect(isAgentGroupResolved([group([group([tool('executing')])])])).toBe(false)
70+
})
71+
})

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

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
44
import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/components/emcn'
55
import { cn } from '@/lib/core/utils/cn'
66
import type { ToolCallData } from '../../../../types'
7-
import { getAgentIcon } from '../../utils'
7+
import { getAgentIcon, isToolDone } from '../../utils'
88
import { ToolCallItem } from './tool-call-item'
99

1010
/**
@@ -35,15 +35,18 @@ interface AgentGroupProps {
3535
defaultExpanded?: boolean
3636
}
3737

38-
function isToolDone(status: ToolCallData['status']): boolean {
39-
return (
40-
status === 'success' ||
41-
status === 'error' ||
42-
status === 'cancelled' ||
43-
status === 'skipped' ||
44-
status === 'rejected' ||
45-
status === 'interrupted'
46-
)
38+
export function isAgentGroupResolved(items: AgentGroupItem[]): boolean {
39+
let hasWork = false
40+
for (const item of items) {
41+
if (item.type === 'tool') {
42+
hasWork = true
43+
if (!isToolDone(item.data.status)) return false
44+
} else if (item.type === 'agent_group') {
45+
hasWork = true
46+
if (item.group.isDelegating || !isAgentGroupResolved(item.group.items)) return false
47+
}
48+
}
49+
return hasWork
4750
}
4851

4952
export function AgentGroup({
@@ -56,20 +59,18 @@ export function AgentGroup({
5659
}: AgentGroupProps) {
5760
const AgentIcon = getAgentIcon(agentName)
5861
const hasItems = items.length > 0
59-
const toolItems = items.filter(
60-
(item): item is Extract<AgentGroupItem, { type: 'tool' }> => item.type === 'tool'
61-
)
62-
const allDone = toolItems.length > 0 && toolItems.every((t) => isToolDone(t.data.status))
63-
// Only a live turn can be delegating. Once the turn is terminal (complete,
64-
// errored, or stopped) no subagent should spin — even one aborted before its
65-
// first tool call, where `allDone` is false because there are no tools yet.
66-
const showDelegatingSpinner = isStreaming && isDelegating && !allDone
62+
const resolved = isAgentGroupResolved(items)
63+
// Pure projection of the run's own state: a subagent header spins while it is
64+
// delegating with no resolved work yet. A terminal turn closes the lane (its
65+
// subagent block is stamped ended), which clears `isDelegating`, so no
66+
// transport gating is needed to stop an aborted-before-first-tool spinner.
67+
const showDelegatingSpinner = isDelegating && !resolved
6768

6869
// Expand only while the turn is live and the group is still open or working.
6970
// Once the turn ends (isStreaming false) — or a subagent closes mid-turn — the
7071
// group auto-collapses, so finished subagent blocks never stay expanded. A
7172
// manual toggle pins the choice for the rest of the message.
72-
const autoExpanded = isStreaming && (defaultExpanded || !allDone)
73+
const autoExpanded = isStreaming && (defaultExpanded || !resolved)
7374
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
7475
const expanded = manualExpanded ?? autoExpanded
7576

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
2-
export { AgentGroup } from './agent-group'
2+
export { AgentGroup, isAgentGroupResolved } from './agent-group'
33
export { CircleStop } from './tool-call-item'

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useMemo } from 'react'
22
import { PillsRing } from '@/components/emcn'
33
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
44
import type { ToolCallStatus } from '../../../../types'
5-
import { getToolIcon } from '../../utils'
5+
import { getToolIcon, resolveToolDisplayState } from '../../utils'
66

77
function CircleCheck({ className }: { className?: string }) {
88
return (
@@ -58,13 +58,14 @@ function Hyphen({ className }: { className?: string }) {
5858
}
5959

6060
function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
61-
if (status === 'executing') {
61+
const display = resolveToolDisplayState(status)
62+
if (display === 'spinner') {
6263
return <PillsRing className='size-[15px] text-[var(--text-tertiary)]' animate />
6364
}
64-
if (status === 'cancelled') {
65+
if (display === 'cancelled') {
6566
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
6667
}
67-
if (status === 'interrupted') {
68+
if (display === 'interrupted') {
6869
return <Hyphen className='size-[15px] text-[var(--text-tertiary)]' />
6970
}
7071
const Icon = getToolIcon(toolName)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,21 +279,30 @@ interface ChatContentProps {
279279
isStreaming?: boolean
280280
onOptionSelect?: (id: string) => void
281281
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
282+
onRevealStateChange?: (isRevealing: boolean) => void
282283
}
283284

284285
function ChatContentInner({
285286
content,
286287
isStreaming = false,
287288
onOptionSelect,
288289
onWorkspaceResourceSelect,
290+
onRevealStateChange,
289291
}: ChatContentProps) {
290292
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
291293
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
292294

295+
const onRevealStateChangeRef = useRef(onRevealStateChange)
296+
onRevealStateChangeRef.current = onRevealStateChange
297+
293298
const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content])
294299
const streamedContent = useSmoothText(displayContent, isStreaming)
295300
const isRevealing = isStreaming || streamedContent.length < displayContent.length
296301

302+
useEffect(() => {
303+
onRevealStateChangeRef.current?.(isRevealing)
304+
}, [isRevealing])
305+
297306
/**
298307
* One-way latch: once a message has streamed in this mount, keep rendering it
299308
* through Streamdown's streaming/animation pipeline for the rest of its life.

0 commit comments

Comments
 (0)