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

@@ -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.