mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat: enhances the capabilities of subagents by allowing them to use tools that require user
confirmation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,12 +450,10 @@ const ResultsSection: React.FC<{
|
||||
<Text color={theme.status.warning}>⏹ User Cancelled</Text>
|
||||
</Box>
|
||||
)}
|
||||
{data.status === 'failed' &&
|
||||
data.terminateReason &&
|
||||
data.terminateReason !== 'CANCELLED' && (
|
||||
{data.status === 'failed' && (
|
||||
<Box flexDirection="row">
|
||||
<Text color={Colors.AccentRed}>❌ Failed: </Text>
|
||||
<Text color={Colors.Gray}>{data.terminateReason}</Text>
|
||||
<Text color={theme.status.error}>Task Failed: </Text>
|
||||
<Text color={theme.status.error}>{data.terminateReason}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
const scope = await 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.',
|
||||
);
|
||||
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', () => {
|
||||
|
||||
@@ -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,55 +533,31 @@ 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 = {
|
||||
callId,
|
||||
name: functionCall.name as string,
|
||||
args: (functionCall.args ?? {}) as Record<string, unknown>,
|
||||
isClientInitiated: true,
|
||||
prompt_id: promptId,
|
||||
};
|
||||
// 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;
|
||||
|
||||
// Get tool description before execution
|
||||
const description = this.getToolDescription(toolName, requestInfo.args);
|
||||
|
||||
// Emit tool call event BEFORE execution
|
||||
this.eventEmitter?.emit(SubAgentEventType.TOOL_CALL, {
|
||||
subagentId: this.subagentId,
|
||||
round: currentRound,
|
||||
callId,
|
||||
name: toolName,
|
||||
args: requestInfo.args,
|
||||
description,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentToolCallEvent);
|
||||
|
||||
// Execute tools with timing and hooks
|
||||
const start = Date.now();
|
||||
await 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
|
||||
// Update aggregate stats
|
||||
this.executionStats.totalToolCalls += 1;
|
||||
if (toolResponse.error) {
|
||||
this.executionStats.failedToolCalls += 1;
|
||||
} else {
|
||||
if (success) {
|
||||
this.executionStats.successfulToolCalls += 1;
|
||||
} else {
|
||||
this.executionStats.failedToolCalls += 1;
|
||||
}
|
||||
|
||||
// Update per-tool usage
|
||||
// Per-tool usage
|
||||
const tu = this.toolUsage.get(toolName) || {
|
||||
count: 0,
|
||||
success: 0,
|
||||
@@ -644,34 +566,29 @@ export class SubAgentScope {
|
||||
averageDurationMs: 0,
|
||||
};
|
||||
tu.count += 1;
|
||||
if (toolResponse?.error) {
|
||||
tu.failure += 1;
|
||||
tu.lastError = toolResponse.error?.message || 'Unknown error';
|
||||
} else {
|
||||
if (success) {
|
||||
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;
|
||||
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,
|
||||
callId: call.request.callId,
|
||||
name: toolName,
|
||||
success: !toolResponse?.error,
|
||||
error: toolResponse?.error?.message,
|
||||
resultDisplay: toolResponse?.resultDisplay
|
||||
? typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
: JSON.stringify(toolResponse.resultDisplay)
|
||||
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(),
|
||||
@@ -680,7 +597,7 @@ export class SubAgentScope {
|
||||
// Update statistics service
|
||||
this.stats.recordToolCall(
|
||||
toolName,
|
||||
!toolResponse?.error,
|
||||
success,
|
||||
duration,
|
||||
this.toolUsage.get(toolName)?.lastError,
|
||||
);
|
||||
@@ -690,23 +607,17 @@ export class SubAgentScope {
|
||||
subagentId: this.subagentId,
|
||||
name: this.name,
|
||||
toolName,
|
||||
args: requestInfo.args,
|
||||
success: !toolResponse?.error,
|
||||
args: call.request.args,
|
||||
success,
|
||||
durationMs: duration,
|
||||
errorMessage: toolResponse?.error?.message,
|
||||
errorMessage,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (toolResponse.error) {
|
||||
console.error(
|
||||
`Error executing tool ${functionCall.name}: ${toolResponse.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
const parts = Array.isArray(toolResponse.responseParts)
|
||||
? toolResponse.responseParts
|
||||
: [toolResponse.responseParts];
|
||||
// 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 });
|
||||
@@ -716,6 +627,99 @@ export class SubAgentScope {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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: toolName,
|
||||
args,
|
||||
isClientInitiated: true,
|
||||
prompt_id: promptId,
|
||||
};
|
||||
|
||||
const description = this.getToolDescription(toolName, args);
|
||||
this.eventEmitter?.emit(SubAgentEventType.TOOL_CALL, {
|
||||
subagentId: this.subagentId,
|
||||
round: currentRound,
|
||||
callId,
|
||||
name: toolName,
|
||||
args,
|
||||
description,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentToolCallEvent);
|
||||
|
||||
// pre-tool hook
|
||||
void this.hooks?.preToolUse?.({
|
||||
subagentId: this.subagentId,
|
||||
name: this.name,
|
||||
toolName,
|
||||
args,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return request;
|
||||
});
|
||||
|
||||
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) {
|
||||
toolResponseParts.push({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user