Merge branch 'main' into chore/sync-gemini-cli-v0.3.4

This commit is contained in:
mingholy.lmh
2025-09-15 13:42:42 +08:00
123 changed files with 13595 additions and 1237 deletions

View File

@@ -207,7 +207,7 @@ describe('MemoryTool', () => {
it('should have correct name, displayName, description, and schema', () => {
expect(memoryTool.name).toBe('save_memory');
expect(memoryTool.displayName).toBe('Save Memory');
expect(memoryTool.displayName).toBe('SaveMemory');
expect(memoryTool.description).toContain(
'Saves a specific piece of information',
);

View File

@@ -395,7 +395,7 @@ export class MemoryTool
constructor() {
super(
MemoryTool.Name,
'Save Memory',
'SaveMemory',
memoryToolDescription,
Kind.Think,
memoryToolSchemaData.parametersJsonSchema as Record<string, unknown>,

View File

@@ -16,6 +16,7 @@ import {
processSingleFileContent,
DEFAULT_ENCODING,
getSpecificMimeType,
DEFAULT_MAX_LINES_TEXT_FILE,
} from '../utils/fileUtils.js';
import type { PartListUnion } from '@google/genai';
import type { Config } from '../config/config.js';
@@ -322,6 +323,8 @@ ${finalExclusionPatternsForDescription
}
const sortedFiles = Array.from(filesToConsider).sort();
const file_line_limit =
DEFAULT_MAX_LINES_TEXT_FILE / Math.max(1, sortedFiles.length);
const fileProcessingPromises = sortedFiles.map(
async (filePath): Promise<FileProcessingResult> => {
@@ -360,6 +363,8 @@ ${finalExclusionPatternsForDescription
filePath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
0,
file_line_limit,
);
if (fileReadResult.error) {

View File

@@ -13,6 +13,7 @@ import { ToolErrorType } from './tool-error.js';
import type {
ToolInvocation,
ToolResult,
ToolResultDisplay,
ToolCallConfirmationDetails,
ToolExecuteConfirmationDetails,
} from './tools.js';
@@ -101,7 +102,7 @@ class ShellToolInvocation extends BaseToolInvocation<
async execute(
signal: AbortSignal,
updateOutput?: (output: string) => void,
updateOutput?: (output: ToolResultDisplay) => void,
terminalColumns?: number,
terminalRows?: number,
): Promise<ToolResult> {

View File

@@ -0,0 +1,497 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TaskTool, TaskParams } from './task.js';
import type { PartListUnion } from '@google/genai';
import type { ToolResultDisplay, TaskResultDisplay } from './tools.js';
import { Config } from '../config/config.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import { SubagentConfig, SubagentTerminateMode } from '../subagents/types.js';
import { SubAgentScope, ContextState } from '../subagents/subagent.js';
import { partToString } from '../utils/partUtils.js';
// Type for accessing protected methods in tests
type TaskToolWithProtectedMethods = TaskTool & {
createInvocation: (params: TaskParams) => {
execute: (
signal?: AbortSignal,
liveOutputCallback?: (chunk: string) => void,
) => Promise<{
llmContent: PartListUnion;
returnDisplay: ToolResultDisplay;
}>;
getDescription: () => string;
shouldConfirmExecute: () => Promise<boolean>;
};
};
// Mock dependencies
vi.mock('../subagents/subagent-manager.js');
vi.mock('../subagents/subagent.js');
const MockedSubagentManager = vi.mocked(SubagentManager);
const MockedContextState = vi.mocked(ContextState);
describe('TaskTool', () => {
let config: Config;
let taskTool: TaskTool;
let mockSubagentManager: SubagentManager;
const mockSubagents: SubagentConfig[] = [
{
name: 'file-search',
description: 'Specialized agent for searching and analyzing files',
systemPrompt: 'You are a file search specialist.',
level: 'project',
filePath: '/project/.qwen/agents/file-search.md',
},
{
name: 'code-review',
description: 'Agent for reviewing code quality and best practices',
systemPrompt: 'You are a code review specialist.',
level: 'user',
filePath: '/home/user/.qwen/agents/code-review.md',
},
];
beforeEach(async () => {
// Setup fake timers
vi.useFakeTimers();
// Create mock config
config = {
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getSubagentManager: vi.fn(),
} as unknown as Config;
// Setup SubagentManager mock
mockSubagentManager = {
listSubagents: vi.fn().mockResolvedValue(mockSubagents),
loadSubagent: vi.fn(),
createSubagentScope: vi.fn(),
} as unknown as SubagentManager;
MockedSubagentManager.mockImplementation(() => mockSubagentManager);
// Make config return the mock SubagentManager
vi.mocked(config.getSubagentManager).mockReturnValue(mockSubagentManager);
// Create TaskTool instance
taskTool = new TaskTool(config);
// Allow async initialization to complete
await vi.runAllTimersAsync();
});
afterEach(() => {
vi.useRealTimers();
});
describe('initialization', () => {
it('should initialize with correct name and properties', () => {
expect(taskTool.name).toBe('task');
expect(taskTool.displayName).toBe('Task');
expect(taskTool.kind).toBe('other');
});
it('should load available subagents during initialization', () => {
expect(mockSubagentManager.listSubagents).toHaveBeenCalled();
});
it('should update description with available subagents', () => {
expect(taskTool.description).toContain('file-search');
expect(taskTool.description).toContain(
'Specialized agent for searching and analyzing files',
);
expect(taskTool.description).toContain('code-review');
expect(taskTool.description).toContain(
'Agent for reviewing code quality and best practices',
);
});
it('should handle empty subagents list gracefully', async () => {
vi.mocked(mockSubagentManager.listSubagents).mockResolvedValue([]);
const emptyTaskTool = new TaskTool(config);
await vi.runAllTimersAsync();
expect(emptyTaskTool.description).toContain(
'No subagents are currently configured',
);
});
it('should handle subagent loading errors gracefully', async () => {
vi.mocked(mockSubagentManager.listSubagents).mockRejectedValue(
new Error('Loading failed'),
);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
new TaskTool(config);
await vi.runAllTimersAsync();
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to load subagents for Task tool:',
expect.any(Error),
);
consoleSpy.mockRestore();
});
});
describe('schema generation', () => {
it('should generate schema with subagent names as enum', () => {
const schema = taskTool.schema;
const properties = schema.parametersJsonSchema as {
properties: {
subagent_type: {
enum?: string[];
};
};
};
expect(properties.properties.subagent_type.enum).toEqual([
'file-search',
'code-review',
]);
});
it('should generate schema without enum when no subagents available', async () => {
vi.mocked(mockSubagentManager.listSubagents).mockResolvedValue([]);
const emptyTaskTool = new TaskTool(config);
await vi.runAllTimersAsync();
const schema = emptyTaskTool.schema;
const properties = schema.parametersJsonSchema as {
properties: {
subagent_type: {
enum?: string[];
};
};
};
expect(properties.properties.subagent_type.enum).toBeUndefined();
});
});
describe('validateToolParams', () => {
const validParams: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files in the project',
subagent_type: 'file-search',
};
it('should validate valid parameters', async () => {
const result = taskTool.validateToolParams(validParams);
expect(result).toBeNull();
});
it('should reject empty description', async () => {
const result = taskTool.validateToolParams({
...validParams,
description: '',
});
expect(result).toBe(
'Parameter "description" must be a non-empty string.',
);
});
it('should reject empty prompt', async () => {
const result = taskTool.validateToolParams({
...validParams,
prompt: '',
});
expect(result).toBe('Parameter "prompt" must be a non-empty string.');
});
it('should reject empty subagent_type', async () => {
const result = taskTool.validateToolParams({
...validParams,
subagent_type: '',
});
expect(result).toBe(
'Parameter "subagent_type" must be a non-empty string.',
);
});
it('should reject non-existent subagent', async () => {
const result = taskTool.validateToolParams({
...validParams,
subagent_type: 'non-existent',
});
expect(result).toBe(
'Subagent "non-existent" not found. Available subagents: file-search, code-review',
);
});
});
describe('refreshSubagents', () => {
it('should refresh available subagents and update description', async () => {
const newSubagents: SubagentConfig[] = [
{
name: 'test-agent',
description: 'A test agent',
systemPrompt: 'Test prompt',
level: 'project',
filePath: '/project/.qwen/agents/test-agent.md',
},
];
vi.mocked(mockSubagentManager.listSubagents).mockResolvedValue(
newSubagents,
);
await taskTool.refreshSubagents();
expect(taskTool.description).toContain('test-agent');
expect(taskTool.description).toContain('A test agent');
});
});
describe('TaskToolInvocation', () => {
let mockSubagentScope: SubAgentScope;
let mockContextState: ContextState;
beforeEach(() => {
mockSubagentScope = {
runNonInteractive: vi.fn().mockResolvedValue(undefined),
result: 'Task completed successfully',
terminateMode: SubagentTerminateMode.GOAL,
getFinalText: vi.fn().mockReturnValue('Task completed successfully'),
formatCompactResult: vi
.fn()
.mockReturnValue(
'✅ Success: Search files completed with GOAL termination',
),
getExecutionSummary: vi.fn().mockReturnValue({
rounds: 2,
totalDurationMs: 1500,
totalToolCalls: 3,
successfulToolCalls: 3,
failedToolCalls: 0,
successRate: 100,
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
estimatedCost: 0.045,
toolUsage: [
{
name: 'grep',
count: 2,
success: 2,
failure: 0,
totalDurationMs: 800,
averageDurationMs: 400,
},
{
name: 'read_file',
count: 1,
success: 1,
failure: 0,
totalDurationMs: 200,
averageDurationMs: 200,
},
],
}),
getStatistics: vi.fn().mockReturnValue({
rounds: 2,
totalDurationMs: 1500,
totalToolCalls: 3,
successfulToolCalls: 3,
failedToolCalls: 0,
}),
getTerminateMode: vi.fn().mockReturnValue(SubagentTerminateMode.GOAL),
} as unknown as SubAgentScope;
mockContextState = {
set: vi.fn(),
} as unknown as ContextState;
MockedContextState.mockImplementation(() => mockContextState);
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
mockSubagents[0],
);
vi.mocked(mockSubagentManager.createSubagentScope).mockResolvedValue(
mockSubagentScope,
);
});
it('should execute subagent successfully', async () => {
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
expect(mockSubagentManager.loadSubagent).toHaveBeenCalledWith(
'file-search',
);
expect(mockSubagentManager.createSubagentScope).toHaveBeenCalledWith(
mockSubagents[0],
config,
expect.any(Object), // eventEmitter parameter
);
expect(mockSubagentScope.runNonInteractive).toHaveBeenCalledWith(
mockContextState,
undefined, // signal parameter (undefined when not provided)
);
const llmText = partToString(result.llmContent);
expect(llmText).toBe('Task completed successfully');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.type).toBe('task_execution');
expect(display.status).toBe('completed');
expect(display.subagentName).toBe('file-search');
});
it('should handle subagent not found error', async () => {
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(null);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'non-existent',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
const llmText = partToString(result.llmContent);
expect(llmText).toContain('Subagent "non-existent" not found');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('failed');
expect(display.subagentName).toBe('non-existent');
});
it('should handle execution errors gracefully', async () => {
vi.mocked(mockSubagentManager.createSubagentScope).mockRejectedValue(
new Error('Creation failed'),
);
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
const llmText = partToString(result.llmContent);
expect(llmText).toContain('Failed to run subagent: Creation failed');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('failed');
});
it('should execute subagent without live output callback', async () => {
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
// Verify that the task completed successfully
expect(result.llmContent).toBeDefined();
expect(result.returnDisplay).toBeDefined();
// Verify the result has the expected structure
const text = partToString(result.llmContent);
expect(text).toBe('Task completed successfully');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('completed');
expect(display.subagentName).toBe('file-search');
});
it('should set context variables correctly', async () => {
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
await invocation.execute();
expect(mockContextState.set).toHaveBeenCalledWith(
'task_prompt',
'Find all TypeScript files',
);
});
it('should return structured display object', async () => {
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
expect(typeof result.returnDisplay).toBe('object');
expect(result.returnDisplay).toHaveProperty('type', 'task_execution');
expect(result.returnDisplay).toHaveProperty(
'subagentName',
'file-search',
);
expect(result.returnDisplay).toHaveProperty(
'taskDescription',
'Search files',
);
expect(result.returnDisplay).toHaveProperty('status', 'completed');
});
it('should not require confirmation', async () => {
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const shouldConfirm = await invocation.shouldConfirmExecute();
expect(shouldConfirm).toBe(false);
});
it('should provide correct description', async () => {
const params: TaskParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
taskTool as TaskToolWithProtectedMethods
).createInvocation(params);
const description = invocation.getDescription();
expect(description).toBe('file-search subagent: "Search files"');
});
});
});

View File

@@ -0,0 +1,558 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolResult,
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';
import { ContextState } from '../subagents/subagent.js';
import {
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentFinishEvent,
SubAgentEventType,
SubAgentErrorEvent,
SubAgentApprovalRequestEvent,
} from '../subagents/subagent-events.js';
export interface TaskParams {
description: string;
prompt: string;
subagent_type: string;
}
/**
* Task tool that enables primary agents to delegate tasks to specialized subagents.
* The tool dynamically loads available subagents and includes them in its description
* for the model to choose from.
*/
export class TaskTool extends BaseDeclarativeTool<TaskParams, ToolResult> {
static readonly Name: string = 'task';
private subagentManager: SubagentManager;
private availableSubagents: SubagentConfig[] = [];
constructor(private readonly config: Config) {
// Initialize with a basic schema first
const initialSchema = {
type: 'object',
properties: {
description: {
type: 'string',
description: 'A short (3-5 word) description of the task',
},
prompt: {
type: 'string',
description: 'The task for the agent to perform',
},
subagent_type: {
type: 'string',
description: 'The type of specialized agent to use for this task',
},
},
required: ['description', 'prompt', 'subagent_type'],
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
};
super(
TaskTool.Name,
'Task',
'Delegate tasks to specialized subagents. Loading available subagents...', // Initial description
Kind.Other,
initialSchema,
true, // isOutputMarkdown
true, // canUpdateOutput - Enable live output updates for real-time progress
);
this.subagentManager = config.getSubagentManager();
// Initialize the tool asynchronously
this.initializeAsync();
}
/**
* Asynchronously initializes the tool by loading available subagents
* and updating the description and schema.
*/
private async initializeAsync(): Promise<void> {
try {
this.availableSubagents = await this.subagentManager.listSubagents();
this.updateDescriptionAndSchema();
} catch (error) {
console.warn('Failed to load subagents for Task tool:', error);
this.availableSubagents = [];
this.updateDescriptionAndSchema();
}
}
/**
* Updates the tool's description and schema based on available subagents.
*/
private updateDescriptionAndSchema(): void {
let subagentDescriptions = '';
if (this.availableSubagents.length === 0) {
subagentDescriptions =
'No subagents are currently configured. You can create subagents using the /agents command.';
} else {
subagentDescriptions = this.availableSubagents
.map((subagent) => `- **${subagent.name}**: ${subagent.description}`)
.join('\n');
}
const baseDescription = `Launch a new agent to handle complex, multi-step tasks autonomously.
Available agent types and the tools they have access to:
${subagentDescriptions}
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
When NOT to use the Agent tool:
- If you want to read a specific file path, use the Read or Glob tool instead of the Agent tool, to find the match more quickly
- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly
- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Agent tool, to find the match more quickly
- Other tasks that are not related to the agent descriptions above
Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
Example usage:
<example_agent_descriptions>
"code-reviewer": use this agent after you are done writing a signficant piece of code
"greeting-responder": use this agent when to respond to user greetings with a friendly joke
</example_agent_description>
<example>
user: "Please write a function that checks if a number is prime"
assistant: Sure let me write a function that checks if a number is prime
assistant: First let me use the Write tool to write a function that checks if a number is prime
assistant: I'm going to use the Write tool to write the following code:
<code>
function isPrime(n) {
if (n <= 1) return false
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false
}
return true
}
</code>
<commentary>
Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
</commentary>
assistant: Now let me use the code-reviewer agent to review the code
assistant: Uses the Task tool to launch the with the code-reviewer agent
</example>
<example>
user: "Hello"
<commentary>
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
</commentary>
assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent"
</example>
`;
// Update description using object property assignment since it's readonly
(this as { description: string }).description =
baseDescription + subagentDescriptions;
// Generate dynamic schema with enum of available subagent names
const subagentNames = this.availableSubagents.map((s) => s.name);
// Update the parameter schema by modifying the existing object
const schema = this.parameterSchema as {
properties?: {
subagent_type?: {
enum?: string[];
};
};
};
if (schema.properties && schema.properties.subagent_type) {
if (subagentNames.length > 0) {
schema.properties.subagent_type.enum = subagentNames;
} else {
delete schema.properties.subagent_type.enum;
}
}
}
/**
* Refreshes the available subagents and updates the tool description.
* This can be called when subagents are added or removed.
*/
async refreshSubagents(): Promise<void> {
await this.initializeAsync();
}
override validateToolParams(params: TaskParams): string | null {
// Validate required fields
if (
!params.description ||
typeof params.description !== 'string' ||
params.description.trim() === ''
) {
return 'Parameter "description" must be a non-empty string.';
}
if (
!params.prompt ||
typeof params.prompt !== 'string' ||
params.prompt.trim() === ''
) {
return 'Parameter "prompt" must be a non-empty string.';
}
if (
!params.subagent_type ||
typeof params.subagent_type !== 'string' ||
params.subagent_type.trim() === ''
) {
return 'Parameter "subagent_type" must be a non-empty string.';
}
// Validate that the subagent exists
const subagentExists = this.availableSubagents.some(
(subagent) => subagent.name === params.subagent_type,
);
if (!subagentExists) {
const availableNames = this.availableSubagents.map((s) => s.name);
return `Subagent "${params.subagent_type}" not found. Available subagents: ${availableNames.join(', ')}`;
}
return null;
}
protected createInvocation(params: TaskParams) {
return new TaskToolInvocation(this.config, this.subagentManager, params);
}
}
class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
private readonly _eventEmitter: SubAgentEventEmitter;
private currentDisplay: TaskResultDisplay | null = null;
private currentToolCalls: TaskResultDisplay['toolCalls'] = [];
constructor(
private readonly config: Config,
private readonly subagentManager: SubagentManager,
params: TaskParams,
) {
super(params);
this._eventEmitter = new SubAgentEventEmitter();
}
get eventEmitter(): SubAgentEventEmitter {
return this._eventEmitter;
}
/**
* Updates the current display state and calls updateOutput if provided
*/
private updateDisplay(
updates: Partial<TaskResultDisplay>,
updateOutput?: (output: ToolResultDisplay) => void,
): void {
if (!this.currentDisplay) return;
this.currentDisplay = {
...this.currentDisplay,
...updates,
};
if (updateOutput) {
updateOutput(this.currentDisplay);
}
}
/**
* Sets up event listeners for real-time subagent progress updates
*/
private setupEventListeners(
updateOutput?: (output: ToolResultDisplay) => void,
): void {
this.eventEmitter.on(SubAgentEventType.START, () => {
this.updateDisplay({ status: 'running' }, updateOutput);
});
this.eventEmitter.on(SubAgentEventType.TOOL_CALL, (...args: unknown[]) => {
const event = args[0] as SubAgentToolCallEvent;
const newToolCall = {
callId: event.callId,
name: event.name,
status: 'executing' as const,
args: event.args,
description: event.description,
};
this.currentToolCalls!.push(newToolCall);
this.updateDisplay(
{
toolCalls: [...this.currentToolCalls!],
},
updateOutput,
);
});
this.eventEmitter.on(
SubAgentEventType.TOOL_RESULT,
(...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
const toolCallIndex = this.currentToolCalls!.findIndex(
(call) => call.callId === event.callId,
);
if (toolCallIndex >= 0) {
this.currentToolCalls![toolCallIndex] = {
...this.currentToolCalls![toolCallIndex],
status: event.success ? 'success' : 'failed',
error: event.error,
resultDisplay: event.resultDisplay,
};
this.updateDisplay(
{
toolCalls: [...this.currentToolCalls!],
},
updateOutput,
);
}
},
);
this.eventEmitter.on(SubAgentEventType.FINISH, (...args: unknown[]) => {
const event = args[0] as SubAgentFinishEvent;
this.updateDisplay(
{
status: event.terminateReason === 'GOAL' ? 'completed' : 'failed',
terminateReason: event.terminateReason,
},
updateOutput,
);
});
this.eventEmitter.on(SubAgentEventType.ERROR, (...args: unknown[]) => {
const event = args[0] as SubAgentErrorEvent;
this.updateDisplay(
{
status: 'failed',
terminateReason: event.error,
},
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 {
return `${this.params.subagent_type} subagent: "${this.params.description}"`;
}
override async shouldConfirmExecute(): Promise<false> {
// Task delegation should execute automatically without user confirmation
return false;
}
async execute(
signal?: AbortSignal,
updateOutput?: (output: ToolResultDisplay) => void,
): Promise<ToolResult> {
try {
// Load the subagent configuration
const subagentConfig = await this.subagentManager.loadSubagent(
this.params.subagent_type,
);
if (!subagentConfig) {
const errorDisplay = {
type: 'task_execution' as const,
subagentName: this.params.subagent_type,
taskDescription: this.params.description,
taskPrompt: this.params.prompt,
status: 'failed' as const,
terminateReason: `Subagent "${this.params.subagent_type}" not found`,
};
return {
llmContent: `Subagent "${this.params.subagent_type}" not found`,
returnDisplay: errorDisplay,
};
}
// Initialize the current display state
this.currentDisplay = {
type: 'task_execution' as const,
subagentName: subagentConfig.name,
taskDescription: this.params.description,
taskPrompt: this.params.prompt,
status: 'running' as const,
subagentColor: subagentConfig.color,
};
// Set up event listeners for real-time updates
this.setupEventListeners(updateOutput);
// Send initial display
if (updateOutput) {
updateOutput(this.currentDisplay);
}
const subagentScope = await this.subagentManager.createSubagentScope(
subagentConfig,
this.config,
{ eventEmitter: this.eventEmitter },
);
// Create context state with the task prompt
const contextState = new ContextState();
contextState.set('task_prompt', this.params.prompt);
// Execute the subagent (blocking)
await subagentScope.runNonInteractive(contextState, signal);
// Get the results
const finalText = subagentScope.getFinalText();
const terminateMode = subagentScope.getTerminateMode();
const success = terminateMode === SubagentTerminateMode.GOAL;
const executionSummary = subagentScope.getExecutionSummary();
if (signal?.aborted) {
this.updateDisplay(
{
status: 'cancelled',
terminateReason: 'Task was cancelled by user',
executionSummary,
},
updateOutput,
);
} else {
this.updateDisplay(
{
status: success ? 'completed' : 'failed',
terminateReason: terminateMode,
result: finalText,
executionSummary,
},
updateOutput,
);
}
return {
llmContent: [{ text: finalText }],
returnDisplay: this.currentDisplay!,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[TaskTool] Error running subagent: ${errorMessage}`);
const errorDisplay: TaskResultDisplay = {
...this.currentDisplay!,
status: 'failed',
terminateReason: `Failed to run subagent: ${errorMessage}`,
};
return {
llmContent: `Failed to run subagent: ${errorMessage}`,
returnDisplay: errorDisplay,
};
}
}
}

View File

@@ -243,7 +243,7 @@ describe('TodoWriteTool', () => {
});
it('should have correct display name', () => {
expect(tool.displayName).toBe('Todo Write');
expect(tool.displayName).toBe('TodoWrite');
});
it('should have correct kind', () => {

View File

@@ -403,7 +403,7 @@ export class TodoWriteTool extends BaseDeclarativeTool<
constructor(private readonly config: Config) {
super(
TodoWriteTool.Name,
'Todo Write',
'TodoWrite',
todoWriteToolDescription,
Kind.Think,
todoWriteToolSchemaData.parametersJsonSchema as Record<string, unknown>,

View File

@@ -8,6 +8,7 @@ import type { FunctionDeclaration } from '@google/genai';
import type {
AnyDeclarativeTool,
ToolResult,
ToolResultDisplay,
ToolInvocation,
} from './tools.js';
import { Kind, BaseDeclarativeTool, BaseToolInvocation } from './tools.js';
@@ -41,7 +42,7 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
async execute(
_signal: AbortSignal,
_updateOutput?: (output: string) => void,
_updateOutput?: (output: ToolResultDisplay) => void,
): Promise<ToolResult> {
const callCommand = this.config.getToolCallCommand()!;
const child = spawn(callCommand, [this.toolName]);

View File

@@ -8,6 +8,7 @@ import type { FunctionDeclaration, PartListUnion } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
import type { DiffUpdateResult } from '../ide/ideContext.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { SubagentStatsSummary } from '../subagents/subagent-statistics.js';
/**
* Represents a validated and ready-to-execute tool call.
@@ -51,7 +52,7 @@ export interface ToolInvocation<
*/
execute(
signal: AbortSignal,
updateOutput?: (output: string) => void,
updateOutput?: (output: ToolResultDisplay) => void,
): Promise<TResult>;
}
@@ -79,7 +80,7 @@ export abstract class BaseToolInvocation<
abstract execute(
signal: AbortSignal,
updateOutput?: (output: string) => void,
updateOutput?: (output: ToolResultDisplay) => void,
): Promise<TResult>;
}
@@ -197,7 +198,7 @@ export abstract class DeclarativeTool<
async buildAndExecute(
params: TParams,
signal: AbortSignal,
updateOutput?: (output: string) => void,
updateOutput?: (output: ToolResultDisplay) => void,
): Promise<TResult> {
const invocation = this.build(params);
return invocation.execute(signal, updateOutput);
@@ -432,7 +433,38 @@ export function hasCycleInSchema(schema: object): boolean {
return traverse(schema, new Set<string>(), new Set<string>());
}
export type ToolResultDisplay = string | FileDiff | TodoResultDisplay;
export interface TaskResultDisplay {
type: 'task_execution';
subagentName: string;
subagentColor?: string;
taskDescription: string;
taskPrompt: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
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' | 'awaiting_approval' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
resultDisplay?: string;
description?: string;
}>;
}
export type ToolResultDisplay =
| string
| FileDiff
| TodoResultDisplay
| TaskResultDisplay;
export interface FileDiff {
fileDiff: string;

View File

@@ -157,7 +157,7 @@ export class WebSearchTool extends BaseDeclarativeTool<
constructor(private readonly config: Config) {
super(
WebSearchTool.Name,
'TavilySearch',
'WebSearch',
'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.',
Kind.Search,
{