feat: subagent runtime & CLI display - done

This commit is contained in:
tanzhenxin
2025-09-09 15:53:10 +08:00
parent 4985bfc000
commit 35e996d46c
23 changed files with 767 additions and 684 deletions

View File

@@ -47,7 +47,7 @@ export type {
ToolConfig,
SubagentTerminateMode,
OutputObject,
} from './subagent.js';
} from './types.js';
export { SubAgentScope } from './subagent.js';
@@ -55,11 +55,19 @@ export { SubAgentScope } from './subagent.js';
export type {
SubAgentEvent,
SubAgentStartEvent,
SubAgentFinishEvent,
SubAgentRoundEvent,
SubAgentStreamTextEvent,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentModelTextEvent,
SubAgentFinishEvent,
SubAgentErrorEvent,
} from './subagent-events.js';
export { SubAgentEventEmitter } from './subagent-events.js';
// Statistics and formatting
export type {
SubagentStatsSummary,
ToolUsageStats,
} from './subagent-statistics.js';
export { formatCompact, formatDetailed } from './subagent-result-format.js';

View File

@@ -10,17 +10,21 @@ export type SubAgentEvent =
| 'start'
| 'round_start'
| 'round_end'
| 'model_text'
| 'stream_text'
| 'tool_call'
| 'tool_result'
| 'finish'
| 'error';
export interface SubAgentModelTextEvent {
subagentId: string;
round: number;
text: string;
timestamp: number;
export enum SubAgentEventType {
START = 'start',
ROUND_START = 'round_start',
ROUND_END = 'round_end',
STREAM_TEXT = 'stream_text',
TOOL_CALL = 'tool_call',
TOOL_RESULT = 'tool_result',
FINISH = 'finish',
ERROR = 'error',
}
export interface SubAgentStartEvent {
@@ -38,12 +42,20 @@ export interface SubAgentRoundEvent {
timestamp: number;
}
export interface SubAgentStreamTextEvent {
subagentId: string;
round: number;
text: string;
timestamp: number;
}
export interface SubAgentToolCallEvent {
subagentId: string;
round: number;
callId: string;
name: string;
args: Record<string, unknown>;
description: string;
timestamp: number;
}
@@ -54,6 +66,7 @@ export interface SubAgentToolResultEvent {
name: string;
success: boolean;
error?: string;
resultDisplay?: string;
durationMs?: number;
timestamp: number;
}
@@ -72,6 +85,12 @@ export interface SubAgentFinishEvent {
totalTokens?: number;
}
export interface SubAgentErrorEvent {
subagentId: string;
error: string;
timestamp: number;
}
export class SubAgentEventEmitter {
private ee = new EventEmitter();

View File

@@ -21,15 +21,13 @@ import {
CreateSubagentOptions,
SubagentError,
SubagentErrorCode,
} from './types.js';
import { SubagentValidator } from './validation.js';
import {
SubAgentScope,
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from './subagent.js';
} from './types.js';
import { SubagentValidator } from './validation.js';
import { SubAgentScope } from './subagent.js';
import { Config } from '../config/config.js';
const QWEN_CONFIG_DIR = '.qwen';
@@ -369,9 +367,7 @@ export class SubagentManager {
const runConfig = frontmatter['runConfig'] as
| Record<string, unknown>
| undefined;
const backgroundColor = frontmatter['backgroundColor'] as
| string
| undefined;
const color = frontmatter['color'] as string | undefined;
// Determine level from file path
// Project level paths contain the project root, user level paths are in home directory
@@ -387,11 +383,9 @@ export class SubagentManager {
systemPrompt: systemPrompt.trim(),
level,
filePath,
modelConfig: modelConfig as Partial<
import('./subagent.js').ModelConfig
>,
runConfig: runConfig as Partial<import('./subagent.js').RunConfig>,
backgroundColor,
modelConfig: modelConfig as Partial<ModelConfig>,
runConfig: runConfig as Partial<RunConfig>,
color,
};
// Validate the parsed configuration
@@ -436,8 +430,8 @@ export class SubagentManager {
frontmatter['runConfig'] = config.runConfig;
}
if (config.backgroundColor && config.backgroundColor !== 'auto') {
frontmatter['backgroundColor'] = config.backgroundColor;
if (config.color && config.color !== 'auto') {
frontmatter['color'] = config.color;
}
// Serialize to YAML

View File

@@ -4,17 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
export interface SubAgentBasicStats {
rounds: number;
totalDurationMs: number;
totalToolCalls: number;
successfulToolCalls: number;
failedToolCalls: number;
successRate?: number;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
}
import { SubagentStatsSummary } from './subagent-statistics.js';
function fmtDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
@@ -30,7 +20,7 @@ function fmtDuration(ms: number): string {
}
export function formatCompact(
stats: SubAgentBasicStats,
stats: SubagentStatsSummary,
taskDesc: string,
): string {
const sr =
@@ -52,16 +42,7 @@ export function formatCompact(
}
export function formatDetailed(
stats: SubAgentBasicStats & {
toolUsage?: Array<{
name: string;
count: number;
success: number;
failure: number;
lastError?: string;
averageDurationMs?: number;
}>;
},
stats: SubagentStatsSummary,
taskDesc: string,
): string {
const sr =
@@ -118,18 +99,7 @@ export function formatDetailed(
return lines.join('\n');
}
export function generatePerformanceTips(
stats: SubAgentBasicStats & {
toolUsage?: Array<{
name: string;
count: number;
success: number;
failure: number;
lastError?: string;
averageDurationMs?: number;
}>;
},
): string[] {
export function generatePerformanceTips(stats: SubagentStatsSummary): string[] {
const tips: string[] = [];
const totalCalls = stats.totalToolCalls;
const sr =

View File

@@ -14,7 +14,7 @@ export interface ToolUsageStats {
averageDurationMs: number;
}
export interface SubagentSummary {
export interface SubagentStatsSummary {
rounds: number;
totalDurationMs: number;
totalToolCalls: number;
@@ -79,7 +79,7 @@ export class SubagentStatistics {
this.outputTokens += Math.max(0, output || 0);
}
getSummary(now = Date.now()): SubagentSummary {
getSummary(now = Date.now()): SubagentStatsSummary {
const totalDurationMs = this.startTimeMs ? now - this.startTimeMs : 0;
const totalToolCalls = this.totalToolCalls;
const successRate =

View File

@@ -5,15 +5,14 @@
*/
import { vi, describe, it, expect, beforeEach, Mock, afterEach } from 'vitest';
import { ContextState, SubAgentScope } from './subagent.js';
import {
ContextState,
SubAgentScope,
SubagentTerminateMode,
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from './subagent.js';
} from './types.js';
import { Config, ConfigParameters } from '../config/config.js';
import { GeminiChat } from '../core/geminiChat.js';
import { createContentGenerator } from '../core/contentGenerator.js';

View File

@@ -19,9 +19,30 @@ import {
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import { GeminiChat } from '../core/geminiChat.js';
import { SubAgentEventEmitter } from './subagent-events.js';
import { formatCompact, formatDetailed } from './subagent-result-format.js';
import { SubagentStatistics } from './subagent-statistics.js';
import {
OutputObject,
SubagentTerminateMode,
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from './types.js';
import {
SubAgentEventEmitter,
SubAgentEventType,
SubAgentFinishEvent,
SubAgentRoundEvent,
SubAgentStartEvent,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentStreamTextEvent,
SubAgentErrorEvent,
} from './subagent-events.js';
import { formatCompact } from './subagent-result-format.js';
import {
SubagentStatistics,
SubagentStatsSummary,
} from './subagent-statistics.js';
import { SubagentHooks } from './subagent-hooks.js';
import { logSubagentExecution } from '../telemetry/loggers.js';
import { SubagentExecutionEvent } from '../telemetry/types.js';
@@ -46,114 +67,6 @@ interface ExecutionStats {
estimatedCost?: number;
}
/**
* Describes the possible termination modes for a subagent.
* This enum provides a clear indication of why a subagent's execution might have ended.
*/
export enum SubagentTerminateMode {
/**
* Indicates that the subagent's execution terminated due to an unrecoverable error.
*/
ERROR = 'ERROR',
/**
* Indicates that the subagent's execution terminated because it exceeded the maximum allowed working time.
*/
TIMEOUT = 'TIMEOUT',
/**
* Indicates that the subagent's execution successfully completed all its defined goals.
*/
GOAL = 'GOAL',
/**
* Indicates that the subagent's execution terminated because it exceeded the maximum number of turns.
*/
MAX_TURNS = 'MAX_TURNS',
}
/**
* Represents the output structure of a subagent's execution.
* This interface defines the data that a subagent will return upon completion,
* including the final result and the reason for its termination.
*/
export interface OutputObject {
/**
* The final result text returned by the subagent upon completion.
* This contains the direct output from the model's final response.
*/
result: string;
/**
* The reason for the subagent's termination, indicating whether it completed
* successfully, timed out, or encountered an error.
*/
terminate_reason: SubagentTerminateMode;
}
/**
* Configures the initial prompt for the subagent.
*/
export interface PromptConfig {
/**
* A single system prompt string that defines the subagent's persona and instructions.
* Note: You should use either `systemPrompt` or `initialMessages`, but not both.
*/
systemPrompt?: string;
/**
* An array of user/model content pairs to seed the chat history for few-shot prompting.
* Note: You should use either `systemPrompt` or `initialMessages`, but not both.
*/
initialMessages?: Content[];
}
/**
* Configures the tools available to the subagent during its execution.
*/
export interface ToolConfig {
/**
* A list of tool names (from the tool registry) or full function declarations
* that the subagent is permitted to use.
*/
tools: Array<string | FunctionDeclaration>;
}
/**
* Configures the generative model parameters for the subagent.
* This interface specifies the model to be used and its associated generation settings,
* such as temperature and top-p values, which influence the creativity and diversity of the model's output.
*/
export interface ModelConfig {
/**
* The name or identifier of the model to be used (e.g., 'gemini-2.5-pro').
*
* TODO: In the future, this needs to support 'auto' or some other string to support routing use cases.
*/
model?: string;
/**
* The temperature for the model's sampling process.
*/
temp?: number;
/**
* The top-p value for nucleus sampling.
*/
top_p?: number;
}
/**
* Configures the execution environment and constraints for the subagent.
* This interface defines parameters that control the subagent's runtime behavior,
* such as maximum execution time, to prevent infinite loops or excessive resource consumption.
*
* TODO: Consider adding max_tokens as a form of budgeting.
*/
export interface RunConfig {
/** The maximum execution time for the subagent in minutes. */
max_time_minutes?: number;
/**
* The maximum number of conversational turns (a user message + model response)
* before the execution is terminated. Helps prevent infinite loops.
*/
max_turns?: number;
}
/**
* Manages the runtime context state for the subagent.
* This class provides a mechanism to store and retrieve key-value pairs
@@ -450,7 +363,7 @@ export class SubAgentScope {
let turnCounter = 0;
try {
// Emit start event
this.eventEmitter?.emit('start', {
this.eventEmitter?.emit(SubAgentEventType.START, {
subagentId: this.subagentId,
name: this.name,
model: this.modelConfig.model,
@@ -458,7 +371,7 @@ export class SubAgentScope {
typeof t === 'string' ? t : t.name,
),
timestamp: Date.now(),
});
} as SubAgentStartEvent);
// Log telemetry for subagent start
const startEvent = new SubagentExecutionEvent(this.name, 'started');
@@ -494,12 +407,12 @@ export class SubAgentScope {
messageParams,
promptId,
);
this.eventEmitter?.emit('round_start', {
this.eventEmitter?.emit(SubAgentEventType.ROUND_START, {
subagentId: this.subagentId,
round: turnCounter,
promptId,
timestamp: Date.now(),
});
} as SubAgentRoundEvent);
const functionCalls: FunctionCall[] = [];
let roundText = '';
@@ -514,12 +427,12 @@ export class SubAgentScope {
const txt = (p as Part & { text?: string }).text;
if (txt) roundText += txt;
if (txt)
this.eventEmitter?.emit('model_text', {
this.eventEmitter?.emit(SubAgentEventType.STREAM_TEXT, {
subagentId: this.subagentId,
round: turnCounter,
text: txt,
timestamp: Date.now(),
});
} as SubAgentStreamTextEvent);
}
if (resp.usageMetadata) lastUsage = resp.usageMetadata;
}
@@ -565,6 +478,7 @@ export class SubAgentScope {
functionCalls,
abortController,
promptId,
turnCounter,
);
} else {
// No tool calls — treat this as the model's final answer.
@@ -586,21 +500,21 @@ export class SubAgentScope {
},
];
}
this.eventEmitter?.emit('round_end', {
this.eventEmitter?.emit(SubAgentEventType.ROUND_END, {
subagentId: this.subagentId,
round: turnCounter,
promptId,
timestamp: Date.now(),
});
} as SubAgentRoundEvent);
}
} catch (error) {
console.error('Error during subagent execution:', error);
this.output.terminate_reason = SubagentTerminateMode.ERROR;
this.eventEmitter?.emit('error', {
this.eventEmitter?.emit(SubAgentEventType.ERROR, {
subagentId: this.subagentId,
error: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
});
} as SubAgentErrorEvent);
// Log telemetry for subagent error
const errorEvent = new SubagentExecutionEvent(this.name, 'failed', {
@@ -614,7 +528,7 @@ export class SubAgentScope {
if (externalSignal) externalSignal.removeEventListener('abort', onAbort);
this.executionStats.totalDurationMs = Date.now() - startTime;
const summary = this.stats.getSummary(Date.now());
this.eventEmitter?.emit('finish', {
this.eventEmitter?.emit(SubAgentEventType.FINISH, {
subagentId: this.subagentId,
terminate_reason: this.output.terminate_reason,
timestamp: Date.now(),
@@ -626,7 +540,7 @@ export class SubAgentScope {
inputTokens: summary.inputTokens,
outputTokens: summary.outputTokens,
totalTokens: summary.totalTokens,
});
} as SubAgentFinishEvent);
// Log telemetry for subagent completion
const completionEvent = new SubagentExecutionEvent(
@@ -637,7 +551,8 @@ export class SubAgentScope {
{
terminate_reason: this.output.terminate_reason,
result: this.finalText,
execution_summary: this.formatCompactResult(
execution_summary: formatCompact(
summary,
'Subagent execution completed',
),
},
@@ -669,6 +584,7 @@ export class SubAgentScope {
functionCalls: FunctionCall[],
abortController: AbortController,
promptId: string,
currentRound: number,
): Promise<Content[]> {
const toolResponseParts: Part[] = [];
@@ -683,6 +599,20 @@ export class SubAgentScope {
prompt_id: promptId,
};
// Get tool description before execution
const description = this.getToolDescription(toolName, requestInfo.args);
// Emit tool call event BEFORE execution
this.eventEmitter?.emit(SubAgentEventType.TOOL_CALL, {
subagentId: this.subagentId,
round: currentRound,
callId,
name: toolName,
args: requestInfo.args,
description,
timestamp: Date.now(),
} as SubAgentToolCallEvent);
// Execute tools with timing and hooks
const start = Date.now();
await this.hooks?.preToolUse?.({
@@ -717,13 +647,7 @@ export class SubAgentScope {
tu.count += 1;
if (toolResponse?.error) {
tu.failure += 1;
const disp =
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: toolResponse.resultDisplay
? JSON.stringify(toolResponse.resultDisplay)
: undefined;
tu.lastError = disp || toolResponse.error?.message || 'Unknown error';
tu.lastError = toolResponse.error?.message || 'Unknown error';
} else {
tu.success += 1;
}
@@ -737,31 +661,22 @@ export class SubAgentScope {
}
this.toolUsage.set(toolName, tu);
// Emit tool call/result events
this.eventEmitter?.emit('tool_call', {
// Emit tool result event
this.eventEmitter?.emit(SubAgentEventType.TOOL_RESULT, {
subagentId: this.subagentId,
round: this.executionStats.rounds,
callId,
name: toolName,
args: requestInfo.args,
timestamp: Date.now(),
});
this.eventEmitter?.emit('tool_result', {
subagentId: this.subagentId,
round: this.executionStats.rounds,
round: currentRound,
callId,
name: toolName,
success: !toolResponse?.error,
error: toolResponse?.error
error: toolResponse?.error?.message,
resultDisplay: toolResponse?.resultDisplay
? typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: toolResponse.resultDisplay
? JSON.stringify(toolResponse.resultDisplay)
: toolResponse.error.message
: JSON.stringify(toolResponse.resultDisplay)
: undefined,
durationMs: duration,
timestamp: Date.now(),
});
} as SubAgentToolResultEvent);
// Update statistics service
this.stats.recordToolCall(
@@ -779,19 +694,13 @@ export class SubAgentScope {
args: requestInfo.args,
success: !toolResponse?.error,
durationMs: duration,
errorMessage: toolResponse?.error
? typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: toolResponse.resultDisplay
? JSON.stringify(toolResponse.resultDisplay)
: toolResponse.error.message
: undefined,
errorMessage: toolResponse?.error?.message,
timestamp: Date.now(),
});
if (toolResponse.error) {
console.error(
`Error executing tool ${functionCall.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
`Error executing tool ${functionCall.name}: ${toolResponse.error.message}`,
);
}
@@ -836,47 +745,14 @@ export class SubAgentScope {
};
}
formatCompactResult(taskDesc: string, _useColors = false) {
const stats = this.getStatistics();
return formatCompact(
{
rounds: stats.rounds,
totalDurationMs: stats.totalDurationMs,
totalToolCalls: stats.totalToolCalls,
successfulToolCalls: stats.successfulToolCalls,
failedToolCalls: stats.failedToolCalls,
successRate: stats.successRate,
inputTokens: this.executionStats.inputTokens,
outputTokens: this.executionStats.outputTokens,
totalTokens: this.executionStats.totalTokens,
},
taskDesc,
);
getExecutionSummary(): SubagentStatsSummary {
return this.stats.getSummary();
}
getFinalText(): string {
return this.finalText;
}
formatDetailedResult(taskDesc: string) {
const stats = this.getStatistics();
return formatDetailed(
{
rounds: stats.rounds,
totalDurationMs: stats.totalDurationMs,
totalToolCalls: stats.totalToolCalls,
successfulToolCalls: stats.successfulToolCalls,
failedToolCalls: stats.failedToolCalls,
successRate: stats.successRate,
inputTokens: this.executionStats.inputTokens,
outputTokens: this.executionStats.outputTokens,
totalTokens: this.executionStats.totalTokens,
toolUsage: stats.toolUsage,
},
taskDesc,
);
}
private async createChatObject(context: ContextState) {
if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) {
throw new Error(
@@ -944,6 +820,33 @@ export class SubAgentScope {
}
}
/**
* Safely retrieves the description of a tool by attempting to build it.
* Returns an empty string if any error occurs during the process.
*
* @param toolName The name of the tool to get description for.
* @param args The arguments that would be passed to the tool.
* @returns The tool description or empty string if error occurs.
*/
private getToolDescription(
toolName: string,
args: Record<string, unknown>,
): string {
try {
const toolRegistry = this.runtimeContext.getToolRegistry();
const tool = toolRegistry.getTool(toolName);
if (!tool) {
return '';
}
const toolInstance = tool.build(args);
return toolInstance.getDescription() || '';
} catch {
// Safely ignore all runtime errors and return empty string
return '';
}
}
private buildChatSystemPrompt(context: ContextState): string {
if (!this.promptConfig.systemPrompt) {
// This should ideally be caught in createChatObject, but serves as a safeguard.

View File

@@ -4,12 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from './subagent.js';
import { Content, FunctionDeclaration } from '@google/genai';
/**
* Represents the storage level for a subagent configuration.
@@ -61,10 +56,10 @@ export interface SubagentConfig {
runConfig?: Partial<RunConfig>;
/**
* Optional background color for runtime display.
* Optional color for runtime display.
* If 'auto' or omitted, uses automatic color assignment.
*/
backgroundColor?: string;
color?: string;
}
/**
@@ -159,3 +154,111 @@ export const SubagentErrorCode = {
export type SubagentErrorCode =
(typeof SubagentErrorCode)[keyof typeof SubagentErrorCode];
/**
* Describes the possible termination modes for a subagent.
* This enum provides a clear indication of why a subagent's execution might have ended.
*/
export enum SubagentTerminateMode {
/**
* Indicates that the subagent's execution terminated due to an unrecoverable error.
*/
ERROR = 'ERROR',
/**
* Indicates that the subagent's execution terminated because it exceeded the maximum allowed working time.
*/
TIMEOUT = 'TIMEOUT',
/**
* Indicates that the subagent's execution successfully completed all its defined goals.
*/
GOAL = 'GOAL',
/**
* Indicates that the subagent's execution terminated because it exceeded the maximum number of turns.
*/
MAX_TURNS = 'MAX_TURNS',
}
/**
* Represents the output structure of a subagent's execution.
* This interface defines the data that a subagent will return upon completion,
* including the final result and the reason for its termination.
*/
export interface OutputObject {
/**
* The final result text returned by the subagent upon completion.
* This contains the direct output from the model's final response.
*/
result: string;
/**
* The reason for the subagent's termination, indicating whether it completed
* successfully, timed out, or encountered an error.
*/
terminate_reason: SubagentTerminateMode;
}
/**
* Configures the initial prompt for the subagent.
*/
export interface PromptConfig {
/**
* A single system prompt string that defines the subagent's persona and instructions.
* Note: You should use either `systemPrompt` or `initialMessages`, but not both.
*/
systemPrompt?: string;
/**
* An array of user/model content pairs to seed the chat history for few-shot prompting.
* Note: You should use either `systemPrompt` or `initialMessages`, but not both.
*/
initialMessages?: Content[];
}
/**
* Configures the tools available to the subagent during its execution.
*/
export interface ToolConfig {
/**
* A list of tool names (from the tool registry) or full function declarations
* that the subagent is permitted to use.
*/
tools: Array<string | FunctionDeclaration>;
}
/**
* Configures the generative model parameters for the subagent.
* This interface specifies the model to be used and its associated generation settings,
* such as temperature and top-p values, which influence the creativity and diversity of the model's output.
*/
export interface ModelConfig {
/**
* The name or identifier of the model to be used (e.g., 'gemini-2.5-pro').
*
* TODO: In the future, this needs to support 'auto' or some other string to support routing use cases.
*/
model?: string;
/**
* The temperature for the model's sampling process.
*/
temp?: number;
/**
* The top-p value for nucleus sampling.
*/
top_p?: number;
}
/**
* Configures the execution environment and constraints for the subagent.
* This interface defines parameters that control the subagent's runtime behavior,
* such as maximum execution time, to prevent infinite loops or excessive resource consumption.
*
* TODO: Consider adding max_tokens as a form of budgeting.
*/
export interface RunConfig {
/** The maximum execution time for the subagent in minutes. */
max_time_minutes?: number;
/**
* The maximum number of conversational turns (a user message + model response)
* before the execution is terminated. Helps prevent infinite loops.
*/
max_turns?: number;
}

View File

@@ -9,6 +9,8 @@ import {
ValidationResult,
SubagentError,
SubagentErrorCode,
ModelConfig,
RunConfig,
} from './types.js';
/**
@@ -250,9 +252,7 @@ export class SubagentValidator {
* @param modelConfig - Partial model configuration to validate
* @returns ValidationResult
*/
validateModelConfig(
modelConfig: Partial<import('./subagent.js').ModelConfig>,
): ValidationResult {
validateModelConfig(modelConfig: ModelConfig): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
@@ -298,9 +298,7 @@ export class SubagentValidator {
* @param runConfig - Partial run configuration to validate
* @returns ValidationResult
*/
validateRunConfig(
runConfig: Partial<import('./subagent.js').RunConfig>,
): ValidationResult {
validateRunConfig(runConfig: RunConfig): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];

View File

@@ -6,14 +6,12 @@
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 } from '../subagents/types.js';
import {
SubAgentScope,
ContextState,
SubagentTerminateMode,
} from '../subagents/subagent.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
@@ -23,8 +21,8 @@ type TaskToolWithProtectedMethods = TaskTool & {
signal?: AbortSignal,
liveOutputCallback?: (chunk: string) => void,
) => Promise<{
llmContent: string;
returnDisplay: unknown;
llmContent: PartListUnion;
returnDisplay: ToolResultDisplay;
}>;
getDescription: () => string;
shouldConfirmExecute: () => Promise<boolean>;
@@ -270,6 +268,36 @@ describe('TaskTool', () => {
.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,
@@ -319,13 +347,11 @@ describe('TaskTool', () => {
);
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');
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 () => {
@@ -343,13 +369,10 @@ describe('TaskTool', () => {
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');
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 subagent execution failure', async () => {
@@ -366,16 +389,9 @@ describe('TaskTool', () => {
).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',
);
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('failed');
expect(display.terminateReason).toBe('ERROR');
});
it('should handle execution errors gracefully', async () => {
@@ -395,13 +411,13 @@ describe('TaskTool', () => {
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');
expect(llmText).toContain('Failed to run subagent: Creation failed');
const display = result.returnDisplay as TaskResultDisplay;
expect(display.status).toBe('failed');
expect(display.result ?? '').toContain(
'Failed to run subagent: Creation failed',
);
});
it('should execute subagent without live output callback', async () => {
@@ -421,12 +437,11 @@ describe('TaskTool', () => {
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');
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 () => {
@@ -460,7 +475,7 @@ describe('TaskTool', () => {
const result = await invocation.execute();
expect(typeof result.returnDisplay).toBe('object');
expect(result.returnDisplay).toHaveProperty('type', 'subagent_execution');
expect(result.returnDisplay).toHaveProperty('type', 'task_execution');
expect(result.returnDisplay).toHaveProperty(
'subagentName',
'file-search',
@@ -499,9 +514,7 @@ describe('TaskTool', () => {
).createInvocation(params);
const description = invocation.getDescription();
expect(description).toBe(
'file-search subagent: "Search files"',
);
expect(description).toBe('file-search subagent: "Search files"');
});
});
});

View File

@@ -21,6 +21,8 @@ import {
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentFinishEvent,
SubAgentEventType,
SubAgentErrorEvent,
} from '../subagents/subagent-events.js';
import { ChatRecordingService } from '../services/chatRecordingService.js';
@@ -30,14 +32,6 @@ export interface TaskParams {
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
@@ -107,21 +101,6 @@ export class TaskTool extends BaseDeclarativeTool<TaskParams, ToolResult> {
* 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 =
@@ -132,6 +111,63 @@ Use this tool proactively when:
.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;
@@ -211,14 +247,7 @@ Use this tool proactively when:
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;
}> = [];
private currentToolCalls: TaskResultDisplay['toolCalls'] = [];
constructor(
private readonly config: Config,
@@ -258,72 +287,75 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
private setupEventListeners(
updateOutput?: (output: ToolResultDisplay) => void,
): void {
this.eventEmitter.on('start', () => {
this.eventEmitter.on(SubAgentEventType.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[]) => {
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.currentToolCalls!.push(newToolCall);
this.updateDisplay(
{
progress: {
toolCalls: [...this.currentToolCalls],
},
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(
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.eventEmitter.on('finish', (...args: unknown[]) => {
this.updateDisplay(
{
toolCalls: [...this.currentToolCalls!],
},
updateOutput,
);
}
},
);
this.eventEmitter.on(SubAgentEventType.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
// Keep toolCalls data for final display
},
updateOutput,
);
});
this.eventEmitter.on('error', () => {
this.updateDisplay({ status: 'failed' }, updateOutput);
this.eventEmitter.on(SubAgentEventType.ERROR, (...args: unknown[]) => {
const event = args[0] as SubAgentErrorEvent;
this.updateDisplay(
{
status: 'failed',
terminateReason: event.error,
},
updateOutput,
);
});
}
@@ -348,38 +380,50 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
if (!subagentConfig) {
const errorDisplay = {
type: 'subagent_execution' as const,
type: 'task_execution' as const,
subagentName: this.params.subagent_type,
taskDescription: this.params.description,
taskPrompt: this.params.prompt,
status: 'failed' as const,
terminateReason: 'ERROR',
result: `Subagent "${this.params.subagent_type}" not found`,
subagentColor: undefined,
};
return {
llmContent: [
{
text: JSON.stringify({
success: false,
error: `Subagent "${this.params.subagent_type}" not found`,
}),
},
],
llmContent: `Subagent "${this.params.subagent_type}" not found`,
returnDisplay: errorDisplay,
};
}
// Initialize the current display state
this.currentDisplay = {
type: 'subagent_execution' as const,
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);
if (signal) {
signal.addEventListener('abort', () => {
if (this.currentDisplay) {
this.updateDisplay(
{
status: 'failed',
terminateReason: 'CANCELLED',
result: 'Task was cancelled by user',
},
updateOutput,
);
}
});
}
// Send initial display
if (updateOutput) {
updateOutput(this.currentDisplay);
@@ -432,25 +476,7 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
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}`;
}
const executionSummary = subagentScope.getExecutionSummary();
// Update the final display state
this.updateDisplay(
@@ -459,38 +485,28 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
terminateReason,
result: finalText,
executionSummary,
// Keep progress data including tool calls for final display
},
updateOutput,
);
return {
llmContent: [{ text: JSON.stringify(result) }],
llmContent: [{ text: finalText }],
returnDisplay: this.currentDisplay!,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`[TaskTool] Error starting subagent: ${errorMessage}`);
console.error(`[TaskTool] Error running subagent: ${errorMessage}`);
const errorDisplay = {
type: 'subagent_execution' as const,
subagentName: this.params.subagent_type,
taskDescription: this.params.description,
const errorDisplay: TaskResultDisplay = {
...this.currentDisplay!,
status: 'failed' as const,
terminateReason: 'ERROR',
result: `Failed to start subagent: ${errorMessage}`,
result: `Failed to run subagent: ${errorMessage}`,
};
return {
llmContent: [
{
text: JSON.stringify({
success: false,
error: `Failed to start subagent: ${errorMessage}`,
}),
},
],
llmContent: `Failed to run subagent: ${errorMessage}`,
returnDisplay: errorDisplay,
};
}

View File

@@ -8,6 +8,7 @@ import { FunctionDeclaration, PartListUnion } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
import { 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.
@@ -422,23 +423,25 @@ export function hasCycleInSchema(schema: object): boolean {
}
export interface TaskResultDisplay {
type: 'subagent_execution';
type: 'task_execution';
subagentName: string;
subagentColor?: string;
taskDescription: string;
taskPrompt: 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;
}>;
};
executionSummary?: SubagentStatsSummary;
toolCalls?: Array<{
callId: string;
name: string;
status: 'executing' | 'success' | 'failed';
error?: string;
args?: Record<string, unknown>;
result?: string;
resultDisplay?: string;
description?: string;
}>;
}
export type ToolResultDisplay =