mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Fix parallel tool calls for subtasks
This commit is contained in:
parent
edd7cc0986
commit
6df9befc24
4 changed files with 631 additions and 73 deletions
|
|
@ -161,6 +161,27 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
childTaskId?: string
|
||||
pendingNewTaskToolCallId?: string
|
||||
|
||||
/** Queue of subtasks to execute sequentially when multiple new_task calls are made in parallel. */
|
||||
pendingSubtasks: Array<{
|
||||
toolCallId: string
|
||||
message: string
|
||||
mode: string
|
||||
todoItems: TodoItem[]
|
||||
}> = []
|
||||
|
||||
/** Results from completed subtasks, added to conversation only after ALL subtasks finish. */
|
||||
completedSubtaskResults: Array<{
|
||||
toolCallId: string
|
||||
result: string
|
||||
}> = []
|
||||
|
||||
/** Pending tool results from OTHER tools called in the same turn.
|
||||
* Saved with subtask state so they're combined with subtask results at the end. */
|
||||
pendingOtherToolResults: Array<Anthropic.ToolResultBlockParam> = []
|
||||
|
||||
/** Tool call ID of the currently-executing subtask. */
|
||||
currentSubtaskToolCallId?: string
|
||||
|
||||
readonly instanceId: string
|
||||
readonly metadata: TaskMetadata
|
||||
|
||||
|
|
@ -821,6 +842,141 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.userMessageContent = []
|
||||
}
|
||||
|
||||
/** Execute pending subtasks sequentially. Returns empty array since delegation suspends the parent. */
|
||||
public async executePendingSubtasks(): Promise<Array<{ toolCallId: string; result: string }>> {
|
||||
if (this.pendingSubtasks.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
throw new Error("Provider not available for subtask execution")
|
||||
}
|
||||
|
||||
// Save other tool results (like update_todo_list) that were called in the same turn.
|
||||
// Don't flush them to history yet - they'll be combined with subtask results later.
|
||||
// Only save non-new_task tool results (filter out new_task tool results which are pending subtasks).
|
||||
const pendingSubtaskToolIds = new Set(this.pendingSubtasks.map((s) => s.toolCallId))
|
||||
if (this.currentSubtaskToolCallId) {
|
||||
pendingSubtaskToolIds.add(this.currentSubtaskToolCallId)
|
||||
}
|
||||
|
||||
// Extract tool_result blocks from userMessageContent that are NOT subtask-related
|
||||
const otherToolResults = this.userMessageContent.filter((block): block is Anthropic.ToolResultBlockParam => {
|
||||
if (block.type !== "tool_result") return false
|
||||
return !pendingSubtaskToolIds.has(block.tool_use_id)
|
||||
})
|
||||
|
||||
// If this is the first subtask, save the other tool results
|
||||
if (this.pendingOtherToolResults.length === 0 && otherToolResults.length > 0) {
|
||||
this.pendingOtherToolResults = otherToolResults
|
||||
}
|
||||
|
||||
// Clear userMessageContent since we're about to delegate (don't flush to history)
|
||||
this.userMessageContent = []
|
||||
|
||||
const currentSubtask = this.pendingSubtasks.shift()!
|
||||
this.currentSubtaskToolCallId = currentSubtask.toolCallId
|
||||
|
||||
try {
|
||||
// State must be saved before delegating since a new parent instance is created on resume
|
||||
await this.savePendingSubtasks()
|
||||
|
||||
await (provider as any).delegateParentAndOpenChild({
|
||||
parentTaskId: this.taskId,
|
||||
message: currentSubtask.message,
|
||||
initialTodos: currentSubtask.todoItems,
|
||||
mode: currentSubtask.mode,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.completedSubtaskResults.push({
|
||||
toolCallId: currentSubtask.toolCallId,
|
||||
result: `Failed to execute subtask: ${errorMessage}`,
|
||||
})
|
||||
this.currentSubtaskToolCallId = undefined
|
||||
await this.savePendingSubtasks()
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
public hasPendingSubtasks(): boolean {
|
||||
return this.pendingSubtasks.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Save subtask state to VSCode's workspaceState via the provider.
|
||||
* The workspaceState persists across extension restarts, so this state survives
|
||||
* when the parent is disposed and recreated after a child task completes.
|
||||
*/
|
||||
public async savePendingSubtasks(): Promise<void> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
console.error(`[Task#savePendingSubtasks] Provider not available`)
|
||||
return
|
||||
}
|
||||
|
||||
const hasState =
|
||||
this.pendingSubtasks.length > 0 ||
|
||||
this.completedSubtaskResults.length > 0 ||
|
||||
this.currentSubtaskToolCallId ||
|
||||
this.pendingOtherToolResults.length > 0
|
||||
|
||||
if (hasState) {
|
||||
await provider.setSubtaskState(this.taskId, {
|
||||
pendingSubtasks: this.pendingSubtasks,
|
||||
completedSubtaskResults: this.completedSubtaskResults,
|
||||
currentSubtaskToolCallId: this.currentSubtaskToolCallId,
|
||||
pendingOtherToolResults: this.pendingOtherToolResults as any,
|
||||
})
|
||||
} else {
|
||||
// Clean up state if nothing to store
|
||||
await provider.clearSubtaskState(this.taskId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load subtask state from workspaceState via the provider.
|
||||
* Called when the parent resumes after a child task completes.
|
||||
*/
|
||||
public loadPendingSubtasks(): void {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = provider.getSubtaskState(this.taskId)
|
||||
if (state) {
|
||||
if (Array.isArray(state.pendingSubtasks)) {
|
||||
this.pendingSubtasks = state.pendingSubtasks
|
||||
}
|
||||
if (Array.isArray(state.completedSubtaskResults)) {
|
||||
this.completedSubtaskResults = state.completedSubtaskResults
|
||||
}
|
||||
if (state.currentSubtaskToolCallId) {
|
||||
this.currentSubtaskToolCallId = state.currentSubtaskToolCallId
|
||||
}
|
||||
if (Array.isArray(state.pendingOtherToolResults)) {
|
||||
this.pendingOtherToolResults = state.pendingOtherToolResults as Anthropic.ToolResultBlockParam[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear subtask state from workspaceState after all subtasks are complete.
|
||||
*/
|
||||
public async clearSubtaskState(): Promise<void> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (provider) {
|
||||
await provider.clearSubtaskState(this.taskId)
|
||||
}
|
||||
this.pendingSubtasks = []
|
||||
this.completedSubtaskResults = []
|
||||
this.currentSubtaskToolCallId = undefined
|
||||
this.pendingOtherToolResults = []
|
||||
}
|
||||
|
||||
private async saveApiConversationHistory() {
|
||||
try {
|
||||
await saveApiMessages({
|
||||
|
|
@ -2046,6 +2202,25 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// Save the updated history
|
||||
await this.saveApiConversationHistory()
|
||||
|
||||
// Load any pending subtasks from the provider's in-memory storage.
|
||||
// When multiple new_task tools are called in parallel, remaining subtasks are
|
||||
// saved before each delegation. We load them here on resume.
|
||||
this.loadPendingSubtasks()
|
||||
|
||||
// Check if there are pending subtasks to execute sequentially.
|
||||
// This happens when multiple new_task tools were called in parallel -
|
||||
// they are queued and executed one at a time.
|
||||
if (this.hasPendingSubtasks()) {
|
||||
const provider = this.providerRef.deref()
|
||||
if (provider) {
|
||||
// Execute the next pending subtask
|
||||
await this.executePendingSubtasks()
|
||||
// Don't call initiateTaskLoop - the next subtask will run,
|
||||
// and when it completes, this method will be called again.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Continue task loop - pass empty array to signal no new user content needed
|
||||
// The initiateTaskLoop will handle this by skipping user message addition
|
||||
await this.initiateTaskLoop([])
|
||||
|
|
@ -3031,6 +3206,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
await pWaitFor(() => this.userMessageContentReady)
|
||||
|
||||
// Execute pending subtasks after all tool blocks have been processed.
|
||||
// This ensures the assistant message is already in history before delegating.
|
||||
if (typeof this.hasPendingSubtasks === "function" && this.hasPendingSubtasks()) {
|
||||
await this.executePendingSubtasks()
|
||||
// executePendingSubtasks delegates and suspends this task.
|
||||
// The parent will resume when all subtasks complete.
|
||||
return false
|
||||
}
|
||||
|
||||
// If the model did not tool use, then we need to tell it to
|
||||
// either use a tool or attempt_completion.
|
||||
const didToolUse = this.assistantMessageContent.some(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as vscode from "vscode"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { TodoItem } from "@roo-code/types"
|
||||
|
||||
|
|
@ -17,6 +18,32 @@ interface NewTaskParams {
|
|||
todos?: string
|
||||
}
|
||||
|
||||
/** Counts completed new_task tool blocks in the current assistant message. */
|
||||
function countNewTaskBlocks(task: Task): number {
|
||||
if (!task.assistantMessageContent) {
|
||||
return 0
|
||||
}
|
||||
return task.assistantMessageContent.filter(
|
||||
(block) => block.type === "tool_use" && (block as any).name === "new_task" && !(block as any).partial,
|
||||
).length
|
||||
}
|
||||
|
||||
/** Checks if there are any tool blocks AFTER the current streaming index that haven't been processed yet. */
|
||||
function hasRemainingToolBlocks(task: Task): boolean {
|
||||
if (!task.assistantMessageContent) {
|
||||
return false
|
||||
}
|
||||
// Check all blocks after the current streaming index (which is the new_task we're processing)
|
||||
// If there are any non-partial tool blocks remaining, we need to queue this new_task
|
||||
for (let i = task.currentStreamingContentIndex + 1; i < task.assistantMessageContent.length; i++) {
|
||||
const block = task.assistantMessageContent[i]
|
||||
if (block.type === "tool_use" && !(block as any).partial) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export class NewTaskTool extends BaseTool<"new_task"> {
|
||||
readonly name = "new_task" as const
|
||||
|
||||
|
|
@ -92,8 +119,7 @@ export class NewTaskTool extends BaseTool<"new_task"> {
|
|||
|
||||
task.consecutiveMistakeCount = 0
|
||||
|
||||
// Un-escape one level of backslashes before '@' for hierarchical subtasks
|
||||
// Un-escape one level: \\@ -> \@ (removes one backslash for hierarchical subtasks)
|
||||
// Un-escape \\@ -> \@ for hierarchical subtasks
|
||||
const unescapedMessage = message.replace(/\\\\@/g, "\\@")
|
||||
|
||||
// Verify the mode exists
|
||||
|
|
@ -117,13 +143,45 @@ export class NewTaskTool extends BaseTool<"new_task"> {
|
|||
return
|
||||
}
|
||||
|
||||
// Provider is guaranteed to be defined here due to earlier check.
|
||||
|
||||
if (task.enableCheckpoints) {
|
||||
task.checkpointSave(true)
|
||||
}
|
||||
|
||||
// Delegate parent and open child as sole active task
|
||||
// Queue this new_task if there are:
|
||||
// 1. Multiple new_task blocks (to execute sequentially), OR
|
||||
// 2. Any remaining tool blocks after this one (so they can execute before delegation)
|
||||
const newTaskBlockCount = countNewTaskBlocks(task)
|
||||
const hasRemainingTools = hasRemainingToolBlocks(task)
|
||||
|
||||
if (newTaskBlockCount > 1 || hasRemainingTools) {
|
||||
task.pendingSubtasks.push({
|
||||
toolCallId: toolCallId ?? "",
|
||||
message: unescapedMessage,
|
||||
mode,
|
||||
todoItems,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Save other tool results (e.g., update_todo_list) that were called in the same turn.
|
||||
// This prevents them from being lost or incorrectly ordered when the parent resumes.
|
||||
const currentToolCallId = toolCallId ?? ""
|
||||
const otherToolResults = task.userMessageContent.filter(
|
||||
(block): block is Anthropic.ToolResultBlockParam =>
|
||||
block.type === "tool_result" && block.tool_use_id !== currentToolCallId,
|
||||
)
|
||||
|
||||
if (otherToolResults.length > 0) {
|
||||
task.pendingOtherToolResults = otherToolResults
|
||||
}
|
||||
|
||||
// Track this subtask and clear userMessageContent to prevent incorrect flushing
|
||||
task.currentSubtaskToolCallId = currentToolCallId
|
||||
task.userMessageContent = []
|
||||
|
||||
// Save state before delegation so it survives parent disposal
|
||||
await task.savePendingSubtasks()
|
||||
|
||||
const child = await (provider as any).delegateParentAndOpenChild({
|
||||
parentTaskId: task.taskId,
|
||||
message: unescapedMessage,
|
||||
|
|
@ -131,7 +189,6 @@ export class NewTaskTool extends BaseTool<"new_task"> {
|
|||
mode,
|
||||
})
|
||||
|
||||
// Reflect delegation in tool result (no pause/unpause, no wait)
|
||||
pushToolResult(`Delegated to child task ${child.taskId}`)
|
||||
return
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ const mockCline = {
|
|||
enableCheckpoints: false,
|
||||
checkpointSave: mockCheckpointSave,
|
||||
startSubtask: mockStartSubtask,
|
||||
userMessageContent: [] as any[],
|
||||
pendingOtherToolResults: [] as any[],
|
||||
currentSubtaskToolCallId: undefined as string | undefined,
|
||||
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
|
||||
providerRef: {
|
||||
deref: vi.fn(() => ({
|
||||
getState: vi.fn(() => ({ customModes: [], mode: "ask" })),
|
||||
|
|
@ -651,6 +655,10 @@ describe("newTaskTool delegation flow", () => {
|
|||
enableCheckpoints: false,
|
||||
checkpointSave: mockCheckpointSave,
|
||||
startSubtask: localStartSubtask,
|
||||
userMessageContent: [] as any[],
|
||||
pendingOtherToolResults: [] as any[],
|
||||
currentSubtaskToolCallId: undefined as string | undefined,
|
||||
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
|
||||
providerRef: {
|
||||
deref: vi.fn(() => providerSpy),
|
||||
},
|
||||
|
|
@ -697,3 +705,187 @@ describe("newTaskTool delegation flow", () => {
|
|||
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task child-1"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("newTaskTool parallel execution", () => {
|
||||
it("should queue subtasks when multiple new_task blocks are detected", async () => {
|
||||
const providerSpy = {
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
mode: "ask",
|
||||
experiments: {},
|
||||
}),
|
||||
delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }),
|
||||
handleModeSwitch: vi.fn(),
|
||||
} as any
|
||||
|
||||
const pendingSubtasks: any[] = []
|
||||
const localCline = {
|
||||
ask: vi.fn(),
|
||||
sayAndCreateMissingParamError: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
recordToolError: vi.fn(),
|
||||
consecutiveMistakeCount: 0,
|
||||
isPaused: false,
|
||||
pausedModeSlug: "ask",
|
||||
taskId: "mock-parent-task-id",
|
||||
enableCheckpoints: false,
|
||||
checkpointSave: vi.fn(),
|
||||
startSubtask: vi.fn(),
|
||||
userMessageContent: [] as any[],
|
||||
pendingOtherToolResults: [] as any[],
|
||||
currentSubtaskToolCallId: undefined as string | undefined,
|
||||
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
|
||||
providerRef: {
|
||||
deref: vi.fn(() => providerSpy),
|
||||
},
|
||||
assistantMessageContent: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "new_task",
|
||||
id: "tool-1",
|
||||
params: { mode: "code", message: "First task" },
|
||||
partial: false,
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "new_task",
|
||||
id: "tool-2",
|
||||
params: { mode: "code", message: "Second task" },
|
||||
partial: false,
|
||||
},
|
||||
],
|
||||
pendingSubtasks,
|
||||
}
|
||||
|
||||
const mockPushToolResult = vi.fn()
|
||||
const mockAskApproval = vi.fn().mockResolvedValue(true)
|
||||
|
||||
const block1: ToolUse = {
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "new_task",
|
||||
params: {
|
||||
mode: "code",
|
||||
message: "First task",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
await newTaskTool.handle(localCline as any, block1 as ToolUse<"new_task">, {
|
||||
askApproval: mockAskApproval,
|
||||
handleError: vi.fn(),
|
||||
pushToolResult: mockPushToolResult,
|
||||
removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""),
|
||||
toolProtocol: "xml",
|
||||
toolCallId: "tool-1",
|
||||
})
|
||||
|
||||
expect(pendingSubtasks.length).toBe(1)
|
||||
expect(pendingSubtasks[0].mode).toBe("code")
|
||||
expect(pendingSubtasks[0].message).toBe("First task")
|
||||
expect(pendingSubtasks[0].toolCallId).toBe("tool-1")
|
||||
expect(mockPushToolResult).not.toHaveBeenCalled()
|
||||
expect(providerSpy.delegateParentAndOpenChild).not.toHaveBeenCalled()
|
||||
|
||||
const block2: ToolUse = {
|
||||
type: "tool_use",
|
||||
id: "tool-2",
|
||||
name: "new_task",
|
||||
params: {
|
||||
mode: "code",
|
||||
message: "Second task",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
await newTaskTool.handle(localCline as any, block2 as ToolUse<"new_task">, {
|
||||
askApproval: mockAskApproval,
|
||||
handleError: vi.fn(),
|
||||
pushToolResult: mockPushToolResult,
|
||||
removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""),
|
||||
toolProtocol: "xml",
|
||||
toolCallId: "tool-2",
|
||||
})
|
||||
|
||||
expect(pendingSubtasks.length).toBe(2)
|
||||
expect(pendingSubtasks[1].mode).toBe("code")
|
||||
expect(pendingSubtasks[1].message).toBe("Second task")
|
||||
expect(pendingSubtasks[1].toolCallId).toBe("tool-2")
|
||||
expect(mockPushToolResult).not.toHaveBeenCalled()
|
||||
expect(providerSpy.delegateParentAndOpenChild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should execute immediately when only one new_task block is present", async () => {
|
||||
const providerSpy = {
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
mode: "ask",
|
||||
experiments: {},
|
||||
}),
|
||||
delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }),
|
||||
handleModeSwitch: vi.fn(),
|
||||
} as any
|
||||
|
||||
const pendingSubtasks: any[] = []
|
||||
const localCline = {
|
||||
ask: vi.fn(),
|
||||
sayAndCreateMissingParamError: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
recordToolError: vi.fn(),
|
||||
consecutiveMistakeCount: 0,
|
||||
isPaused: false,
|
||||
pausedModeSlug: "ask",
|
||||
taskId: "mock-parent-task-id",
|
||||
enableCheckpoints: false,
|
||||
checkpointSave: vi.fn(),
|
||||
startSubtask: vi.fn(),
|
||||
userMessageContent: [] as any[],
|
||||
pendingOtherToolResults: [] as any[],
|
||||
currentSubtaskToolCallId: undefined as string | undefined,
|
||||
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
|
||||
providerRef: {
|
||||
deref: vi.fn(() => providerSpy),
|
||||
},
|
||||
assistantMessageContent: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: "new_task",
|
||||
id: "tool-1",
|
||||
params: { mode: "code", message: "Single task" },
|
||||
partial: false,
|
||||
},
|
||||
],
|
||||
pendingSubtasks,
|
||||
}
|
||||
|
||||
const mockPushToolResult = vi.fn()
|
||||
const mockAskApproval = vi.fn().mockResolvedValue(true)
|
||||
|
||||
const block: ToolUse = {
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "new_task",
|
||||
params: {
|
||||
mode: "code",
|
||||
message: "Single task",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
await newTaskTool.handle(localCline as any, block as ToolUse<"new_task">, {
|
||||
askApproval: mockAskApproval,
|
||||
handleError: vi.fn(),
|
||||
pushToolResult: mockPushToolResult,
|
||||
removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""),
|
||||
toolProtocol: "xml",
|
||||
toolCallId: "tool-1",
|
||||
})
|
||||
|
||||
expect(pendingSubtasks.length).toBe(0)
|
||||
expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({
|
||||
parentTaskId: "mock-parent-task-id",
|
||||
message: "Single task",
|
||||
initialTodos: [],
|
||||
mode: "code",
|
||||
})
|
||||
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -118,6 +118,32 @@ interface PendingEditOperation {
|
|||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* State for pending subtasks during parallel new_task execution.
|
||||
* Stored in VSCode's workspaceState for persistence across extension restarts.
|
||||
*/
|
||||
export interface SubtaskState {
|
||||
pendingSubtasks: Array<{
|
||||
toolCallId: string
|
||||
message: string
|
||||
mode: string
|
||||
todoItems: TodoItem[]
|
||||
}>
|
||||
completedSubtaskResults: Array<{
|
||||
toolCallId: string
|
||||
result: string
|
||||
}>
|
||||
currentSubtaskToolCallId?: string
|
||||
/** Pending tool results from OTHER tools called in the same turn as new_task.
|
||||
* These are saved before the first subtask executes so they can be combined
|
||||
* with subtask results into a single user message when all complete. */
|
||||
pendingOtherToolResults?: Array<{
|
||||
type: "tool_result"
|
||||
tool_use_id: string
|
||||
content: string | Array<{ type: "text"; text: string } | { type: "image"; source: any }>
|
||||
}>
|
||||
}
|
||||
|
||||
export class ClineProvider
|
||||
extends EventEmitter<TaskProviderEvents>
|
||||
implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike
|
||||
|
|
@ -146,6 +172,9 @@ export class ClineProvider
|
|||
private pendingOperations: Map<string, PendingEditOperation> = new Map()
|
||||
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
|
||||
|
||||
// Storage key prefix for subtask state in VSCode workspaceState
|
||||
private static readonly SUBTASK_STATE_KEY_PREFIX = "subtaskState:"
|
||||
|
||||
private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null
|
||||
private cloudOrganizationsCacheTimestamp: number | null = null
|
||||
private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds
|
||||
|
|
@ -550,6 +579,47 @@ export class ClineProvider
|
|||
this.log(`[clearAllPendingEditOperations] Cleared all pending operations`)
|
||||
}
|
||||
|
||||
// Subtask State Management
|
||||
// These methods manage state for parallel new_task execution using VSCode's workspaceState.
|
||||
// workspaceState persists data across extension restarts for the current workspace.
|
||||
|
||||
/**
|
||||
* Gets the subtask state key for a given task ID.
|
||||
*/
|
||||
private getSubtaskStateKey(taskId: string): string {
|
||||
return `${ClineProvider.SUBTASK_STATE_KEY_PREFIX}${taskId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subtask state for a given task ID from workspaceState.
|
||||
*/
|
||||
public getSubtaskState(taskId: string): SubtaskState | undefined {
|
||||
const key = this.getSubtaskStateKey(taskId)
|
||||
return this.context.workspaceState.get<SubtaskState>(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the subtask state for a given task ID in workspaceState.
|
||||
*/
|
||||
public async setSubtaskState(taskId: string, state: SubtaskState): Promise<void> {
|
||||
const key = this.getSubtaskStateKey(taskId)
|
||||
await this.context.workspaceState.update(key, state)
|
||||
this.log(`[setSubtaskState] Set subtask state for task ${taskId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the subtask state for a given task ID from workspaceState.
|
||||
* Should be called after all subtasks complete.
|
||||
*/
|
||||
public async clearSubtaskState(taskId: string): Promise<void> {
|
||||
const key = this.getSubtaskStateKey(taskId)
|
||||
const exists = this.context.workspaceState.get(key) !== undefined
|
||||
await this.context.workspaceState.update(key, undefined)
|
||||
if (exists) {
|
||||
this.log(`[clearSubtaskState] Cleared subtask state for task ${taskId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
|
||||
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
|
||||
|
|
@ -2990,24 +3060,26 @@ export class ClineProvider
|
|||
`[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`,
|
||||
)
|
||||
}
|
||||
// 2) Flush pending tool results to API history BEFORE disposing the parent.
|
||||
// This is critical for native tool protocol: when tools are called before new_task,
|
||||
// their tool_result blocks are in userMessageContent but not yet saved to API history.
|
||||
// If we don't flush them, the parent's API conversation will be incomplete and
|
||||
// cause 400 errors when resumed (missing tool_result for tool_use blocks).
|
||||
// 2) DON'T flush pending tool results to history when we're in subtask execution flow.
|
||||
// The executePendingSubtasks() method saves other tool results (like update_todo_list)
|
||||
// to pendingOtherToolResults, which will be combined with subtask results into a
|
||||
// SINGLE user message when all subtasks complete. If we flush here, we'd create
|
||||
// a separate user message that breaks the conversation structure (tool results
|
||||
// MUST follow the assistant message that called them in a single user message).
|
||||
//
|
||||
// NOTE: We do NOT pass the assistant message here because the assistant message
|
||||
// is already added to apiConversationHistory by the normal flow in
|
||||
// recursivelyMakeClineRequests BEFORE tools start executing. We only need to
|
||||
// flush the pending user message with tool_results.
|
||||
try {
|
||||
await parent.flushPendingToolResultsToHistory()
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
// Only flush if this is a direct delegation (no pending subtask state), which means
|
||||
// the parent called new_task without the parallel subtask execution flow.
|
||||
const hasSubtaskState = this.getSubtaskState(parentTaskId) !== undefined
|
||||
if (!hasSubtaskState) {
|
||||
try {
|
||||
await parent.flushPendingToolResultsToHistory()
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Enforce single-open invariant by closing/disposing the parent first
|
||||
|
|
@ -3111,13 +3183,22 @@ export class ClineProvider
|
|||
parentApiMessages = []
|
||||
}
|
||||
|
||||
// 2) Inject synthetic records: UI subtask_result and update API tool_result
|
||||
const ts = Date.now()
|
||||
// Load subtask state from workspaceState
|
||||
const subtaskState = this.getSubtaskState(parentTaskId)
|
||||
let pendingSubtasks: Array<{ toolCallId: string; message: string; mode: string; todoItems: any[] }> =
|
||||
subtaskState?.pendingSubtasks ?? []
|
||||
let completedSubtaskResults: Array<{ toolCallId: string; result: string }> =
|
||||
subtaskState?.completedSubtaskResults ?? []
|
||||
let currentSubtaskToolCallId: string | undefined = subtaskState?.currentSubtaskToolCallId
|
||||
// Other tool results (like update_todo_list) that were called in the same turn as new_task
|
||||
const pendingOtherToolResults: Array<{ type: string; tool_use_id: string; content: any }> =
|
||||
(subtaskState?.pendingOtherToolResults as any) ?? []
|
||||
|
||||
// Defensive: ensure arrays
|
||||
const ts = Date.now()
|
||||
if (!Array.isArray(parentClineMessages)) parentClineMessages = []
|
||||
if (!Array.isArray(parentApiMessages)) parentApiMessages = []
|
||||
|
||||
// Add the child's result to the UI messages
|
||||
const subtaskUiMessage: ClineMessage = {
|
||||
type: "say",
|
||||
say: "subtask_result",
|
||||
|
|
@ -3127,67 +3208,98 @@ export class ClineProvider
|
|||
parentClineMessages.push(subtaskUiMessage)
|
||||
await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath })
|
||||
|
||||
// Find the tool_use_id from the last assistant message's new_task tool_use
|
||||
let toolUseId: string | undefined
|
||||
for (let i = parentApiMessages.length - 1; i >= 0; i--) {
|
||||
const msg = parentApiMessages[i]
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.name === "new_task") {
|
||||
toolUseId = block.id
|
||||
break
|
||||
// Determine the toolUseId for this subtask result
|
||||
// Priority: 1) currentSubtaskToolCallId from state, 2) fallback to finding the last one
|
||||
let toolUseId: string | undefined = currentSubtaskToolCallId
|
||||
|
||||
if (!toolUseId) {
|
||||
// Fallback: find the tool_use_id from the last assistant message's new_task tool_use
|
||||
for (let i = parentApiMessages.length - 1; i >= 0; i--) {
|
||||
const msg = parentApiMessages[i]
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.name === "new_task") {
|
||||
toolUseId = block.id
|
||||
break
|
||||
}
|
||||
}
|
||||
if (toolUseId) break
|
||||
}
|
||||
if (toolUseId) break
|
||||
}
|
||||
}
|
||||
|
||||
// The API expects: user → assistant (with tool_use) → user (with tool_result)
|
||||
// We need to add a NEW user message with the tool_result AFTER the assistant's tool_use
|
||||
// NOT add it to an existing user message
|
||||
// Store this subtask's result
|
||||
if (toolUseId) {
|
||||
// Check if the last message is already a user message with a tool_result for this tool_use_id
|
||||
// (in case this is a retry or the history was already updated)
|
||||
completedSubtaskResults.push({
|
||||
toolCallId: toolUseId,
|
||||
result: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Clear currentSubtaskToolCallId since this subtask is complete
|
||||
currentSubtaskToolCallId = undefined
|
||||
|
||||
// Check if there are more pending subtasks to execute
|
||||
const hasMoreSubtasks = pendingSubtasks.length > 0
|
||||
|
||||
if (!hasMoreSubtasks && completedSubtaskResults.length > 0) {
|
||||
// All subtasks complete - add ALL tool_results to the conversation now
|
||||
// The API expects: user → assistant (with tool_use) → user (with tool_result)
|
||||
|
||||
// Check if the last message is already a user message we can append to
|
||||
const lastMsg = parentApiMessages[parentApiMessages.length - 1]
|
||||
let alreadyHasToolResult = false
|
||||
let userMessageContent: any[] = []
|
||||
|
||||
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}`
|
||||
alreadyHasToolResult = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Filter out any existing tool_results for our subtask IDs (to avoid duplicates)
|
||||
const allToolResultIds = new Set([
|
||||
...completedSubtaskResults.map((r) => r.toolCallId),
|
||||
...pendingOtherToolResults.map((r) => r.tool_use_id),
|
||||
])
|
||||
userMessageContent = lastMsg.content.filter(
|
||||
(block: any) => !(block.type === "tool_result" && allToolResultIds.has(block.tool_use_id)),
|
||||
)
|
||||
// Remove the last message so we can replace it with an updated one
|
||||
parentApiMessages.pop()
|
||||
}
|
||||
|
||||
// If no existing tool_result found, create a NEW user message with the tool_result
|
||||
if (!alreadyHasToolResult) {
|
||||
parentApiMessages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: toolUseId,
|
||||
content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
|
||||
},
|
||||
],
|
||||
ts,
|
||||
// FIRST: Add other tool results (e.g., update_todo_list) that were called in the same turn
|
||||
// These should come before subtask results to maintain the original tool call order
|
||||
for (const toolResult of pendingOtherToolResults) {
|
||||
userMessageContent.push({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: toolResult.tool_use_id,
|
||||
content: toolResult.content,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Fallback for XML protocol or when toolUseId couldn't be found:
|
||||
// Add a text block (not ideal but maintains backward compatibility)
|
||||
|
||||
// THEN: Add all completed subtask results as tool_result blocks
|
||||
for (const result of completedSubtaskResults) {
|
||||
userMessageContent.push({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: result.toolCallId,
|
||||
content: result.result,
|
||||
})
|
||||
}
|
||||
|
||||
parentApiMessages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
|
||||
},
|
||||
],
|
||||
content: userMessageContent,
|
||||
ts,
|
||||
})
|
||||
|
||||
// Clear completed results since they're now in the conversation
|
||||
completedSubtaskResults = []
|
||||
|
||||
// Clean up the subtask state from workspaceState
|
||||
await this.clearSubtaskState(parentTaskId)
|
||||
} else {
|
||||
// More subtasks remain - save state for the next resumption
|
||||
await this.setSubtaskState(parentTaskId, {
|
||||
pendingSubtasks,
|
||||
completedSubtaskResults,
|
||||
currentSubtaskToolCallId,
|
||||
})
|
||||
}
|
||||
|
||||
await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath })
|
||||
|
|
@ -3249,8 +3361,21 @@ export class ClineProvider
|
|||
// non-fatal
|
||||
}
|
||||
|
||||
// Auto-resume parent without ask("resume_task")
|
||||
await parentInstance.resumeAfterDelegation()
|
||||
// Load subtask state into the parent instance (synchronous - reads from provider's in-memory state)
|
||||
parentInstance.loadPendingSubtasks()
|
||||
|
||||
// Check if there are more pending subtasks to execute
|
||||
if (parentInstance.hasPendingSubtasks()) {
|
||||
// Execute the next pending subtask
|
||||
// This will cause the parent to be "paused" again and a new child will run
|
||||
this.log(
|
||||
`[reopenParentFromDelegation] Parent ${parentTaskId} has ${parentInstance.pendingSubtasks.length} more subtasks, executing next one`,
|
||||
)
|
||||
await parentInstance.executePendingSubtasks()
|
||||
} else {
|
||||
// No more pending subtasks - resume the parent with the API
|
||||
await parentInstance.resumeAfterDelegation()
|
||||
}
|
||||
}
|
||||
|
||||
// 9) Emit TaskDelegationResumed (provider-level)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue