Skip to content

Commit c097601

Browse files
committed
Fix subagent streaming and parent tracking for nested agents
🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 42cb2e5 commit c097601

File tree

6 files changed

+156
-75
lines changed

6 files changed

+156
-75
lines changed

backend/src/client-wrapper.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,23 @@ export function sendSubagentChunkWs(
196196
ws: WebSocket
197197
} & ParamsOf<SendSubagentChunkFn>,
198198
): ReturnType<SendSubagentChunkFn> {
199-
const { ws, userInputId, agentId, agentType, chunk, prompt } = params
199+
const {
200+
ws,
201+
userInputId,
202+
agentId,
203+
agentType,
204+
chunk,
205+
prompt,
206+
forwardToPrompt,
207+
} = params
200208
return sendAction(ws, {
201209
type: 'subagent-response-chunk',
202210
userInputId,
203211
agentId,
204212
agentType,
205213
chunk,
206214
prompt,
215+
forwardToPrompt,
207216
})
208217
}
209218

backend/src/run-programmatic-step.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -273,11 +273,13 @@ export async function runProgrammaticStep(
273273
role: 'assistant' as const,
274274
content: toolCallString,
275275
})
276-
state.sendSubagentChunk({
276+
// Optional call handles both top-level and nested agents
277+
state.sendSubagentChunk?.({
277278
userInputId,
278279
agentId: state.agentState.agentId,
279280
agentType: state.agentState.agentType!,
280281
chunk: toolCallString,
282+
forwardToPrompt: !state.agentState.parentId,
281283
})
282284
}
283285

@@ -331,10 +333,7 @@ export async function runProgrammaticStep(
331333
}
332334

333335
// Add parentAgentId to tool calls and results from nested agents
334-
if (
335-
chunk.type === 'tool_call' ||
336-
chunk.type === 'tool_result'
337-
) {
336+
if (chunk.type === 'tool_call' || chunk.type === 'tool_result') {
338337
// Only add parentAgentId if it's not already set
339338
if (!(chunk as any).parentAgentId) {
340339
logger.debug(

backend/src/tools/handlers/tool/spawn-agents.ts

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type SendSubagentChunk = (data: {
2626
agentType: string
2727
chunk: string
2828
prompt?: string
29+
forwardToPrompt?: boolean
2930
}) => void
3031

3132
type ToolName = 'spawn_agents'
@@ -158,42 +159,49 @@ export const handleSpawnAgents = ((
158159
return
159160
}
160161

161-
// For nested agent events, add parentAgentId to enable proper nesting in UI
162-
if (
163-
chunk.type === 'subagent_start' ||
164-
chunk.type === 'subagent_finish'
165-
) {
166-
logger.debug(
167-
{
168-
eventType: chunk.type,
169-
agentId: chunk.agentId,
170-
parentId: subAgentState.agentId,
171-
parentAgentId: subAgentState.agentId,
172-
},
173-
`spawn-agents: Adding parentAgentId to ${chunk.type} event`,
174-
)
175-
writeToClient({
176-
...chunk,
177-
parentAgentId: subAgentState.agentId,
178-
})
162+
if (chunk.type === 'text') {
163+
if (chunk.text) {
164+
sendSubagentChunk({
165+
userInputId,
166+
agentId: subAgentState.agentId,
167+
agentType,
168+
chunk: chunk.text,
169+
prompt,
170+
})
171+
}
179172
return
180173
}
181174

182-
// For tool calls and results from nested agents, preserve the agentId but add parentAgentId
183-
if (chunk.type === 'tool_call' || chunk.type === 'tool_result') {
184-
logger.debug(
185-
{
186-
eventType: chunk.type,
187-
agentId: (chunk as any).agentId,
188-
parentId: subAgentState.agentId,
189-
parentAgentId: subAgentState.agentId,
190-
},
191-
`spawn-agents: Adding parentAgentId to ${chunk.type} event`,
192-
)
193-
writeToClient({
194-
...chunk,
195-
parentAgentId: subAgentState.agentId,
196-
})
175+
// Add parentAgentId for proper nesting in UI
176+
const ensureParentAgentId = () => {
177+
if (
178+
chunk.type === 'subagent_start' ||
179+
chunk.type === 'subagent_finish'
180+
) {
181+
return (
182+
chunk.parentAgentId ??
183+
subAgentState.parentId ??
184+
parentAgentState?.agentId
185+
)
186+
}
187+
if (
188+
chunk.type === 'tool_call' ||
189+
chunk.type === 'tool_result'
190+
) {
191+
return (chunk as any).parentAgentId ?? subAgentState.agentId
192+
}
193+
return undefined
194+
}
195+
196+
const parentAgentId = ensureParentAgentId()
197+
if (
198+
parentAgentId !== undefined &&
199+
(chunk.type === 'subagent_start' ||
200+
chunk.type === 'subagent_finish' ||
201+
chunk.type === 'tool_call' ||
202+
chunk.type === 'tool_result')
203+
) {
204+
writeToClient({ ...chunk, parentAgentId })
197205
return
198206
}
199207

common/src/actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export const SERVER_ACTION_SCHEMA = z.discriminatedUnion('type', [
143143
agentType: z.string(),
144144
chunk: z.string(),
145145
prompt: z.string().optional(),
146+
forwardToPrompt: z.boolean().optional(),
146147
}),
147148
z.object({
148149
type: z.literal('handlesteps-log-chunk'),

common/src/types/contracts/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type SendSubagentChunkFn = (params: {
3535
agentType: string
3636
chunk: string
3737
prompt?: string | undefined
38+
forwardToPrompt?: boolean
3839
}) => void
3940

4041
export type HandleStepsLogChunkFn = (params: {

npm-app/src/client.ts

Lines changed: 99 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@ export class Client {
209209
private responseComplete: boolean = false
210210
private userInputId: string | undefined
211211
private currentOnChunk: ((chunk: string | PrintModeEvent) => void) | undefined
212+
private onlyChildAgents: Set<string> = new Set()
213+
private emitPromptChunkToParser:
214+
| ((textChunk: string) => boolean)
215+
| undefined
212216

213217
public usageData: UsageData = {
214218
usage: 0,
@@ -952,11 +956,25 @@ export class Client {
952956
})
953957
// Handle subagent streaming messages
954958
this.webSocket.subscribe('subagent-response-chunk', (action) => {
955-
const { agentId, agentType, chunk, prompt } = action
959+
const {
960+
agentId,
961+
agentType,
962+
chunk,
963+
prompt,
964+
forwardToPrompt,
965+
} = action
956966

957967
// Store the chunk locally
958968
storeSubagentChunk({ agentId, agentType, chunk, prompt })
959969

970+
if (
971+
forwardToPrompt !== false &&
972+
this.onlyChildAgents.has(agentId) &&
973+
this.emitPromptChunkToParser
974+
) {
975+
this.emitPromptChunkToParser(chunk)
976+
}
977+
960978
// Refresh display if we're currently viewing this agent
961979
refreshSubagentDisplay(agentId)
962980
})
@@ -1340,6 +1358,44 @@ export class Client {
13401358

13411359
this.userInputId = userInputId
13421360
this.currentOnChunk = onChunk
1361+
this.onlyChildAgents.clear()
1362+
1363+
const emitChunkToParser = (textChunk: string) => {
1364+
rawChunkBuffer.push(textChunk)
1365+
1366+
const trimmed = textChunk.trim()
1367+
for (const tag of ONE_TIME_TAGS) {
1368+
if (trimmed.startsWith(`<${tag}>`) && trimmed.endsWith(closeXml(tag))) {
1369+
if (this.oneTimeFlags[tag]) {
1370+
return true
1371+
}
1372+
Spinner.get().stop()
1373+
const warningMessage = trimmed
1374+
.replace(`<${tag}>`, '')
1375+
.replace(closeXml(tag), '')
1376+
process.stdout.write(yellow(`\n\n${warningMessage}\n\n`))
1377+
this.oneTimeFlags[tag as (typeof ONE_TIME_LABELS)[number]] = true
1378+
return true
1379+
}
1380+
}
1381+
1382+
try {
1383+
xmlStreamParser.write(textChunk, 'utf8')
1384+
} catch (e) {
1385+
logger.error(
1386+
{
1387+
errorMessage: e instanceof Error ? e.message : String(e),
1388+
errorStack: e instanceof Error ? e.stack : undefined,
1389+
chunk: textChunk,
1390+
},
1391+
'Error writing chunk to XML stream parser',
1392+
)
1393+
}
1394+
1395+
return false
1396+
}
1397+
1398+
this.emitPromptChunkToParser = emitChunkToParser
13431399

13441400
const stopResponse = () => {
13451401
responseStopped = true
@@ -1349,6 +1405,8 @@ export class Client {
13491405
this.currentOnChunk = undefined
13501406

13511407
xmlStreamParser.destroy()
1408+
this.emitPromptChunkToParser = undefined
1409+
this.onlyChildAgents.clear()
13521410

13531411
const additionalMessages = prompt
13541412
? [
@@ -1394,45 +1452,47 @@ export class Client {
13941452

13951453
unsubscribeChunks = this.webSocket.subscribe('response-chunk', (a) => {
13961454
if (a.userInputId !== userInputId) return
1397-
if (typeof a.chunk === 'string') {
1398-
const { chunk } = a
1399-
1400-
rawChunkBuffer.push(chunk)
1401-
1402-
const trimmed = chunk.trim()
1403-
for (const tag of ONE_TIME_TAGS) {
1404-
if (
1405-
trimmed.startsWith(`<${tag}>`) &&
1406-
trimmed.endsWith(closeXml(tag))
1407-
) {
1408-
if (this.oneTimeFlags[tag]) {
1409-
return
1410-
}
1411-
Spinner.get().stop()
1412-
const warningMessage = trimmed
1413-
.replace(`<${tag}>`, '')
1414-
.replace(closeXml(tag), '')
1415-
process.stdout.write(yellow(`\n\n${warningMessage}\n\n`))
1416-
this.oneTimeFlags[tag as (typeof ONE_TIME_LABELS)[number]] = true
1417-
return
1418-
}
1455+
const incomingChunk = a.chunk
1456+
1457+
if (typeof incomingChunk === 'string') {
1458+
emitChunkToParser(incomingChunk)
1459+
return
1460+
}
1461+
1462+
if (incomingChunk.type === 'text') {
1463+
printModeLog(incomingChunk)
1464+
// Skip nested subagent text from streaming output
1465+
if (incomingChunk.agentId) return
1466+
emitChunkToParser(incomingChunk.text)
1467+
return
1468+
}
1469+
1470+
if (incomingChunk.type === 'error') {
1471+
const errorText = `${yellow(incomingChunk.message)}\n`
1472+
const handler = this.currentOnChunk
1473+
if (handler) {
1474+
handler(errorText)
1475+
} else {
1476+
Spinner.get().stop()
1477+
DiffManager.receivedResponse()
1478+
process.stdout.write(errorText)
14191479
}
1480+
}
14201481

1421-
try {
1422-
xmlStreamParser.write(chunk, 'utf8')
1423-
} catch (e) {
1424-
logger.error(
1425-
{
1426-
errorMessage: e instanceof Error ? e.message : String(e),
1427-
errorStack: e instanceof Error ? e.stack : undefined,
1428-
chunk,
1429-
},
1430-
'Error writing chunk to XML stream parser',
1431-
)
1482+
// Track only-child agents for proper display
1483+
if ('agentId' in incomingChunk && incomingChunk.agentId) {
1484+
if (
1485+
incomingChunk.type === 'subagent_start' &&
1486+
'onlyChild' in incomingChunk &&
1487+
incomingChunk.onlyChild
1488+
) {
1489+
this.onlyChildAgents.add(incomingChunk.agentId)
1490+
} else if (incomingChunk.type === 'subagent_finish') {
1491+
this.onlyChildAgents.delete(incomingChunk.agentId)
14321492
}
1433-
} else {
1434-
onChunk(a.chunk)
14351493
}
1494+
1495+
onChunk(incomingChunk)
14361496
})
14371497

14381498
let stepsCount = 0
@@ -1445,6 +1505,8 @@ export class Client {
14451505

14461506
if (action.promptId !== userInputId) return
14471507
this.responseComplete = true
1508+
this.emitPromptChunkToParser = undefined
1509+
this.onlyChildAgents.clear()
14481510

14491511
Spinner.get().stop()
14501512

@@ -1552,6 +1614,7 @@ Go to https://www.codebuff.com/config for more information.`) +
15521614

15531615
unsubscribeChunks()
15541616
unsubscribeComplete()
1617+
this.onlyChildAgents.clear()
15551618

15561619
// Clear the onChunk callback when response is complete
15571620
this.currentOnChunk = undefined

0 commit comments

Comments
 (0)