Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent-runtime/server/agent-event-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ export interface AgentEventPayloads {
callStack: string[]
depth: number
input: string
parentAgentId?: string | null
parentRunId?: string | null
parentToolCallId?: string | null
parentToolId?: string | null
reportBackToParent?: boolean
streamToken: string
}
}
Expand Down
4 changes: 4 additions & 0 deletions agent-runtime/server/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,12 @@ export async function dispatchInvocation(input: {
childAgentId: string
childUserId: string
parentUserId: string
parentAgentId: string
parentRunId: string | null
parentToolId: string
parentToolCallId?: string | null
instruction: string
reportBackToParent?: boolean
streamToken: string
callStack: string[]
depth: number
Expand Down Expand Up @@ -150,9 +152,11 @@ export async function dispatchInvocation(input: {
callStack: input.callStack,
depth: input.depth,
input: input.instruction,
parentAgentId: input.parentAgentId,
parentRunId: input.parentRunId,
parentToolCallId: input.parentToolCallId ?? null,
parentToolId: input.parentToolId,
reportBackToParent: input.reportBackToParent ?? false,
streamToken: input.streamToken,
},
source: 'invocation',
Expand Down
2 changes: 2 additions & 0 deletions agent-runtime/workflows/events/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,11 @@ async function dispatchAgentEvent(event: WorkflowAgentEvent): Promise<void> {
callStack: payload.callStack,
depth: payload.depth,
input: payload.input,
parentAgentId: payload.parentAgentId ?? null,
parentRunId: payload.parentRunId ?? null,
parentToolCallId: payload.parentToolCallId ?? null,
parentToolId: payload.parentToolId ?? null,
reportBackToParent: payload.reportBackToParent ?? false,
parentStream: null,
replyToken: replyNamespaceForEvent(event.id),
streamToken: payload.streamToken,
Expand Down
77 changes: 77 additions & 0 deletions agent-runtime/workflows/session/handlers/handle-invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,26 @@ export async function handleInvocation(input: {
agentId: string
input: string
streamToken: string
parentAgentId?: string | null
parentRunId?: string | null
parentToolId?: string | null
parentToolCallId?: string | null
parentStream?: WritableStream<UIMessageChunk> | null
reportBackToParent?: boolean
replyToken?: string | null
callStack: string[]
depth: number
}): Promise<void> {
const {
agentId,
input: instruction,
parentAgentId,
streamToken,
parentRunId,
parentToolId,
parentToolCallId,
parentStream,
reportBackToParent,
replyToken,
callStack,
depth,
Expand Down Expand Up @@ -151,6 +155,13 @@ export async function handleInvocation(input: {
})
await finishInvocationStreams(streamNamespaces)
await forwardPromise
await maybeReportBackToParent({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make parent report-back failures non-fatal

Awaiting maybeReportBackToParent in the main try means any dispatch error (for example, parent agent deleted/disabled between queue and completion, or a transient DB failure) is treated as a child-run failure, because control falls into failInvocation after the child result has already been finalized. This mislabels successful async sub-agent runs as failed and can break monitoring/automation that depends on run status.

Useful? React with 👍 / 👎.

childAgentId: agentId,
childName: built.meta.name,
finalText: extractFinalAssistantText(result.uiMessages),
parentAgentId: parentAgentId ?? null,
reportBackToParent: reportBackToParent ?? false,
})
} catch (err) {
await failInvocation({
err,
Expand All @@ -163,6 +174,72 @@ export async function handleInvocation(input: {
}
}

async function maybeReportBackToParent(input: {
childAgentId: string
childName: string
finalText: string | null
parentAgentId: string | null
reportBackToParent: boolean
}): Promise<void> {
if (!input.reportBackToParent || !input.parentAgentId || !input.finalText) {
return
}
const { dispatchInvocation } = await import(
'@/agent-runtime/server/session-events'
)
await dispatchInvocation({
childAgentId: input.parentAgentId,
childUserId: await resolveAgentUserId(input.parentAgentId),
parentAgentId: input.childAgentId,
parentUserId: await resolveAgentUserId(input.parentAgentId),
parentRunId: null,
parentToolId: 'sub_agent_report_back',
instruction: [
`Sub-agent "${input.childName}" completed delegated async work.`,
`Result: ${input.finalText}`,
].join('\n'),
streamToken: `report_back_${Date.now().toString(36)}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use collision-resistant token for report-back invocation

The report-back streamToken is derived only from Date.now(), but dispatchInvocation builds idempotency keys from parentToolCallId ?? streamToken (with parentRunId set to root here). If two async children report back to the same parent within the same millisecond, they generate the same idempotency key and one event is dropped by the unique idempotency constraint, causing a lost completion notification.

Useful? React with 👍 / 👎.

callStack: [input.parentAgentId],
depth: 0,
})
}

async function resolveAgentUserId(agentId: string): Promise<string> {
const { db } = await import('@/shared/db')
const { agent } = await import('@/shared/db/schema')
const { eq } = await import('drizzle-orm')
const [row] = await db.select().from(agent).where(eq(agent.id, agentId)).limit(1)
if (!row) {
throw new Error(`resolveAgentUserId: agent ${agentId} not found`)
}
return row.userId
}

function extractFinalAssistantText(
messages: readonly UIMessage[] | undefined
): string | null {
if (!messages) {
return null
}
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== 'assistant') {
continue
}
const chunks: string[] = []
for (const part of message.parts ?? []) {
if (part.type === 'text' && typeof part.text === 'string') {
chunks.push(part.text)
}
}
const text = chunks.join('').trim()
if (text.length > 0) {
return text
}
}
return null
}

async function finishInvocationStreams(
namespaces: readonly string[]
): Promise<void> {
Expand Down
31 changes: 28 additions & 3 deletions tools/sub-agents/agent-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,20 @@ export function buildAgentTool(handle: AgentToolHandle) {
'text returned as the tool result.',
].join(' ')
),
waitForCompletion: z
.boolean()
.optional()
.describe(
[
'Whether to wait for the sub-agent final answer.',
'Set to false for long-running delegated work that does not',
'block your next step; in that case this tool returns immediately',
'after the child run is queued.',
].join(' ')
),
}),
async execute({ instruction }, { toolCallId }) {
async execute({ instruction, waitForCompletion }, { toolCallId }) {
const shouldWait = waitForCompletion ?? true
const streamToken = newInvocationStreamToken()
const messages: AgentChatMessage[] = []
await emitPreliminarySubAgentOutput({
Expand All @@ -64,9 +76,18 @@ export function buildAgentTool(handle: AgentToolHandle) {
const { sessionRunId } = await dispatchSubAgentInvocation({
handle,
instruction,
reportBackToParent: !shouldWait,
streamToken,
toolCallId,
})
if (!shouldWait) {
return subAgentOutput({
finalText: `Sub-agent "${handle.childName}" started asynchronously (run: ${sessionRunId}). Continue your current work and use this run id to inspect completion later.`,
handle,
messages,
status: 'completed',
})
}
const { error, messages: childMessages } =
await collectSubAgentMessages({
progress: {
Expand Down Expand Up @@ -132,22 +153,26 @@ function modelOutputText(output: unknown): string {
async function dispatchSubAgentInvocation(input: {
handle: AgentToolHandle
instruction: string
reportBackToParent: boolean
streamToken: string
toolCallId: string
}): Promise<{ sessionRunId: string }> {
'use step'
const { handle, instruction, streamToken, toolCallId } = input
const { handle, instruction, reportBackToParent, streamToken, toolCallId } =
input
const { dispatchInvocation } = await import(
'@/agent-runtime/server/session-events'
)
return await dispatchInvocation({
childAgentId: handle.childAgentId,
childUserId: handle.childUserId,
parentAgentId: handle.parentAgentId,
parentUserId: handle.parentUserId,
parentRunId: handle.parentRunId,
parentToolId: handle.parentToolId,
parentToolCallId: toolCallId,
instruction,
reportBackToParent,
streamToken,
callStack: [...handle.parentCallStack, handle.parentAgentId],
depth: handle.parentDepth + 1,
Expand Down Expand Up @@ -210,7 +235,7 @@ function composeDescription(handle: AgentToolHandle): string {
(summary ? `Capability summary: ${summary} ` : '') +
'Provide a fully self-contained instruction; the sub-agent does ' +
'not see your conversation, memory, or files unless you include ' +
"them in the instruction. Returns the sub-agent's final text reply."
'them in the instruction. By default this tool waits for the sub-agent final text reply, but you can set waitForCompletion=false for long-running work and continue immediately.'
)
}

Expand Down