diff --git a/lib/agent/messageMetadata/__tests__/sumLanguageModelUsage.test.ts b/lib/agent/messageMetadata/__tests__/sumLanguageModelUsage.test.ts new file mode 100644 index 000000000..403bbe5ab --- /dev/null +++ b/lib/agent/messageMetadata/__tests__/sumLanguageModelUsage.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { sumLanguageModelUsage } from "@/lib/agent/messageMetadata/sumLanguageModelUsage"; + +describe("sumLanguageModelUsage", () => { + it("returns undefined when both inputs are undefined", () => { + expect(sumLanguageModelUsage(undefined, undefined)).toBeUndefined(); + }); + + it("returns the second input when first is undefined", () => { + const u = { inputTokens: 100, outputTokens: 50 }; + expect(sumLanguageModelUsage(undefined, u as never)).toBe(u); + }); + + it("returns the first input when second is undefined", () => { + const u = { inputTokens: 100, outputTokens: 50 }; + expect(sumLanguageModelUsage(u as never, undefined)).toBe(u); + }); + + it("sums the two inputs pointwise when both are present", () => { + const result = sumLanguageModelUsage( + { inputTokens: 100, outputTokens: 50 } as never, + { inputTokens: 200, outputTokens: 75 } as never, + ); + expect(result?.inputTokens).toBe(300); + expect(result?.outputTokens).toBe(125); + }); +}); diff --git a/lib/agent/messageMetadata/sumLanguageModelUsage.ts b/lib/agent/messageMetadata/sumLanguageModelUsage.ts new file mode 100644 index 000000000..2f400f33b --- /dev/null +++ b/lib/agent/messageMetadata/sumLanguageModelUsage.ts @@ -0,0 +1,21 @@ +import type { LanguageModelUsage } from "ai"; +import { addLanguageModelUsage } from "@/lib/agent/messageMetadata/addLanguageModelUsage"; + +/** + * Sum two optional `LanguageModelUsage` records. Returns the sum when + * both are defined, the defined one when only one is, or `undefined` + * when neither is. Mirrors open-agents' `sumLanguageModelUsage` in + * `packages/agent/usage.ts`. + * + * Used by the `task` tool's progress streaming to accumulate usage + * across subagent steps without introducing zero-tokens placeholders + * before the first step finishes. + */ +export function sumLanguageModelUsage( + a: LanguageModelUsage | undefined, + b: LanguageModelUsage | undefined, +): LanguageModelUsage | undefined { + if (!a) return b; + if (!b) return a; + return addLanguageModelUsage(a, b); +} diff --git a/lib/agent/tools/__tests__/askUserQuestionTool.test.ts b/lib/agent/tools/__tests__/askUserQuestionTool.test.ts index ee55e6305..79995551a 100644 --- a/lib/agent/tools/__tests__/askUserQuestionTool.test.ts +++ b/lib/agent/tools/__tests__/askUserQuestionTool.test.ts @@ -81,14 +81,16 @@ describe("askUserQuestionTool — server-side wiring", () => { describe("askUserQuestionTool.toModelOutput", () => { it("returns a generic message when no output is present", () => { - expect(askUserQuestionTool.toModelOutput!(undefined as never)).toEqual({ + expect(askUserQuestionTool.toModelOutput!({ output: undefined } as never)).toEqual({ type: "text", value: "User did not respond to questions.", }); }); it("formats `declined: true` as a clear decline message", () => { - const result = askUserQuestionTool.toModelOutput!({ declined: true } as never); + const result = askUserQuestionTool.toModelOutput!({ + output: { declined: true }, + } as never); expect(result).toMatchObject({ type: "text", value: expect.stringMatching(/declined to answer/i), @@ -97,9 +99,11 @@ describe("askUserQuestionTool.toModelOutput", () => { it("formats answered questions as a parseable Q=A summary", () => { const result = askUserQuestionTool.toModelOutput!({ - answers: { - "Which model do you want?": "Haiku", - "Which features?": ["Streaming", "Tools"], + output: { + answers: { + "Which model do you want?": "Haiku", + "Which features?": ["Streaming", "Tools"], + }, }, } as never); expect(result).toMatchObject({ diff --git a/lib/agent/tools/__tests__/taskTool.test.ts b/lib/agent/tools/__tests__/taskTool.test.ts index 609037918..8e876afdb 100644 --- a/lib/agent/tools/__tests__/taskTool.test.ts +++ b/lib/agent/tools/__tests__/taskTool.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { taskTool } from "@/lib/agent/tools/taskTool"; +import { taskTool, type TaskToolOutput } from "@/lib/agent/tools/taskTool"; import { streamText } from "ai"; import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; @@ -12,79 +12,176 @@ vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({ connectVercel: vi.fn(), })); -// `model` is normally attached by `runAgentStep` before the subagent -// sees the context. The opaque sentinel below is enough for taskTool -// to pass it into `streamText` — we assert the same instance flows -// through. -const mainModel = { __sentinel: "main-model" } as never; -const subagentModelOverride = { __sentinel: "subagent-model" } as never; +const mainModel = { modelId: "anthropic/claude-haiku-4.5" } as never; +const subagentModelOverride = { modelId: "anthropic/claude-sonnet-4.6" } as never; const ctx = { sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" }, model: mainModel, }; -function makeStreamTextResult(finalText: string) { +function makeStreamResult(opts: { + toolCalls?: Array<{ toolName: string; input: unknown }>; + finishSteps?: number; + responseMessages?: Array<{ role: string; content: unknown }>; + totalUsage?: unknown; +}) { + const calls = opts.toolCalls ?? []; + const finishCount = opts.finishSteps ?? 1; return { fullStream: (async function* () { - // empty — execute only awaits `result.finishReason` + result.response + for (const c of calls) { + yield { type: "tool-call", toolName: c.toolName, input: c.input }; + } + for (let i = 0; i < finishCount; i++) { + yield { + type: "finish-step", + usage: { inputTokens: 100, outputTokens: 25, totalTokens: 125 }, + }; + } })(), + response: Promise.resolve({ messages: opts.responseMessages ?? [] }), + totalUsage: Promise.resolve(opts.totalUsage ?? { inputTokens: 0, outputTokens: 0 }), finishReason: Promise.resolve("stop"), - response: Promise.resolve({ - messages: [ - { - role: "assistant", - content: [{ type: "text", text: finalText }], - }, - ], - }), }; } +async function drainGenerator(gen: AsyncGenerator | AsyncIterable): Promise { + const out: T[] = []; + for await (const chunk of gen) out.push(chunk); + return out; +} + beforeEach(() => { vi.clearAllMocks(); vi.mocked(connectVercel).mockResolvedValue({ workingDirectory: "/sandbox/mono" } as never); }); -describe("taskTool.execute", () => { - it("runs a sub-streamText with the subagent system prompt + task + instructions", async () => { - vi.mocked(streamText).mockReturnValue(makeStreamTextResult("Task done.") as never); - const result = (await taskTool.execute!( - { task: "Find the largest .ts file", instructions: "Use glob and stat to find it" }, - { experimental_context: ctx } as never, - )) as { success: boolean; summary: string }; - expect(result.success).toBe(true); - expect(result.summary).toBe("Task done."); - const args = vi.mocked(streamText).mock.calls[0]?.[0] as Record; - // system prompt contains task + instructions so the subagent knows its scope - expect(args.system).toEqual(expect.stringContaining("Find the largest .ts file")); - expect(args.system).toEqual(expect.stringContaining("Use glob and stat")); +describe("taskTool.execute (async generator)", () => { + it("yields an initial chunk with toolCallCount=0 + startedAt + modelId before the subagent does any work", async () => { + vi.mocked(streamText).mockReturnValue(makeStreamResult({}) as never); + const gen = taskTool.execute!({ task: "x", instructions: "y" }, { + experimental_context: ctx, + } as never) as AsyncGenerator; + const first = await gen.next(); + expect(first.done).toBe(false); + expect(first.value).toMatchObject({ + toolCallCount: 0, + modelId: "anthropic/claude-haiku-4.5", + }); + expect(first.value.startedAt).toBeTypeOf("number"); + // Drain to finish. + await drainGenerator(gen); }); - it("registers only the executor tool set (no recursion, no task/ask/skill/todo/fetch)", async () => { - vi.mocked(streamText).mockReturnValue(makeStreamTextResult("done") as never); - await taskTool.execute!({ task: "x", instructions: "y" }, { - experimental_context: ctx, - } as never); + it("emits a `pending` chunk with name + input on every tool-call", async () => { + vi.mocked(streamText).mockReturnValue( + makeStreamResult({ + toolCalls: [ + { toolName: "bash", input: { command: "ls" } }, + { toolName: "read", input: { path: "/foo" } }, + ], + finishSteps: 1, + responseMessages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }], + }) as never, + ); + const chunks = (await drainGenerator( + taskTool.execute!({ task: "x", instructions: "y" }, { + experimental_context: ctx, + } as never) as AsyncGenerator, + )) as TaskToolOutput[]; + // Two tool-call yields + one finish-step yield (sticky pending so the + // UI doesn't flicker back to an initializing state between steps). + const pendingChunks = chunks.filter(c => c.pending); + expect(pendingChunks).toHaveLength(3); + expect(pendingChunks[0]?.pending).toEqual({ name: "bash", input: { command: "ls" } }); + expect(pendingChunks[0]?.toolCallCount).toBe(1); + expect(pendingChunks[1]?.pending).toEqual({ name: "read", input: { path: "/foo" } }); + expect(pendingChunks[1]?.toolCallCount).toBe(2); + // Finish-step keeps the most recent pending sticky. + expect(pendingChunks[2]?.pending).toEqual({ name: "read", input: { path: "/foo" } }); + }); + + it("accumulates usage across finish-step parts", async () => { + vi.mocked(streamText).mockReturnValue( + makeStreamResult({ + finishSteps: 2, + responseMessages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }], + }) as never, + ); + const chunks = (await drainGenerator( + taskTool.execute!({ task: "x", instructions: "y" }, { + experimental_context: ctx, + } as never) as AsyncGenerator, + )) as TaskToolOutput[]; + const usageChunks = chunks.filter(c => c.usage); + // 2 finish-step yields + 1 final yield = 3 chunks carrying usage + expect(usageChunks.length).toBeGreaterThanOrEqual(2); + const last = usageChunks[usageChunks.length - 1]!; + expect(last.usage).toMatchObject({ inputTokens: 200, outputTokens: 50 }); + }); + + it("emits a final chunk containing the subagent's full response.messages transcript", async () => { + const responseMessages = [ + { role: "assistant", content: [{ type: "tool-call", toolName: "bash" }] }, + { role: "tool", content: [{ type: "tool-result", output: "..." }] }, + { role: "assistant", content: [{ type: "text", text: "Done." }] }, + ]; + vi.mocked(streamText).mockReturnValue(makeStreamResult({ responseMessages }) as never); + const chunks = (await drainGenerator( + taskTool.execute!({ task: "x", instructions: "y" }, { + experimental_context: ctx, + } as never) as AsyncGenerator, + )) as TaskToolOutput[]; + const finalChunk = chunks.find(c => c.final); + expect(finalChunk).toBeDefined(); + expect(finalChunk!.final).toEqual(responseMessages); + expect(finalChunk!.toolCallCount).toBe(0); + expect(finalChunk!.usage).toBeDefined(); + }); + + it("uses the subagentModel override when set on the agent context", async () => { + vi.mocked(streamText).mockReturnValue(makeStreamResult({}) as never); + await drainGenerator( + taskTool.execute!({ task: "x", instructions: "y" }, { + experimental_context: { ...ctx, subagentModel: subagentModelOverride }, + } as never) as AsyncGenerator, + ); + const args = vi.mocked(streamText).mock.calls[0]?.[0] as { model: unknown }; + expect(args.model).toBe(subagentModelOverride); + }); + + it("throws when agent context is missing the `model` field", async () => { + const gen = taskTool.execute!({ task: "x", instructions: "y" }, { + experimental_context: { sandbox: ctx.sandbox /* no model */ }, + } as never) as AsyncGenerator; + await expect(gen.next()).rejects.toThrow(/model not initialized/i); + }); + + it("registers only the executor tool set on the inner streamText call", async () => { + vi.mocked(streamText).mockReturnValue(makeStreamResult({}) as never); + await drainGenerator( + taskTool.execute!({ task: "x", instructions: "y" }, { + experimental_context: ctx, + } as never) as AsyncGenerator, + ); const args = vi.mocked(streamText).mock.calls[0]?.[0] as { tools: Record }; - const toolNames = Object.keys(args.tools).sort(); - expect(toolNames).toEqual(["bash", "edit", "glob", "grep", "read", "write"]); - // Critical: NO task (recursion guard) and NO client-side tools. - expect(args.tools).not.toHaveProperty("task"); - expect(args.tools).not.toHaveProperty("ask_user_question"); - expect(args.tools).not.toHaveProperty("skill"); - expect(args.tools).not.toHaveProperty("todo_write"); - expect(args.tools).not.toHaveProperty("web_fetch"); + expect(Object.keys(args.tools).sort()).toEqual([ + "bash", + "edit", + "glob", + "grep", + "read", + "write", + ]); }); - it("passes a non-empty prompt so the model has something to act on", async () => { - // Regression: a previous version called streamText with `messages: []`, - // which caused the AI SDK to throw NoOutputGeneratedError because zero - // steps were recorded — the model had a system prompt but no user turn - // to respond to. The subagent must receive an explicit user-side trigger. - vi.mocked(streamText).mockReturnValue(makeStreamTextResult("done") as never); - await taskTool.execute!({ task: "x", instructions: "y" }, { - experimental_context: ctx, - } as never); + it("passes a non-empty prompt so the model has something to act on (NoOutputGeneratedError regression)", async () => { + vi.mocked(streamText).mockReturnValue(makeStreamResult({}) as never); + await drainGenerator( + taskTool.execute!({ task: "x", instructions: "y" }, { + experimental_context: ctx, + } as never) as AsyncGenerator, + ); const args = vi.mocked(streamText).mock.calls[0]?.[0] as { prompt?: string; messages?: unknown[]; @@ -93,54 +190,46 @@ describe("taskTool.execute", () => { const hasMessages = Array.isArray(args.messages) && args.messages.length > 0; expect(hasPrompt || hasMessages).toBe(true); }); +}); - it("inherits the parent's `model` from agent context when no subagentModel override is set", async () => { - vi.mocked(streamText).mockReturnValue(makeStreamTextResult("done") as never); - await taskTool.execute!({ task: "x", instructions: "y" }, { - experimental_context: ctx, - } as never); - const args = vi.mocked(streamText).mock.calls[0]?.[0] as { model: unknown }; - expect(args.model).toBe(mainModel); +describe("taskTool.toModelOutput", () => { + it("returns 'Task completed.' when no `final` is present", () => { + const out = taskTool.toModelOutput!({ output: {} } as never); + expect(out).toEqual({ type: "text", value: "Task completed." }); }); - it("prefers `subagentModel` over `model` when both are set on the context", async () => { - vi.mocked(streamText).mockReturnValue(makeStreamTextResult("done") as never); - await taskTool.execute!({ task: "x", instructions: "y" }, { - experimental_context: { ...ctx, subagentModel: subagentModelOverride }, + it("extracts the last assistant text part from the transcript", () => { + const out = taskTool.toModelOutput!({ + output: { + final: [ + { role: "assistant", content: [{ type: "tool-call", toolName: "bash" }] }, + { role: "tool", content: [{ type: "tool-result" }] }, + { + role: "assistant", + content: [ + { type: "tool-call", toolName: "read" }, + { type: "text", text: "Found 3 files." }, + ], + }, + ], + }, } as never); - const args = vi.mocked(streamText).mock.calls[0]?.[0] as { model: unknown }; - expect(args.model).toBe(subagentModelOverride); + expect(out).toEqual({ type: "text", value: "Found 3 files." }); }); - it("returns success:false when no assistant text is in the response", async () => { - vi.mocked(streamText).mockReturnValue({ - fullStream: (async function* () {})(), - finishReason: Promise.resolve("stop"), - response: Promise.resolve({ messages: [] }), + it("handles a string-valued content directly", () => { + const out = taskTool.toModelOutput!({ + output: { final: [{ role: "assistant", content: "plain text reply" }] }, } as never); - const result = (await taskTool.execute!({ task: "x", instructions: "y" }, { - experimental_context: ctx, - } as never)) as { success: boolean; summary: string }; - expect(result.success).toBe(false); - expect(result.summary).toMatch(/no.*assistant/i); - }); - - it("returns success:false with a descriptive error when streamText throws", async () => { - vi.mocked(streamText).mockImplementation(() => { - throw new Error("gateway down"); - }); - const result = (await taskTool.execute!({ task: "x", instructions: "y" }, { - experimental_context: ctx, - } as never)) as { success: boolean; error: string }; - expect(result.success).toBe(false); - expect(result.error).toMatch(/gateway down/); + expect(out).toEqual({ type: "text", value: "plain text reply" }); }); - it("throws when agent context is missing the `model` field", async () => { - await expect( - taskTool.execute!({ task: "x", instructions: "y" }, { - experimental_context: { sandbox: ctx.sandbox /* no model */ }, - } as never), - ).rejects.toThrow(/model not initialized/i); + it("falls back to 'Task completed.' when the last assistant message has no text parts", () => { + const out = taskTool.toModelOutput!({ + output: { + final: [{ role: "assistant", content: [{ type: "tool-call", toolName: "bash" }] }], + }, + } as never); + expect(out).toEqual({ type: "text", value: "Task completed." }); }); }); diff --git a/lib/agent/tools/askUserQuestionTool.ts b/lib/agent/tools/askUserQuestionTool.ts index 8d5e1f4ed..1e15b27f4 100644 --- a/lib/agent/tools/askUserQuestionTool.ts +++ b/lib/agent/tools/askUserQuestionTool.ts @@ -57,7 +57,7 @@ Usage notes: outputSchema: askUserQuestionOutputSchema, // NO execute: this is a client-side tool. streamText halts the run after // emitting the tool-call; the chat UI fulfills it asynchronously. - toModelOutput: output => { + toModelOutput: ({ output }) => { if (!output) { return { type: "text", value: "User did not respond to questions." }; } diff --git a/lib/agent/tools/taskTool.ts b/lib/agent/tools/taskTool.ts index 83381d58f..270974fce 100644 --- a/lib/agent/tools/taskTool.ts +++ b/lib/agent/tools/taskTool.ts @@ -1,7 +1,8 @@ -import { streamText, stepCountIs, tool } from "ai"; +import { streamText, stepCountIs, tool, type LanguageModelUsage, type ModelMessage } from "ai"; import { z } from "zod"; import { buildSubagentTools } from "@/lib/agent/tools/buildSubagentTools"; import { getSubagentModel } from "@/lib/agent/tools/getSubagentModel"; +import { sumLanguageModelUsage } from "@/lib/agent/messageMetadata/sumLanguageModelUsage"; const SUBAGENT_STEP_LIMIT = 30; @@ -20,6 +21,32 @@ const taskInputSchema = z.object({ ), }); +const taskPendingToolCallSchema = z.object({ + name: z.string(), + input: z.unknown(), +}); + +export type TaskPendingToolCall = z.infer; + +/** + * Output schema mirrors open-agents' `taskOutputSchema` + * (`packages/agent/tools/task.ts`) so the chat UI can render the same + * live progress card and expandable subagent transcript when cut over + * to api's `/api/chat/workflow`. The `execute` is an async generator + * that yields multiple chunks during the subagent run; the AI SDK + * pipes each yield through `tool-output-available`. + */ +const taskOutputSchema = z.object({ + pending: taskPendingToolCallSchema.optional(), + toolCallCount: z.number().int().nonnegative().optional(), + startedAt: z.number().int().nonnegative().optional(), + modelId: z.string().optional(), + final: z.custom().optional(), + usage: z.custom().optional(), +}); + +export type TaskToolOutput = z.infer; + const SUBAGENT_SYSTEM_PROMPT = `You are a focused subagent invoked by a parent agent. Run autonomously — do not ask the user clarifying questions. Complete the delegated task using the tools you have, then return a concise summary of what you did. Constraints: @@ -35,9 +62,11 @@ Constraints: * concise summary that the parent can incorporate. * * Slim port of open-agents' multi-type SUBAGENT_REGISTRY → single - * generic subagent. Streaming progress isn't piped to the UI (the - * parent sees one long-running tool call until completion); add an - * async-generator execute later if live progress matters. + * generic subagent, but the live-progress streaming pattern is a + * faithful port: the execute is `async function*`, yielding + * `{pending, toolCallCount, usage, modelId, startedAt}` chunks + * throughout the subagent run and a final `{final: ModelMessage[], …}` + * chunk carrying the full subagent transcript for UI rendering. */ export const taskTool = tool({ description: `Launch a subagent to handle complex tasks autonomously. @@ -66,57 +95,81 @@ IMPORTANT: - Include critical context (APIs, function names, file paths) in the instructions - The parent agent does not see the subagent's internal tool calls, only its final summary`, inputSchema: taskInputSchema, - execute: async ({ task, instructions }, { experimental_context, abortSignal }) => { - // Resolves to ctx.subagentModel ?? ctx.model, throwing if context - // wasn't populated by runAgentStep. Mirrors open-agents' task tool - // (`getSubagentModel(experimental_context, "task")`). + outputSchema: taskOutputSchema, + execute: async function* ({ task, instructions }, { experimental_context, abortSignal }) { const subagentModel = getSubagentModel(experimental_context, "task"); - - try { - // `prompt` (not `messages: []`) is required — the AI SDK records zero - // steps and throws NoOutputGeneratedError if the model has only a - // system prompt with no user turn. Mirrors open-agents' task tool. - const result = streamText({ - model: subagentModel, - system: `${SUBAGENT_SYSTEM_PROMPT}\n\n## Your Task\n${task}\n\n## Instructions\n${instructions}`, - prompt: "Complete this task and provide a summary of what you accomplished.", - tools: buildSubagentTools(), - stopWhen: stepCountIs(SUBAGENT_STEP_LIMIT), - experimental_context, - abortSignal, - }); - - // Drain fullStream so the subagent actually runs to completion. - // Streaming progress back to the parent UI is not wired in this slim - // port — the parent sees one long-running tool call until the - // subagent finishes. - for await (const _part of result.fullStream) { - void _part; - } - - const response = await result.response; - const lastAssistant = response.messages.findLast(m => m.role === "assistant"); - const content = lastAssistant?.content; - - let summary = ""; - if (typeof content === "string") { - summary = content; - } else if (Array.isArray(content)) { - const lastText = content.findLast(p => p.type === "text"); - if (lastText && "text" in lastText) summary = lastText.text; + const subagentModelId = + typeof subagentModel === "string" + ? subagentModel + : (subagentModel as { modelId?: string }).modelId; + + // `prompt` (not `messages: []`) is required — the AI SDK records zero + // steps and throws NoOutputGeneratedError if the model has only a + // system prompt with no user turn. Mirrors open-agents' task tool. + const result = streamText({ + model: subagentModel, + system: `${SUBAGENT_SYSTEM_PROMPT}\n\n## Your Task\n${task}\n\n## Instructions\n${instructions}`, + prompt: "Complete this task and provide a summary of what you accomplished.", + tools: buildSubagentTools(), + stopWhen: stepCountIs(SUBAGENT_STEP_LIMIT), + experimental_context, + abortSignal, + }); + + const startedAt = Date.now(); + let toolCallCount = 0; + let pending: TaskPendingToolCall | undefined; + let usage: LanguageModelUsage | undefined; + + // Emit an initial chunk so the UI can render elapsed time from a + // stable timestamp and show "Subagent · 0 tools · 0 tokens" before + // the first step finishes. + yield { toolCallCount, startedAt, modelId: subagentModelId }; + + for await (const part of result.fullStream) { + if (part.type === "tool-call") { + toolCallCount += 1; + pending = { name: part.toolName, input: part.input }; + yield { pending, toolCallCount, usage, startedAt, modelId: subagentModelId }; } - if (!summary) { - return { - success: false, - summary: "Subagent finished with no assistant text. The task may be incomplete.", - }; + if (part.type === "finish-step") { + usage = sumLanguageModelUsage(usage, part.usage); + // Keep the last observed `pending` so task UIs don't flicker + // back to an initializing state between subagent steps. + yield { pending, toolCallCount, usage, startedAt, modelId: subagentModelId }; } - - return { success: true, summary }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { success: false, error: `Subagent failed: ${message}` }; } + + const response = await result.response; + const finalUsage = usage ?? (await result.totalUsage); + yield { + final: response.messages, + toolCallCount, + usage: finalUsage, + startedAt, + modelId: subagentModelId, + }; + }, + /** + * Extract the last assistant text from the subagent's transcript + * for inclusion in the parent agent's context. Mirrors open-agents' + * `toModelOutput` (`packages/agent/tools/task.ts`). Operates on the + * FINAL yielded chunk's `output.final`. + */ + toModelOutput: ({ output }) => { + const messages = output?.final; + if (!messages) return { type: "text", value: "Task completed." }; + + const lastAssistant = messages.findLast(m => m.role === "assistant"); + const content = lastAssistant?.content; + if (!content) return { type: "text", value: "Task completed." }; + + if (typeof content === "string") return { type: "text", value: content }; + + const lastTextPart = content.findLast(p => p.type === "text"); + if (!lastTextPart) return { type: "text", value: "Task completed." }; + + return { type: "text", value: lastTextPart.text }; }, });