feat: enhances the capabilities of subagents by allowing them to use tools that require user

confirmation
This commit is contained in:
tanzhenxin
2025-09-11 15:16:52 +08:00
parent 88941daf93
commit d0735e8eb4
8 changed files with 383 additions and 297 deletions

View File

@@ -106,7 +106,13 @@ const SubagentExecutionRenderer: React.FC<{
data: TaskResultDisplay;
availableHeight?: number;
childWidth: number;
}> = ({ data }) => <AgentExecutionDisplay data={data} />;
}> = ({ data, availableHeight, childWidth }) => (
<AgentExecutionDisplay
data={data}
availableHeight={availableHeight}
childWidth={childWidth}
/>
);
/**
* Component to render string results (markdown or plain text)

View File

@@ -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<AgentExecutionDisplayProps> = ({
data,
availableHeight,
childWidth,
}) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('default');
@@ -137,6 +147,18 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
</Box>
)}
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column">
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={true}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth ?? 80}
/>
</Box>
)}
{/* 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<string, unknown>;
result?: string;
@@ -263,6 +285,8 @@ const ToolCallItem: React.FC<{
switch (toolCall.status) {
case 'executing':
return <Text color={color}></Text>; // Using same as ToolMessage
case 'awaiting_approval':
return <Text color={theme.status.warning}>?</Text>;
case 'success':
return <Text color={color}></Text>;
case 'failed':
@@ -401,7 +425,7 @@ const ResultsSection: React.FC<{
)}
{/* Execution Summary section - hide when cancelled */}
{data.status !== 'cancelled' && (
{data.status === 'completed' && (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Execution Summary:</Text>
@@ -411,7 +435,7 @@ const ResultsSection: React.FC<{
)}
{/* Tool Usage section - hide when cancelled */}
{data.status !== 'cancelled' && data.executionSummary && (
{data.status === 'completed' && data.executionSummary && (
<Box flexDirection="column">
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.primary}>Tool Usage:</Text>
@@ -426,13 +450,11 @@ const ResultsSection: React.FC<{
<Text color={theme.status.warning}> User Cancelled</Text>
</Box>
)}
{data.status === 'failed' &&
data.terminateReason &&
data.terminateReason !== 'CANCELLED' && (
<Box flexDirection="row">
<Text color={Colors.AccentRed}> Failed: </Text>
<Text color={Colors.Gray}>{data.terminateReason}</Text>
</Box>
)}
{data.status === 'failed' && (
<Box flexDirection="row">
<Text color={theme.status.error}>Task Failed: </Text>
<Text color={theme.status.error}>{data.terminateReason}</Text>
</Box>
)}
</Box>
);

View File

@@ -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<ToolCallConfirmationDetails, 'onConfirm'> & {
type: ToolCallConfirmationDetails['type'];
};
respond: (
outcome: ToolConfirmationOutcome,
payload?: Parameters<ToolCallConfirmationDetails['onConfirm']>[1],
) => Promise<void>;
timestamp: number;
}
export interface SubAgentFinishEvent {
subagentId: string;
terminate_reason: string;
terminateReason: string;
timestamp: number;
rounds?: number;
totalDurationMs?: number;

View File

@@ -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', () => {

View File

@@ -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<SubAgentScope> {
// 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<Content[]> {
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<string>();
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<string, unknown>;
const request: ToolCallRequestInfo = {
callId,
name: functionCall.name as string,
args: (functionCall.args ?? {}) as Record<string, unknown>,
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<void>((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) {

View File

@@ -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 () => {

View File

@@ -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<TaskParams, ToolResult> {
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<TaskParams, ToolResult> {
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>([
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<TaskParams, ToolResult> {
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<TaskParams, ToolResult> {
// 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<TaskParams, ToolResult> {
this.updateDisplay(
{
status: success ? 'completed' : 'failed',
terminateReason,
terminateReason: terminateMode,
result: finalText,
executionSummary,
},
@@ -465,8 +546,7 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
const errorDisplay: TaskResultDisplay = {
...this.currentDisplay!,
status: 'failed',
terminateReason: 'ERROR',
result: `Failed to run subagent: ${errorMessage}`,
terminateReason: `Failed to run subagent: ${errorMessage}`,
};
return {

View File

@@ -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<string, unknown>;
result?: string;