diff --git a/agent-runtime/server/agent-event-store.ts b/agent-runtime/server/agent-event-store.ts index 2dc0e71d..bfd69533 100644 --- a/agent-runtime/server/agent-event-store.ts +++ b/agent-runtime/server/agent-event-store.ts @@ -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 } } diff --git a/agent-runtime/server/session-events.ts b/agent-runtime/server/session-events.ts index c737ac1c..d7064128 100644 --- a/agent-runtime/server/session-events.ts +++ b/agent-runtime/server/session-events.ts @@ -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 @@ -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', diff --git a/agent-runtime/workflows/events/workflow.ts b/agent-runtime/workflows/events/workflow.ts index 4027727b..29c8311b 100644 --- a/agent-runtime/workflows/events/workflow.ts +++ b/agent-runtime/workflows/events/workflow.ts @@ -137,9 +137,11 @@ async function dispatchAgentEvent(event: WorkflowAgentEvent): Promise { 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, diff --git a/agent-runtime/workflows/session/handlers/handle-invocation.ts b/agent-runtime/workflows/session/handlers/handle-invocation.ts index 5b480dfe..4105ac3c 100644 --- a/agent-runtime/workflows/session/handlers/handle-invocation.ts +++ b/agent-runtime/workflows/session/handlers/handle-invocation.ts @@ -29,10 +29,12 @@ 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 | null + reportBackToParent?: boolean replyToken?: string | null callStack: string[] depth: number @@ -40,11 +42,13 @@ export async function handleInvocation(input: { const { agentId, input: instruction, + parentAgentId, streamToken, parentRunId, parentToolId, parentToolCallId, parentStream, + reportBackToParent, replyToken, callStack, depth, @@ -151,6 +155,13 @@ export async function handleInvocation(input: { }) await finishInvocationStreams(streamNamespaces) await forwardPromise + await maybeReportBackToParent({ + childAgentId: agentId, + childName: built.meta.name, + finalText: extractFinalAssistantText(result.uiMessages), + parentAgentId: parentAgentId ?? null, + reportBackToParent: reportBackToParent ?? false, + }) } catch (err) { await failInvocation({ err, @@ -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 { + 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)}`, + callStack: [input.parentAgentId], + depth: 0, + }) +} + +async function resolveAgentUserId(agentId: string): Promise { + 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 { diff --git a/tools/sub-agents/agent-tool.ts b/tools/sub-agents/agent-tool.ts index 470082f7..b3beb0ee 100644 --- a/tools/sub-agents/agent-tool.ts +++ b/tools/sub-agents/agent-tool.ts @@ -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({ @@ -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: { @@ -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, @@ -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.' ) }