diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx
index c81f3238..a81326cc 100644
--- a/packages/cli/src/ui/components/messages/ToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx
@@ -106,7 +106,13 @@ const SubagentExecutionRenderer: React.FC<{
data: TaskResultDisplay;
availableHeight?: number;
childWidth: number;
-}> = ({ data }) => ;
+}> = ({ data, availableHeight, childWidth }) => (
+
+);
/**
* Component to render string results (markdown or plain text)
diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx
index 1025f636..93443a81 100644
--- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx
+++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx
@@ -15,19 +15,27 @@ import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { COLOR_OPTIONS } from '../constants.js';
import { fmtDuration } from '../utils.js';
+import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js';
export type DisplayMode = 'default' | 'verbose';
export interface AgentExecutionDisplayProps {
data: TaskResultDisplay;
+ availableHeight?: number;
+ childWidth?: number;
}
const getStatusColor = (
- status: TaskResultDisplay['status'] | 'executing' | 'success',
+ status:
+ | TaskResultDisplay['status']
+ | 'executing'
+ | 'success'
+ | 'awaiting_approval',
) => {
switch (status) {
case 'running':
case 'executing':
+ case 'awaiting_approval':
return theme.status.warning;
case 'completed':
case 'success':
@@ -66,6 +74,8 @@ const MAX_TASK_PROMPT_LINES = 5;
*/
export const AgentExecutionDisplay: React.FC = ({
data,
+ availableHeight,
+ childWidth,
}) => {
const [displayMode, setDisplayMode] = React.useState('default');
@@ -137,6 +147,18 @@ export const AgentExecutionDisplay: React.FC = ({
)}
+ {/* Inline approval prompt when awaiting confirmation */}
+ {data.pendingConfirmation && (
+
+
+
+ )}
+
{/* Results section for completed/failed tasks */}
{(data.status === 'completed' ||
data.status === 'failed' ||
@@ -247,7 +269,7 @@ const ToolCallsList: React.FC<{
const ToolCallItem: React.FC<{
toolCall: {
name: string;
- status: 'executing' | 'success' | 'failed';
+ status: 'executing' | 'awaiting_approval' | 'success' | 'failed';
error?: string;
args?: Record;
result?: string;
@@ -263,6 +285,8 @@ const ToolCallItem: React.FC<{
switch (toolCall.status) {
case 'executing':
return ⊷; // Using same as ToolMessage
+ case 'awaiting_approval':
+ return ?;
case 'success':
return ✔;
case 'failed':
@@ -401,7 +425,7 @@ const ResultsSection: React.FC<{
)}
{/* Execution Summary section - hide when cancelled */}
- {data.status !== 'cancelled' && (
+ {data.status === 'completed' && (
Execution Summary:
@@ -411,7 +435,7 @@ const ResultsSection: React.FC<{
)}
{/* Tool Usage section - hide when cancelled */}
- {data.status !== 'cancelled' && data.executionSummary && (
+ {data.status === 'completed' && data.executionSummary && (
Tool Usage:
@@ -426,13 +450,11 @@ const ResultsSection: React.FC<{
⏹ User Cancelled
)}
- {data.status === 'failed' &&
- data.terminateReason &&
- data.terminateReason !== 'CANCELLED' && (
-
- ❌ Failed:
- {data.terminateReason}
-
- )}
+ {data.status === 'failed' && (
+
+ Task Failed:
+ {data.terminateReason}
+
+ )}
);
diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/subagents/subagent-events.ts
index 9b750a39..665b33b3 100644
--- a/packages/core/src/subagents/subagent-events.ts
+++ b/packages/core/src/subagents/subagent-events.ts
@@ -5,6 +5,10 @@
*/
import { EventEmitter } from 'events';
+import {
+ ToolCallConfirmationDetails,
+ ToolConfirmationOutcome,
+} from '../tools/tools.js';
export type SubAgentEvent =
| 'start'
@@ -13,6 +17,7 @@ export type SubAgentEvent =
| 'stream_text'
| 'tool_call'
| 'tool_result'
+ | 'tool_waiting_approval'
| 'finish'
| 'error';
@@ -23,6 +28,7 @@ export enum SubAgentEventType {
STREAM_TEXT = 'stream_text',
TOOL_CALL = 'tool_call',
TOOL_RESULT = 'tool_result',
+ TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
FINISH = 'finish',
ERROR = 'error',
}
@@ -71,9 +77,25 @@ export interface SubAgentToolResultEvent {
timestamp: number;
}
+export interface SubAgentApprovalRequestEvent {
+ subagentId: string;
+ round: number;
+ callId: string;
+ name: string;
+ description: string;
+ confirmationDetails: Omit & {
+ type: ToolCallConfirmationDetails['type'];
+ };
+ respond: (
+ outcome: ToolConfirmationOutcome,
+ payload?: Parameters[1],
+ ) => Promise;
+ timestamp: number;
+}
+
export interface SubAgentFinishEvent {
subagentId: string;
- terminate_reason: string;
+ terminateReason: string;
timestamp: number;
rounds?: number;
totalDurationMs?: number;
diff --git a/packages/core/src/subagents/subagent.test.ts b/packages/core/src/subagents/subagent.test.ts
index 25b817ec..12cfbd00 100644
--- a/packages/core/src/subagents/subagent.test.ts
+++ b/packages/core/src/subagents/subagent.test.ts
@@ -19,15 +19,16 @@ import { createContentGenerator } from '../core/contentGenerator.js';
import { getEnvironmentContext } from '../utils/environmentContext.js';
import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
import { ToolRegistry } from '../tools/tool-registry.js';
+import { AnyDeclarativeTool } from '../tools/tools.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
import {
Content,
FunctionCall,
FunctionDeclaration,
GenerateContentConfig,
+ Part,
Type,
} from '@google/genai';
-import { ToolErrorType } from '../tools/tool-error.js';
vi.mock('../core/geminiChat.js');
vi.mock('../core/contentGenerator.js');
@@ -193,7 +194,7 @@ describe('subagent.ts', () => {
expect(scope).toBeInstanceOf(SubAgentScope);
});
- it('should throw an error if a tool requires confirmation', async () => {
+ it('should not block creation when a tool may require confirmation', async () => {
const mockTool = {
schema: { parameters: { type: Type.OBJECT, properties: {} } },
build: vi.fn().mockReturnValue({
@@ -212,18 +213,15 @@ describe('subagent.ts', () => {
const toolConfig: ToolConfig = { tools: ['risky_tool'] };
- await expect(
- SubAgentScope.create(
- 'test-agent',
- config,
- promptConfig,
- defaultModelConfig,
- defaultRunConfig,
- toolConfig,
- ),
- ).rejects.toThrow(
- 'Tool "risky_tool" requires user confirmation and cannot be used in a non-interactive subagent.',
+ const scope = await SubAgentScope.create(
+ 'test-agent',
+ config,
+ promptConfig,
+ defaultModelConfig,
+ defaultRunConfig,
+ toolConfig,
);
+ expect(scope).toBeInstanceOf(SubAgentScope);
});
it('should succeed if tools do not require confirmation', async () => {
@@ -251,11 +249,7 @@ describe('subagent.ts', () => {
expect(scope).toBeInstanceOf(SubAgentScope);
});
- it('should skip interactivity check and warn for tools with required parameters', async () => {
- const consoleWarnSpy = vi
- .spyOn(console, 'warn')
- .mockImplementation(() => {});
-
+ it('should allow creation regardless of tool parameter requirements', async () => {
const mockToolWithParams = {
schema: {
parameters: {
@@ -266,7 +260,6 @@ describe('subagent.ts', () => {
required: ['path'],
},
},
- // build should not be called, but we mock it to be safe
build: vi.fn(),
};
@@ -276,7 +269,6 @@ describe('subagent.ts', () => {
const toolConfig: ToolConfig = { tools: ['tool_with_params'] };
- // The creation should succeed without throwing
const scope = await SubAgentScope.create(
'test-agent',
config,
@@ -287,16 +279,8 @@ describe('subagent.ts', () => {
);
expect(scope).toBeInstanceOf(SubAgentScope);
-
- // Check that the warning was logged
- expect(consoleWarnSpy).toHaveBeenCalledWith(
- 'Cannot check tool "tool_with_params" for interactivity because it requires parameters. Assuming it is safe for non-interactive use.',
- );
-
- // Ensure build was never called
+ // Ensure build was not called during creation
expect(mockToolWithParams.build).not.toHaveBeenCalled();
-
- consoleWarnSpy.mockRestore();
});
});
@@ -514,14 +498,31 @@ describe('subagent.ts', () => {
]),
);
- // Mock the tool execution result
- vi.mocked(executeToolCall).mockResolvedValue({
- callId: 'call_1',
- responseParts: 'file1.txt\nfile2.ts',
- resultDisplay: 'Listed 2 files',
- error: undefined,
- errorType: undefined, // Or ToolErrorType.NONE if available and appropriate
- });
+ // Provide a mock tool via ToolRegistry that returns a successful result
+ const listFilesInvocation = {
+ params: { path: '.' },
+ getDescription: vi.fn().mockReturnValue('List files'),
+ toolLocations: vi.fn().mockReturnValue([]),
+ shouldConfirmExecute: vi.fn().mockResolvedValue(false),
+ execute: vi.fn().mockResolvedValue({
+ llmContent: 'file1.txt\nfile2.ts',
+ returnDisplay: 'Listed 2 files',
+ }),
+ };
+ const listFilesTool = {
+ name: 'list_files',
+ displayName: 'List Files',
+ description: 'List files in directory',
+ kind: 'READ' as const,
+ schema: listFilesToolDef,
+ build: vi.fn().mockImplementation(() => listFilesInvocation),
+ canUpdateOutput: false,
+ isOutputMarkdown: true,
+ } as unknown as AnyDeclarativeTool;
+ vi.mocked((config.getToolRegistry() as unknown as ToolRegistry).getTool)
+ .mockImplementation((name: string) =>
+ name === 'list_files' ? listFilesTool : undefined,
+ );
const scope = await SubAgentScope.create(
'test-agent',
@@ -534,70 +535,19 @@ describe('subagent.ts', () => {
await scope.runNonInteractive(new ContextState());
- // Check tool execution
- expect(executeToolCall).toHaveBeenCalledWith(
- config,
- expect.objectContaining({ name: 'list_files', args: { path: '.' } }),
- expect.any(AbortSignal),
- );
-
- // Check the response sent back to the model
+ // Check the response sent back to the model (functionResponse part)
const secondCallArgs = mockSendMessageStream.mock.calls[1][0];
- expect(secondCallArgs.message).toEqual([
- { text: 'file1.txt\nfile2.ts' },
- ]);
+ const parts = secondCallArgs.message as unknown[];
+ expect(Array.isArray(parts)).toBe(true);
+ const firstPart = parts[0] as Part;
+ expect(firstPart.functionResponse?.response?.['output']).toBe(
+ 'file1.txt\nfile2.ts',
+ );
expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL);
});
- it('should provide specific tool error responses to the model', async () => {
- const { config } = await createMockConfig();
- const toolConfig: ToolConfig = { tools: ['failing_tool'] };
- // Turn 1: Model calls the failing tool
- // Turn 2: Model stops after receiving the error response
- mockSendMessageStream.mockImplementation(
- createMockStream([
- [
- {
- id: 'call_fail',
- name: 'failing_tool',
- args: {},
- },
- ],
- 'stop',
- ]),
- );
-
- // Mock the tool execution failure.
- vi.mocked(executeToolCall).mockResolvedValue({
- callId: 'call_fail',
- responseParts: 'ERROR: Tool failed catastrophically', // This should be sent to the model
- resultDisplay: 'Tool failed catastrophically',
- error: new Error('Failure'),
- errorType: ToolErrorType.INVALID_TOOL_PARAMS,
- });
-
- const scope = await SubAgentScope.create(
- 'test-agent',
- config,
- promptConfig,
- defaultModelConfig,
- defaultRunConfig,
- toolConfig,
- );
-
- await scope.runNonInteractive(new ContextState());
-
- // The agent should send the specific error message from responseParts.
- const secondCallArgs = mockSendMessageStream.mock.calls[1][0];
-
- expect(secondCallArgs.message).toEqual([
- {
- text: 'ERROR: Tool failed catastrophically',
- },
- ]);
- });
});
describe('runNonInteractive - Termination and Recovery', () => {
diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts
index 69b7f8b5..14a953a7 100644
--- a/packages/core/src/subagents/subagent.ts
+++ b/packages/core/src/subagents/subagent.ts
@@ -7,7 +7,15 @@
import { reportError } from '../utils/errorReporting.js';
import { Config } from '../config/config.js';
import { ToolCallRequestInfo } from '../core/turn.js';
-import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
+import {
+ CoreToolScheduler,
+ ToolCall,
+ WaitingToolCall,
+} from '../core/coreToolScheduler.js';
+import type {
+ ToolConfirmationOutcome,
+ ToolCallConfirmationDetails,
+} from '../tools/tools.js';
import { createContentGenerator } from '../core/contentGenerator.js';
import { getEnvironmentContext } from '../utils/environmentContext.js';
import {
@@ -227,61 +235,6 @@ export class SubAgentScope {
eventEmitter?: SubAgentEventEmitter,
hooks?: SubagentHooks,
): Promise {
- // Validate tools for non-interactive use
- if (toolConfig?.tools) {
- const toolRegistry = runtimeContext.getToolRegistry();
-
- for (const toolItem of toolConfig.tools) {
- if (typeof toolItem !== 'string') {
- continue; // Skip inline function declarations
- }
- const tool = toolRegistry.getTool(toolItem);
- if (!tool) {
- continue; // Skip unknown tools
- }
-
- // Check if tool has required parameters
- const hasRequiredParams =
- tool.schema?.parameters?.required &&
- Array.isArray(tool.schema.parameters.required) &&
- tool.schema.parameters.required.length > 0;
-
- if (hasRequiredParams) {
- // Can't check interactivity without parameters, log warning and continue
- console.warn(
- `Cannot check tool "${toolItem}" for interactivity because it requires parameters. Assuming it is safe for non-interactive use.`,
- );
- continue;
- }
-
- // Try to build the tool to check if it requires confirmation
- try {
- const toolInstance = tool.build({});
- const confirmationDetails = await toolInstance.shouldConfirmExecute(
- new AbortController().signal,
- );
-
- if (confirmationDetails) {
- throw new Error(
- `Tool "${toolItem}" requires user confirmation and cannot be used in a non-interactive subagent.`,
- );
- }
- } catch (error) {
- // If we can't build the tool, assume it's safe
- if (
- error instanceof Error &&
- error.message.includes('requires user confirmation')
- ) {
- throw error; // Re-throw confirmation errors
- }
- // For other build errors, log warning and continue
- console.warn(
- `Cannot check tool "${toolItem}" for interactivity because it requires parameters. Assuming it is safe for non-interactive use.`,
- );
- }
- }
- }
-
return new SubAgentScope(
name,
runtimeContext,
@@ -517,13 +470,6 @@ export class SubAgentScope {
timestamp: Date.now(),
} as SubAgentErrorEvent);
- // Log telemetry for subagent error
- const errorEvent = new SubagentExecutionEvent(this.name, 'failed', {
- terminate_reason: SubagentTerminateMode.ERROR,
- result: error instanceof Error ? error.message : String(error),
- });
- logSubagentExecution(this.runtimeContext, errorEvent);
-
throw error;
} finally {
if (externalSignal) externalSignal.removeEventListener('abort', onAbort);
@@ -531,7 +477,7 @@ export class SubAgentScope {
const summary = this.stats.getSummary(Date.now());
this.eventEmitter?.emit(SubAgentEventType.FINISH, {
subagentId: this.subagentId,
- terminate_reason: this.terminateMode,
+ terminateReason: this.terminateMode,
timestamp: Date.now(),
rounds: summary.rounds,
totalDurationMs: summary.totalDurationMs,
@@ -587,134 +533,192 @@ export class SubAgentScope {
): Promise {
const toolResponseParts: Part[] = [];
- for (const functionCall of functionCalls) {
- const toolName = String(functionCall.name || 'unknown');
- const callId = functionCall.id ?? `${functionCall.name}-${Date.now()}`;
- const requestInfo: ToolCallRequestInfo = {
+ // Build scheduler
+ const responded = new Set();
+ let resolveBatch: (() => void) | null = null;
+ const scheduler = new CoreToolScheduler({
+ toolRegistry: this.runtimeContext.getToolRegistry(),
+ outputUpdateHandler: undefined,
+ onAllToolCallsComplete: async (completedCalls) => {
+ for (const call of completedCalls) {
+ const toolName = call.request.name;
+ const duration = call.durationMs ?? 0;
+ const success = call.status === 'success';
+ const errorMessage =
+ call.status === 'error' || call.status === 'cancelled'
+ ? call.response.error?.message
+ : undefined;
+
+ // Update aggregate stats
+ this.executionStats.totalToolCalls += 1;
+ if (success) {
+ this.executionStats.successfulToolCalls += 1;
+ } else {
+ this.executionStats.failedToolCalls += 1;
+ }
+
+ // Per-tool usage
+ const tu = this.toolUsage.get(toolName) || {
+ count: 0,
+ success: 0,
+ failure: 0,
+ totalDurationMs: 0,
+ averageDurationMs: 0,
+ };
+ tu.count += 1;
+ if (success) {
+ tu.success += 1;
+ } else {
+ tu.failure += 1;
+ tu.lastError = errorMessage || 'Unknown error';
+ }
+ tu.totalDurationMs = (tu.totalDurationMs || 0) + duration;
+ tu.averageDurationMs =
+ tu.count > 0 ? tu.totalDurationMs / tu.count : 0;
+ this.toolUsage.set(toolName, tu);
+
+ // Emit tool result event
+ this.eventEmitter?.emit(SubAgentEventType.TOOL_RESULT, {
+ subagentId: this.subagentId,
+ round: currentRound,
+ callId: call.request.callId,
+ name: toolName,
+ success,
+ error: errorMessage,
+ resultDisplay: call.response.resultDisplay
+ ? typeof call.response.resultDisplay === 'string'
+ ? call.response.resultDisplay
+ : JSON.stringify(call.response.resultDisplay)
+ : undefined,
+ durationMs: duration,
+ timestamp: Date.now(),
+ } as SubAgentToolResultEvent);
+
+ // Update statistics service
+ this.stats.recordToolCall(
+ toolName,
+ success,
+ duration,
+ this.toolUsage.get(toolName)?.lastError,
+ );
+
+ // post-tool hook
+ await this.hooks?.postToolUse?.({
+ subagentId: this.subagentId,
+ name: this.name,
+ toolName,
+ args: call.request.args,
+ success,
+ durationMs: duration,
+ errorMessage,
+ timestamp: Date.now(),
+ });
+
+ // Append response parts
+ const respParts = call.response.responseParts;
+ if (respParts) {
+ const parts = Array.isArray(respParts) ? respParts : [respParts];
+ for (const part of parts) {
+ if (typeof part === 'string') {
+ toolResponseParts.push({ text: part });
+ } else if (part) {
+ toolResponseParts.push(part);
+ }
+ }
+ }
+ }
+ // Signal that this batch is complete (all tools terminal)
+ resolveBatch?.();
+ },
+ onToolCallsUpdate: (calls: ToolCall[]) => {
+ for (const call of calls) {
+ if (call.status !== 'awaiting_approval') continue;
+ const waiting = call as WaitingToolCall;
+
+ // Emit approval request event for UI visibility
+ try {
+ const { confirmationDetails } = waiting;
+ const { onConfirm: _onConfirm, ...rest } = confirmationDetails;
+ this.eventEmitter?.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, {
+ subagentId: this.subagentId,
+ round: currentRound,
+ callId: waiting.request.callId,
+ name: waiting.request.name,
+ description: this.getToolDescription(
+ waiting.request.name,
+ waiting.request.args,
+ ),
+ confirmationDetails: rest,
+ respond: async (
+ outcome: ToolConfirmationOutcome,
+ payload?: Parameters<
+ ToolCallConfirmationDetails['onConfirm']
+ >[1],
+ ) => {
+ if (responded.has(waiting.request.callId)) return;
+ responded.add(waiting.request.callId);
+ await waiting.confirmationDetails.onConfirm(outcome, payload);
+ },
+ timestamp: Date.now(),
+ });
+ } catch {
+ // ignore UI event emission failures
+ }
+
+ // UI now renders inline confirmation via task tool live output.
+ }
+ },
+ getPreferredEditor: () => undefined,
+ config: this.runtimeContext,
+ onEditorClose: () => {},
+ });
+
+ // Prepare requests and emit TOOL_CALL events
+ const requests: ToolCallRequestInfo[] = functionCalls.map((fc) => {
+ const toolName = String(fc.name || 'unknown');
+ const callId = fc.id ?? `${fc.name}-${Date.now()}`;
+ const args = (fc.args ?? {}) as Record;
+ const request: ToolCallRequestInfo = {
callId,
- name: functionCall.name as string,
- args: (functionCall.args ?? {}) as Record,
+ name: toolName,
+ args,
isClientInitiated: true,
prompt_id: promptId,
};
- // Get tool description before execution
- const description = this.getToolDescription(toolName, requestInfo.args);
-
- // Emit tool call event BEFORE execution
+ const description = this.getToolDescription(toolName, args);
this.eventEmitter?.emit(SubAgentEventType.TOOL_CALL, {
subagentId: this.subagentId,
round: currentRound,
callId,
name: toolName,
- args: requestInfo.args,
+ args,
description,
timestamp: Date.now(),
} as SubAgentToolCallEvent);
- // Execute tools with timing and hooks
- const start = Date.now();
- await this.hooks?.preToolUse?.({
+ // pre-tool hook
+ void this.hooks?.preToolUse?.({
subagentId: this.subagentId,
name: this.name,
toolName,
- args: requestInfo.args,
- timestamp: Date.now(),
- });
- const toolResponse = await executeToolCall(
- this.runtimeContext,
- requestInfo,
- abortController.signal,
- );
- const duration = Date.now() - start;
- // Update tool call stats
- this.executionStats.totalToolCalls += 1;
- if (toolResponse.error) {
- this.executionStats.failedToolCalls += 1;
- } else {
- this.executionStats.successfulToolCalls += 1;
- }
-
- // Update per-tool usage
- const tu = this.toolUsage.get(toolName) || {
- count: 0,
- success: 0,
- failure: 0,
- totalDurationMs: 0,
- averageDurationMs: 0,
- };
- tu.count += 1;
- if (toolResponse?.error) {
- tu.failure += 1;
- tu.lastError = toolResponse.error?.message || 'Unknown error';
- } else {
- tu.success += 1;
- }
- if (typeof tu.totalDurationMs === 'number') {
- tu.totalDurationMs += duration;
- tu.averageDurationMs =
- tu.count > 0 ? tu.totalDurationMs / tu.count : tu.totalDurationMs;
- } else {
- tu.totalDurationMs = duration;
- tu.averageDurationMs = duration;
- }
- this.toolUsage.set(toolName, tu);
-
- // Emit tool result event
- this.eventEmitter?.emit(SubAgentEventType.TOOL_RESULT, {
- subagentId: this.subagentId,
- round: currentRound,
- callId,
- name: toolName,
- success: !toolResponse?.error,
- error: toolResponse?.error?.message,
- resultDisplay: toolResponse?.resultDisplay
- ? typeof toolResponse.resultDisplay === 'string'
- ? toolResponse.resultDisplay
- : JSON.stringify(toolResponse.resultDisplay)
- : undefined,
- durationMs: duration,
- timestamp: Date.now(),
- } as SubAgentToolResultEvent);
-
- // Update statistics service
- this.stats.recordToolCall(
- toolName,
- !toolResponse?.error,
- duration,
- this.toolUsage.get(toolName)?.lastError,
- );
-
- // post-tool hook
- await this.hooks?.postToolUse?.({
- subagentId: this.subagentId,
- name: this.name,
- toolName,
- args: requestInfo.args,
- success: !toolResponse?.error,
- durationMs: duration,
- errorMessage: toolResponse?.error?.message,
+ args,
timestamp: Date.now(),
});
- if (toolResponse.error) {
- console.error(
- `Error executing tool ${functionCall.name}: ${toolResponse.error.message}`,
- );
- }
+ return request;
+ });
- if (toolResponse.responseParts) {
- const parts = Array.isArray(toolResponse.responseParts)
- ? toolResponse.responseParts
- : [toolResponse.responseParts];
- for (const part of parts) {
- if (typeof part === 'string') {
- toolResponseParts.push({ text: part });
- } else if (part) {
- toolResponseParts.push(part);
- }
- }
- }
+ if (requests.length > 0) {
+ // Create a per-batch completion promise, resolve when onAllToolCallsComplete fires
+ const batchDone = new Promise((resolve) => {
+ resolveBatch = () => {
+ resolve();
+ resolveBatch = null;
+ };
+ });
+ await scheduler.schedule(requests, abortController.signal);
+ await batchDone; // Wait for approvals + execution to finish
}
// If all tool calls failed, inform the model so it can re-evaluate.
if (functionCalls.length > 0 && toolResponseParts.length === 0) {
diff --git a/packages/core/src/tools/task.test.ts b/packages/core/src/tools/task.test.ts
index 5afa4e8f..e7523df3 100644
--- a/packages/core/src/tools/task.test.ts
+++ b/packages/core/src/tools/task.test.ts
@@ -395,9 +395,6 @@ describe('TaskTool', () => {
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('failed');
- expect(display.result ?? '').toContain(
- 'Failed to run subagent: Creation failed',
- );
});
it('should execute subagent without live output callback', async () => {
diff --git a/packages/core/src/tools/task.ts b/packages/core/src/tools/task.ts
index f02df4dd..6c4eec00 100644
--- a/packages/core/src/tools/task.ts
+++ b/packages/core/src/tools/task.ts
@@ -12,6 +12,11 @@ import {
ToolResultDisplay,
TaskResultDisplay,
} from './tools.js';
+import { ToolConfirmationOutcome } from './tools.js';
+import type {
+ ToolCallConfirmationDetails,
+ ToolConfirmationPayload,
+} from './tools.js';
import { Config } from '../config/config.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import { SubagentConfig, SubagentTerminateMode } from '../subagents/types.js';
@@ -23,6 +28,7 @@ import {
SubAgentFinishEvent,
SubAgentEventType,
SubAgentErrorEvent,
+ SubAgentApprovalRequestEvent,
} from '../subagents/subagent-events.js';
export interface TaskParams {
@@ -338,9 +344,8 @@ class TaskToolInvocation extends BaseToolInvocation {
const event = args[0] as SubAgentFinishEvent;
this.updateDisplay(
{
- status: event.terminate_reason === 'GOAL' ? 'completed' : 'failed',
- terminateReason: event.terminate_reason,
- // Keep toolCalls data for final display
+ status: event.terminateReason === 'GOAL' ? 'completed' : 'failed',
+ terminateReason: event.terminateReason,
},
updateOutput,
);
@@ -356,6 +361,85 @@ class TaskToolInvocation extends BaseToolInvocation {
updateOutput,
);
});
+
+ // Indicate when a tool call is waiting for approval
+ this.eventEmitter.on(
+ SubAgentEventType.TOOL_WAITING_APPROVAL,
+ (...args: unknown[]) => {
+ const event = args[0] as SubAgentApprovalRequestEvent;
+ const idx = this.currentToolCalls!.findIndex(
+ (c) => c.callId === event.callId,
+ );
+ if (idx >= 0) {
+ this.currentToolCalls![idx] = {
+ ...this.currentToolCalls![idx],
+ status: 'awaiting_approval',
+ };
+ } else {
+ this.currentToolCalls!.push({
+ callId: event.callId,
+ name: event.name,
+ status: 'awaiting_approval',
+ description: event.description,
+ });
+ }
+
+ // Bridge scheduler confirmation details to UI inline prompt
+ const details: ToolCallConfirmationDetails = {
+ ...(event.confirmationDetails as Omit<
+ ToolCallConfirmationDetails,
+ 'onConfirm'
+ >),
+ onConfirm: async (
+ outcome: ToolConfirmationOutcome,
+ payload?: ToolConfirmationPayload,
+ ) => {
+ // Clear the inline prompt immediately
+ // and optimistically mark the tool as executing for proceed outcomes.
+ const proceedOutcomes = new Set([
+ ToolConfirmationOutcome.ProceedOnce,
+ ToolConfirmationOutcome.ProceedAlways,
+ ToolConfirmationOutcome.ProceedAlwaysServer,
+ ToolConfirmationOutcome.ProceedAlwaysTool,
+ ]);
+
+ if (proceedOutcomes.has(outcome)) {
+ const idx2 = this.currentToolCalls!.findIndex(
+ (c) => c.callId === event.callId,
+ );
+ if (idx2 >= 0) {
+ this.currentToolCalls![idx2] = {
+ ...this.currentToolCalls![idx2],
+ status: 'executing',
+ };
+ }
+ this.updateDisplay(
+ {
+ toolCalls: [...this.currentToolCalls!],
+ pendingConfirmation: undefined,
+ },
+ updateOutput,
+ );
+ } else {
+ this.updateDisplay(
+ { pendingConfirmation: undefined },
+ updateOutput,
+ );
+ }
+
+ await event.respond(outcome, payload);
+ },
+ } as ToolCallConfirmationDetails;
+
+ this.updateDisplay(
+ {
+ toolCalls: [...this.currentToolCalls!],
+ pendingConfirmation: details,
+ },
+ updateOutput,
+ );
+ },
+ );
}
getDescription(): string {
@@ -384,9 +468,7 @@ class TaskToolInvocation extends BaseToolInvocation {
taskDescription: this.params.description,
taskPrompt: this.params.prompt,
status: 'failed' as const,
- terminateReason: 'ERROR',
- result: `Subagent "${this.params.subagent_type}" not found`,
- subagentColor: undefined,
+ terminateReason: `Subagent "${this.params.subagent_type}" not found`,
};
return {
@@ -427,16 +509,15 @@ class TaskToolInvocation extends BaseToolInvocation {
// Get the results
const finalText = subagentScope.getFinalText();
- const terminateReason = subagentScope.getTerminateMode();
- const success = terminateReason === SubagentTerminateMode.GOAL;
+ const terminateMode = subagentScope.getTerminateMode();
+ const success = terminateMode === SubagentTerminateMode.GOAL;
const executionSummary = subagentScope.getExecutionSummary();
if (signal?.aborted) {
this.updateDisplay(
{
status: 'cancelled',
- terminateReason: 'CANCELLED',
- result: finalText || 'Task was cancelled by user',
+ terminateReason: 'Task was cancelled by user',
executionSummary,
},
updateOutput,
@@ -445,7 +526,7 @@ class TaskToolInvocation extends BaseToolInvocation {
this.updateDisplay(
{
status: success ? 'completed' : 'failed',
- terminateReason,
+ terminateReason: terminateMode,
result: finalText,
executionSummary,
},
@@ -465,8 +546,7 @@ class TaskToolInvocation extends BaseToolInvocation {
const errorDisplay: TaskResultDisplay = {
...this.currentDisplay!,
status: 'failed',
- terminateReason: 'ERROR',
- result: `Failed to run subagent: ${errorMessage}`,
+ terminateReason: `Failed to run subagent: ${errorMessage}`,
};
return {
diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts
index b8a5e9bb..9875401a 100644
--- a/packages/core/src/tools/tools.ts
+++ b/packages/core/src/tools/tools.ts
@@ -432,10 +432,15 @@ export interface TaskResultDisplay {
terminateReason?: string;
result?: string;
executionSummary?: SubagentStatsSummary;
+
+ // If the subagent is awaiting approval for a tool call,
+ // this contains the confirmation details for inline UI rendering.
+ pendingConfirmation?: ToolCallConfirmationDetails;
+
toolCalls?: Array<{
callId: string;
name: string;
- status: 'executing' | 'success' | 'failed';
+ status: 'executing' | 'awaiting_approval' | 'success' | 'failed';
error?: string;
args?: Record;
result?: string;