Skip to content

Commit 3819fc9

Browse files
feat(agents): implement proper agent nesting with parentAgentId propagation
Enables correct UI nesting of spawned agents by propagating parentAgentId through the event system. Backend now adds parentAgentId to nested agent events, and CLI uses this to nest spawned agents under their parents instead of showing them at top level. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent c6d3fd3 commit 3819fc9

File tree

8 files changed

+317
-97
lines changed

8 files changed

+317
-97
lines changed

backend/src/run-programmatic-step.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import type {
2020
} from '@codebuff/common/types/messages/content-part'
2121
import type { PrintModeEvent } from '@codebuff/common/types/print-mode'
2222
import type { AgentState } from '@codebuff/common/types/session-state'
23-
import type { ParamsExcluding, ParamsOf } from '@codebuff/common/types/function-params'
23+
import type {
24+
ParamsExcluding,
25+
ParamsOf,
26+
} from '@codebuff/common/types/function-params'
2427
import type { Logger } from '@codebuff/common/types/contracts/logger'
2528
import type { WebSocket } from 'ws'
2629

@@ -271,6 +274,7 @@ export async function runProgrammaticStep(
271274
}
272275

273276
// Execute the tool synchronously and get the result immediately
277+
// Wrap onResponseChunk to add parentAgentId to nested agent events
274278
await executeToolCall({
275279
...params,
276280
toolName: toolCall.toolName,
@@ -284,7 +288,65 @@ export async function runProgrammaticStep(
284288
fullResponse: '',
285289
state,
286290
autoInsertEndStepParam: true,
287-
excludeToolFromMessageHistory,
291+
excludeToolFromMessageHistory, onResponseChunk: (chunk: string | PrintModeEvent) => {
292+
if (typeof chunk === 'string') {
293+
onResponseChunk(chunk)
294+
return
295+
}
296+
297+
// Only add parentAgentId if this programmatic agent has a parent (i.e., it's nested)
298+
// This ensures we don't add parentAgentId to top-level spawns
299+
if (state.agentState.parentId) {
300+
// Add parentAgentId to nested agent events for proper nesting in UI
301+
if (
302+
chunk.type === 'subagent_start' ||
303+
chunk.type === 'subagent_finish'
304+
) {
305+
// Only add parentAgentId if it's not already set (e.g., by spawn_agents)
306+
if (!(chunk as any).parentAgentId) {
307+
logger.debug(
308+
{
309+
eventType: chunk.type,
310+
agentId: chunk.agentId,
311+
parentId: state.agentState.agentId,
312+
},
313+
`run-programmatic-step: Adding parentAgentId to ${chunk.type} event`,
314+
)
315+
onResponseChunk({
316+
...chunk,
317+
parentAgentId: state.agentState.agentId,
318+
})
319+
return
320+
}
321+
}
322+
323+
// Add parentAgentId to tool calls and results from nested agents
324+
if (
325+
chunk.type === 'tool_call' ||
326+
chunk.type === 'tool_result'
327+
) {
328+
// Only add parentAgentId if it's not already set
329+
if (!(chunk as any).parentAgentId) {
330+
logger.debug(
331+
{
332+
eventType: chunk.type,
333+
agentId: (chunk as any).agentId,
334+
parentId: state.agentState.agentId,
335+
},
336+
`run-programmatic-step: Adding parentAgentId to ${chunk.type} event`,
337+
)
338+
onResponseChunk({
339+
...chunk,
340+
parentAgentId: state.agentState.agentId,
341+
})
342+
return
343+
}
344+
}
345+
}
346+
347+
// For other events or top-level spawns, send as-is
348+
onResponseChunk(chunk)
349+
},
288350
})
289351

290352
// TODO: Remove messages from state and always use agentState.messageHistory.

backend/src/tools/handlers/tool/spawn-agent-utils.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,14 +332,22 @@ export async function executeSubagent(
332332
const { onResponseChunk, agentTemplate, parentAgentState, isOnlyChild } =
333333
withDefaults
334334

335-
onResponseChunk({
336-
type: 'subagent_start',
335+
const startEvent = {
336+
type: 'subagent_start' as const,
337337
agentId: withDefaults.agentState.agentId,
338338
agentType: agentTemplate.id,
339339
displayName: agentTemplate.displayName,
340340
onlyChild: isOnlyChild,
341341
parentAgentId: parentAgentState.agentId,
342-
})
342+
}
343+
withDefaults.logger.debug(
344+
{
345+
...startEvent,
346+
parentAgentId: parentAgentState.agentId,
347+
},
348+
'executeSubagent: Sending subagent_start event',
349+
)
350+
onResponseChunk(startEvent)
343351

344352
// Import loopAgentSteps dynamically to avoid circular dependency
345353
const { loopAgentSteps } = await import('../../../run-agent-step')

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

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,42 @@ export const handleSpawnAgents = ((
158158
return
159159
}
160160

161-
// Don't overwrite agentId for events that already have the correct agent ID
162-
// (subagent_start/finish, tool_call, tool_result from nested agents)
161+
// For nested agent events, add parentAgentId to enable proper nesting in UI
163162
if (
164163
chunk.type === 'subagent_start' ||
165-
chunk.type === 'subagent_finish' ||
166-
chunk.type === 'tool_call' ||
167-
chunk.type === 'tool_result'
164+
chunk.type === 'subagent_finish'
168165
) {
169-
writeToClient(chunk)
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+
})
179+
return
180+
}
181+
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+
})
170197
return
171198
}
172199

cli/src/chat.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type ContentBlock =
4949
toolName: ToolName
5050
input: any
5151
output?: string
52+
agentId?: string
5253
}
5354
| {
5455
type: 'agent'

cli/src/components/branch-item.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface BranchItemProps {
2121
name: string
2222
content: ReactNode
2323
prompt?: string
24+
agentId?: string
2425
isCollapsed: boolean
2526
isStreaming: boolean
2627
branchChar: string
@@ -34,6 +35,7 @@ export const BranchItem = ({
3435
name,
3536
content,
3637
prompt,
38+
agentId,
3739
isCollapsed,
3840
isStreaming,
3941
branchChar,

cli/src/components/message-block.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export const MessageBlock = ({
165165
<BranchItem
166166
name={displayInfo.name}
167167
content={displayContent}
168+
agentId={toolBlock.agentId}
168169
isCollapsed={isCollapsed}
169170
isStreaming={isStreaming}
170171
branchChar={branchChar}
@@ -231,6 +232,7 @@ export const MessageBlock = ({
231232
name={agentBlock.agentName}
232233
content={displayContent}
233234
prompt={agentBlock.initialPrompt}
235+
agentId={agentBlock.agentId}
234236
isCollapsed={isCollapsed}
235237
isStreaming={isStreaming}
236238
branchChar={branchChar}

0 commit comments

Comments
 (0)