mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
feat: subagent runtime & CLI display - wip
This commit is contained in:
@@ -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> {
|
||||
|
||||
507
packages/core/src/tools/task.test.ts
Normal file
507
packages/core/src/tools/task.test.ts
Normal 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
498
packages/core/src/tools/task.ts
Normal file
498
packages/core/src/tools/task.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user