diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index a60d1a75b65..302fbe3454c 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -29,3 +29,34 @@ export const historyItemSchema = z.object({ }) export type HistoryItem = z.infer + +/** + * SubtaskSummary + * + * Structured metadata produced when a subtask completes via attempt_completion + * and hands off context back to its parent task. This enriches the handoff + * with visibility into what the subtask actually did. + */ +export const subtaskSummarySchema = z.object({ + /** The completion result text from attempt_completion */ + result: z.string(), + /** Mode slug the subtask ran in (e.g. "code", "architect") */ + mode: z.string().optional(), + /** Files that were created or modified (write_to_file, apply_diff, insert_content) */ + filesModified: z.array(z.string()).optional(), + /** Files that were read during the subtask */ + filesRead: z.array(z.string()).optional(), + /** Shell commands that were executed */ + commandsExecuted: z.array(z.string()).optional(), + /** Summary of tool usage counts: tool name -> number of attempts */ + toolUsageSummary: z.record(z.string(), z.number()).optional(), + /** Todo list status at completion: [completed, total] */ + todoStats: z + .object({ + completed: z.number(), + total: z.number(), + }) + .optional(), +}) + +export type SubtaskSummary = z.infer diff --git a/src/core/task/__tests__/buildSubtaskSummary.spec.ts b/src/core/task/__tests__/buildSubtaskSummary.spec.ts new file mode 100644 index 00000000000..055c8f556b5 --- /dev/null +++ b/src/core/task/__tests__/buildSubtaskSummary.spec.ts @@ -0,0 +1,308 @@ +import { buildSubtaskSummary, formatSubtaskSummaryForApi, type SubtaskContext } from "../buildSubtaskSummary" + +function createContext(overrides: Partial = {}): SubtaskContext { + return { + apiConversationHistory: [], + toolUsage: {}, + todoList: undefined, + taskMode: "code", + ...overrides, + } +} + +describe("buildSubtaskSummary", () => { + it("should return a minimal summary with just result and mode", () => { + const context = createContext() + const summary = buildSubtaskSummary(context, "Task completed successfully") + + expect(summary.result).toBe("Task completed successfully") + expect(summary.mode).toBe("code") + expect(summary.filesModified).toBeUndefined() + expect(summary.filesRead).toBeUndefined() + expect(summary.commandsExecuted).toBeUndefined() + expect(summary.toolUsageSummary).toBeUndefined() + expect(summary.todoStats).toBeUndefined() + }) + + it("should extract files modified from write_to_file tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "write_to_file", + input: { path: "src/index.ts", content: "hello" }, + }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toEqual(["src/index.ts"]) + }) + + it("should extract files modified from apply_diff tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_2", + name: "apply_diff", + input: { path: "src/utils.ts", diff: "--- a\n+++ b" }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toEqual(["src/utils.ts"]) + }) + + it("should extract files read from read_file tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_3", + name: "read_file", + input: { path: "package.json" }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesRead).toEqual(["package.json"]) + }) + + it("should extract commands from execute_command tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_4", + name: "execute_command", + input: { command: "npm test" }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.commandsExecuted).toEqual(["npm test"]) + }) + + it("should truncate very long commands", () => { + const longCmd = "a".repeat(200) + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_5", + name: "execute_command", + input: { command: longCmd }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.commandsExecuted![0].length).toBeLessThanOrEqual(120) + expect(summary.commandsExecuted![0].endsWith("...")).toBe(true) + }) + + it("should deduplicate modified files", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_6", + name: "write_to_file", + input: { path: "src/index.ts", content: "v1" }, + }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_6", content: "ok" }] }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_7", + name: "apply_diff", + input: { path: "src/index.ts", diff: "diff" }, + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toEqual(["src/index.ts"]) + }) + + it("should include tool usage summary from toolUsage", () => { + const context = createContext({ + toolUsage: { + write_to_file: { attempts: 3, failures: 0 }, + read_file: { attempts: 5, failures: 1 }, + } as any, + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.toolUsageSummary).toEqual({ + write_to_file: 3, + read_file: 5, + }) + }) + + it("should include todo stats when todoList is present", () => { + const context = createContext({ + todoList: [ + { id: "1", task: "Do A", status: "completed" }, + { id: "2", task: "Do B", status: "completed" }, + { id: "3", task: "Do C", status: "pending" }, + ] as any, + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.todoStats).toEqual({ completed: 2, total: 3 }) + }) + + it("should skip user messages when scanning for tool_use blocks", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "user", + content: [ + { + type: "tool_result" as any, + tool_use_id: "toolu_x", + content: "ok", + }, + ], + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toBeUndefined() + expect(summary.commandsExecuted).toBeUndefined() + }) + + it("should handle empty conversation history", () => { + const context = createContext({ apiConversationHistory: [] }) + const summary = buildSubtaskSummary(context, "Nothing happened") + + expect(summary.result).toBe("Nothing happened") + expect(summary.mode).toBe("code") + }) + + it("should handle messages with non-array content (string content)", () => { + const context = createContext({ + apiConversationHistory: [ + { + role: "assistant", + content: "Just text response", + }, + ], + }) + + const summary = buildSubtaskSummary(context, "Done") + expect(summary.filesModified).toBeUndefined() + }) +}) + +describe("formatSubtaskSummaryForApi", () => { + it("should format a minimal summary", () => { + const text = formatSubtaskSummaryForApi({ result: "All done" }) + expect(text).toContain("## Result\nAll done") + }) + + it("should include mode section", () => { + const text = formatSubtaskSummaryForApi({ result: "Done", mode: "architect" }) + expect(text).toContain("## Mode\narchitect") + }) + + it("should include files modified section", () => { + const text = formatSubtaskSummaryForApi({ + result: "Done", + filesModified: ["src/a.ts", "src/b.ts"], + }) + expect(text).toContain("## Files Modified") + expect(text).toContain("- src/a.ts") + expect(text).toContain("- src/b.ts") + }) + + it("should include files read section", () => { + const text = formatSubtaskSummaryForApi({ + result: "Done", + filesRead: ["package.json"], + }) + expect(text).toContain("## Files Read") + expect(text).toContain("- package.json") + }) + + it("should include commands section", () => { + const text = formatSubtaskSummaryForApi({ + result: "Done", + commandsExecuted: ["npm test", "npm build"], + }) + expect(text).toContain("## Commands Executed") + expect(text).toContain("- `npm test`") + expect(text).toContain("- `npm build`") + }) + + it("should include todo stats", () => { + const text = formatSubtaskSummaryForApi({ + result: "Done", + todoStats: { completed: 3, total: 5 }, + }) + expect(text).toContain("## Todos\n3/5 completed") + }) + + it("should format a comprehensive summary with all sections", () => { + const text = formatSubtaskSummaryForApi({ + result: "Implemented the feature", + mode: "code", + filesModified: ["src/feature.ts"], + filesRead: ["src/config.ts"], + commandsExecuted: ["npm test"], + todoStats: { completed: 2, total: 2 }, + }) + + expect(text).toContain("## Result") + expect(text).toContain("## Mode") + expect(text).toContain("## Files Modified") + expect(text).toContain("## Files Read") + expect(text).toContain("## Commands Executed") + expect(text).toContain("## Todos") + }) +}) diff --git a/src/core/task/buildSubtaskSummary.ts b/src/core/task/buildSubtaskSummary.ts new file mode 100644 index 00000000000..00ecc38cf8d --- /dev/null +++ b/src/core/task/buildSubtaskSummary.ts @@ -0,0 +1,189 @@ +import type { SubtaskSummary } from "@roo-code/types" +import type { ToolUsage } from "@roo-code/types" +import type { TodoItem } from "@roo-code/types" +import type Anthropic from "@anthropic-ai/sdk" + +/** + * File-modifying tool names. When these appear as tool_use blocks in the + * API conversation history, the first positional argument (typically `path`) + * is extracted as a modified file. + */ +const FILE_WRITE_TOOLS = new Set(["write_to_file", "apply_diff", "insert_content"]) + +/** + * File-reading tool names. + */ +const FILE_READ_TOOLS = new Set(["read_file", "search_files", "list_files", "list_code_definition_names"]) + +/** + * Extract a file path from a tool_use input object. + * Native tool calls store params as structured objects with a `path` field. + */ +function extractPath(input: Record): string | undefined { + if (typeof input.path === "string" && input.path.length > 0) { + return input.path + } + return undefined +} + +/** + * Extract a command string from a tool_use input for execute_command. + */ +function extractCommand(input: Record): string | undefined { + if (typeof input.command === "string" && input.command.length > 0) { + const cmd = input.command + return cmd.length > 120 ? cmd.slice(0, 117) + "..." : cmd + } + return undefined +} + +/** + * Minimal interface representing the data we need from a Task instance. + * Using an interface avoids importing the full Task class (circular deps). + */ +export interface SubtaskContext { + apiConversationHistory: Anthropic.MessageParam[] + toolUsage: ToolUsage + todoList?: TodoItem[] + taskMode: string +} + +/** + * Builds a structured SubtaskSummary from task context. + * + * This scans the task's API conversation history to extract: + * - Files modified (write_to_file, apply_diff, insert_content) + * - Files read (read_file, search_files, etc.) + * - Commands executed (execute_command) + * - Tool usage summary (from toolUsage) + * - Todo completion stats (from todoList) + * + * The result text comes from attempt_completion and is passed in separately. + */ +export function buildSubtaskSummary(context: SubtaskContext, completionResult: string): SubtaskSummary { + const filesModified = new Set() + const filesRead = new Set() + const commandsExecuted: string[] = [] + + // Scan API conversation history for tool_use blocks + for (const message of context.apiConversationHistory) { + if (message.role !== "assistant" || !Array.isArray(message.content)) { + continue + } + + for (const block of message.content as Anthropic.ContentBlockParam[]) { + if (block.type !== "tool_use") { + continue + } + + const toolBlock = block as Anthropic.ToolUseBlockParam + const toolName = toolBlock.name + const input = (toolBlock.input ?? {}) as Record + + if (FILE_WRITE_TOOLS.has(toolName)) { + const path = extractPath(input) + if (path) { + filesModified.add(path) + } + } else if (FILE_READ_TOOLS.has(toolName)) { + const path = extractPath(input) + if (path) { + filesRead.add(path) + } + } else if (toolName === "execute_command") { + const cmd = extractCommand(input) + if (cmd) { + commandsExecuted.push(cmd) + } + } + } + } + + // Build tool usage summary from toolUsage + const toolUsageSummary: Record = {} + if (context.toolUsage) { + for (const [toolName, usage] of Object.entries(context.toolUsage)) { + const u = usage as { attempts: number; failures: number } | undefined + if (u && u.attempts > 0) { + toolUsageSummary[toolName] = u.attempts + } + } + } + + // Build todo stats + let todoStats: SubtaskSummary["todoStats"] + if (context.todoList && context.todoList.length > 0) { + const completed = context.todoList.filter((t: TodoItem) => t.status === "completed").length + todoStats = { completed, total: context.todoList.length } + } + + const summary: SubtaskSummary = { + result: completionResult, + mode: context.taskMode, + } + + if (filesModified.size > 0) { + summary.filesModified = Array.from(filesModified) + } + + if (filesRead.size > 0) { + summary.filesRead = Array.from(filesRead) + } + + if (commandsExecuted.length > 0) { + summary.commandsExecuted = commandsExecuted + } + + if (Object.keys(toolUsageSummary).length > 0) { + summary.toolUsageSummary = toolUsageSummary + } + + if (todoStats) { + summary.todoStats = todoStats + } + + return summary +} + +/** + * Formats a SubtaskSummary into a human-readable string suitable for + * injection into the parent's API history (tool_result content). + * This enriched format gives the parent LLM much better context about + * what the subtask accomplished. + */ +export function formatSubtaskSummaryForApi(summary: SubtaskSummary): string { + const sections: string[] = [] + + // Result section (always present) + sections.push(`## Result\n${summary.result}`) + + // Mode + if (summary.mode) { + sections.push(`## Mode\n${summary.mode}`) + } + + // Files modified + if (summary.filesModified && summary.filesModified.length > 0) { + const fileList = summary.filesModified.map((f: string) => `- ${f}`).join("\n") + sections.push(`## Files Modified\n${fileList}`) + } + + // Files read + if (summary.filesRead && summary.filesRead.length > 0) { + const fileList = summary.filesRead.map((f: string) => `- ${f}`).join("\n") + sections.push(`## Files Read\n${fileList}`) + } + + // Commands executed + if (summary.commandsExecuted && summary.commandsExecuted.length > 0) { + const cmdList = summary.commandsExecuted.map((c: string) => `- \`${c}\``).join("\n") + sections.push(`## Commands Executed\n${cmdList}`) + } + + // Todo stats + if (summary.todoStats) { + sections.push(`## Todos\n${summary.todoStats.completed}/${summary.todoStats.total} completed`) + } + + return sections.join("\n\n") +} diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 16e0428120c..7a024735f13 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -7,6 +7,7 @@ import { formatResponse } from "../prompts/responses" import { Package } from "../../shared/package" import type { ToolUse } from "../../shared/tools" import { t } from "../../i18n" +import { buildSubtaskSummary } from "../task/buildSubtaskSummary" import { BaseTool, ToolCallbacks } from "./BaseTool" @@ -168,10 +169,31 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { pushToolResult("") + // Build a structured summary of what this subtask accomplished. + // This enriches the handoff with files changed, tools used, etc. + // Wrapped in try/catch: if summary building fails (e.g. mode not initialized), + // we fall back to the plain result string for backward compatibility. + let completionResultSummary: string + try { + const summary = buildSubtaskSummary( + { + apiConversationHistory: task.apiConversationHistory, + toolUsage: task.toolUsage, + todoList: task.todoList ?? undefined, + taskMode: task.taskMode, + }, + result, + ) + completionResultSummary = JSON.stringify(summary) + } catch { + // Fallback: use plain result text if structured summary cannot be built + completionResultSummary = result + } + await provider.reopenParentFromDelegation({ parentTaskId: task.parentTaskId!, childTaskId: task.taskId, - completionResultSummary: result, + completionResultSummary, }) return "delegated" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5430f959d05..c1b086a6a1a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -92,6 +92,8 @@ import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" +import { formatSubtaskSummaryForApi } from "../task/buildSubtaskSummary" +import type { SubtaskSummary } from "@roo-code/types" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -3183,6 +3185,21 @@ export class ClineProvider if (!Array.isArray(parentClineMessages)) parentClineMessages = [] if (!Array.isArray(parentApiMessages)) parentApiMessages = [] + // Try to parse completionResultSummary as a structured SubtaskSummary (JSON). + // If it's not valid JSON, treat it as a plain-text result for backward compatibility. + let parsedSummary: SubtaskSummary | undefined + let apiResultText: string + try { + parsedSummary = JSON.parse(completionResultSummary) as SubtaskSummary + // Use the enriched format for API history so the parent LLM gets structured context + apiResultText = `Subtask ${childTaskId} completed.\n\n${formatSubtaskSummaryForApi(parsedSummary)}` + } catch { + // Not JSON - plain text result (backward compatible path) + apiResultText = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + } + + // For the UI message, pass the raw completionResultSummary (JSON or plain text). + // The webview ChatRow component will detect JSON and render structured data. const subtaskUiMessage: ClineMessage = { type: "say", say: "subtask_result", @@ -3218,8 +3235,8 @@ export class ClineProvider if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { for (const block of lastMsg.content) { if (block.type === "tool_result" && block.tool_use_id === toolUseId) { - // Update the existing tool_result content - block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}` + // Update the existing tool_result content with enriched summary + block.content = apiResultText alreadyHasToolResult = true break } @@ -3234,7 +3251,7 @@ export class ClineProvider { type: "tool_result" as const, tool_use_id: toolUseId, - content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + content: apiResultText, }, ], ts, @@ -3257,7 +3274,7 @@ export class ClineProvider content: [ { type: "text" as const, - text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`, + text: apiResultText, }, ], ts, diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 33c9acb2df2..ec02fda04cd 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1020,16 +1020,95 @@ export const ChatRowContent = ({ showCopyButton={true} /> ) - case "subtask_result": + case "subtask_result": { // Get the child task ID that produced this result const completedChildTaskId = currentTaskItem?.completedByChildId + + // Try to parse structured summary (JSON). Falls back to plain text. + let structuredSummary: { + result?: string + mode?: string + filesModified?: string[] + filesRead?: string[] + commandsExecuted?: string[] + toolUsageSummary?: Record + todoStats?: { completed: number; total: number } + } | null = null + try { + if (message.text?.startsWith("{")) { + structuredSummary = JSON.parse(message.text) + } + } catch { + // Not JSON, use plain text rendering + } + return (
{t("chat:subtasks.resultContent")}
- + + {structuredSummary ? ( +
+ {structuredSummary.mode && ( +
+ + {structuredSummary.mode} + +
+ )} + + {structuredSummary.result && } + + {structuredSummary.filesModified && structuredSummary.filesModified.length > 0 && ( +
+
+ {t("chat:subtasks.filesModified")} +
+
    + {structuredSummary.filesModified.map((f: string, i: number) => ( +
  • + {f} +
  • + ))} +
+
+ )} + + {structuredSummary.commandsExecuted && + structuredSummary.commandsExecuted.length > 0 && ( +
+
+ {t("chat:subtasks.commandsExecuted")} +
+
    + {structuredSummary.commandsExecuted.map((c: string, i: number) => ( +
  • + {c} +
  • + ))} +
+
+ )} + + {structuredSummary.todoStats && ( +
+ {t("chat:subtasks.todoStats", { + completed: structuredSummary.todoStats.completed, + total: structuredSummary.todoStats.total, + })} +
+ )} +
+ ) : ( + + )} + {completedChildTaskId && (