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:
965
packages/core/src/subagents/subagent.ts
Normal file
965
packages/core/src/subagents/subagent.ts
Normal file
@@ -0,0 +1,965 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { reportError } from '../utils/errorReporting.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { ToolCallRequestInfo } from '../core/turn.js';
|
||||
import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
|
||||
import { createContentGenerator } from '../core/contentGenerator.js';
|
||||
import { getEnvironmentContext } from '../utils/environmentContext.js';
|
||||
import {
|
||||
Content,
|
||||
Part,
|
||||
FunctionCall,
|
||||
GenerateContentConfig,
|
||||
FunctionDeclaration,
|
||||
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 { SubagentHooks } from './subagent-hooks.js';
|
||||
import { logSubagentExecution } from '../telemetry/loggers.js';
|
||||
import { SubagentExecutionEvent } from '../telemetry/types.js';
|
||||
|
||||
/**
|
||||
* @fileoverview Defines the configuration interfaces for a subagent.
|
||||
*
|
||||
* These interfaces specify the structure for defining the subagent's prompt,
|
||||
* the model parameters, and the execution settings.
|
||||
*/
|
||||
|
||||
interface ExecutionStats {
|
||||
startTimeMs: number;
|
||||
totalDurationMs: number;
|
||||
rounds: number;
|
||||
totalToolCalls: number;
|
||||
successfulToolCalls: number;
|
||||
failedToolCalls: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
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
|
||||
* that represent the dynamic state and variables accessible to the subagent
|
||||
* during its execution.
|
||||
*/
|
||||
export class ContextState {
|
||||
private state: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* Retrieves a value from the context state.
|
||||
*
|
||||
* @param key - The key of the value to retrieve.
|
||||
* @returns The value associated with the key, or undefined if the key is not found.
|
||||
*/
|
||||
get(key: string): unknown {
|
||||
return this.state[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value in the context state.
|
||||
*
|
||||
* @param key - The key to set the value under.
|
||||
* @param value - The value to set.
|
||||
*/
|
||||
set(key: string, value: unknown): void {
|
||||
this.state[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all keys in the context state.
|
||||
*
|
||||
* @returns An array of all keys in the context state.
|
||||
*/
|
||||
get_keys(): string[] {
|
||||
return Object.keys(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces `${...}` placeholders in a template string with values from a context.
|
||||
*
|
||||
* This function identifies all placeholders in the format `${key}`, validates that
|
||||
* each key exists in the provided `ContextState`, and then performs the substitution.
|
||||
*
|
||||
* @param template The template string containing placeholders.
|
||||
* @param context The `ContextState` object providing placeholder values.
|
||||
* @returns The populated string with all placeholders replaced.
|
||||
* @throws {Error} if any placeholder key is not found in the context.
|
||||
*/
|
||||
function templateString(template: string, context: ContextState): string {
|
||||
const placeholderRegex = /\$\{(\w+)\}/g;
|
||||
|
||||
// First, find all unique keys required by the template.
|
||||
const requiredKeys = new Set(
|
||||
Array.from(template.matchAll(placeholderRegex), (match) => match[1]),
|
||||
);
|
||||
|
||||
// Check if all required keys exist in the context.
|
||||
const contextKeys = new Set(context.get_keys());
|
||||
const missingKeys = Array.from(requiredKeys).filter(
|
||||
(key) => !contextKeys.has(key),
|
||||
);
|
||||
|
||||
if (missingKeys.length > 0) {
|
||||
throw new Error(
|
||||
`Missing context values for the following keys: ${missingKeys.join(
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Perform the replacement using a replacer function.
|
||||
return template.replace(placeholderRegex, (_match, key) =>
|
||||
String(context.get(key)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the scope and execution environment for a subagent.
|
||||
* This class orchestrates the subagent's lifecycle, managing its chat interactions,
|
||||
* runtime context, and the collection of its outputs.
|
||||
*/
|
||||
export class SubAgentScope {
|
||||
output: OutputObject = {
|
||||
terminate_reason: SubagentTerminateMode.ERROR,
|
||||
result: '',
|
||||
};
|
||||
executionStats: ExecutionStats = {
|
||||
startTimeMs: 0,
|
||||
totalDurationMs: 0,
|
||||
rounds: 0,
|
||||
totalToolCalls: 0,
|
||||
successfulToolCalls: 0,
|
||||
failedToolCalls: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCost: 0,
|
||||
};
|
||||
private toolUsage = new Map<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
lastError?: string;
|
||||
totalDurationMs?: number;
|
||||
averageDurationMs?: number;
|
||||
}
|
||||
>();
|
||||
private eventEmitter?: SubAgentEventEmitter;
|
||||
private finalText: string = '';
|
||||
private readonly stats = new SubagentStatistics();
|
||||
private hooks?: SubagentHooks;
|
||||
private readonly subagentId: string;
|
||||
|
||||
/**
|
||||
* Constructs a new SubAgentScope instance.
|
||||
* @param name - The name for the subagent, used for logging and identification.
|
||||
* @param runtimeContext - The shared runtime configuration and services.
|
||||
* @param promptConfig - Configuration for the subagent's prompt and behavior.
|
||||
* @param modelConfig - Configuration for the generative model parameters.
|
||||
* @param runConfig - Configuration for the subagent's execution environment.
|
||||
* @param toolConfig - Optional configuration for tools available to the subagent.
|
||||
*/
|
||||
private constructor(
|
||||
readonly name: string,
|
||||
readonly runtimeContext: Config,
|
||||
private readonly promptConfig: PromptConfig,
|
||||
private readonly modelConfig: ModelConfig,
|
||||
private readonly runConfig: RunConfig,
|
||||
private readonly toolConfig?: ToolConfig,
|
||||
eventEmitter?: SubAgentEventEmitter,
|
||||
hooks?: SubagentHooks,
|
||||
) {
|
||||
const randomPart = Math.random().toString(36).slice(2, 8);
|
||||
this.subagentId = `${this.name}-${randomPart}`;
|
||||
this.eventEmitter = eventEmitter;
|
||||
this.hooks = hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and validates a new SubAgentScope instance.
|
||||
* This factory method ensures that all tools provided in the prompt configuration
|
||||
* are valid for non-interactive use before creating the subagent instance.
|
||||
* @param {string} name - The name of the subagent.
|
||||
* @param {Config} runtimeContext - The shared runtime configuration and services.
|
||||
* @param {PromptConfig} promptConfig - Configuration for the subagent's prompt and behavior.
|
||||
* @param {ModelConfig} modelConfig - Configuration for the generative model parameters.
|
||||
* @param {RunConfig} runConfig - Configuration for the subagent's execution environment.
|
||||
* @param {ToolConfig} [toolConfig] - Optional configuration for tools.
|
||||
* @returns {Promise<SubAgentScope>} A promise that resolves to a valid SubAgentScope instance.
|
||||
* @throws {Error} If any tool requires user confirmation.
|
||||
*/
|
||||
static async create(
|
||||
name: string,
|
||||
runtimeContext: Config,
|
||||
promptConfig: PromptConfig,
|
||||
modelConfig: ModelConfig,
|
||||
runConfig: RunConfig,
|
||||
toolConfig?: ToolConfig,
|
||||
eventEmitter?: SubAgentEventEmitter,
|
||||
hooks?: SubagentHooks,
|
||||
): Promise<SubAgentScope> {
|
||||
// Validate tools for non-interactive use
|
||||
if (toolConfig?.tools) {
|
||||
const toolRegistry = runtimeContext.getToolRegistry();
|
||||
|
||||
for (const toolItem of toolConfig.tools) {
|
||||
if (typeof toolItem !== 'string') {
|
||||
continue; // Skip inline function declarations
|
||||
}
|
||||
const tool = toolRegistry.getTool(toolItem);
|
||||
if (!tool) {
|
||||
continue; // Skip unknown tools
|
||||
}
|
||||
|
||||
// Check if tool has required parameters
|
||||
const hasRequiredParams =
|
||||
tool.schema?.parameters?.required &&
|
||||
Array.isArray(tool.schema.parameters.required) &&
|
||||
tool.schema.parameters.required.length > 0;
|
||||
|
||||
if (hasRequiredParams) {
|
||||
// Can't check interactivity without parameters, log warning and continue
|
||||
console.warn(
|
||||
`Cannot check tool "${toolItem}" for interactivity because it requires parameters. Assuming it is safe for non-interactive use.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to build the tool to check if it requires confirmation
|
||||
try {
|
||||
const toolInstance = tool.build({});
|
||||
const confirmationDetails = await toolInstance.shouldConfirmExecute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
if (confirmationDetails) {
|
||||
throw new Error(
|
||||
`Tool "${toolItem}" requires user confirmation and cannot be used in a non-interactive subagent.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't build the tool, assume it's safe
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('requires user confirmation')
|
||||
) {
|
||||
throw error; // Re-throw confirmation errors
|
||||
}
|
||||
// For other build errors, log warning and continue
|
||||
console.warn(
|
||||
`Cannot check tool "${toolItem}" for interactivity because it requires parameters. Assuming it is safe for non-interactive use.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SubAgentScope(
|
||||
name,
|
||||
runtimeContext,
|
||||
promptConfig,
|
||||
modelConfig,
|
||||
runConfig,
|
||||
toolConfig,
|
||||
eventEmitter,
|
||||
hooks,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the subagent in a non-interactive mode.
|
||||
* This method orchestrates the subagent's execution loop, including prompt templating,
|
||||
* tool execution, and termination conditions.
|
||||
* @param {ContextState} context - The current context state containing variables for prompt templating.
|
||||
* @returns {Promise<void>} A promise that resolves when the subagent has completed its execution.
|
||||
*/
|
||||
async runNonInteractive(
|
||||
context: ContextState,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const chat = await this.createChatObject(context);
|
||||
|
||||
if (!chat) {
|
||||
this.output.terminate_reason = SubagentTerminateMode.ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const onAbort = () => abortController.abort();
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) abortController.abort();
|
||||
externalSignal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
const toolRegistry = this.runtimeContext.getToolRegistry();
|
||||
|
||||
// Prepare the list of tools available to the subagent.
|
||||
// If no explicit toolConfig or it contains "*" or is empty, inherit all tools.
|
||||
const toolsList: FunctionDeclaration[] = [];
|
||||
if (this.toolConfig) {
|
||||
const asStrings = this.toolConfig.tools.filter(
|
||||
(t): t is string => typeof t === 'string',
|
||||
);
|
||||
const hasWildcard = asStrings.includes('*');
|
||||
const onlyInlineDecls = this.toolConfig.tools.filter(
|
||||
(t): t is FunctionDeclaration => typeof t !== 'string',
|
||||
);
|
||||
|
||||
if (hasWildcard || asStrings.length === 0) {
|
||||
toolsList.push(...toolRegistry.getFunctionDeclarations());
|
||||
} else {
|
||||
toolsList.push(
|
||||
...toolRegistry.getFunctionDeclarationsFiltered(asStrings),
|
||||
);
|
||||
}
|
||||
toolsList.push(...onlyInlineDecls);
|
||||
} else {
|
||||
// Inherit all available tools by default when not specified.
|
||||
toolsList.push(...toolRegistry.getFunctionDeclarations());
|
||||
}
|
||||
|
||||
const initialTaskText = String(
|
||||
(context.get('task_prompt') as string) ?? 'Get Started!',
|
||||
);
|
||||
let currentMessages: Content[] = [
|
||||
{ role: 'user', parts: [{ text: initialTaskText }] },
|
||||
];
|
||||
|
||||
const startTime = Date.now();
|
||||
this.executionStats.startTimeMs = startTime;
|
||||
this.stats.start(startTime);
|
||||
let turnCounter = 0;
|
||||
try {
|
||||
// Emit start event
|
||||
this.eventEmitter?.emit('start', {
|
||||
subagentId: this.subagentId,
|
||||
name: this.name,
|
||||
model: this.modelConfig.model,
|
||||
tools: (this.toolConfig?.tools || ['*']).map((t) =>
|
||||
typeof t === 'string' ? t : t.name,
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Log telemetry for subagent start
|
||||
const startEvent = new SubagentExecutionEvent(this.name, 'started');
|
||||
logSubagentExecution(this.runtimeContext, startEvent);
|
||||
while (true) {
|
||||
// Check termination conditions.
|
||||
if (
|
||||
this.runConfig.max_turns &&
|
||||
turnCounter >= this.runConfig.max_turns
|
||||
) {
|
||||
this.output.terminate_reason = SubagentTerminateMode.MAX_TURNS;
|
||||
break;
|
||||
}
|
||||
let durationMin = (Date.now() - startTime) / (1000 * 60);
|
||||
if (
|
||||
this.runConfig.max_time_minutes &&
|
||||
durationMin >= this.runConfig.max_time_minutes
|
||||
) {
|
||||
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT;
|
||||
break;
|
||||
}
|
||||
|
||||
const promptId = `${this.runtimeContext.getSessionId()}#${this.subagentId}#${turnCounter++}`;
|
||||
const messageParams = {
|
||||
message: currentMessages[0]?.parts || [],
|
||||
config: {
|
||||
abortSignal: abortController.signal,
|
||||
tools: [{ functionDeclarations: toolsList }],
|
||||
},
|
||||
};
|
||||
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
messageParams,
|
||||
promptId,
|
||||
);
|
||||
this.eventEmitter?.emit('round_start', {
|
||||
subagentId: this.subagentId,
|
||||
round: turnCounter,
|
||||
promptId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let roundText = '';
|
||||
let lastUsage: GenerateContentResponseUsageMetadata | undefined =
|
||||
undefined;
|
||||
for await (const resp of responseStream) {
|
||||
if (abortController.signal.aborted) return;
|
||||
if (resp.functionCalls) functionCalls.push(...resp.functionCalls);
|
||||
const content = resp.candidates?.[0]?.content;
|
||||
const parts = content?.parts || [];
|
||||
for (const p of parts) {
|
||||
const txt = (p as Part & { text?: string }).text;
|
||||
if (txt) roundText += txt;
|
||||
if (txt)
|
||||
this.eventEmitter?.emit('model_text', {
|
||||
subagentId: this.subagentId,
|
||||
round: turnCounter,
|
||||
text: txt,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
if (resp.usageMetadata) lastUsage = resp.usageMetadata;
|
||||
}
|
||||
this.executionStats.rounds = turnCounter;
|
||||
this.stats.setRounds(turnCounter);
|
||||
|
||||
durationMin = (Date.now() - startTime) / (1000 * 60);
|
||||
if (
|
||||
this.runConfig.max_time_minutes &&
|
||||
durationMin >= this.runConfig.max_time_minutes
|
||||
) {
|
||||
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT;
|
||||
break;
|
||||
}
|
||||
|
||||
// Update token usage if available
|
||||
if (lastUsage) {
|
||||
const inTok = Number(lastUsage.promptTokenCount || 0);
|
||||
const outTok = Number(lastUsage.candidatesTokenCount || 0);
|
||||
if (isFinite(inTok) || isFinite(outTok)) {
|
||||
this.stats.recordTokens(
|
||||
isFinite(inTok) ? inTok : 0,
|
||||
isFinite(outTok) ? outTok : 0,
|
||||
);
|
||||
// mirror legacy fields for compatibility
|
||||
this.executionStats.inputTokens =
|
||||
(this.executionStats.inputTokens || 0) +
|
||||
(isFinite(inTok) ? inTok : 0);
|
||||
this.executionStats.outputTokens =
|
||||
(this.executionStats.outputTokens || 0) +
|
||||
(isFinite(outTok) ? outTok : 0);
|
||||
this.executionStats.totalTokens =
|
||||
(this.executionStats.inputTokens || 0) +
|
||||
(this.executionStats.outputTokens || 0);
|
||||
this.executionStats.estimatedCost =
|
||||
(this.executionStats.inputTokens || 0) * 3e-5 +
|
||||
(this.executionStats.outputTokens || 0) * 6e-5;
|
||||
}
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
currentMessages = await this.processFunctionCalls(
|
||||
functionCalls,
|
||||
abortController,
|
||||
promptId,
|
||||
);
|
||||
} else {
|
||||
// No tool calls — treat this as the model's final answer.
|
||||
if (roundText && roundText.trim().length > 0) {
|
||||
this.finalText = roundText.trim();
|
||||
this.output.result = this.finalText;
|
||||
this.output.terminate_reason = SubagentTerminateMode.GOAL;
|
||||
break;
|
||||
}
|
||||
// Otherwise, nudge the model to finalize a result.
|
||||
currentMessages = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: 'Please provide the final result now and stop calling tools.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
this.eventEmitter?.emit('round_end', {
|
||||
subagentId: this.subagentId,
|
||||
round: turnCounter,
|
||||
promptId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during subagent execution:', error);
|
||||
this.output.terminate_reason = SubagentTerminateMode.ERROR;
|
||||
this.eventEmitter?.emit('error', {
|
||||
subagentId: this.subagentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Log telemetry for subagent error
|
||||
const errorEvent = new SubagentExecutionEvent(this.name, 'failed', {
|
||||
terminate_reason: SubagentTerminateMode.ERROR,
|
||||
result: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
logSubagentExecution(this.runtimeContext, errorEvent);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
if (externalSignal) externalSignal.removeEventListener('abort', onAbort);
|
||||
this.executionStats.totalDurationMs = Date.now() - startTime;
|
||||
const summary = this.stats.getSummary(Date.now());
|
||||
this.eventEmitter?.emit('finish', {
|
||||
subagentId: this.subagentId,
|
||||
terminate_reason: this.output.terminate_reason,
|
||||
timestamp: Date.now(),
|
||||
rounds: summary.rounds,
|
||||
totalDurationMs: summary.totalDurationMs,
|
||||
totalToolCalls: summary.totalToolCalls,
|
||||
successfulToolCalls: summary.successfulToolCalls,
|
||||
failedToolCalls: summary.failedToolCalls,
|
||||
inputTokens: summary.inputTokens,
|
||||
outputTokens: summary.outputTokens,
|
||||
totalTokens: summary.totalTokens,
|
||||
});
|
||||
|
||||
// Log telemetry for subagent completion
|
||||
const completionEvent = new SubagentExecutionEvent(
|
||||
this.name,
|
||||
this.output.terminate_reason === SubagentTerminateMode.GOAL
|
||||
? 'completed'
|
||||
: 'failed',
|
||||
{
|
||||
terminate_reason: this.output.terminate_reason,
|
||||
result: this.finalText,
|
||||
execution_summary: this.formatCompactResult(
|
||||
'Subagent execution completed',
|
||||
),
|
||||
},
|
||||
);
|
||||
logSubagentExecution(this.runtimeContext, completionEvent);
|
||||
|
||||
await this.hooks?.onStop?.({
|
||||
subagentId: this.subagentId,
|
||||
name: this.name,
|
||||
terminateReason: this.output.terminate_reason,
|
||||
summary: summary as unknown as Record<string, unknown>,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a list of function calls, executing each one and collecting their responses.
|
||||
* This method iterates through the provided function calls, executes them using the
|
||||
* `executeToolCall` function (or handles `self.emitvalue` internally), and aggregates
|
||||
* their results. It also manages error reporting for failed tool executions.
|
||||
* @param {FunctionCall[]} functionCalls - An array of `FunctionCall` objects to process.
|
||||
* @param {ToolRegistry} toolRegistry - The tool registry to look up and execute tools.
|
||||
* @param {AbortController} abortController - An `AbortController` to signal cancellation of tool executions.
|
||||
* @returns {Promise<Content[]>} A promise that resolves to an array of `Content` parts representing the tool responses,
|
||||
* which are then used to update the chat history.
|
||||
*/
|
||||
private async processFunctionCalls(
|
||||
functionCalls: FunctionCall[],
|
||||
abortController: AbortController,
|
||||
promptId: string,
|
||||
): Promise<Content[]> {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const functionCall of functionCalls) {
|
||||
const toolName = String(functionCall.name || 'unknown');
|
||||
const callId = functionCall.id ?? `${functionCall.name}-${Date.now()}`;
|
||||
const requestInfo: ToolCallRequestInfo = {
|
||||
callId,
|
||||
name: functionCall.name as string,
|
||||
args: (functionCall.args ?? {}) as Record<string, unknown>,
|
||||
isClientInitiated: true,
|
||||
prompt_id: promptId,
|
||||
};
|
||||
|
||||
// Execute tools with timing and hooks
|
||||
const start = Date.now();
|
||||
await this.hooks?.preToolUse?.({
|
||||
subagentId: this.subagentId,
|
||||
name: this.name,
|
||||
toolName,
|
||||
args: requestInfo.args,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const toolResponse = await executeToolCall(
|
||||
this.runtimeContext,
|
||||
requestInfo,
|
||||
abortController.signal,
|
||||
);
|
||||
const duration = Date.now() - start;
|
||||
// Update tool call stats
|
||||
this.executionStats.totalToolCalls += 1;
|
||||
if (toolResponse.error) {
|
||||
this.executionStats.failedToolCalls += 1;
|
||||
} else {
|
||||
this.executionStats.successfulToolCalls += 1;
|
||||
}
|
||||
|
||||
// Update per-tool usage
|
||||
const tu = this.toolUsage.get(toolName) || {
|
||||
count: 0,
|
||||
success: 0,
|
||||
failure: 0,
|
||||
totalDurationMs: 0,
|
||||
averageDurationMs: 0,
|
||||
};
|
||||
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';
|
||||
} else {
|
||||
tu.success += 1;
|
||||
}
|
||||
if (typeof tu.totalDurationMs === 'number') {
|
||||
tu.totalDurationMs += duration;
|
||||
tu.averageDurationMs =
|
||||
tu.count > 0 ? tu.totalDurationMs / tu.count : tu.totalDurationMs;
|
||||
} else {
|
||||
tu.totalDurationMs = duration;
|
||||
tu.averageDurationMs = duration;
|
||||
}
|
||||
this.toolUsage.set(toolName, tu);
|
||||
|
||||
// Emit tool call/result events
|
||||
this.eventEmitter?.emit('tool_call', {
|
||||
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,
|
||||
callId,
|
||||
name: toolName,
|
||||
success: !toolResponse?.error,
|
||||
error: toolResponse?.error
|
||||
? typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
: toolResponse.resultDisplay
|
||||
? JSON.stringify(toolResponse.resultDisplay)
|
||||
: toolResponse.error.message
|
||||
: undefined,
|
||||
durationMs: duration,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Update statistics service
|
||||
this.stats.recordToolCall(
|
||||
toolName,
|
||||
!toolResponse?.error,
|
||||
duration,
|
||||
this.toolUsage.get(toolName)?.lastError,
|
||||
);
|
||||
|
||||
// post-tool hook
|
||||
await this.hooks?.postToolUse?.({
|
||||
subagentId: this.subagentId,
|
||||
name: this.name,
|
||||
toolName,
|
||||
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,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (toolResponse.error) {
|
||||
console.error(
|
||||
`Error executing tool ${functionCall.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
const parts = Array.isArray(toolResponse.responseParts)
|
||||
? toolResponse.responseParts
|
||||
: [toolResponse.responseParts];
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
toolResponseParts.push({ text: part });
|
||||
} else if (part) {
|
||||
toolResponseParts.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If all tool calls failed, inform the model so it can re-evaluate.
|
||||
if (functionCalls.length > 0 && toolResponseParts.length === 0) {
|
||||
toolResponseParts.push({
|
||||
text: 'All tool calls failed. Please analyze the errors and try an alternative approach.',
|
||||
});
|
||||
}
|
||||
|
||||
return [{ role: 'user', parts: toolResponseParts }];
|
||||
}
|
||||
|
||||
getEventEmitter() {
|
||||
return this.eventEmitter;
|
||||
}
|
||||
|
||||
getStatistics() {
|
||||
const total = this.executionStats.totalToolCalls;
|
||||
const successRate =
|
||||
total > 0 ? (this.executionStats.successfulToolCalls / total) * 100 : 0;
|
||||
return {
|
||||
...this.executionStats,
|
||||
successRate,
|
||||
toolUsage: Array.from(this.toolUsage.entries()).map(([name, v]) => ({
|
||||
name,
|
||||
...v,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
'PromptConfig must have either `systemPrompt` or `initialMessages` defined.',
|
||||
);
|
||||
}
|
||||
if (this.promptConfig.systemPrompt && this.promptConfig.initialMessages) {
|
||||
throw new Error(
|
||||
'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.',
|
||||
);
|
||||
}
|
||||
|
||||
const envParts = await getEnvironmentContext(this.runtimeContext);
|
||||
const envHistory: Content[] = [
|
||||
{ role: 'user', parts: envParts },
|
||||
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
|
||||
];
|
||||
|
||||
const start_history = [
|
||||
...envHistory,
|
||||
...(this.promptConfig.initialMessages ?? []),
|
||||
];
|
||||
|
||||
const systemInstruction = this.promptConfig.systemPrompt
|
||||
? this.buildChatSystemPrompt(context)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const generationConfig: GenerateContentConfig & {
|
||||
systemInstruction?: string | Content;
|
||||
} = {
|
||||
temperature: this.modelConfig.temp,
|
||||
topP: this.modelConfig.top_p,
|
||||
};
|
||||
|
||||
if (systemInstruction) {
|
||||
generationConfig.systemInstruction = systemInstruction;
|
||||
}
|
||||
|
||||
const contentGenerator = await createContentGenerator(
|
||||
this.runtimeContext.getContentGeneratorConfig(),
|
||||
this.runtimeContext,
|
||||
this.runtimeContext.getSessionId(),
|
||||
);
|
||||
|
||||
if (this.modelConfig.model) {
|
||||
this.runtimeContext.setModel(this.modelConfig.model);
|
||||
}
|
||||
|
||||
return new GeminiChat(
|
||||
this.runtimeContext,
|
||||
contentGenerator,
|
||||
generationConfig,
|
||||
start_history,
|
||||
);
|
||||
} catch (error) {
|
||||
await reportError(
|
||||
error,
|
||||
'Error initializing Gemini chat session.',
|
||||
start_history,
|
||||
'startChat',
|
||||
);
|
||||
// The calling function will handle the undefined return.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private buildChatSystemPrompt(context: ContextState): string {
|
||||
if (!this.promptConfig.systemPrompt) {
|
||||
// This should ideally be caught in createChatObject, but serves as a safeguard.
|
||||
return '';
|
||||
}
|
||||
|
||||
let finalPrompt = templateString(this.promptConfig.systemPrompt, context);
|
||||
|
||||
// Add general non-interactive instructions.
|
||||
finalPrompt += `
|
||||
|
||||
Important Rules:
|
||||
- You operate in non-interactive mode: do not ask the user questions; proceed with available context.
|
||||
- Use tools only when necessary to obtain facts or make changes.
|
||||
- When the task is complete, return the final result as a normal model response (not a tool call) and stop.`;
|
||||
|
||||
return finalPrompt;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user