feat: subagent runtime & CLI display - wip

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

View File

@@ -47,6 +47,19 @@ export type {
ToolConfig,
SubagentTerminateMode,
OutputObject,
} from '../core/subagent.js';
} from './subagent.js';
export { SubAgentScope } from '../core/subagent.js';
export { SubAgentScope } from './subagent.js';
// Event system for UI integration
export type {
SubAgentEvent,
SubAgentStartEvent,
SubAgentFinishEvent,
SubAgentRoundEvent,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentModelTextEvent,
} from './subagent-events.js';
export { SubAgentEventEmitter } from './subagent-events.js';

View File

@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { EventEmitter } from 'events';
export type SubAgentEvent =
| 'start'
| 'round_start'
| 'round_end'
| 'model_text'
| 'tool_call'
| 'tool_result'
| 'finish'
| 'error';
export interface SubAgentModelTextEvent {
subagentId: string;
round: number;
text: string;
timestamp: number;
}
export interface SubAgentStartEvent {
subagentId: string;
name: string;
model?: string;
tools: string[];
timestamp: number;
}
export interface SubAgentRoundEvent {
subagentId: string;
round: number;
promptId: string;
timestamp: number;
}
export interface SubAgentToolCallEvent {
subagentId: string;
round: number;
callId: string;
name: string;
args: Record<string, unknown>;
timestamp: number;
}
export interface SubAgentToolResultEvent {
subagentId: string;
round: number;
callId: string;
name: string;
success: boolean;
error?: string;
durationMs?: number;
timestamp: number;
}
export interface SubAgentFinishEvent {
subagentId: string;
terminate_reason: string;
timestamp: number;
rounds?: number;
totalDurationMs?: number;
totalToolCalls?: number;
successfulToolCalls?: number;
failedToolCalls?: number;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
}
export class SubAgentEventEmitter {
private ee = new EventEmitter();
on(event: SubAgentEvent, listener: (...args: unknown[]) => void) {
this.ee.on(event, listener);
}
off(event: SubAgentEvent, listener: (...args: unknown[]) => void) {
this.ee.off(event, listener);
}
emit(event: SubAgentEvent, payload: unknown) {
this.ee.emit(event, payload);
}
}

View File

@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface PreToolUsePayload {
subagentId: string;
name: string; // subagent name
toolName: string;
args: Record<string, unknown>;
timestamp: number;
}
export interface PostToolUsePayload extends PreToolUsePayload {
success: boolean;
durationMs: number;
errorMessage?: string;
}
export interface SubagentStopPayload {
subagentId: string;
name: string; // subagent name
terminateReason: string;
summary: Record<string, unknown>;
timestamp: number;
}
export interface SubagentHooks {
preToolUse?(payload: PreToolUsePayload): Promise<void> | void;
postToolUse?(payload: PostToolUsePayload): Promise<void> | void;
onStop?(payload: SubagentStopPayload): Promise<void> | void;
}

View File

@@ -11,6 +11,8 @@ import * as os from 'os';
import { SubagentManager } from './subagent-manager.js';
import { SubagentConfig, SubagentError } from './types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { Config } from '../config/config.js';
import { makeFakeConfig } from '../test-utils/config.js';
// Mock file system operations
vi.mock('fs/promises');
@@ -36,15 +38,30 @@ vi.mock('./validation.js', () => ({
},
}));
vi.mock('../core/subagent.js');
vi.mock('./subagent.js');
describe('SubagentManager', () => {
let manager: SubagentManager;
let mockToolRegistry: ToolRegistry;
const projectRoot = '/test/project';
let mockConfig: Config;
beforeEach(() => {
mockToolRegistry = {} as ToolRegistry;
mockToolRegistry = {
getAllTools: vi.fn().mockReturnValue([
{ name: 'read_file', displayName: 'Read File' },
{ name: 'write_file', displayName: 'Write File' },
{ name: 'grep', displayName: 'Search Files' },
]),
} as unknown as ToolRegistry;
// Create mock Config object using test utility
mockConfig = makeFakeConfig({
sessionId: 'test-session-id',
});
// Mock the tool registry and project root methods
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(mockToolRegistry);
vi.spyOn(mockConfig, 'getProjectRoot').mockReturnValue('/test/project');
// Mock os.homedir
vi.mocked(os.homedir).mockReturnValue('/home/user');
@@ -134,7 +151,7 @@ describe('SubagentManager', () => {
return yaml.trim();
});
manager = new SubagentManager(projectRoot, mockToolRegistry);
manager = new SubagentManager(mockConfig);
});
afterEach(() => {
@@ -739,6 +756,25 @@ System prompt 3`);
]);
});
it('should transform display names to tool names in tool configuration', () => {
const configWithDisplayNames: SubagentConfig = {
...validConfig,
tools: ['Read File', 'write_file', 'Search Files', 'unknown_tool'],
};
const runtimeConfig = manager.convertToRuntimeConfig(
configWithDisplayNames,
);
expect(runtimeConfig.toolConfig).toBeDefined();
expect(runtimeConfig.toolConfig!.tools).toEqual([
'read_file', // 'Read File' -> 'read_file' (display name match)
'write_file', // 'write_file' -> 'write_file' (exact name match)
'grep', // 'Search Files' -> 'grep' (display name match)
'unknown_tool', // 'unknown_tool' -> 'unknown_tool' (preserved as-is)
]);
});
it('should merge custom model and run configurations', () => {
const configWithCustom: SubagentConfig = {
...validConfig,

View File

@@ -29,9 +29,8 @@ import {
ModelConfig,
RunConfig,
ToolConfig,
} from '../core/subagent.js';
} from './subagent.js';
import { Config } from '../config/config.js';
import { ToolRegistry } from '../tools/tool-registry.js';
const QWEN_CONFIG_DIR = '.qwen';
const AGENT_CONFIG_DIR = 'agents';
@@ -43,11 +42,8 @@ const AGENT_CONFIG_DIR = 'agents';
export class SubagentManager {
private readonly validator: SubagentValidator;
constructor(
private readonly projectRoot: string,
private readonly toolRegistry?: ToolRegistry,
) {
this.validator = new SubagentValidator(toolRegistry);
constructor(private readonly config: Config) {
this.validator = new SubagentValidator();
}
/**
@@ -61,7 +57,6 @@ export class SubagentManager {
config: SubagentConfig,
options: CreateSubagentOptions,
): Promise<void> {
// Validate the configuration
this.validator.validateOrThrow(config);
// Determine file path
@@ -381,7 +376,7 @@ export class SubagentManager {
// Determine level from file path
// Project level paths contain the project root, user level paths are in home directory
const isProjectLevel =
filePath.includes(this.projectRoot) &&
filePath.includes(this.config.getProjectRoot()) &&
filePath.includes(`/${QWEN_CONFIG_DIR}/${AGENT_CONFIG_DIR}/`);
const level: SubagentLevel = isProjectLevel ? 'project' : 'user';
@@ -393,11 +388,9 @@ export class SubagentManager {
level,
filePath,
modelConfig: modelConfig as Partial<
import('../core/subagent.js').ModelConfig
>,
runConfig: runConfig as Partial<
import('../core/subagent.js').RunConfig
import('./subagent.js').ModelConfig
>,
runConfig: runConfig as Partial<import('./subagent.js').RunConfig>,
backgroundColor,
};
@@ -433,6 +426,8 @@ export class SubagentManager {
frontmatter['tools'] = config.tools;
}
// No outputs section
if (config.modelConfig) {
frontmatter['modelConfig'] = config.modelConfig;
}
@@ -465,6 +460,10 @@ export class SubagentManager {
async createSubagentScope(
config: SubagentConfig,
runtimeContext: Config,
options?: {
eventEmitter?: import('./subagent-events.js').SubAgentEventEmitter;
hooks?: import('./subagent-hooks.js').SubagentHooks;
},
): Promise<SubAgentScope> {
try {
const runtimeConfig = this.convertToRuntimeConfig(config);
@@ -476,6 +475,8 @@ export class SubagentManager {
runtimeConfig.modelConfig,
runtimeConfig.runConfig,
runtimeConfig.toolConfig,
options?.eventEmitter,
options?.hooks,
);
} catch (error) {
if (error instanceof Error) {
@@ -515,8 +516,10 @@ export class SubagentManager {
// Build tool configuration if tools are specified
let toolConfig: ToolConfig | undefined;
if (config.tools && config.tools.length > 0) {
// Transform tools array to ensure all entries are tool names (not display names)
const toolNames = this.transformToToolNames(config.tools);
toolConfig = {
tools: config.tools,
tools: toolNames,
};
}
@@ -528,6 +531,53 @@ export class SubagentManager {
};
}
/**
* Transforms a tools array that may contain tool names or display names
* into an array containing only tool names.
*
* @param tools - Array of tool names or display names
* @returns Array of tool names
* @private
*/
private transformToToolNames(tools: string[]): string[] {
const toolRegistry = this.config.getToolRegistry();
if (!toolRegistry) {
return tools;
}
const allTools = toolRegistry.getAllTools();
const result: string[] = [];
for (const toolIdentifier of tools) {
// First, try to find an exact match by tool name (highest priority)
const exactNameMatch = allTools.find(
(tool) => tool.name === toolIdentifier,
);
if (exactNameMatch) {
result.push(exactNameMatch.name);
continue;
}
// If no exact name match, try to find by display name
const displayNameMatch = allTools.find(
(tool) => tool.displayName === toolIdentifier,
);
if (displayNameMatch) {
result.push(displayNameMatch.name);
continue;
}
// If no match found, preserve the original identifier as-is
// This allows for tools that might not be registered yet or custom tools
result.push(toolIdentifier);
console.warn(
`Tool "${toolIdentifier}" not found in tool registry, preserving as-is`,
);
}
return result;
}
/**
* Merges partial configurations with defaults, useful for updating
* existing configurations.
@@ -563,7 +613,11 @@ export class SubagentManager {
getSubagentPath(name: string, level: SubagentLevel): string {
const baseDir =
level === 'project'
? path.join(this.projectRoot, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR)
? path.join(
this.config.getProjectRoot(),
QWEN_CONFIG_DIR,
AGENT_CONFIG_DIR,
)
: path.join(os.homedir(), QWEN_CONFIG_DIR, AGENT_CONFIG_DIR);
return path.join(baseDir, `${name}.md`);
@@ -580,7 +634,11 @@ export class SubagentManager {
): Promise<SubagentConfig[]> {
const baseDir =
level === 'project'
? path.join(this.projectRoot, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR)
? path.join(
this.config.getProjectRoot(),
QWEN_CONFIG_DIR,
AGENT_CONFIG_DIR,
)
: path.join(os.homedir(), QWEN_CONFIG_DIR, AGENT_CONFIG_DIR);
try {
@@ -630,20 +688,4 @@ export class SubagentManager {
return false; // Name is already in use
}
/**
* Gets available tools from the tool registry.
* Useful for validation and UI purposes.
*
* @returns Array of available tool names
*/
getAvailableTools(): string[] {
if (!this.toolRegistry) {
return [];
}
// This would need to be implemented in ToolRegistry
// For now, return empty array
return [];
}
}

View File

@@ -0,0 +1,186 @@
/**
* @license
* Copyright 2025 Google LLC
* 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;
}
function fmtDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) {
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${m}m ${s}s`;
}
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
return `${h}h ${m}m`;
}
export function formatCompact(
stats: SubAgentBasicStats,
taskDesc: string,
): string {
const sr =
stats.totalToolCalls > 0
? (stats.successRate ??
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
: 0;
const lines = [
`📋 Task Completed: ${taskDesc}`,
`🔧 Tool Usage: ${stats.totalToolCalls} calls${stats.totalToolCalls ? `, ${sr.toFixed(1)}% success` : ''}`,
`⏱️ Duration: ${fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
];
if (typeof stats.totalTokens === 'number') {
lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
);
}
return lines.join('\n');
}
export function formatDetailed(
stats: SubAgentBasicStats & {
toolUsage?: Array<{
name: string;
count: number;
success: number;
failure: number;
lastError?: string;
averageDurationMs?: number;
}>;
},
taskDesc: string,
): string {
const sr =
stats.totalToolCalls > 0
? (stats.successRate ??
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
: 0;
const lines: string[] = [];
lines.push(`📋 Task Completed: ${taskDesc}`);
lines.push(
`⏱️ Duration: ${fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
);
// Quality indicator
let quality = 'Poor execution';
if (sr >= 95) quality = 'Excellent execution';
else if (sr >= 85) quality = 'Good execution';
else if (sr >= 70) quality = 'Fair execution';
lines.push(`✅ Quality: ${quality} (${sr.toFixed(1)}% tool success)`);
// Speed category
const d = stats.totalDurationMs;
let speed = 'Long execution - consider breaking down tasks';
if (d < 10_000) speed = 'Fast completion - under 10 seconds';
else if (d < 60_000) speed = 'Good speed - under a minute';
else if (d < 300_000) speed = 'Moderate duration - a few minutes';
lines.push(`🚀 Speed: ${speed}`);
lines.push(
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
);
if (typeof stats.totalTokens === 'number') {
lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
);
}
if (stats.toolUsage && stats.toolUsage.length) {
const sorted = [...stats.toolUsage]
.sort((a, b) => b.count - a.count)
.slice(0, 5);
lines.push('\nTop tools:');
for (const t of sorted) {
const avg =
typeof t.averageDurationMs === 'number'
? `, avg ${fmtDuration(Math.round(t.averageDurationMs))}`
: '';
lines.push(
` - ${t.name}: ${t.count} calls (${t.success} ok, ${t.failure} fail${avg}${t.lastError ? `, last error: ${t.lastError}` : ''})`,
);
}
}
const tips = generatePerformanceTips(stats);
if (tips.length) {
lines.push('\n💡 Performance Insights:');
for (const tip of tips.slice(0, 3)) lines.push(` - ${tip}`);
}
return lines.join('\n');
}
export function generatePerformanceTips(
stats: SubAgentBasicStats & {
toolUsage?: Array<{
name: string;
count: number;
success: number;
failure: number;
lastError?: string;
averageDurationMs?: number;
}>;
},
): string[] {
const tips: string[] = [];
const totalCalls = stats.totalToolCalls;
const sr =
stats.totalToolCalls > 0
? (stats.successRate ??
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
: 0;
// High failure rate
if (sr < 80)
tips.push('Low tool success rate - review inputs and error messages');
// Long duration
if (stats.totalDurationMs > 60_000)
tips.push('Long execution time - consider breaking down complex tasks');
// Token usage
if (typeof stats.totalTokens === 'number' && stats.totalTokens > 100_000) {
tips.push(
'High token usage - consider optimizing prompts or narrowing scope',
);
}
if (typeof stats.totalTokens === 'number' && totalCalls > 0) {
const avgTokPerCall = stats.totalTokens / totalCalls;
if (avgTokPerCall > 5_000)
tips.push(
`High token usage per tool call (~${Math.round(avgTokPerCall)} tokens/call)`,
);
}
// Network failures
const isNetworkTool = (name: string) => /web|fetch|search/i.test(name);
const hadNetworkFailure = (stats.toolUsage || []).some(
(t) =>
isNetworkTool(t.name) &&
t.lastError &&
/timeout|network/i.test(t.lastError),
);
if (hadNetworkFailure)
tips.push(
'Network operations had failures - consider increasing timeout or checking connectivity',
);
// Slow tools
const slow = (stats.toolUsage || [])
.filter((t) => (t.averageDurationMs ?? 0) > 10_000)
.sort((a, b) => (b.averageDurationMs ?? 0) - (a.averageDurationMs ?? 0));
if (slow.length)
tips.push(
`Consider optimizing ${slow[0].name} operations (avg ${fmtDuration(Math.round(slow[0].averageDurationMs!))})`,
);
return tips;
}

View File

@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface ToolUsageStats {
name: string;
count: number;
success: number;
failure: number;
lastError?: string;
totalDurationMs: number;
averageDurationMs: number;
}
export interface SubagentSummary {
rounds: number;
totalDurationMs: number;
totalToolCalls: number;
successfulToolCalls: number;
failedToolCalls: number;
successRate: number;
inputTokens: number;
outputTokens: number;
totalTokens: number;
estimatedCost: number;
toolUsage: ToolUsageStats[];
}
export class SubagentStatistics {
private startTimeMs = 0;
private rounds = 0;
private totalToolCalls = 0;
private successfulToolCalls = 0;
private failedToolCalls = 0;
private inputTokens = 0;
private outputTokens = 0;
private toolUsage = new Map<string, ToolUsageStats>();
start(now = Date.now()) {
this.startTimeMs = now;
}
setRounds(rounds: number) {
this.rounds = rounds;
}
recordToolCall(
name: string,
success: boolean,
durationMs: number,
lastError?: string,
) {
this.totalToolCalls += 1;
if (success) this.successfulToolCalls += 1;
else this.failedToolCalls += 1;
const tu = this.toolUsage.get(name) || {
name,
count: 0,
success: 0,
failure: 0,
lastError: undefined,
totalDurationMs: 0,
averageDurationMs: 0,
};
tu.count += 1;
if (success) tu.success += 1;
else tu.failure += 1;
if (lastError) tu.lastError = lastError;
tu.totalDurationMs += Math.max(0, durationMs || 0);
tu.averageDurationMs = tu.count > 0 ? tu.totalDurationMs / tu.count : 0;
this.toolUsage.set(name, tu);
}
recordTokens(input: number, output: number) {
this.inputTokens += Math.max(0, input || 0);
this.outputTokens += Math.max(0, output || 0);
}
getSummary(now = Date.now()): SubagentSummary {
const totalDurationMs = this.startTimeMs ? now - this.startTimeMs : 0;
const totalToolCalls = this.totalToolCalls;
const successRate =
totalToolCalls > 0
? (this.successfulToolCalls / totalToolCalls) * 100
: 0;
const totalTokens = this.inputTokens + this.outputTokens;
const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
return {
rounds: this.rounds,
totalDurationMs,
totalToolCalls,
successfulToolCalls: this.successfulToolCalls,
failedToolCalls: this.failedToolCalls,
successRate,
inputTokens: this.inputTokens,
outputTokens: this.outputTokens,
totalTokens,
estimatedCost,
toolUsage: Array.from(this.toolUsage.values()),
};
}
}

View File

@@ -0,0 +1,721 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock, afterEach } from 'vitest';
import {
ContextState,
SubAgentScope,
SubagentTerminateMode,
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from './subagent.js';
import { Config, ConfigParameters } from '../config/config.js';
import { GeminiChat } from '../core/geminiChat.js';
import { createContentGenerator } from '../core/contentGenerator.js';
import { getEnvironmentContext } from '../utils/environmentContext.js';
import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
import { ToolRegistry } from '../tools/tool-registry.js';
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
import {
Content,
FunctionCall,
FunctionDeclaration,
GenerateContentConfig,
Type,
} from '@google/genai';
import { ToolErrorType } from '../tools/tool-error.js';
vi.mock('../core/geminiChat.js');
vi.mock('../core/contentGenerator.js');
vi.mock('../utils/environmentContext.js');
vi.mock('../core/nonInteractiveToolExecutor.js');
vi.mock('../ide/ide-client.js');
async function createMockConfig(
toolRegistryMocks = {},
): Promise<{ config: Config; toolRegistry: ToolRegistry }> {
const configParams: ConfigParameters = {
sessionId: 'test-session',
model: DEFAULT_GEMINI_MODEL,
targetDir: '.',
debugMode: false,
cwd: process.cwd(),
};
const config = new Config(configParams);
await config.initialize();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await config.refreshAuth('test-auth' as any);
// Mock ToolRegistry
const mockToolRegistry = {
getTool: vi.fn(),
getFunctionDeclarations: vi.fn().mockReturnValue([]),
getFunctionDeclarationsFiltered: vi.fn().mockReturnValue([]),
...toolRegistryMocks,
} as unknown as ToolRegistry;
vi.spyOn(config, 'getToolRegistry').mockReturnValue(mockToolRegistry);
return { config, toolRegistry: mockToolRegistry };
}
// Helper to simulate LLM responses (sequence of tool calls over multiple turns)
const createMockStream = (
functionCallsList: Array<FunctionCall[] | 'stop'>,
) => {
let index = 0;
return vi.fn().mockImplementation(() => {
const response = functionCallsList[index] || 'stop';
index++;
return (async function* () {
if (response === 'stop') {
// When stopping, the model might return text, but the subagent logic primarily cares about the absence of functionCalls.
yield {
candidates: [
{
content: {
parts: [{ text: 'Done.' }],
},
},
],
};
} else if (response.length > 0) {
yield { functionCalls: response };
} else {
yield {
candidates: [
{
content: {
parts: [{ text: 'Done.' }],
},
},
],
}; // Handle empty array also as stop
}
})();
});
};
describe('subagent.ts', () => {
describe('ContextState', () => {
it('should set and get values correctly', () => {
const context = new ContextState();
context.set('key1', 'value1');
context.set('key2', 123);
expect(context.get('key1')).toBe('value1');
expect(context.get('key2')).toBe(123);
expect(context.get_keys()).toEqual(['key1', 'key2']);
});
it('should return undefined for missing keys', () => {
const context = new ContextState();
expect(context.get('missing')).toBeUndefined();
});
});
describe('SubAgentScope', () => {
let mockSendMessageStream: Mock;
const defaultModelConfig: ModelConfig = {
model: 'gemini-1.5-flash-latest',
temp: 0.5, // Specific temp to test override
top_p: 1,
};
const defaultRunConfig: RunConfig = {
max_time_minutes: 5,
max_turns: 10,
};
beforeEach(async () => {
vi.clearAllMocks();
vi.mocked(getEnvironmentContext).mockResolvedValue([
{ text: 'Env Context' },
]);
vi.mocked(createContentGenerator).mockResolvedValue({
getGenerativeModel: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
mockSendMessageStream = vi.fn();
// We mock the implementation of the constructor.
vi.mocked(GeminiChat).mockImplementation(
() =>
({
sendMessageStream: mockSendMessageStream,
}) as unknown as GeminiChat,
);
// Default mock for executeToolCall
vi.mocked(executeToolCall).mockResolvedValue({
callId: 'default-call',
responseParts: 'default response',
resultDisplay: 'Default tool result',
error: undefined,
errorType: undefined,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
// Helper to safely access generationConfig from mock calls
const getGenerationConfigFromMock = (
callIndex = 0,
): GenerateContentConfig & { systemInstruction?: string | Content } => {
const callArgs = vi.mocked(GeminiChat).mock.calls[callIndex];
const generationConfig = callArgs?.[2];
// Ensure it's defined before proceeding
expect(generationConfig).toBeDefined();
if (!generationConfig) throw new Error('generationConfig is undefined');
return generationConfig as GenerateContentConfig & {
systemInstruction?: string | Content;
};
};
describe('create (Tool Validation)', () => {
const promptConfig: PromptConfig = { systemPrompt: 'Test prompt' };
it('should create a SubAgentScope successfully with minimal config', async () => {
const { config } = await createMockConfig();
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
);
expect(scope).toBeInstanceOf(SubAgentScope);
});
it('should throw an error if a tool requires confirmation', async () => {
const mockTool = {
schema: { parameters: { type: Type.OBJECT, properties: {} } },
build: vi.fn().mockReturnValue({
shouldConfirmExecute: vi.fn().mockResolvedValue({
type: 'exec',
title: 'Confirm',
command: 'rm -rf /',
}),
}),
};
const { config } = await createMockConfig({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTool: vi.fn().mockReturnValue(mockTool as any),
});
const toolConfig: ToolConfig = { tools: ['risky_tool'] };
await expect(
SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
toolConfig,
),
).rejects.toThrow(
'Tool "risky_tool" requires user confirmation and cannot be used in a non-interactive subagent.',
);
});
it('should succeed if tools do not require confirmation', async () => {
const mockTool = {
schema: { parameters: { type: Type.OBJECT, properties: {} } },
build: vi.fn().mockReturnValue({
shouldConfirmExecute: vi.fn().mockResolvedValue(null),
}),
};
const { config } = await createMockConfig({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTool: vi.fn().mockReturnValue(mockTool as any),
});
const toolConfig: ToolConfig = { tools: ['safe_tool'] };
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
toolConfig,
);
expect(scope).toBeInstanceOf(SubAgentScope);
});
it('should skip interactivity check and warn for tools with required parameters', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {});
const mockToolWithParams = {
schema: {
parameters: {
type: Type.OBJECT,
properties: {
path: { type: Type.STRING },
},
required: ['path'],
},
},
// build should not be called, but we mock it to be safe
build: vi.fn(),
};
const { config } = await createMockConfig({
getTool: vi.fn().mockReturnValue(mockToolWithParams),
});
const toolConfig: ToolConfig = { tools: ['tool_with_params'] };
// The creation should succeed without throwing
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
toolConfig,
);
expect(scope).toBeInstanceOf(SubAgentScope);
// Check that the warning was logged
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Cannot check tool "tool_with_params" for interactivity because it requires parameters. Assuming it is safe for non-interactive use.',
);
// Ensure build was never called
expect(mockToolWithParams.build).not.toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
});
describe('runNonInteractive - Initialization and Prompting', () => {
it('should correctly template the system prompt and initialize GeminiChat', async () => {
const { config } = await createMockConfig();
vi.mocked(GeminiChat).mockClear();
const promptConfig: PromptConfig = {
systemPrompt: 'Hello ${name}, your task is ${task}.',
};
const context = new ContextState();
context.set('name', 'Agent');
context.set('task', 'Testing');
// Model stops immediately
mockSendMessageStream.mockImplementation(createMockStream(['stop']));
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
);
await scope.runNonInteractive(context);
// Check if GeminiChat was initialized correctly by the subagent
expect(GeminiChat).toHaveBeenCalledTimes(1);
const callArgs = vi.mocked(GeminiChat).mock.calls[0];
// Check Generation Config
const generationConfig = getGenerationConfigFromMock();
// Check temperature override
expect(generationConfig.temperature).toBe(defaultModelConfig.temp);
expect(generationConfig.systemInstruction).toContain(
'Hello Agent, your task is Testing.',
);
expect(generationConfig.systemInstruction).toContain(
'Important Rules:',
);
// Check History (should include environment context)
const history = callArgs[3];
expect(history).toEqual([
{ role: 'user', parts: [{ text: 'Env Context' }] },
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the context!' }],
},
]);
});
it('should use initialMessages instead of systemPrompt if provided', async () => {
const { config } = await createMockConfig();
vi.mocked(GeminiChat).mockClear();
const initialMessages: Content[] = [
{ role: 'user', parts: [{ text: 'Hi' }] },
];
const promptConfig: PromptConfig = { initialMessages };
const context = new ContextState();
// Model stops immediately
mockSendMessageStream.mockImplementation(createMockStream(['stop']));
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
);
await scope.runNonInteractive(context);
const callArgs = vi.mocked(GeminiChat).mock.calls[0];
const generationConfig = getGenerationConfigFromMock();
const history = callArgs[3];
expect(generationConfig.systemInstruction).toBeUndefined();
expect(history).toEqual([
{ role: 'user', parts: [{ text: 'Env Context' }] },
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the context!' }],
},
...initialMessages,
]);
});
it('should throw an error if template variables are missing', async () => {
const { config } = await createMockConfig();
const promptConfig: PromptConfig = {
systemPrompt: 'Hello ${name}, you are missing ${missing}.',
};
const context = new ContextState();
context.set('name', 'Agent');
// 'missing' is not set
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
);
// The error from templating causes the runNonInteractive to reject and the terminate_reason to be ERROR.
await expect(scope.runNonInteractive(context)).rejects.toThrow(
'Missing context values for the following keys: missing',
);
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.ERROR);
});
it('should validate that systemPrompt and initialMessages are mutually exclusive', async () => {
const { config } = await createMockConfig();
const promptConfig: PromptConfig = {
systemPrompt: 'System',
initialMessages: [{ role: 'user', parts: [{ text: 'Hi' }] }],
};
const context = new ContextState();
const agent = await SubAgentScope.create(
'TestAgent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
);
await expect(agent.runNonInteractive(context)).rejects.toThrow(
'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.',
);
expect(agent.output.terminate_reason).toBe(SubagentTerminateMode.ERROR);
});
});
describe('runNonInteractive - Execution and Tool Use', () => {
const promptConfig: PromptConfig = { systemPrompt: 'Execute task.' };
it('should terminate with GOAL if no outputs are expected and model stops', async () => {
const { config } = await createMockConfig();
// Model stops immediately
mockSendMessageStream.mockImplementation(createMockStream(['stop']));
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
// No ToolConfig, No OutputConfig
);
await scope.runNonInteractive(new ContextState());
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL);
expect(scope.output.result).toBe('Done.');
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
// Check the initial message
expect(mockSendMessageStream.mock.calls[0][0].message).toEqual([
{ text: 'Get Started!' },
]);
});
it('should terminate with GOAL when model provides final text', async () => {
const { config } = await createMockConfig();
// Model stops immediately with text response
mockSendMessageStream.mockImplementation(createMockStream(['stop']));
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
);
await scope.runNonInteractive(new ContextState());
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL);
expect(scope.output.result).toBe('Done.');
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});
it('should execute external tools and provide the response to the model', async () => {
const listFilesToolDef: FunctionDeclaration = {
name: 'list_files',
description: 'Lists files',
parameters: { type: Type.OBJECT, properties: {} },
};
const { config } = await createMockConfig({
getFunctionDeclarationsFiltered: vi
.fn()
.mockReturnValue([listFilesToolDef]),
});
const toolConfig: ToolConfig = { tools: ['list_files'] };
// Turn 1: Model calls the external tool
// Turn 2: Model stops
mockSendMessageStream.mockImplementation(
createMockStream([
[
{
id: 'call_1',
name: 'list_files',
args: { path: '.' },
},
],
'stop',
]),
);
// Mock the tool execution result
vi.mocked(executeToolCall).mockResolvedValue({
callId: 'call_1',
responseParts: 'file1.txt\nfile2.ts',
resultDisplay: 'Listed 2 files',
error: undefined,
errorType: undefined, // Or ToolErrorType.NONE if available and appropriate
});
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
toolConfig,
);
await scope.runNonInteractive(new ContextState());
// Check tool execution
expect(executeToolCall).toHaveBeenCalledWith(
config,
expect.objectContaining({ name: 'list_files', args: { path: '.' } }),
expect.any(AbortSignal),
);
// Check the response sent back to the model
const secondCallArgs = mockSendMessageStream.mock.calls[1][0];
expect(secondCallArgs.message).toEqual([
{ text: 'file1.txt\nfile2.ts' },
]);
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL);
});
it('should provide specific tool error responses to the model', async () => {
const { config } = await createMockConfig();
const toolConfig: ToolConfig = { tools: ['failing_tool'] };
// Turn 1: Model calls the failing tool
// Turn 2: Model stops after receiving the error response
mockSendMessageStream.mockImplementation(
createMockStream([
[
{
id: 'call_fail',
name: 'failing_tool',
args: {},
},
],
'stop',
]),
);
// Mock the tool execution failure.
vi.mocked(executeToolCall).mockResolvedValue({
callId: 'call_fail',
responseParts: 'ERROR: Tool failed catastrophically', // This should be sent to the model
resultDisplay: 'Tool failed catastrophically',
error: new Error('Failure'),
errorType: ToolErrorType.INVALID_TOOL_PARAMS,
});
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
toolConfig,
);
await scope.runNonInteractive(new ContextState());
// The agent should send the specific error message from responseParts.
const secondCallArgs = mockSendMessageStream.mock.calls[1][0];
expect(secondCallArgs.message).toEqual([
{
text: 'ERROR: Tool failed catastrophically',
},
]);
});
});
describe('runNonInteractive - Termination and Recovery', () => {
const promptConfig: PromptConfig = { systemPrompt: 'Execute task.' };
it('should terminate with MAX_TURNS if the limit is reached', async () => {
const { config } = await createMockConfig();
const runConfig: RunConfig = { ...defaultRunConfig, max_turns: 2 };
// Model keeps calling tools repeatedly
mockSendMessageStream.mockImplementation(
createMockStream([
[
{
name: 'list_files',
args: { path: '/test' },
},
],
[
{
name: 'list_files',
args: { path: '/test2' },
},
],
// This turn should not happen
[
{
name: 'list_files',
args: { path: '/test3' },
},
],
]),
);
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
runConfig,
);
await scope.runNonInteractive(new ContextState());
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
expect(scope.output.terminate_reason).toBe(
SubagentTerminateMode.MAX_TURNS,
);
});
it('should terminate with TIMEOUT if the time limit is reached during an LLM call', async () => {
// Use fake timers to reliably test timeouts
vi.useFakeTimers();
const { config } = await createMockConfig();
const runConfig: RunConfig = { max_time_minutes: 5, max_turns: 100 };
// We need to control the resolution of the sendMessageStream promise to advance the timer during execution.
let resolveStream: (
value: AsyncGenerator<unknown, void, unknown>,
) => void;
const streamPromise = new Promise<
AsyncGenerator<unknown, void, unknown>
>((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolveStream = resolve as any;
});
// The LLM call will hang until we resolve the promise.
mockSendMessageStream.mockReturnValue(streamPromise);
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
runConfig,
);
const runPromise = scope.runNonInteractive(new ContextState());
// Advance time beyond the limit (6 minutes) while the agent is awaiting the LLM response.
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
// Now resolve the stream. The model returns 'stop'.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolveStream!(createMockStream(['stop'])() as any);
await runPromise;
expect(scope.output.terminate_reason).toBe(
SubagentTerminateMode.TIMEOUT,
);
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('should terminate with ERROR if the model call throws', async () => {
const { config } = await createMockConfig();
mockSendMessageStream.mockRejectedValue(new Error('API Failure'));
const scope = await SubAgentScope.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
);
await expect(
scope.runNonInteractive(new ContextState()),
).rejects.toThrow('API Failure');
expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.ERROR);
});
});
});
});

View 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;
}
}

View File

@@ -9,7 +9,7 @@ import {
ModelConfig,
RunConfig,
ToolConfig,
} from '../core/subagent.js';
} from './subagent.js';
/**
* Represents the storage level for a subagent configuration.

View File

@@ -4,21 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { SubagentValidator } from './validation.js';
import { SubagentConfig, SubagentError } from './types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
describe('SubagentValidator', () => {
let validator: SubagentValidator;
let mockToolRegistry: ToolRegistry;
beforeEach(() => {
mockToolRegistry = {
getTool: vi.fn(),
} as unknown as ToolRegistry;
validator = new SubagentValidator(mockToolRegistry);
validator = new SubagentValidator();
});
describe('validateName', () => {
@@ -191,9 +185,6 @@ describe('SubagentValidator', () => {
describe('validateTools', () => {
it('should accept valid tool arrays', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(mockToolRegistry.getTool).mockReturnValue({} as any);
const result = validator.validateTools(['read_file', 'write_file']);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
@@ -215,9 +206,6 @@ describe('SubagentValidator', () => {
});
it('should warn about duplicate tools', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(mockToolRegistry.getTool).mockReturnValue({} as any);
const result = validator.validateTools([
'read_file',
'read_file',
@@ -243,16 +231,6 @@ describe('SubagentValidator', () => {
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Tool name cannot be empty');
});
it('should reject unknown tools when registry is available', () => {
vi.mocked(mockToolRegistry.getTool).mockReturnValue(undefined);
const result = validator.validateTools(['unknown_tool']);
expect(result.isValid).toBe(false);
expect(result.errors).toContain(
'Tool "unknown_tool" not found in tool registry',
);
});
});
describe('validateModelConfig', () => {

View File

@@ -10,15 +10,12 @@ import {
SubagentError,
SubagentErrorCode,
} from './types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
/**
* Validates subagent configurations to ensure they are well-formed
* and compatible with the runtime system.
*/
export class SubagentValidator {
constructor(private readonly toolRegistry?: ToolRegistry) {}
/**
* Validates a complete subagent configuration.
*
@@ -238,14 +235,6 @@ export class SubagentValidator {
errors.push('Tool name cannot be empty');
continue;
}
// Check if tool exists in registry (if available)
if (this.toolRegistry) {
const toolInstance = this.toolRegistry.getTool(tool);
if (!toolInstance) {
errors.push(`Tool "${tool}" not found in tool registry`);
}
}
}
return {
@@ -262,7 +251,7 @@ export class SubagentValidator {
* @returns ValidationResult
*/
validateModelConfig(
modelConfig: Partial<import('../core/subagent.js').ModelConfig>,
modelConfig: Partial<import('./subagent.js').ModelConfig>,
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
@@ -310,7 +299,7 @@ export class SubagentValidator {
* @returns ValidationResult
*/
validateRunConfig(
runConfig: Partial<import('../core/subagent.js').RunConfig>,
runConfig: Partial<import('./subagent.js').RunConfig>,
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];