feat: subagent runtime & CLI display - wip

This commit is contained in:
tanzhenxin
2025-09-08 20:01:49 +08:00
parent 1f8ea7ab7a
commit 4985bfc000
31 changed files with 2664 additions and 390 deletions

View File

@@ -14,6 +14,7 @@ import {
BaseToolInvocation,
ToolInvocation,
ToolResult,
ToolResultDisplay,
ToolCallConfirmationDetails,
ToolExecuteConfirmationDetails,
ToolConfirmationOutcome,
@@ -100,7 +101,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,507 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TaskTool, TaskParams } from './task.js';
import { Config } from '../config/config.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import { SubagentConfig } from '../subagents/types.js';
import {
SubAgentScope,
ContextState,
SubagentTerminateMode,
} 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: string;
returnDisplay: unknown;
}>;
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('execute');
});
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),
output: {
result: 'Task completed successfully',
terminate_reason: SubagentTerminateMode.GOAL,
},
getFinalText: vi.fn().mockReturnValue('Task completed successfully'),
formatCompactResult: vi
.fn()
.mockReturnValue(
'✅ Success: Search files completed with GOAL termination',
),
getStatistics: vi.fn().mockReturnValue({
rounds: 2,
totalDurationMs: 1500,
totalToolCalls: 3,
successfulToolCalls: 3,
failedToolCalls: 0,
}),
} 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);
const parsedResult = JSON.parse(llmText) as {
success: boolean;
subagent_name?: string;
error?: string;
};
expect(parsedResult.success).toBe(true);
expect(parsedResult.subagent_name).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);
const parsedResult = JSON.parse(llmText) as {
success: boolean;
subagent_name?: string;
error?: string;
};
expect(parsedResult.success).toBe(false);
expect(parsedResult.error).toContain('Subagent "non-existent" not found');
});
it('should handle subagent execution failure', async () => {
mockSubagentScope.output.terminate_reason = SubagentTerminateMode.ERROR;
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);
const parsedResult = JSON.parse(llmText) as {
success: boolean;
subagent_name?: string;
error?: string;
};
expect(parsedResult.success).toBe(false);
expect(parsedResult.error).toContain(
'Task did not complete successfully',
);
});
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);
const parsedResult = JSON.parse(llmText) as {
success: boolean;
subagent_name?: string;
error?: string;
};
expect(parsedResult.success).toBe(false);
expect(parsedResult.error).toContain('Failed to start subagent');
});
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 llmContent = Array.isArray(result.llmContent)
? result.llmContent
: [result.llmContent];
const parsedResult = JSON.parse((llmContent[0] as { text: string }).text);
expect(parsedResult.success).toBe(true);
expect(parsedResult.subagent_name).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', 'subagent_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,498 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolResult,
ToolResultDisplay,
TaskResultDisplay,
} from './tools.js';
import { Config } from '../config/config.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import { SubagentConfig } from '../subagents/types.js';
import { ContextState } from '../subagents/subagent.js';
import {
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentFinishEvent,
} from '../subagents/subagent-events.js';
import { ChatRecordingService } from '../services/chatRecordingService.js';
export interface TaskParams {
description: string;
prompt: string;
subagent_type: string;
}
export interface TaskResult {
success: boolean;
output?: string;
error?: string;
subagent_name?: string;
execution_summary?: 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.Execute,
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 {
// Generate dynamic description
const baseDescription = `Delegate tasks to specialized subagents. This tool allows you to offload specific tasks to agents optimized for particular domains, reducing context usage and improving task completion.
## When to Use This Tool
Use this tool proactively when:
- The task matches a specialized agent's description
- You want to reduce context usage for file searches or analysis
- The task requires domain-specific expertise
- You need to perform focused work that doesn't require the full conversation context
## Available Subagents
`;
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');
}
// 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: Array<{
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
returnDisplay?: string;
}> = [];
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('start', () => {
this.updateDisplay({ status: 'running' }, updateOutput);
});
this.eventEmitter.on('model_text', (..._args: unknown[]) => {
// Model text events are no longer displayed as currentStep
// Keep the listener for potential future use
});
this.eventEmitter.on('tool_call', (...args: unknown[]) => {
const event = args[0] as SubAgentToolCallEvent;
const newToolCall = {
name: event.name,
status: 'executing' as const,
args: event.args,
};
this.currentToolCalls.push(newToolCall);
this.updateDisplay(
{
progress: {
toolCalls: [...this.currentToolCalls],
},
},
updateOutput,
);
});
this.eventEmitter.on('tool_result', (...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
const toolCallIndex = this.currentToolCalls.findIndex(
(call) => call.name === event.name,
);
if (toolCallIndex >= 0) {
this.currentToolCalls[toolCallIndex] = {
...this.currentToolCalls[toolCallIndex],
status: event.success ? 'success' : 'failed',
error: event.error,
// Note: result would need to be added to SubAgentToolResultEvent to be captured
};
this.updateDisplay(
{
progress: {
toolCalls: [...this.currentToolCalls],
},
},
updateOutput,
);
}
});
this.eventEmitter.on('finish', (...args: unknown[]) => {
const event = args[0] as SubAgentFinishEvent;
this.updateDisplay(
{
status: event.terminate_reason === 'GOAL' ? 'completed' : 'failed',
terminateReason: event.terminate_reason,
// Keep progress data including tool calls for final display
},
updateOutput,
);
});
this.eventEmitter.on('error', () => {
this.updateDisplay({ status: 'failed' }, 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: 'subagent_execution' as const,
subagentName: this.params.subagent_type,
taskDescription: this.params.description,
status: 'failed' as const,
terminateReason: 'ERROR',
result: `Subagent "${this.params.subagent_type}" not found`,
};
return {
llmContent: [
{
text: JSON.stringify({
success: false,
error: `Subagent "${this.params.subagent_type}" not found`,
}),
},
],
returnDisplay: errorDisplay,
};
}
// Initialize the current display state
this.currentDisplay = {
type: 'subagent_execution' as const,
subagentName: subagentConfig.name,
taskDescription: this.params.description,
status: 'running' as const,
};
// Set up event listeners for real-time updates
this.setupEventListeners(updateOutput);
// Send initial display
if (updateOutput) {
updateOutput(this.currentDisplay);
}
const chatRecorder = new ChatRecordingService(this.config);
try {
chatRecorder.initialize();
} catch {
// Initialization failed, continue without recording
}
const subagentScope = await this.subagentManager.createSubagentScope(
subagentConfig,
this.config,
{ eventEmitter: this.eventEmitter },
);
// Set up basic event listeners for chat recording
this.eventEmitter.on('start', () => {
chatRecorder.recordMessage({
type: 'user',
content: `Subagent(${this.params.subagent_type}) Task: ${this.params.description}\n\n${this.params.prompt}`,
});
});
this.eventEmitter.on('finish', (e) => {
const finishEvent = e as {
inputTokens?: number;
outputTokens?: number;
};
const text = subagentScope.getFinalText() || '';
chatRecorder.recordMessage({ type: 'gemini', content: text });
const input = finishEvent.inputTokens ?? 0;
const output = finishEvent.outputTokens ?? 0;
chatRecorder.recordMessageTokens({
input,
output,
cached: 0,
total: input + output,
});
});
// 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 terminateReason = subagentScope.output.terminate_reason;
const success = terminateReason === 'GOAL';
// Format the results based on description (iflow-like switch)
const wantDetailed = /\b(stats|statistics|detailed)\b/i.test(
this.params.description,
);
const executionSummary = wantDetailed
? subagentScope.formatDetailedResult(this.params.description)
: subagentScope.formatCompactResult(this.params.description);
const result: TaskResult = {
success,
output: finalText,
subagent_name: subagentConfig.name,
execution_summary: executionSummary,
};
if (!success) {
result.error = `Task did not complete successfully. Termination reason: ${terminateReason}`;
}
// Update the final display state
this.updateDisplay(
{
status: success ? 'completed' : 'failed',
terminateReason,
result: finalText,
executionSummary,
// Keep progress data including tool calls for final display
},
updateOutput,
);
return {
llmContent: [{ text: JSON.stringify(result) }],
returnDisplay: this.currentDisplay!,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[TaskTool] Error starting subagent: ${errorMessage}`);
const errorDisplay = {
type: 'subagent_execution' as const,
subagentName: this.params.subagent_type,
taskDescription: this.params.description,
status: 'failed' as const,
terminateReason: 'ERROR',
result: `Failed to start subagent: ${errorMessage}`,
};
return {
llmContent: [
{
text: JSON.stringify({
success: false,
error: `Failed to start subagent: ${errorMessage}`,
}),
},
],
returnDisplay: errorDisplay,
};
}
}
}

View File

@@ -9,6 +9,7 @@ import {
AnyDeclarativeTool,
Kind,
ToolResult,
ToolResultDisplay,
BaseDeclarativeTool,
BaseToolInvocation,
ToolInvocation,
@@ -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

@@ -50,7 +50,7 @@ export interface ToolInvocation<
*/
execute(
signal: AbortSignal,
updateOutput?: (output: string) => void,
updateOutput?: (output: ToolResultDisplay) => void,
): Promise<TResult>;
}
@@ -78,7 +78,7 @@ export abstract class BaseToolInvocation<
abstract execute(
signal: AbortSignal,
updateOutput?: (output: string) => void,
updateOutput?: (output: ToolResultDisplay) => void,
): Promise<TResult>;
}
@@ -196,7 +196,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);
@@ -421,7 +421,31 @@ 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: 'subagent_execution';
subagentName: string;
taskDescription: string;
status: 'running' | 'completed' | 'failed';
terminateReason?: string;
result?: string;
executionSummary?: string;
progress?: {
toolCalls?: Array<{
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
returnDisplay?: string;
}>;
};
}
export type ToolResultDisplay =
| string
| FileDiff
| TodoResultDisplay
| TaskResultDisplay;
export interface FileDiff {
fileDiff: string;