mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
feat: enhance subagent functionality for parallel execution
- Introduced support for running multiple subagents in parallel by managing child tasks with unique toolCallIds. - Updated the Task class to hold active subagent children in a Map for better tracking and management. - Enhanced the reportSubagentProgress method to update specific subagent messages based on runId, allowing for real-time progress updates. - Modified the ClineProvider to handle cancellation of multiple running subagents. - Improved error handling and messaging for subagent execution. These changes significantly improve the subagent's capabilities, enabling more efficient task management and user feedback during concurrent operations.
This commit is contained in:
parent
0ead5bb238
commit
2d8842b728
10 changed files with 416 additions and 125 deletions
|
|
@ -87,6 +87,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
}
|
||||
|
||||
let block: any
|
||||
let blocksConsumed = 1
|
||||
try {
|
||||
// Performance optimization: Use shallow copy instead of deep clone.
|
||||
// The block is used read-only throughout this function - we never mutate its properties.
|
||||
|
|
@ -410,8 +411,36 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
break
|
||||
}
|
||||
|
||||
// Track if we've already pushed a tool result for this tool call (native tool calling only)
|
||||
let hasToolResult = false
|
||||
// Track which tool_use_ids have already received a tool_result (native tool calling only)
|
||||
const toolResultsPushed = new Set<string>()
|
||||
const pushToolResultFor = (id: string, content: ToolResponse) => {
|
||||
if (toolResultsPushed.has(id)) {
|
||||
console.warn(`[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${id}`)
|
||||
return
|
||||
}
|
||||
let resultContent: string
|
||||
const imageBlocks: Anthropic.ImageBlockParam[] = []
|
||||
if (typeof content === "string") {
|
||||
resultContent = content || "(tool did not return anything)"
|
||||
} else {
|
||||
const textBlocks = content.filter((item) => item.type === "text")
|
||||
imageBlocks.push(
|
||||
...(content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[]),
|
||||
)
|
||||
resultContent =
|
||||
textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") ||
|
||||
"(tool did not return anything)"
|
||||
}
|
||||
cline.pushToolResultToUserContent({
|
||||
type: "tool_result",
|
||||
tool_use_id: sanitizeToolUseId(id),
|
||||
content: resultContent,
|
||||
})
|
||||
if (imageBlocks.length > 0) {
|
||||
cline.userMessageContent.push(...imageBlocks)
|
||||
}
|
||||
toolResultsPushed.add(id)
|
||||
}
|
||||
|
||||
// If this is a native tool call but the parser couldn't construct nativeArgs
|
||||
// (e.g., malformed/unfinished JSON in a streaming tool call), we must NOT attempt to
|
||||
|
|
@ -452,17 +481,8 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
let approvalFeedback: { text: string; images?: string[] } | undefined
|
||||
|
||||
const pushToolResult = (content: ToolResponse) => {
|
||||
// Native tool calling: only allow ONE tool_result per tool call
|
||||
if (hasToolResult) {
|
||||
console.warn(
|
||||
`[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${toolCallId}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let resultContent: string
|
||||
let imageBlocks: Anthropic.ImageBlockParam[] = []
|
||||
|
||||
if (typeof content === "string") {
|
||||
resultContent = content || "(tool did not return anything)"
|
||||
} else {
|
||||
|
|
@ -472,7 +492,6 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") ||
|
||||
"(tool did not return anything)"
|
||||
}
|
||||
|
||||
// Merge approval feedback into tool result (GitHub #10465)
|
||||
if (approvalFeedback) {
|
||||
const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text)
|
||||
|
|
@ -482,18 +501,9 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
imageBlocks = [...feedbackImageBlocks, ...imageBlocks]
|
||||
}
|
||||
}
|
||||
|
||||
cline.pushToolResultToUserContent({
|
||||
type: "tool_result",
|
||||
tool_use_id: sanitizeToolUseId(toolCallId),
|
||||
content: resultContent,
|
||||
})
|
||||
|
||||
if (imageBlocks.length > 0) {
|
||||
cline.userMessageContent.push(...imageBlocks)
|
||||
}
|
||||
|
||||
hasToolResult = true
|
||||
const mergedContent: ToolResponse =
|
||||
imageBlocks.length > 0 ? [{ type: "text", text: resultContent }, ...imageBlocks] : resultContent
|
||||
pushToolResultFor(toolCallId, mergedContent)
|
||||
}
|
||||
|
||||
const askApproval = async (
|
||||
|
|
@ -818,14 +828,92 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
toolCallId: block.id,
|
||||
})
|
||||
break
|
||||
case "subagent":
|
||||
await subagentTool.handle(cline, block as ToolUse<"subagent">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
toolCallId: block.id,
|
||||
})
|
||||
case "subagent": {
|
||||
if (block.partial) {
|
||||
break
|
||||
}
|
||||
// Collect consecutive subagent tool_use blocks for potential parallel execution
|
||||
const subagentBlocks: (typeof block)[] = [block]
|
||||
for (
|
||||
let i = cline.currentStreamingContentIndex + 1;
|
||||
i < cline.assistantMessageContent.length;
|
||||
i++
|
||||
) {
|
||||
const b = cline.assistantMessageContent[i]
|
||||
if (b?.type === "tool_use" && b?.name === "subagent" && !b.partial) {
|
||||
subagentBlocks.push(b)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (subagentBlocks.length === 1) {
|
||||
await subagentTool.handle(cline, block as ToolUse<"subagent">, {
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult,
|
||||
toolCallId: block.id,
|
||||
})
|
||||
} else {
|
||||
// Parallel subagent batch: start all, await all, finish each
|
||||
const subagentCallbacks = (subagentBlock: typeof block) => ({
|
||||
askApproval,
|
||||
handleError,
|
||||
pushToolResult: (content: ToolResponse) => pushToolResultFor(subagentBlock.id, content),
|
||||
toolCallId: subagentBlock.id,
|
||||
})
|
||||
const getParams = (
|
||||
b: typeof block,
|
||||
): { description: string; prompt: string; subagent_type: "general" | "explore" } => {
|
||||
if (b.nativeArgs && typeof b.nativeArgs === "object") {
|
||||
const a = b.nativeArgs as Record<string, string>
|
||||
return {
|
||||
description: a.description ?? "",
|
||||
prompt: a.prompt ?? "",
|
||||
subagent_type: (a.subagent_type === "explore" ? "explore" : "general") as
|
||||
| "general"
|
||||
| "explore",
|
||||
}
|
||||
}
|
||||
return subagentTool.parseLegacy((b.params ?? {}) as Record<string, string>)
|
||||
}
|
||||
const promises = subagentBlocks.map((subagentBlock) =>
|
||||
subagentTool.startAndReturnPromise(
|
||||
cline,
|
||||
getParams(subagentBlock),
|
||||
subagentCallbacks(subagentBlock),
|
||||
),
|
||||
)
|
||||
const results = await Promise.allSettled(promises)
|
||||
for (let i = 0; i < subagentBlocks.length; i++) {
|
||||
const subagentBlock = subagentBlocks[i]
|
||||
const settled = results[i]
|
||||
if (settled?.status === "fulfilled") {
|
||||
await subagentTool.finish(
|
||||
cline,
|
||||
getParams(subagentBlock),
|
||||
settled.value,
|
||||
subagentCallbacks(subagentBlock),
|
||||
)
|
||||
}
|
||||
if (settled?.status === "rejected") {
|
||||
cline.finalizeSubagentRunning(subagentBlock.id)
|
||||
// startAndReturnPromise already pushed error for missing params; other errors need a tool_result
|
||||
if (
|
||||
settled.reason?.message !== "Missing description" &&
|
||||
settled.reason?.message !== "Missing prompt" &&
|
||||
settled.reason?.message !== "Provider reference lost"
|
||||
) {
|
||||
pushToolResultFor(
|
||||
subagentBlock.id,
|
||||
formatResponse.toolError("The subagent failed."),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
blocksConsumed = subagentBlocks.length
|
||||
}
|
||||
break
|
||||
}
|
||||
case "attempt_completion": {
|
||||
const completionCallbacks: AttemptCompletionCallbacks = {
|
||||
askApproval,
|
||||
|
|
@ -953,7 +1041,7 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
// (instead of preemptively doing it in iterator).
|
||||
if (!block.partial || cline.didRejectTool || cline.didAlreadyUseTool) {
|
||||
// Block is finished streaming and executing.
|
||||
if (cline.currentStreamingContentIndex === cline.assistantMessageContent.length - 1) {
|
||||
if (cline.currentStreamingContentIndex + blocksConsumed >= cline.assistantMessageContent.length) {
|
||||
// It's okay that we increment if !didCompleteReadingStream, it'll
|
||||
// just return because out of bounds and as streaming continues it
|
||||
// will call `presentAssitantMessage` if a new block is ready. If
|
||||
|
|
@ -966,9 +1054,8 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
|
||||
// Call next block if it exists (if not then read stream will call it
|
||||
// when it's ready).
|
||||
// Need to increment regardless, so when read stream calls this function
|
||||
// again it will be streaming the next block.
|
||||
cline.currentStreamingContentIndex++
|
||||
// Need to increment by blocksConsumed (e.g. 1 normally, or N when we processed a parallel subagent batch).
|
||||
cline.currentStreamingContentIndex += blocksConsumed
|
||||
|
||||
if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) {
|
||||
// There are already more content blocks to stream, so we'll call
|
||||
|
|
|
|||
|
|
@ -430,8 +430,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
public subagentProgressCallback?: (currentTask: string) => void
|
||||
/** When false, saveClineMessages does not call updateTaskHistory (e.g. subagents). */
|
||||
private readonly needUpdateHistory: boolean
|
||||
/** When this task is the parent of a running subagent, holds the child task until it completes or is cancelled. */
|
||||
public activeSubagentChild?: Task
|
||||
/** When this task is the parent of running subagents, holds child tasks keyed by toolCallId until they complete or are cancelled. */
|
||||
public activeSubagentChildren = new Map<string, Task>()
|
||||
|
||||
constructor({
|
||||
provider,
|
||||
|
|
@ -1282,19 +1282,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates the last "subagentRunning" say message with currentTask so the UI can show it in real time.
|
||||
* No-op if no such message exists.
|
||||
* Updates a "subagentRunning" say message with currentTask so the UI can show it in real time.
|
||||
* When runId is set, updates the last message whose payload has that runId (for parallel subagents).
|
||||
* When runId is not set, updates the last subagentRunning message (backward compatibility).
|
||||
* No-op if no matching message exists.
|
||||
*/
|
||||
public reportSubagentProgress(currentTask: string): void {
|
||||
const idx = findLastIndex(this.clineMessages, (m) => {
|
||||
if (m.type !== "say" || m.say !== "tool" || !m.text) return false
|
||||
try {
|
||||
const parsed = JSON.parse(m.text) as { tool?: string }
|
||||
return parsed.tool === SUBAGENT_TOOL_NAMES.running
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
public reportSubagentProgress(currentTask: string, runId?: string): void {
|
||||
const idx = this.findSubagentRunningIndex(runId)
|
||||
if (idx === -1) return
|
||||
const msg = this.clineMessages[idx]
|
||||
try {
|
||||
|
|
@ -1307,6 +1301,31 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a "subagentRunning" message entirely so only the completed message remains.
|
||||
* Called when the subagent finishes (success or error).
|
||||
*/
|
||||
public finalizeSubagentRunning(runId?: string): void {
|
||||
const idx = this.findSubagentRunningIndex(runId)
|
||||
if (idx === -1) return
|
||||
this.clineMessages.splice(idx, 1)
|
||||
void this.saveClineMessages()
|
||||
}
|
||||
|
||||
private findSubagentRunningIndex(runId?: string): number {
|
||||
return findLastIndex(this.clineMessages, (m) => {
|
||||
if (m.type !== "say" || m.say !== "tool" || !m.text) return false
|
||||
try {
|
||||
const parsed = JSON.parse(m.text) as SubagentRunningPayload
|
||||
if (parsed.tool !== SUBAGENT_TOOL_NAMES.running) return false
|
||||
if (runId !== undefined) return parsed.runId === runId
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Note that `partial` has three valid states true (partial message),
|
||||
// false (completion of partial message), undefined (individual complete
|
||||
// message).
|
||||
|
|
|
|||
|
|
@ -574,4 +574,38 @@ describe("reportSubagentProgress", () => {
|
|||
clineMessage: lastToolMsg,
|
||||
})
|
||||
})
|
||||
|
||||
it("updates only the subagentRunning message with matching runId when runId is provided", () => {
|
||||
const task = new Task({
|
||||
provider: mockProvider,
|
||||
apiConfiguration: mockApiConfig,
|
||||
task: "parent task",
|
||||
startTask: false,
|
||||
})
|
||||
task.clineMessages = [
|
||||
{
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({ tool: "subagentRunning", description: "Sub A", runId: "id-a" }),
|
||||
ts: 1,
|
||||
},
|
||||
{
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({ tool: "subagentRunning", description: "Sub B", runId: "id-b" }),
|
||||
ts: 2,
|
||||
},
|
||||
]
|
||||
|
||||
task.reportSubagentProgress("Progress for B only", "id-b")
|
||||
|
||||
const msgA = JSON.parse(task.clineMessages[0].text!)
|
||||
const msgB = JSON.parse(task.clineMessages[1].text!)
|
||||
expect(msgA.currentTask).toBeUndefined()
|
||||
expect(msgB.currentTask).toBe("Progress for B only")
|
||||
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "messageUpdated",
|
||||
clineMessage: task.clineMessages[1],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -80,15 +80,12 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
|
|||
|
||||
await task.say("completion_result", result, undefined, false)
|
||||
|
||||
// Force final token usage update before emitting TaskCompleted
|
||||
// This ensures the most recent stats are captured regardless of throttle timer
|
||||
// and properly updates the snapshot to prevent redundant emissions
|
||||
task.emitFinalTokenUsageUpdate()
|
||||
|
||||
TelemetryService.instance.captureTaskCompleted(task.taskId)
|
||||
task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage)
|
||||
|
||||
if (task.backgroundCompletionResolve) {
|
||||
// Subagent path: emit TaskCompleted and telemetry once, then resolve and abort.
|
||||
// Do not run this for normal tasks—emitTaskCompleted() runs on delegation (line 124) or user acceptance (line 151).
|
||||
task.emitFinalTokenUsageUpdate()
|
||||
TelemetryService.instance.captureTaskCompleted(task.taskId)
|
||||
task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage)
|
||||
task.subagentProgressCallback = undefined
|
||||
task.backgroundCompletionResolve(result)
|
||||
task.backgroundCompletionResolve = undefined
|
||||
|
|
|
|||
|
|
@ -33,29 +33,37 @@ export class SubagentTool extends BaseTool<"subagent"> {
|
|||
}
|
||||
}
|
||||
|
||||
async execute(params: SubagentParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
/**
|
||||
* Starts a subagent and returns its Promise without awaiting. Used for parallel subagent batches.
|
||||
* Caller must await the Promise then call finish() with the result.
|
||||
*/
|
||||
async startAndReturnPromise(
|
||||
task: Task,
|
||||
params: SubagentParams,
|
||||
callbacks: ToolCallbacks,
|
||||
): Promise<string | SubagentStructuredResult> {
|
||||
const { description, prompt, subagent_type } = params
|
||||
const { pushToolResult } = callbacks
|
||||
const toolCallId = callbacks.toolCallId ?? `subagent-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const provider = task.providerRef.deref()
|
||||
if (!provider || !isSubagentRunner(provider)) {
|
||||
pushToolResult(formatResponse.toolError("Provider reference lost"))
|
||||
return
|
||||
callbacks.pushToolResult(formatResponse.toolError("Provider reference lost"))
|
||||
return Promise.reject(new Error("Provider reference lost"))
|
||||
}
|
||||
|
||||
if (!description?.trim()) {
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("subagent")
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(await task.sayAndCreateMissingParamError("subagent", "description"))
|
||||
return
|
||||
task.sayAndCreateMissingParamError("subagent", "description").then((msg) => callbacks.pushToolResult(msg))
|
||||
return Promise.reject(new Error("Missing description"))
|
||||
}
|
||||
if (!prompt?.trim()) {
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("subagent")
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(await task.sayAndCreateMissingParamError("subagent", "prompt"))
|
||||
return
|
||||
task.sayAndCreateMissingParamError("subagent", "prompt").then((msg) => callbacks.pushToolResult(msg))
|
||||
return Promise.reject(new Error("Missing prompt"))
|
||||
}
|
||||
|
||||
task.consecutiveMistakeCount = 0
|
||||
|
|
@ -64,60 +72,116 @@ export class SubagentTool extends BaseTool<"subagent"> {
|
|||
tool: SUBAGENT_TOOL_NAMES.running,
|
||||
description,
|
||||
currentTask: SUBAGENT_STATUS_STARTING,
|
||||
runId: toolCallId,
|
||||
}
|
||||
const runningText = JSON.stringify(runningPayload)
|
||||
const progressStatus = { icon: "sync", spin: true }
|
||||
|
||||
await task.say("tool", runningText, undefined, true, undefined, progressStatus, {
|
||||
await task.say("tool", runningText, undefined, undefined, undefined, progressStatus, {
|
||||
isNonInteractive: true,
|
||||
})
|
||||
|
||||
const runParams: RunSubagentInBackgroundParams = {
|
||||
parentTaskId: task.taskId,
|
||||
prompt,
|
||||
subagentType: subagent_type,
|
||||
onProgress: (currentTask) => task.reportSubagentProgress(currentTask, toolCallId),
|
||||
toolCallId,
|
||||
}
|
||||
return provider.runSubagentInBackground(runParams)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits subagentCompleted and pushes the tool result. Call after startAndReturnPromise's Promise resolves.
|
||||
*/
|
||||
async finish(
|
||||
task: Task,
|
||||
params: SubagentParams,
|
||||
result: string | SubagentStructuredResult,
|
||||
callbacks: ToolCallbacks,
|
||||
): Promise<void> {
|
||||
const { description } = params
|
||||
const { pushToolResult } = callbacks
|
||||
const toolCallId = callbacks.toolCallId
|
||||
|
||||
const isStructured = (r: string | SubagentStructuredResult): r is SubagentStructuredResult =>
|
||||
typeof r === "object" && r !== null && "code" in r && "messageKey" in r
|
||||
|
||||
// Stop the spinner on the "Running subagent" row
|
||||
task.finalizeSubagentRunning(toolCallId)
|
||||
|
||||
let completedPayload: SubagentCompletedPayload
|
||||
let toolResult: string
|
||||
if (isStructured(result)) {
|
||||
completedPayload = {
|
||||
tool: SUBAGENT_TOOL_NAMES.completed,
|
||||
description,
|
||||
result: SUBAGENT_CANCELLED_MODEL_MESSAGE,
|
||||
resultCode: result.code,
|
||||
messageKey: result.messageKey,
|
||||
}
|
||||
toolResult = SUBAGENT_CANCELLED_MODEL_MESSAGE
|
||||
} else {
|
||||
completedPayload = {
|
||||
tool: SUBAGENT_TOOL_NAMES.completed,
|
||||
description,
|
||||
result,
|
||||
}
|
||||
toolResult = result
|
||||
}
|
||||
const completedText = JSON.stringify(completedPayload)
|
||||
await task.say("tool", completedText, undefined, undefined, undefined, undefined, {
|
||||
isNonInteractive: true,
|
||||
})
|
||||
pushToolResult(formatResponse.toolResult(toolResult))
|
||||
}
|
||||
|
||||
async execute(params: SubagentParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
||||
const { pushToolResult } = callbacks
|
||||
|
||||
const provider = task.providerRef.deref()
|
||||
if (!provider || !isSubagentRunner(provider)) {
|
||||
pushToolResult(formatResponse.toolError("Provider reference lost"))
|
||||
return
|
||||
}
|
||||
|
||||
if (!params.description?.trim()) {
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("subagent")
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(await task.sayAndCreateMissingParamError("subagent", "description"))
|
||||
return
|
||||
}
|
||||
if (!params.prompt?.trim()) {
|
||||
task.consecutiveMistakeCount++
|
||||
task.recordToolError("subagent")
|
||||
task.didToolFailInCurrentTurn = true
|
||||
pushToolResult(await task.sayAndCreateMissingParamError("subagent", "prompt"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const runParams: RunSubagentInBackgroundParams = {
|
||||
parentTaskId: task.taskId,
|
||||
prompt,
|
||||
subagentType: subagent_type,
|
||||
onProgress: (currentTask) => task.reportSubagentProgress(currentTask),
|
||||
}
|
||||
const result = await provider.runSubagentInBackground(runParams)
|
||||
|
||||
const isStructured = (r: string | SubagentStructuredResult): r is SubagentStructuredResult =>
|
||||
typeof r === "object" && r !== null && "code" in r && "messageKey" in r
|
||||
|
||||
let completedPayload: SubagentCompletedPayload
|
||||
let toolResult: string
|
||||
if (isStructured(result)) {
|
||||
completedPayload = {
|
||||
tool: SUBAGENT_TOOL_NAMES.completed,
|
||||
description,
|
||||
result: SUBAGENT_CANCELLED_MODEL_MESSAGE,
|
||||
resultCode: result.code,
|
||||
messageKey: result.messageKey,
|
||||
}
|
||||
toolResult = SUBAGENT_CANCELLED_MODEL_MESSAGE
|
||||
} else {
|
||||
completedPayload = {
|
||||
tool: SUBAGENT_TOOL_NAMES.completed,
|
||||
description,
|
||||
result,
|
||||
}
|
||||
toolResult = result
|
||||
}
|
||||
const completedText = JSON.stringify(completedPayload)
|
||||
await task.say("tool", completedText, undefined, false, undefined, undefined, {
|
||||
isNonInteractive: true,
|
||||
})
|
||||
pushToolResult(formatResponse.toolResult(toolResult))
|
||||
const result = await this.startAndReturnPromise(task, params, callbacks)
|
||||
await this.finish(task, params, result, callbacks)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message === "Missing description" ||
|
||||
error.message === "Missing prompt" ||
|
||||
error.message === "Provider reference lost")
|
||||
) {
|
||||
return
|
||||
}
|
||||
console.error("Subagent failed:", error)
|
||||
task.finalizeSubagentRunning(callbacks.toolCallId)
|
||||
task.recordToolError("subagent")
|
||||
const errorPayload: SubagentCompletedPayload = {
|
||||
tool: SUBAGENT_TOOL_NAMES.completed,
|
||||
description,
|
||||
description: params.description,
|
||||
error: SUBAGENT_FAILED_MODEL_MESSAGE,
|
||||
}
|
||||
const errorPayloadStr = JSON.stringify(errorPayload)
|
||||
await task.say("tool", errorPayloadStr, undefined, false, undefined, undefined, {
|
||||
await task.say("tool", errorPayloadStr, undefined, undefined, undefined, undefined, {
|
||||
isNonInteractive: true,
|
||||
})
|
||||
pushToolResult(formatResponse.toolError(SUBAGENT_FAILED_MODEL_MESSAGE))
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const mockRunSubagentInBackground =
|
|||
parentTaskId: string
|
||||
prompt: string
|
||||
subagentType: "general" | "explore"
|
||||
toolCallId: string
|
||||
}) => Promise<string | SubagentStructuredResult>
|
||||
>()
|
||||
const mockSay = vi.fn()
|
||||
|
|
@ -28,6 +29,7 @@ const mockHandleError = vi.fn()
|
|||
const mockSayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error")
|
||||
const mockRecordToolError = vi.fn()
|
||||
const mockReportSubagentProgress = vi.fn()
|
||||
const mockFinalizeSubagentRunning = vi.fn()
|
||||
|
||||
const mockTask = {
|
||||
taskId: "parent-1",
|
||||
|
|
@ -37,6 +39,7 @@ const mockTask = {
|
|||
sayAndCreateMissingParamError: mockSayAndCreateMissingParamError,
|
||||
say: mockSay,
|
||||
reportSubagentProgress: mockReportSubagentProgress,
|
||||
finalizeSubagentRunning: mockFinalizeSubagentRunning,
|
||||
providerRef: {
|
||||
deref: () => ({
|
||||
runSubagentInBackground: mockRunSubagentInBackground,
|
||||
|
|
@ -193,4 +196,46 @@ describe("SubagentTool", () => {
|
|||
expect(payload.error).toBe(SUBAGENT_FAILED_MODEL_MESSAGE)
|
||||
})
|
||||
})
|
||||
|
||||
describe("startAndReturnPromise and finish", () => {
|
||||
it("startAndReturnPromise returns a Promise that resolves with the result and finish says completed and pushes", async () => {
|
||||
mockRunSubagentInBackground.mockResolvedValue("Result from subagent")
|
||||
mockSay.mockResolvedValue(undefined)
|
||||
const params = { description: "Do X", prompt: "Do it", subagent_type: "general" as const }
|
||||
const callbacks = {
|
||||
askApproval: vi.fn(),
|
||||
handleError: mockHandleError,
|
||||
pushToolResult: mockPushToolResult,
|
||||
toolCallId: "tool-call-123",
|
||||
}
|
||||
|
||||
const promise = subagentTool.startAndReturnPromise(mockTask as any, params, callbacks)
|
||||
const result = await promise
|
||||
expect(result).toBe("Result from subagent")
|
||||
expect(mockRunSubagentInBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parentTaskId: "parent-1",
|
||||
prompt: "Do it",
|
||||
subagentType: "general",
|
||||
toolCallId: "tool-call-123",
|
||||
}),
|
||||
)
|
||||
|
||||
mockSay.mockClear()
|
||||
mockPushToolResult.mockClear()
|
||||
await subagentTool.finish(mockTask as any, params, result, callbacks)
|
||||
const sayCalls = mockSay.mock.calls
|
||||
const completedCall = sayCalls.find((c) => {
|
||||
try {
|
||||
const payload = JSON.parse(c[1])
|
||||
return payload.tool === "subagentCompleted"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
expect(completedCall).toBeDefined()
|
||||
expect(JSON.parse(completedCall![1]).result).toBe("Result from subagent")
|
||||
expect(mockPushToolResult).toHaveBeenCalledWith("Result from subagent")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2992,19 +2992,21 @@ export class ClineProvider
|
|||
return
|
||||
}
|
||||
|
||||
// If the current task has a running subagent, cancel only the subagent and return the cancellation result to the parent.
|
||||
const subagentChild = task.activeSubagentChild
|
||||
if (subagentChild) {
|
||||
task.activeSubagentChild = undefined
|
||||
subagentChild.subagentProgressCallback = undefined
|
||||
if (subagentChild.backgroundCompletionResolve) {
|
||||
subagentChild.backgroundCompletionResolve(SUBAGENT_CANCELLED_STRUCTURED_RESULT)
|
||||
subagentChild.backgroundCompletionResolve = undefined
|
||||
// If the current task has running subagents, cancel all of them and return (do not cancel the parent).
|
||||
if (task.activeSubagentChildren.size > 0) {
|
||||
const children = Array.from(task.activeSubagentChildren.entries())
|
||||
task.activeSubagentChildren.clear()
|
||||
for (const [_toolCallId, subagentChild] of children) {
|
||||
subagentChild.subagentProgressCallback = undefined
|
||||
if (subagentChild.backgroundCompletionResolve) {
|
||||
subagentChild.backgroundCompletionResolve(SUBAGENT_CANCELLED_STRUCTURED_RESULT)
|
||||
subagentChild.backgroundCompletionResolve = undefined
|
||||
}
|
||||
subagentChild.abandoned = true
|
||||
subagentChild.cancelCurrentRequest()
|
||||
subagentChild.abortTask()
|
||||
this.log(`[cancelTask] cancelled subagent ${subagentChild.taskId}.${subagentChild.instanceId}`)
|
||||
}
|
||||
subagentChild.abandoned = true
|
||||
subagentChild.cancelCurrentRequest()
|
||||
subagentChild.abortTask()
|
||||
this.log(`[cancelTask] cancelled subagent ${subagentChild.taskId}.${subagentChild.instanceId}`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -3388,7 +3390,10 @@ export class ClineProvider
|
|||
public async runSubagentInBackground(
|
||||
params: RunSubagentInBackgroundParams,
|
||||
): Promise<string | SubagentStructuredResult> {
|
||||
const { parentTaskId, prompt, subagentType, onProgress } = params
|
||||
const { parentTaskId, prompt, subagentType, onProgress, toolCallId } = params
|
||||
if (!toolCallId) {
|
||||
throw new Error("[runSubagentInBackground] toolCallId is required")
|
||||
}
|
||||
const parent = this.getCurrentTask()
|
||||
if (!parent) {
|
||||
throw new Error("[runSubagentInBackground] No current task")
|
||||
|
|
@ -3427,14 +3432,17 @@ export class ClineProvider
|
|||
child.subagentProgressCallback = onProgress
|
||||
}
|
||||
|
||||
parent.activeSubagentChild = child
|
||||
parent.activeSubagentChildren.set(toolCallId, child)
|
||||
|
||||
return new Promise<string | SubagentStructuredResult>((resolve, reject) => {
|
||||
const removeChild = () => {
|
||||
parent.activeSubagentChildren.delete(toolCallId)
|
||||
}
|
||||
let settled = false
|
||||
child.backgroundCompletionResolve = (result: string | SubagentStructuredResult) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
parent.activeSubagentChild = undefined
|
||||
removeChild()
|
||||
resolve(result)
|
||||
}
|
||||
}
|
||||
|
|
@ -3443,19 +3451,19 @@ export class ClineProvider
|
|||
.then(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
parent.activeSubagentChild = undefined
|
||||
removeChild()
|
||||
reject(new Error("Subagent ended without attempt_completion"))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
parent.activeSubagentChild = undefined
|
||||
removeChild()
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
parent.activeSubagentChild = undefined
|
||||
removeChild()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export interface SubagentRunningPayload {
|
|||
tool: typeof SUBAGENT_TOOL_NAMES.running
|
||||
description?: string
|
||||
currentTask?: string
|
||||
/** Identifies this run for progress updates when multiple subagents run in parallel (e.g. block.id). */
|
||||
runId?: string
|
||||
}
|
||||
|
||||
/** Payload for the "subagentCompleted" tool message (result or error). */
|
||||
|
|
@ -57,6 +59,8 @@ export interface RunSubagentInBackgroundParams {
|
|||
prompt: string
|
||||
subagentType: SubagentType
|
||||
onProgress?: (currentTask: string) => void
|
||||
/** Required: keys the child in activeSubagentChildren and ties completion to the correct tool_use_id. */
|
||||
toolCallId: string
|
||||
}
|
||||
|
||||
/** Provider interface for running a subagent. Allows SubagentTool to call without type assertion. */
|
||||
|
|
|
|||
|
|
@ -561,6 +561,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
}
|
||||
|
||||
// Check if any subagent is currently running (has a spinning progress indicator).
|
||||
// The parent task's API call is already finished, but we still want the stop button.
|
||||
if (modifiedMessages.some((m) => m.progressStatus?.spin === true)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
|
||||
|
||||
|
|
|
|||
|
|
@ -373,11 +373,38 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
case "messageUpdated": {
|
||||
const clineMessage = message.clineMessage!
|
||||
setState((prevState) => {
|
||||
// worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock
|
||||
const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === clineMessage.ts)
|
||||
if (lastIndex !== -1) {
|
||||
// For subagentRunning updates, match by runId so parallel subagents (same ts) each update their own row
|
||||
let index = -1
|
||||
if (
|
||||
clineMessage.type === "say" &&
|
||||
clineMessage.say === "tool" &&
|
||||
typeof clineMessage.text === "string"
|
||||
) {
|
||||
try {
|
||||
const payload = JSON.parse(clineMessage.text) as { tool?: string; runId?: string }
|
||||
if (payload.tool === "subagentRunning" && payload.runId != null) {
|
||||
index = prevState.clineMessages.findIndex((msg) => {
|
||||
if (msg.type !== "say" || msg.say !== "tool" || typeof msg.text !== "string")
|
||||
return false
|
||||
try {
|
||||
const p = JSON.parse(msg.text) as { tool?: string; runId?: string }
|
||||
return p.tool === "subagentRunning" && p.runId === payload.runId
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// fall through to ts match
|
||||
}
|
||||
}
|
||||
if (index === -1) {
|
||||
// worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock
|
||||
index = findLastIndex(prevState.clineMessages, (msg) => msg.ts === clineMessage.ts)
|
||||
}
|
||||
if (index !== -1) {
|
||||
const newClineMessages = [...prevState.clineMessages]
|
||||
newClineMessages[lastIndex] = clineMessage
|
||||
newClineMessages[index] = clineMessage
|
||||
return { ...prevState, clineMessages: newClineMessages }
|
||||
}
|
||||
// Log a warning if messageUpdated arrives for a timestamp not in the
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue