mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge branch 'main' into chore/sync-gemini-cli-v0.3.4
This commit is contained in:
95
packages/core/src/subagents/builtin-agents.test.ts
Normal file
95
packages/core/src/subagents/builtin-agents.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BuiltinAgentRegistry } from './builtin-agents.js';
|
||||
|
||||
describe('BuiltinAgentRegistry', () => {
|
||||
describe('getBuiltinAgents', () => {
|
||||
it('should return array of builtin agents with correct properties', () => {
|
||||
const agents = BuiltinAgentRegistry.getBuiltinAgents();
|
||||
|
||||
expect(agents).toBeInstanceOf(Array);
|
||||
expect(agents.length).toBeGreaterThan(0);
|
||||
|
||||
agents.forEach((agent) => {
|
||||
expect(agent).toMatchObject({
|
||||
name: expect.any(String),
|
||||
description: expect.any(String),
|
||||
systemPrompt: expect.any(String),
|
||||
level: 'builtin',
|
||||
filePath: `<builtin:${agent.name}>`,
|
||||
isBuiltin: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should include general-purpose agent', () => {
|
||||
const agents = BuiltinAgentRegistry.getBuiltinAgents();
|
||||
const generalAgent = agents.find(
|
||||
(agent) => agent.name === 'general-purpose',
|
||||
);
|
||||
|
||||
expect(generalAgent).toBeDefined();
|
||||
expect(generalAgent?.description).toContain('General-purpose agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuiltinAgent', () => {
|
||||
it('should return correct agent for valid name', () => {
|
||||
const agent = BuiltinAgentRegistry.getBuiltinAgent('general-purpose');
|
||||
|
||||
expect(agent).toMatchObject({
|
||||
name: 'general-purpose',
|
||||
level: 'builtin',
|
||||
filePath: '<builtin:general-purpose>',
|
||||
isBuiltin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for invalid name', () => {
|
||||
expect(BuiltinAgentRegistry.getBuiltinAgent('invalid')).toBeNull();
|
||||
expect(BuiltinAgentRegistry.getBuiltinAgent('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBuiltinAgent', () => {
|
||||
it('should return true for valid builtin agent names', () => {
|
||||
expect(BuiltinAgentRegistry.isBuiltinAgent('general-purpose')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid names', () => {
|
||||
expect(BuiltinAgentRegistry.isBuiltinAgent('invalid')).toBe(false);
|
||||
expect(BuiltinAgentRegistry.isBuiltinAgent('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuiltinAgentNames', () => {
|
||||
it('should return array of agent names', () => {
|
||||
const names = BuiltinAgentRegistry.getBuiltinAgentNames();
|
||||
|
||||
expect(names).toBeInstanceOf(Array);
|
||||
expect(names).toContain('general-purpose');
|
||||
expect(names.every((name) => typeof name === 'string')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistency', () => {
|
||||
it('should maintain consistency across all methods', () => {
|
||||
const agents = BuiltinAgentRegistry.getBuiltinAgents();
|
||||
const names = BuiltinAgentRegistry.getBuiltinAgentNames();
|
||||
|
||||
// Names should match agents
|
||||
expect(names).toEqual(agents.map((agent) => agent.name));
|
||||
|
||||
// Each name should be valid
|
||||
names.forEach((name) => {
|
||||
expect(BuiltinAgentRegistry.isBuiltinAgent(name)).toBe(true);
|
||||
expect(BuiltinAgentRegistry.getBuiltinAgent(name)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
95
packages/core/src/subagents/builtin-agents.ts
Normal file
95
packages/core/src/subagents/builtin-agents.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SubagentConfig } from './types.js';
|
||||
|
||||
/**
|
||||
* Registry of built-in subagents that are always available to all users.
|
||||
* These agents are embedded in the codebase and cannot be modified or deleted.
|
||||
*/
|
||||
export class BuiltinAgentRegistry {
|
||||
private static readonly BUILTIN_AGENTS: Array<
|
||||
Omit<SubagentConfig, 'level' | 'filePath'>
|
||||
> = [
|
||||
{
|
||||
name: 'general-purpose',
|
||||
description:
|
||||
'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.',
|
||||
systemPrompt: `You are a general-purpose research and code analysis agent. Given the user's message, you should use the tools available to complete the task. Do what has been asked; nothing more, nothing less. When you complete the task simply respond with a detailed writeup.
|
||||
|
||||
Your strengths:
|
||||
- Searching for code, configurations, and patterns across large codebases
|
||||
- Analyzing multiple files to understand system architecture
|
||||
- Investigating complex questions that require exploring many files
|
||||
- Performing multi-step research tasks
|
||||
|
||||
Guidelines:
|
||||
- For file searches: Use Grep or Glob when you need to search broadly. Use Read when you know the specific file path.
|
||||
- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
|
||||
- Be thorough: Check multiple locations, consider different naming conventions, look for related files.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.
|
||||
- In your final response always share relevant file names and code snippets. Any file paths you return in your response MUST be absolute. Do NOT use relative paths.
|
||||
- For clear communication, avoid using emojis.
|
||||
|
||||
|
||||
Notes:
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- In your final response always share relevant file names and code snippets. Any file paths you return in your response MUST be absolute. Do NOT use relative paths.
|
||||
- For clear communication with the user the assistant MUST avoid using emojis.`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets all built-in agent configurations.
|
||||
* @returns Array of built-in subagent configurations
|
||||
*/
|
||||
static getBuiltinAgents(): SubagentConfig[] {
|
||||
return this.BUILTIN_AGENTS.map((agent) => ({
|
||||
...agent,
|
||||
level: 'builtin' as const,
|
||||
filePath: `<builtin:${agent.name}>`,
|
||||
isBuiltin: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific built-in agent by name.
|
||||
* @param name - Name of the built-in agent
|
||||
* @returns Built-in agent configuration or null if not found
|
||||
*/
|
||||
static getBuiltinAgent(name: string): SubagentConfig | null {
|
||||
const agent = this.BUILTIN_AGENTS.find((a) => a.name === name);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
level: 'builtin' as const,
|
||||
filePath: `<builtin:${name}>`,
|
||||
isBuiltin: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an agent name corresponds to a built-in agent.
|
||||
* @param name - Agent name to check
|
||||
* @returns True if the name is a built-in agent
|
||||
*/
|
||||
static isBuiltinAgent(name: string): boolean {
|
||||
return this.BUILTIN_AGENTS.some((agent) => agent.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the names of all built-in agents.
|
||||
* @returns Array of built-in agent names
|
||||
*/
|
||||
static getBuiltinAgentNames(): string[] {
|
||||
return this.BUILTIN_AGENTS.map((agent) => agent.name);
|
||||
}
|
||||
}
|
||||
73
packages/core/src/subagents/index.ts
Normal file
73
packages/core/src/subagents/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Subagents Phase 1 implementation - File-based configuration layer
|
||||
*
|
||||
* This module provides the foundation for the subagents feature by implementing
|
||||
* a file-based configuration system that builds on the existing SubAgentScope
|
||||
* runtime system. It includes:
|
||||
*
|
||||
* - Type definitions for file-based subagent configurations
|
||||
* - Validation system for configuration integrity
|
||||
* - Runtime conversion functions integrated into the manager
|
||||
* - Manager class for CRUD operations on subagent files
|
||||
*
|
||||
* The implementation follows the Markdown + YAML frontmatter format , with storage at both project and user levels.
|
||||
*/
|
||||
|
||||
// Core types and interfaces
|
||||
export type {
|
||||
SubagentConfig,
|
||||
SubagentLevel,
|
||||
SubagentRuntimeConfig,
|
||||
ValidationResult,
|
||||
ListSubagentsOptions,
|
||||
CreateSubagentOptions,
|
||||
SubagentErrorCode,
|
||||
} from './types.js';
|
||||
|
||||
export { SubagentError } from './types.js';
|
||||
|
||||
// Built-in agents registry
|
||||
export { BuiltinAgentRegistry } from './builtin-agents.js';
|
||||
|
||||
// Validation system
|
||||
export { SubagentValidator } from './validation.js';
|
||||
|
||||
// Main management class
|
||||
export { SubagentManager } from './subagent-manager.js';
|
||||
|
||||
// Re-export existing runtime types for convenience
|
||||
export type {
|
||||
PromptConfig,
|
||||
ModelConfig,
|
||||
RunConfig,
|
||||
ToolConfig,
|
||||
SubagentTerminateMode,
|
||||
} from './types.js';
|
||||
|
||||
export { SubAgentScope } from './subagent.js';
|
||||
|
||||
// Event system for UI integration
|
||||
export type {
|
||||
SubAgentEvent,
|
||||
SubAgentStartEvent,
|
||||
SubAgentRoundEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentFinishEvent,
|
||||
SubAgentErrorEvent,
|
||||
} from './subagent-events.js';
|
||||
|
||||
export { SubAgentEventEmitter } from './subagent-events.js';
|
||||
|
||||
// Statistics and formatting
|
||||
export type {
|
||||
SubagentStatsSummary,
|
||||
ToolUsageStats,
|
||||
} from './subagent-statistics.js';
|
||||
130
packages/core/src/subagents/subagent-events.ts
Normal file
130
packages/core/src/subagents/subagent-events.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
} from '../tools/tools.js';
|
||||
|
||||
export type SubAgentEvent =
|
||||
| 'start'
|
||||
| 'round_start'
|
||||
| 'round_end'
|
||||
| 'stream_text'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'tool_waiting_approval'
|
||||
| 'finish'
|
||||
| 'error';
|
||||
|
||||
export enum SubAgentEventType {
|
||||
START = 'start',
|
||||
ROUND_START = 'round_start',
|
||||
ROUND_END = 'round_end',
|
||||
STREAM_TEXT = 'stream_text',
|
||||
TOOL_CALL = 'tool_call',
|
||||
TOOL_RESULT = 'tool_result',
|
||||
TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
|
||||
FINISH = 'finish',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
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 SubAgentStreamTextEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentToolCallEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
callId: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
description: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentToolResultEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
callId: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
resultDisplay?: string;
|
||||
durationMs?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentApprovalRequestEvent {
|
||||
subagentId: string;
|
||||
round: number;
|
||||
callId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
confirmationDetails: Omit<ToolCallConfirmationDetails, 'onConfirm'> & {
|
||||
type: ToolCallConfirmationDetails['type'];
|
||||
};
|
||||
respond: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: Parameters<ToolCallConfirmationDetails['onConfirm']>[1],
|
||||
) => Promise<void>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SubAgentFinishEvent {
|
||||
subagentId: string;
|
||||
terminateReason: string;
|
||||
timestamp: number;
|
||||
rounds?: number;
|
||||
totalDurationMs?: number;
|
||||
totalToolCalls?: number;
|
||||
successfulToolCalls?: number;
|
||||
failedToolCalls?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
export interface SubAgentErrorEvent {
|
||||
subagentId: string;
|
||||
error: string;
|
||||
timestamp: 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);
|
||||
}
|
||||
}
|
||||
33
packages/core/src/subagents/subagent-hooks.ts
Normal file
33
packages/core/src/subagents/subagent-hooks.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* 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;
|
||||
}
|
||||
1139
packages/core/src/subagents/subagent-manager.test.ts
Normal file
1139
packages/core/src/subagents/subagent-manager.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
760
packages/core/src/subagents/subagent-manager.ts
Normal file
760
packages/core/src/subagents/subagent-manager.ts
Normal file
@@ -0,0 +1,760 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
// Note: yaml package would need to be added as a dependency
|
||||
// For now, we'll use a simple YAML parser implementation
|
||||
import {
|
||||
parse as parseYaml,
|
||||
stringify as stringifyYaml,
|
||||
} from '../utils/yaml-parser.js';
|
||||
import {
|
||||
SubagentConfig,
|
||||
SubagentRuntimeConfig,
|
||||
SubagentLevel,
|
||||
ListSubagentsOptions,
|
||||
CreateSubagentOptions,
|
||||
SubagentError,
|
||||
SubagentErrorCode,
|
||||
PromptConfig,
|
||||
ModelConfig,
|
||||
RunConfig,
|
||||
ToolConfig,
|
||||
} from './types.js';
|
||||
import { SubagentValidator } from './validation.js';
|
||||
import { SubAgentScope } from './subagent.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { BuiltinAgentRegistry } from './builtin-agents.js';
|
||||
|
||||
const QWEN_CONFIG_DIR = '.qwen';
|
||||
const AGENT_CONFIG_DIR = 'agents';
|
||||
|
||||
/**
|
||||
* Manages subagent configurations stored as Markdown files with YAML frontmatter.
|
||||
* Provides CRUD operations, validation, and integration with the runtime system.
|
||||
*/
|
||||
export class SubagentManager {
|
||||
private readonly validator: SubagentValidator;
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
this.validator = new SubagentValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new subagent configuration.
|
||||
*
|
||||
* @param config - Subagent configuration to create
|
||||
* @param options - Creation options
|
||||
* @throws SubagentError if creation fails
|
||||
*/
|
||||
async createSubagent(
|
||||
config: SubagentConfig,
|
||||
options: CreateSubagentOptions,
|
||||
): Promise<void> {
|
||||
this.validator.validateOrThrow(config);
|
||||
|
||||
// Determine file path
|
||||
const filePath =
|
||||
options.customPath || this.getSubagentPath(config.name, options.level);
|
||||
|
||||
// Check if file already exists
|
||||
if (!options.overwrite) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
throw new SubagentError(
|
||||
`Subagent "${config.name}" already exists at ${filePath}`,
|
||||
SubagentErrorCode.ALREADY_EXISTS,
|
||||
config.name,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SubagentError) throw error;
|
||||
// File doesn't exist, which is what we want
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
// Update config with actual file path and level
|
||||
const finalConfig: SubagentConfig = {
|
||||
...config,
|
||||
level: options.level,
|
||||
filePath,
|
||||
};
|
||||
|
||||
// Serialize and write the file
|
||||
const content = this.serializeSubagent(finalConfig);
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
} catch (error) {
|
||||
throw new SubagentError(
|
||||
`Failed to write subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
SubagentErrorCode.FILE_ERROR,
|
||||
config.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a subagent configuration by name.
|
||||
* If level is specified, only searches that level.
|
||||
* If level is omitted, searches project-level first, then user-level, then built-in.
|
||||
*
|
||||
* @param name - Name of the subagent to load
|
||||
* @param level - Optional level to limit search to specific level
|
||||
* @returns SubagentConfig or null if not found
|
||||
*/
|
||||
async loadSubagent(
|
||||
name: string,
|
||||
level?: SubagentLevel,
|
||||
): Promise<SubagentConfig | null> {
|
||||
if (level) {
|
||||
// Search only the specified level
|
||||
if (level === 'builtin') {
|
||||
return BuiltinAgentRegistry.getBuiltinAgent(name);
|
||||
}
|
||||
|
||||
return this.findSubagentByNameAtLevel(name, level);
|
||||
}
|
||||
|
||||
// Try project level first
|
||||
const projectConfig = await this.findSubagentByNameAtLevel(name, 'project');
|
||||
if (projectConfig) {
|
||||
return projectConfig;
|
||||
}
|
||||
|
||||
// Try user level
|
||||
const userConfig = await this.findSubagentByNameAtLevel(name, 'user');
|
||||
if (userConfig) {
|
||||
return userConfig;
|
||||
}
|
||||
|
||||
// Try built-in agents as fallback
|
||||
return BuiltinAgentRegistry.getBuiltinAgent(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing subagent configuration.
|
||||
*
|
||||
* @param name - Name of the subagent to update
|
||||
* @param updates - Partial configuration updates
|
||||
* @throws SubagentError if subagent not found or update fails
|
||||
*/
|
||||
async updateSubagent(
|
||||
name: string,
|
||||
updates: Partial<SubagentConfig>,
|
||||
level?: SubagentLevel,
|
||||
): Promise<void> {
|
||||
const existing = await this.loadSubagent(name, level);
|
||||
if (!existing) {
|
||||
throw new SubagentError(
|
||||
`Subagent "${name}" not found`,
|
||||
SubagentErrorCode.NOT_FOUND,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent updating built-in agents
|
||||
if (existing.isBuiltin) {
|
||||
throw new SubagentError(
|
||||
`Cannot update built-in subagent "${name}"`,
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
// Merge updates with existing configuration
|
||||
const updatedConfig = this.mergeConfigurations(existing, updates);
|
||||
|
||||
// Validate the updated configuration
|
||||
this.validator.validateOrThrow(updatedConfig);
|
||||
|
||||
// Write the updated configuration
|
||||
const content = this.serializeSubagent(updatedConfig);
|
||||
|
||||
try {
|
||||
await fs.writeFile(existing.filePath, content, 'utf8');
|
||||
} catch (error) {
|
||||
throw new SubagentError(
|
||||
`Failed to update subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
SubagentErrorCode.FILE_ERROR,
|
||||
name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a subagent configuration.
|
||||
*
|
||||
* @param name - Name of the subagent to delete
|
||||
* @param level - Specific level to delete from, or undefined to delete from both
|
||||
* @throws SubagentError if deletion fails
|
||||
*/
|
||||
async deleteSubagent(name: string, level?: SubagentLevel): Promise<void> {
|
||||
// Check if it's a built-in agent first
|
||||
if (BuiltinAgentRegistry.isBuiltinAgent(name)) {
|
||||
throw new SubagentError(
|
||||
`Cannot delete built-in subagent "${name}"`,
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
const levelsToCheck: SubagentLevel[] = level
|
||||
? [level]
|
||||
: ['project', 'user'];
|
||||
let deleted = false;
|
||||
|
||||
for (const currentLevel of levelsToCheck) {
|
||||
// Skip builtin level for deletion
|
||||
if (currentLevel === 'builtin') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the actual subagent file by scanning and parsing
|
||||
const config = await this.findSubagentByNameAtLevel(name, currentLevel);
|
||||
if (config && config.filePath) {
|
||||
try {
|
||||
await fs.unlink(config.filePath);
|
||||
deleted = true;
|
||||
} catch (_error) {
|
||||
// File might not exist or be accessible, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new SubagentError(
|
||||
`Subagent "${name}" not found`,
|
||||
SubagentErrorCode.NOT_FOUND,
|
||||
name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available subagents.
|
||||
*
|
||||
* @param options - Filtering and sorting options
|
||||
* @returns Array of subagent metadata
|
||||
*/
|
||||
async listSubagents(
|
||||
options: ListSubagentsOptions = {},
|
||||
): Promise<SubagentConfig[]> {
|
||||
const subagents: SubagentConfig[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
const levelsToCheck: SubagentLevel[] = options.level
|
||||
? [options.level]
|
||||
: ['project', 'user', 'builtin'];
|
||||
|
||||
// Collect subagents from each level (project takes precedence over user, user takes precedence over builtin)
|
||||
for (const level of levelsToCheck) {
|
||||
const levelSubagents = await this.listSubagentsAtLevel(level);
|
||||
|
||||
for (const subagent of levelSubagents) {
|
||||
// Skip if we've already seen this name (precedence: project > user > builtin)
|
||||
if (seenNames.has(subagent.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply tool filter if specified
|
||||
if (
|
||||
options.hasTool &&
|
||||
(!subagent.tools || !subagent.tools.includes(options.hasTool))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
subagents.push(subagent);
|
||||
seenNames.add(subagent.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort results
|
||||
if (options.sortBy) {
|
||||
subagents.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (options.sortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'level': {
|
||||
// Project comes before user, user comes before builtin
|
||||
const levelOrder = { project: 0, user: 1, builtin: 2 };
|
||||
comparison = levelOrder[a.level] - levelOrder[b.level];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
comparison = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return options.sortOrder === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
}
|
||||
|
||||
return subagents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a subagent by name and returns its metadata.
|
||||
*
|
||||
* @param name - Name of the subagent to find
|
||||
* @returns SubagentConfig or null if not found
|
||||
*/
|
||||
async findSubagentByName(
|
||||
name: string,
|
||||
level?: SubagentLevel,
|
||||
): Promise<SubagentConfig | null> {
|
||||
const config = await this.loadSubagent(name, level);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a subagent file and returns the configuration.
|
||||
*
|
||||
* @param filePath - Path to the subagent file
|
||||
* @returns SubagentConfig
|
||||
* @throws SubagentError if parsing fails
|
||||
*/
|
||||
async parseSubagentFile(filePath: string): Promise<SubagentConfig> {
|
||||
let content: string;
|
||||
|
||||
try {
|
||||
content = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
throw new SubagentError(
|
||||
`Failed to read subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
SubagentErrorCode.FILE_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return this.parseSubagentContent(content, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses subagent content from a string.
|
||||
*
|
||||
* @param content - File content
|
||||
* @param filePath - File path for error reporting
|
||||
* @returns SubagentConfig
|
||||
* @throws SubagentError if parsing fails
|
||||
*/
|
||||
parseSubagentContent(content: string, filePath: string): SubagentConfig {
|
||||
try {
|
||||
// Split frontmatter and content
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Invalid format: missing YAML frontmatter');
|
||||
}
|
||||
|
||||
const [, frontmatterYaml, systemPrompt] = match;
|
||||
|
||||
// Parse YAML frontmatter
|
||||
const frontmatter = parseYaml(frontmatterYaml) as Record<string, unknown>;
|
||||
|
||||
// Extract required fields and convert to strings
|
||||
const nameRaw = frontmatter['name'];
|
||||
const descriptionRaw = frontmatter['description'];
|
||||
|
||||
if (nameRaw == null || nameRaw === '') {
|
||||
throw new Error('Missing "name" in frontmatter');
|
||||
}
|
||||
|
||||
if (descriptionRaw == null || descriptionRaw === '') {
|
||||
throw new Error('Missing "description" in frontmatter');
|
||||
}
|
||||
|
||||
// Convert to strings (handles numbers, booleans, etc.)
|
||||
const name = String(nameRaw);
|
||||
const description = String(descriptionRaw);
|
||||
|
||||
// Extract optional fields
|
||||
const tools = frontmatter['tools'] as string[] | undefined;
|
||||
const modelConfig = frontmatter['modelConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const runConfig = frontmatter['runConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const color = frontmatter['color'] as string | undefined;
|
||||
|
||||
// Determine level from file path using robust, cross-platform check
|
||||
// A project-level agent lives under <projectRoot>/.qwen/agents
|
||||
const projectAgentsDir = path.join(
|
||||
this.config.getProjectRoot(),
|
||||
QWEN_CONFIG_DIR,
|
||||
AGENT_CONFIG_DIR,
|
||||
);
|
||||
const rel = path.relative(
|
||||
path.normalize(projectAgentsDir),
|
||||
path.normalize(filePath),
|
||||
);
|
||||
const isProjectLevel =
|
||||
rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
const level: SubagentLevel = isProjectLevel ? 'project' : 'user';
|
||||
|
||||
const config: SubagentConfig = {
|
||||
name,
|
||||
description,
|
||||
tools,
|
||||
systemPrompt: systemPrompt.trim(),
|
||||
level,
|
||||
filePath,
|
||||
modelConfig: modelConfig as Partial<ModelConfig>,
|
||||
runConfig: runConfig as Partial<RunConfig>,
|
||||
color,
|
||||
};
|
||||
|
||||
// Validate the parsed configuration
|
||||
const validation = this.validator.validateConfig(config);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Warn if filename doesn't match subagent name (potential issue)
|
||||
const expectedFilename = `${config.name}.md`;
|
||||
const actualFilename = path.basename(filePath);
|
||||
if (actualFilename !== expectedFilename) {
|
||||
console.warn(
|
||||
`Warning: Subagent file "${actualFilename}" contains name "${config.name}" but filename suggests "${path.basename(actualFilename, '.md')}". ` +
|
||||
`Consider renaming the file to "${expectedFilename}" for consistency.`,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
throw new SubagentError(
|
||||
`Failed to parse subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a subagent configuration to Markdown format.
|
||||
*
|
||||
* @param config - Configuration to serialize
|
||||
* @returns Markdown content with YAML frontmatter
|
||||
*/
|
||||
serializeSubagent(config: SubagentConfig): string {
|
||||
// Build frontmatter object
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
};
|
||||
|
||||
if (config.tools && config.tools.length > 0) {
|
||||
frontmatter['tools'] = config.tools;
|
||||
}
|
||||
|
||||
// No outputs section
|
||||
|
||||
if (config.modelConfig) {
|
||||
frontmatter['modelConfig'] = config.modelConfig;
|
||||
}
|
||||
|
||||
if (config.runConfig) {
|
||||
frontmatter['runConfig'] = config.runConfig;
|
||||
}
|
||||
|
||||
if (config.color && config.color !== 'auto') {
|
||||
frontmatter['color'] = config.color;
|
||||
}
|
||||
|
||||
// Serialize to YAML
|
||||
const yamlContent = stringifyYaml(frontmatter, {
|
||||
lineWidth: 0, // Disable line wrapping
|
||||
minContentWidth: 0,
|
||||
}).trim();
|
||||
|
||||
// Combine frontmatter and system prompt
|
||||
return `---\n${yamlContent}\n---\n\n${config.systemPrompt}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SubAgentScope from a subagent configuration.
|
||||
*
|
||||
* @param config - Subagent configuration
|
||||
* @param runtimeContext - Runtime context
|
||||
* @returns Promise resolving to SubAgentScope
|
||||
*/
|
||||
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);
|
||||
|
||||
return await SubAgentScope.create(
|
||||
config.name,
|
||||
runtimeContext,
|
||||
runtimeConfig.promptConfig,
|
||||
runtimeConfig.modelConfig,
|
||||
runtimeConfig.runConfig,
|
||||
runtimeConfig.toolConfig,
|
||||
options?.eventEmitter,
|
||||
options?.hooks,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new SubagentError(
|
||||
`Failed to create SubAgentScope: ${error.message}`,
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
config.name,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a file-based SubagentConfig to runtime configuration
|
||||
* compatible with SubAgentScope.create().
|
||||
*
|
||||
* @param config - File-based subagent configuration
|
||||
* @returns Runtime configuration for SubAgentScope
|
||||
*/
|
||||
convertToRuntimeConfig(config: SubagentConfig): SubagentRuntimeConfig {
|
||||
// Build prompt configuration
|
||||
const promptConfig: PromptConfig = {
|
||||
systemPrompt: config.systemPrompt,
|
||||
};
|
||||
|
||||
// Build model configuration
|
||||
const modelConfig: ModelConfig = {
|
||||
...config.modelConfig,
|
||||
};
|
||||
|
||||
// Build run configuration
|
||||
const runConfig: RunConfig = {
|
||||
...config.runConfig,
|
||||
};
|
||||
|
||||
// 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: toolNames,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
promptConfig,
|
||||
modelConfig,
|
||||
runConfig,
|
||||
toolConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param base - Base configuration
|
||||
* @param updates - Partial updates to apply
|
||||
* @returns New configuration with updates applied
|
||||
*/
|
||||
mergeConfigurations(
|
||||
base: SubagentConfig,
|
||||
updates: Partial<SubagentConfig>,
|
||||
): SubagentConfig {
|
||||
return {
|
||||
...base,
|
||||
...updates,
|
||||
// Handle nested objects specially
|
||||
modelConfig: updates.modelConfig
|
||||
? { ...base.modelConfig, ...updates.modelConfig }
|
||||
: base.modelConfig,
|
||||
runConfig: updates.runConfig
|
||||
? { ...base.runConfig, ...updates.runConfig }
|
||||
: base.runConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file path for a subagent at a specific level.
|
||||
*
|
||||
* @param name - Subagent name
|
||||
* @param level - Storage level
|
||||
* @returns Absolute file path
|
||||
*/
|
||||
getSubagentPath(name: string, level: SubagentLevel): string {
|
||||
if (level === 'builtin') {
|
||||
return `<builtin:${name}>`;
|
||||
}
|
||||
|
||||
const baseDir =
|
||||
level === 'project'
|
||||
? 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`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists subagent files at a specific level.
|
||||
* Handles both builtin agents and file-based agents.
|
||||
*
|
||||
* @param level - Storage level to scan
|
||||
* @returns Array of subagent configurations
|
||||
*/
|
||||
private async listSubagentsAtLevel(
|
||||
level: SubagentLevel,
|
||||
): Promise<SubagentConfig[]> {
|
||||
// Handle built-in agents
|
||||
if (level === 'builtin') {
|
||||
return BuiltinAgentRegistry.getBuiltinAgents();
|
||||
}
|
||||
|
||||
const baseDir =
|
||||
level === 'project'
|
||||
? path.join(
|
||||
this.config.getProjectRoot(),
|
||||
QWEN_CONFIG_DIR,
|
||||
AGENT_CONFIG_DIR,
|
||||
)
|
||||
: path.join(os.homedir(), QWEN_CONFIG_DIR, AGENT_CONFIG_DIR);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(baseDir);
|
||||
const subagents: SubagentConfig[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.md')) continue;
|
||||
|
||||
const filePath = path.join(baseDir, file);
|
||||
|
||||
try {
|
||||
const config = await this.parseSubagentFile(filePath);
|
||||
subagents.push(config);
|
||||
} catch (_error) {
|
||||
// Ignore invalid files
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return subagents;
|
||||
} catch (_error) {
|
||||
// Directory doesn't exist or can't be read
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a subagent by name at a specific level by scanning all files.
|
||||
* This method ensures we find subagents even if the filename doesn't match the name.
|
||||
*
|
||||
* @param name - Name of the subagent to find
|
||||
* @param level - Storage level to search
|
||||
* @returns SubagentConfig or null if not found
|
||||
*/
|
||||
private async findSubagentByNameAtLevel(
|
||||
name: string,
|
||||
level: SubagentLevel,
|
||||
): Promise<SubagentConfig | null> {
|
||||
const allSubagents = await this.listSubagentsAtLevel(level);
|
||||
|
||||
// Find the subagent with matching name
|
||||
for (const subagent of allSubagents) {
|
||||
if (subagent.name === name) {
|
||||
return subagent;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a subagent name is available (not already in use).
|
||||
*
|
||||
* @param name - Name to check
|
||||
* @param level - Level to check, or undefined to check both
|
||||
* @returns True if name is available
|
||||
*/
|
||||
async isNameAvailable(name: string, level?: SubagentLevel): Promise<boolean> {
|
||||
const existing = await this.loadSubagent(name, level);
|
||||
|
||||
if (!existing) {
|
||||
return true; // Name is available
|
||||
}
|
||||
|
||||
if (level && existing.level !== level) {
|
||||
return true; // Name is available at the specified level
|
||||
}
|
||||
|
||||
return false; // Name is already in use
|
||||
}
|
||||
}
|
||||
309
packages/core/src/subagents/subagent-statistics.test.ts
Normal file
309
packages/core/src/subagents/subagent-statistics.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { SubagentStatistics } from './subagent-statistics.js';
|
||||
|
||||
describe('SubagentStatistics', () => {
|
||||
let stats: SubagentStatistics;
|
||||
const baseTime = 1000000000000; // Fixed timestamp for consistent testing
|
||||
|
||||
beforeEach(() => {
|
||||
stats = new SubagentStatistics();
|
||||
});
|
||||
|
||||
describe('basic statistics tracking', () => {
|
||||
it('should track execution time', () => {
|
||||
stats.start(baseTime);
|
||||
const summary = stats.getSummary(baseTime + 5000);
|
||||
|
||||
expect(summary.totalDurationMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('should track rounds', () => {
|
||||
stats.setRounds(3);
|
||||
const summary = stats.getSummary();
|
||||
|
||||
expect(summary.rounds).toBe(3);
|
||||
});
|
||||
|
||||
it('should track tool calls', () => {
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordToolCall('web_search', false, 200, 'Network timeout');
|
||||
|
||||
const summary = stats.getSummary();
|
||||
expect(summary.totalToolCalls).toBe(2);
|
||||
expect(summary.successfulToolCalls).toBe(1);
|
||||
expect(summary.failedToolCalls).toBe(1);
|
||||
expect(summary.successRate).toBe(50);
|
||||
});
|
||||
|
||||
it('should track tokens', () => {
|
||||
stats.recordTokens(1000, 500);
|
||||
stats.recordTokens(200, 100);
|
||||
|
||||
const summary = stats.getSummary();
|
||||
expect(summary.inputTokens).toBe(1200);
|
||||
expect(summary.outputTokens).toBe(600);
|
||||
expect(summary.totalTokens).toBe(1800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool usage statistics', () => {
|
||||
it('should track individual tool usage', () => {
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordToolCall('file_read', false, 150, 'Permission denied');
|
||||
stats.recordToolCall('web_search', true, 300);
|
||||
|
||||
const summary = stats.getSummary();
|
||||
const fileReadTool = summary.toolUsage.find(
|
||||
(t) => t.name === 'file_read',
|
||||
);
|
||||
const webSearchTool = summary.toolUsage.find(
|
||||
(t) => t.name === 'web_search',
|
||||
);
|
||||
|
||||
expect(fileReadTool).toEqual({
|
||||
name: 'file_read',
|
||||
count: 2,
|
||||
success: 1,
|
||||
failure: 1,
|
||||
lastError: 'Permission denied',
|
||||
totalDurationMs: 250,
|
||||
averageDurationMs: 125,
|
||||
});
|
||||
|
||||
expect(webSearchTool).toEqual({
|
||||
name: 'web_search',
|
||||
count: 1,
|
||||
success: 1,
|
||||
failure: 0,
|
||||
lastError: undefined,
|
||||
totalDurationMs: 300,
|
||||
averageDurationMs: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCompact', () => {
|
||||
it('should format basic execution summary', () => {
|
||||
stats.start(baseTime);
|
||||
stats.setRounds(2);
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordTokens(1000, 500);
|
||||
|
||||
const result = stats.formatCompact('Test task', baseTime + 5000);
|
||||
|
||||
expect(result).toContain('📋 Task Completed: Test task');
|
||||
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
|
||||
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2');
|
||||
expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)');
|
||||
});
|
||||
|
||||
it('should handle zero tool calls', () => {
|
||||
stats.start(baseTime);
|
||||
|
||||
const result = stats.formatCompact('Empty task', baseTime + 1000);
|
||||
|
||||
expect(result).toContain('🔧 Tool Usage: 0 calls');
|
||||
expect(result).not.toContain('% success');
|
||||
});
|
||||
|
||||
it('should show zero tokens when no tokens recorded', () => {
|
||||
stats.start(baseTime);
|
||||
stats.recordToolCall('test', true, 100);
|
||||
|
||||
const result = stats.formatCompact('No tokens task', baseTime + 1000);
|
||||
|
||||
expect(result).toContain('🔢 Tokens: 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDetailed', () => {
|
||||
beforeEach(() => {
|
||||
stats.start(baseTime);
|
||||
stats.setRounds(3);
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordToolCall('file_read', true, 150);
|
||||
stats.recordToolCall('web_search', false, 2000, 'Network timeout');
|
||||
stats.recordTokens(2000, 1000);
|
||||
});
|
||||
|
||||
it('should include quality assessment', () => {
|
||||
const result = stats.formatDetailed('Complex task', baseTime + 30000);
|
||||
|
||||
expect(result).toContain(
|
||||
'✅ Quality: Poor execution (66.7% tool success)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include speed assessment', () => {
|
||||
const result = stats.formatDetailed('Fast task', baseTime + 5000);
|
||||
|
||||
expect(result).toContain('🚀 Speed: Fast completion - under 10 seconds');
|
||||
});
|
||||
|
||||
it('should show top tools', () => {
|
||||
const result = stats.formatDetailed('Tool-heavy task', baseTime + 15000);
|
||||
|
||||
expect(result).toContain('Top tools:');
|
||||
expect(result).toContain('- file_read: 2 calls (2 ok, 0 fail');
|
||||
expect(result).toContain('- web_search: 1 calls (0 ok, 1 fail');
|
||||
expect(result).toContain('last error: Network timeout');
|
||||
});
|
||||
|
||||
it('should include performance insights', () => {
|
||||
const result = stats.formatDetailed('Slow task', baseTime + 120000);
|
||||
|
||||
expect(result).toContain('💡 Performance Insights:');
|
||||
expect(result).toContain(
|
||||
'Long execution time - consider breaking down complex tasks',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quality categories', () => {
|
||||
it('should categorize excellent execution', () => {
|
||||
stats.recordToolCall('test', true, 100);
|
||||
stats.recordToolCall('test', true, 100);
|
||||
|
||||
const result = stats.formatDetailed('Perfect task');
|
||||
expect(result).toContain('Excellent execution (100.0% tool success)');
|
||||
});
|
||||
|
||||
it('should categorize good execution', () => {
|
||||
// Need 85% success rate for "Good execution" - 17 success, 3 failures = 85%
|
||||
for (let i = 0; i < 17; i++) {
|
||||
stats.recordToolCall('test', true, 100);
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stats.recordToolCall('test', false, 100);
|
||||
}
|
||||
|
||||
const result = stats.formatDetailed('Good task');
|
||||
expect(result).toContain('Good execution (85.0% tool success)');
|
||||
});
|
||||
|
||||
it('should categorize poor execution', () => {
|
||||
stats.recordToolCall('test', false, 100);
|
||||
stats.recordToolCall('test', false, 100);
|
||||
|
||||
const result = stats.formatDetailed('Poor task');
|
||||
expect(result).toContain('Poor execution (0.0% tool success)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('speed categories', () => {
|
||||
it('should categorize fast completion', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatDetailed('Fast task', baseTime + 5000);
|
||||
expect(result).toContain('Fast completion - under 10 seconds');
|
||||
});
|
||||
|
||||
it('should categorize good speed', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatDetailed('Medium task', baseTime + 30000);
|
||||
expect(result).toContain('Good speed - under a minute');
|
||||
});
|
||||
|
||||
it('should categorize moderate duration', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatDetailed('Slow task', baseTime + 120000);
|
||||
expect(result).toContain('Moderate duration - a few minutes');
|
||||
});
|
||||
|
||||
it('should categorize long execution', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatDetailed('Very slow task', baseTime + 600000);
|
||||
expect(result).toContain('Long execution - consider breaking down tasks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance tips', () => {
|
||||
it('should suggest reviewing low success rate', () => {
|
||||
stats.recordToolCall('test', false, 100);
|
||||
stats.recordToolCall('test', false, 100);
|
||||
stats.recordToolCall('test', true, 100);
|
||||
|
||||
const result = stats.formatDetailed('Failing task');
|
||||
expect(result).toContain(
|
||||
'Low tool success rate - review inputs and error messages',
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest breaking down long tasks', () => {
|
||||
stats.start(baseTime);
|
||||
|
||||
const result = stats.formatDetailed('Long task', baseTime + 120000);
|
||||
expect(result).toContain(
|
||||
'Long execution time - consider breaking down complex tasks',
|
||||
);
|
||||
});
|
||||
|
||||
it('should suggest optimizing high token usage', () => {
|
||||
stats.recordTokens(80000, 30000);
|
||||
|
||||
const result = stats.formatDetailed('Token-heavy task');
|
||||
expect(result).toContain(
|
||||
'High token usage - consider optimizing prompts or narrowing scope',
|
||||
);
|
||||
});
|
||||
|
||||
it('should identify high token usage per call', () => {
|
||||
stats.recordToolCall('test', true, 100);
|
||||
stats.recordTokens(6000, 0);
|
||||
|
||||
const result = stats.formatDetailed('Verbose task');
|
||||
expect(result).toContain(
|
||||
'High token usage per tool call (~6000 tokens/call)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should identify network failures', () => {
|
||||
stats.recordToolCall('web_search', false, 100, 'Network timeout');
|
||||
|
||||
const result = stats.formatDetailed('Network task');
|
||||
expect(result).toContain(
|
||||
'Network operations had failures - consider increasing timeout or checking connectivity',
|
||||
);
|
||||
});
|
||||
|
||||
it('should identify slow tools', () => {
|
||||
stats.recordToolCall('slow_tool', true, 15000);
|
||||
|
||||
const result = stats.formatDetailed('Slow tool task');
|
||||
expect(result).toContain(
|
||||
'Consider optimizing slow_tool operations (avg 15.0s)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duration formatting', () => {
|
||||
it('should format milliseconds', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatCompact('Quick task', baseTime + 500);
|
||||
expect(result).toContain('500ms');
|
||||
});
|
||||
|
||||
it('should format seconds', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatCompact('Second task', baseTime + 2500);
|
||||
expect(result).toContain('2.5s');
|
||||
});
|
||||
|
||||
it('should format minutes and seconds', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatCompact('Minute task', baseTime + 125000);
|
||||
expect(result).toContain('2m 5s');
|
||||
});
|
||||
|
||||
it('should format hours and minutes', () => {
|
||||
stats.start(baseTime);
|
||||
const result = stats.formatCompact('Hour task', baseTime + 4500000);
|
||||
expect(result).toContain('1h 15m');
|
||||
});
|
||||
});
|
||||
});
|
||||
250
packages/core/src/subagents/subagent-statistics.ts
Normal file
250
packages/core/src/subagents/subagent-statistics.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* 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 SubagentStatsSummary {
|
||||
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()): SubagentStatsSummary {
|
||||
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()),
|
||||
};
|
||||
}
|
||||
|
||||
formatCompact(taskDesc: string, now = Date.now()): string {
|
||||
const stats = this.getSummary(now);
|
||||
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: ${this.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');
|
||||
}
|
||||
|
||||
formatDetailed(taskDesc: string, now = Date.now()): string {
|
||||
const stats = this.getSummary(now);
|
||||
const sr =
|
||||
stats.totalToolCalls > 0
|
||||
? (stats.successRate ??
|
||||
(stats.successfulToolCalls / stats.totalToolCalls) * 100)
|
||||
: 0;
|
||||
const lines: string[] = [];
|
||||
lines.push(`📋 Task Completed: ${taskDesc}`);
|
||||
lines.push(
|
||||
`⏱️ Duration: ${this.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 ${this.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 = this.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');
|
||||
}
|
||||
|
||||
private 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`;
|
||||
}
|
||||
|
||||
private generatePerformanceTips(stats: SubagentStatsSummary): 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 ${this.fmtDuration(Math.round(slow[0].averageDurationMs!))})`,
|
||||
);
|
||||
|
||||
return tips;
|
||||
}
|
||||
}
|
||||
679
packages/core/src/subagents/subagent.test.ts
Normal file
679
packages/core/src/subagents/subagent.test.ts
Normal file
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Content,
|
||||
FunctionCall,
|
||||
FunctionDeclaration,
|
||||
GenerateContentConfig,
|
||||
Part,
|
||||
} from '@google/genai';
|
||||
import { Type } from '@google/genai';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { Config, type ConfigParameters } from '../config/config.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
|
||||
import { createContentGenerator } from '../core/contentGenerator.js';
|
||||
import { GeminiChat } from '../core/geminiChat.js';
|
||||
import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { type AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { getEnvironmentContext } from '../utils/environmentContext.js';
|
||||
import { ContextState, SubAgentScope } from './subagent.js';
|
||||
import type {
|
||||
ModelConfig,
|
||||
PromptConfig,
|
||||
RunConfig,
|
||||
ToolConfig,
|
||||
} from './types.js';
|
||||
import { SubagentTerminateMode } from './types.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;
|
||||
// This mock now returns a Promise that resolves to the async generator,
|
||||
// matching the new signature for sendMessageStream.
|
||||
return vi.fn().mockImplementation(async () => {
|
||||
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 not block creation when a tool may require confirmation', async () => {
|
||||
const mockTool = {
|
||||
name: 'risky_tool',
|
||||
schema: { parametersJsonSchema: { 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'] };
|
||||
|
||||
const scope = await SubAgentScope.create(
|
||||
'test-agent',
|
||||
config,
|
||||
promptConfig,
|
||||
defaultModelConfig,
|
||||
defaultRunConfig,
|
||||
toolConfig,
|
||||
);
|
||||
expect(scope).toBeInstanceOf(SubAgentScope);
|
||||
});
|
||||
|
||||
it('should succeed if tools do not require confirmation', async () => {
|
||||
const mockTool = {
|
||||
name: 'safe_tool',
|
||||
schema: { parametersJsonSchema: { 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 allow creation regardless of tool parameter requirements', async () => {
|
||||
const mockToolWithParams = {
|
||||
name: 'tool_with_params',
|
||||
schema: {
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
build: vi.fn(),
|
||||
};
|
||||
|
||||
const { config } = await createMockConfig({
|
||||
getTool: vi.fn().mockReturnValue(mockToolWithParams),
|
||||
getAllTools: vi.fn().mockReturnValue([mockToolWithParams]),
|
||||
});
|
||||
|
||||
const toolConfig: ToolConfig = { tools: ['tool_with_params'] };
|
||||
|
||||
const scope = await SubAgentScope.create(
|
||||
'test-agent',
|
||||
config,
|
||||
promptConfig,
|
||||
defaultModelConfig,
|
||||
defaultRunConfig,
|
||||
toolConfig,
|
||||
);
|
||||
|
||||
expect(scope).toBeInstanceOf(SubAgentScope);
|
||||
// Ensure build was not called during creation
|
||||
expect(mockToolWithParams.build).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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.getTerminateMode()).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.getTerminateMode()).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.getTerminateMode()).toBe(SubagentTerminateMode.GOAL);
|
||||
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.getTerminateMode()).toBe(SubagentTerminateMode.GOAL);
|
||||
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]),
|
||||
getTool: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
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',
|
||||
]),
|
||||
);
|
||||
|
||||
// Provide a mock tool via ToolRegistry that returns a successful result
|
||||
const listFilesInvocation = {
|
||||
params: { path: '.' },
|
||||
getDescription: vi.fn().mockReturnValue('List files'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(false),
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'file1.txt\nfile2.ts',
|
||||
returnDisplay: 'Listed 2 files',
|
||||
}),
|
||||
};
|
||||
const listFilesTool = {
|
||||
name: 'list_files',
|
||||
displayName: 'List Files',
|
||||
description: 'List files in directory',
|
||||
kind: 'READ' as const,
|
||||
schema: listFilesToolDef,
|
||||
build: vi.fn().mockImplementation(() => listFilesInvocation),
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
vi.mocked(
|
||||
(config.getToolRegistry() as unknown as ToolRegistry).getTool,
|
||||
).mockImplementation((name: string) =>
|
||||
name === 'list_files' ? listFilesTool : undefined,
|
||||
);
|
||||
|
||||
const scope = await SubAgentScope.create(
|
||||
'test-agent',
|
||||
config,
|
||||
promptConfig,
|
||||
defaultModelConfig,
|
||||
defaultRunConfig,
|
||||
toolConfig,
|
||||
);
|
||||
|
||||
await scope.runNonInteractive(new ContextState());
|
||||
|
||||
// Check the response sent back to the model (functionResponse part)
|
||||
const secondCallArgs = mockSendMessageStream.mock.calls[1][0];
|
||||
const parts = secondCallArgs.message as unknown[];
|
||||
expect(Array.isArray(parts)).toBe(true);
|
||||
const firstPart = parts[0] as Part;
|
||||
expect(firstPart.functionResponse?.response?.['output']).toBe(
|
||||
'file1.txt\nfile2.ts',
|
||||
);
|
||||
|
||||
expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL);
|
||||
});
|
||||
});
|
||||
|
||||
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.getTerminateMode()).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.getTerminateMode()).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.getTerminateMode()).toBe(SubagentTerminateMode.ERROR);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
884
packages/core/src/subagents/subagent.ts
Normal file
884
packages/core/src/subagents/subagent.ts
Normal file
@@ -0,0 +1,884 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* 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 {
|
||||
CoreToolScheduler,
|
||||
ToolCall,
|
||||
WaitingToolCall,
|
||||
} from '../core/coreToolScheduler.js';
|
||||
import type {
|
||||
ToolConfirmationOutcome,
|
||||
ToolCallConfirmationDetails,
|
||||
} from '../tools/tools.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 {
|
||||
SubagentTerminateMode,
|
||||
PromptConfig,
|
||||
ModelConfig,
|
||||
RunConfig,
|
||||
ToolConfig,
|
||||
} from './types.js';
|
||||
import {
|
||||
SubAgentEventEmitter,
|
||||
SubAgentEventType,
|
||||
SubAgentFinishEvent,
|
||||
SubAgentRoundEvent,
|
||||
SubAgentStartEvent,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentErrorEvent,
|
||||
} from './subagent-events.js';
|
||||
import {
|
||||
SubagentStatistics,
|
||||
SubagentStatsSummary,
|
||||
} from './subagent-statistics.js';
|
||||
import { SubagentHooks } from './subagent-hooks.js';
|
||||
import { logSubagentExecution } from '../telemetry/loggers.js';
|
||||
import { SubagentExecutionEvent } from '../telemetry/types.js';
|
||||
import { TaskTool } from '../tools/task.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 terminateMode: SubagentTerminateMode = SubagentTerminateMode.ERROR;
|
||||
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> {
|
||||
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.terminateMode = SubagentTerminateMode.ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const onAbort = () => abortController.abort();
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
abortController.abort();
|
||||
this.terminateMode = SubagentTerminateMode.CANCELLED;
|
||||
return;
|
||||
}
|
||||
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()
|
||||
.filter((t) => t.name !== TaskTool.Name),
|
||||
);
|
||||
} else {
|
||||
toolsList.push(
|
||||
...toolRegistry.getFunctionDeclarationsFiltered(asStrings),
|
||||
);
|
||||
}
|
||||
toolsList.push(...onlyInlineDecls);
|
||||
} else {
|
||||
// Inherit all available tools by default when not specified.
|
||||
toolsList.push(
|
||||
...toolRegistry
|
||||
.getFunctionDeclarations()
|
||||
.filter((t) => t.name !== TaskTool.Name),
|
||||
);
|
||||
}
|
||||
|
||||
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(SubAgentEventType.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(),
|
||||
} as SubAgentStartEvent);
|
||||
|
||||
// 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.terminateMode = SubagentTerminateMode.MAX_TURNS;
|
||||
break;
|
||||
}
|
||||
let durationMin = (Date.now() - startTime) / (1000 * 60);
|
||||
if (
|
||||
this.runConfig.max_time_minutes &&
|
||||
durationMin >= this.runConfig.max_time_minutes
|
||||
) {
|
||||
this.terminateMode = 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(SubAgentEventType.ROUND_START, {
|
||||
subagentId: this.subagentId,
|
||||
round: turnCounter,
|
||||
promptId,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentRoundEvent);
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let roundText = '';
|
||||
let lastUsage: GenerateContentResponseUsageMetadata | undefined =
|
||||
undefined;
|
||||
for await (const resp of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
this.terminateMode = SubagentTerminateMode.CANCELLED;
|
||||
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(SubAgentEventType.STREAM_TEXT, {
|
||||
subagentId: this.subagentId,
|
||||
round: turnCounter,
|
||||
text: txt,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentStreamTextEvent);
|
||||
}
|
||||
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.terminateMode = 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,
|
||||
turnCounter,
|
||||
);
|
||||
} else {
|
||||
// No tool calls — treat this as the model's final answer.
|
||||
if (roundText && roundText.trim().length > 0) {
|
||||
this.finalText = roundText.trim();
|
||||
this.terminateMode = 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(SubAgentEventType.ROUND_END, {
|
||||
subagentId: this.subagentId,
|
||||
round: turnCounter,
|
||||
promptId,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentRoundEvent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during subagent execution:', error);
|
||||
this.terminateMode = SubagentTerminateMode.ERROR;
|
||||
this.eventEmitter?.emit(SubAgentEventType.ERROR, {
|
||||
subagentId: this.subagentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentErrorEvent);
|
||||
|
||||
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(SubAgentEventType.FINISH, {
|
||||
subagentId: this.subagentId,
|
||||
terminateReason: this.terminateMode,
|
||||
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,
|
||||
} as SubAgentFinishEvent);
|
||||
|
||||
const completionEvent = new SubagentExecutionEvent(
|
||||
this.name,
|
||||
this.terminateMode === SubagentTerminateMode.GOAL
|
||||
? 'completed'
|
||||
: 'failed',
|
||||
{
|
||||
terminate_reason: this.terminateMode,
|
||||
result: this.finalText,
|
||||
execution_summary: this.stats.formatCompact(
|
||||
'Subagent execution completed',
|
||||
),
|
||||
},
|
||||
);
|
||||
logSubagentExecution(this.runtimeContext, completionEvent);
|
||||
|
||||
await this.hooks?.onStop?.({
|
||||
subagentId: this.subagentId,
|
||||
name: this.name,
|
||||
terminateReason: this.terminateMode,
|
||||
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,
|
||||
currentRound: number,
|
||||
): Promise<Content[]> {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
// Build scheduler
|
||||
const responded = new Set<string>();
|
||||
let resolveBatch: (() => void) | null = null;
|
||||
const scheduler = new CoreToolScheduler({
|
||||
toolRegistry: this.runtimeContext.getToolRegistry(),
|
||||
outputUpdateHandler: undefined,
|
||||
onAllToolCallsComplete: async (completedCalls) => {
|
||||
for (const call of completedCalls) {
|
||||
const toolName = call.request.name;
|
||||
const duration = call.durationMs ?? 0;
|
||||
const success = call.status === 'success';
|
||||
const errorMessage =
|
||||
call.status === 'error' || call.status === 'cancelled'
|
||||
? call.response.error?.message
|
||||
: undefined;
|
||||
|
||||
// Update aggregate stats
|
||||
this.executionStats.totalToolCalls += 1;
|
||||
if (success) {
|
||||
this.executionStats.successfulToolCalls += 1;
|
||||
} else {
|
||||
this.executionStats.failedToolCalls += 1;
|
||||
}
|
||||
|
||||
// Per-tool usage
|
||||
const tu = this.toolUsage.get(toolName) || {
|
||||
count: 0,
|
||||
success: 0,
|
||||
failure: 0,
|
||||
totalDurationMs: 0,
|
||||
averageDurationMs: 0,
|
||||
};
|
||||
tu.count += 1;
|
||||
if (success) {
|
||||
tu.success += 1;
|
||||
} else {
|
||||
tu.failure += 1;
|
||||
tu.lastError = errorMessage || 'Unknown error';
|
||||
}
|
||||
tu.totalDurationMs = (tu.totalDurationMs || 0) + duration;
|
||||
tu.averageDurationMs =
|
||||
tu.count > 0 ? tu.totalDurationMs / tu.count : 0;
|
||||
this.toolUsage.set(toolName, tu);
|
||||
|
||||
// Emit tool result event
|
||||
this.eventEmitter?.emit(SubAgentEventType.TOOL_RESULT, {
|
||||
subagentId: this.subagentId,
|
||||
round: currentRound,
|
||||
callId: call.request.callId,
|
||||
name: toolName,
|
||||
success,
|
||||
error: errorMessage,
|
||||
resultDisplay: call.response.resultDisplay
|
||||
? typeof call.response.resultDisplay === 'string'
|
||||
? call.response.resultDisplay
|
||||
: JSON.stringify(call.response.resultDisplay)
|
||||
: undefined,
|
||||
durationMs: duration,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentToolResultEvent);
|
||||
|
||||
// Update statistics service
|
||||
this.stats.recordToolCall(
|
||||
toolName,
|
||||
success,
|
||||
duration,
|
||||
this.toolUsage.get(toolName)?.lastError,
|
||||
);
|
||||
|
||||
// post-tool hook
|
||||
await this.hooks?.postToolUse?.({
|
||||
subagentId: this.subagentId,
|
||||
name: this.name,
|
||||
toolName,
|
||||
args: call.request.args,
|
||||
success,
|
||||
durationMs: duration,
|
||||
errorMessage,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Append response parts
|
||||
const respParts = call.response.responseParts;
|
||||
if (respParts) {
|
||||
const parts = Array.isArray(respParts) ? respParts : [respParts];
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
toolResponseParts.push({ text: part });
|
||||
} else if (part) {
|
||||
toolResponseParts.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Signal that this batch is complete (all tools terminal)
|
||||
resolveBatch?.();
|
||||
},
|
||||
onToolCallsUpdate: (calls: ToolCall[]) => {
|
||||
for (const call of calls) {
|
||||
if (call.status !== 'awaiting_approval') continue;
|
||||
const waiting = call as WaitingToolCall;
|
||||
|
||||
// Emit approval request event for UI visibility
|
||||
try {
|
||||
const { confirmationDetails } = waiting;
|
||||
const { onConfirm: _onConfirm, ...rest } = confirmationDetails;
|
||||
this.eventEmitter?.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, {
|
||||
subagentId: this.subagentId,
|
||||
round: currentRound,
|
||||
callId: waiting.request.callId,
|
||||
name: waiting.request.name,
|
||||
description: this.getToolDescription(
|
||||
waiting.request.name,
|
||||
waiting.request.args,
|
||||
),
|
||||
confirmationDetails: rest,
|
||||
respond: async (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: Parameters<
|
||||
ToolCallConfirmationDetails['onConfirm']
|
||||
>[1],
|
||||
) => {
|
||||
if (responded.has(waiting.request.callId)) return;
|
||||
responded.add(waiting.request.callId);
|
||||
await waiting.confirmationDetails.onConfirm(outcome, payload);
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// ignore UI event emission failures
|
||||
}
|
||||
|
||||
// UI now renders inline confirmation via task tool live output.
|
||||
}
|
||||
},
|
||||
getPreferredEditor: () => undefined,
|
||||
config: this.runtimeContext,
|
||||
onEditorClose: () => {},
|
||||
});
|
||||
|
||||
// Prepare requests and emit TOOL_CALL events
|
||||
const requests: ToolCallRequestInfo[] = functionCalls.map((fc) => {
|
||||
const toolName = String(fc.name || 'unknown');
|
||||
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
|
||||
const args = (fc.args ?? {}) as Record<string, unknown>;
|
||||
const request: ToolCallRequestInfo = {
|
||||
callId,
|
||||
name: toolName,
|
||||
args,
|
||||
isClientInitiated: true,
|
||||
prompt_id: promptId,
|
||||
};
|
||||
|
||||
const description = this.getToolDescription(toolName, args);
|
||||
this.eventEmitter?.emit(SubAgentEventType.TOOL_CALL, {
|
||||
subagentId: this.subagentId,
|
||||
round: currentRound,
|
||||
callId,
|
||||
name: toolName,
|
||||
args,
|
||||
description,
|
||||
timestamp: Date.now(),
|
||||
} as SubAgentToolCallEvent);
|
||||
|
||||
// pre-tool hook
|
||||
void this.hooks?.preToolUse?.({
|
||||
subagentId: this.subagentId,
|
||||
name: this.name,
|
||||
toolName,
|
||||
args,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return request;
|
||||
});
|
||||
|
||||
if (requests.length > 0) {
|
||||
// Create a per-batch completion promise, resolve when onAllToolCallsComplete fires
|
||||
const batchDone = new Promise<void>((resolve) => {
|
||||
resolveBatch = () => {
|
||||
resolve();
|
||||
resolveBatch = null;
|
||||
};
|
||||
});
|
||||
await scheduler.schedule(requests, abortController.signal);
|
||||
await batchDone; // Wait for approvals + execution to finish
|
||||
}
|
||||
// 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
getExecutionSummary(): SubagentStatsSummary {
|
||||
return this.stats.getSummary();
|
||||
}
|
||||
|
||||
getFinalText(): string {
|
||||
return this.finalText;
|
||||
}
|
||||
|
||||
getTerminateMode(): SubagentTerminateMode {
|
||||
return this.terminateMode;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely retrieves the description of a tool by attempting to build it.
|
||||
* Returns an empty string if any error occurs during the process.
|
||||
*
|
||||
* @param toolName The name of the tool to get description for.
|
||||
* @param args The arguments that would be passed to the tool.
|
||||
* @returns The tool description or empty string if error occurs.
|
||||
*/
|
||||
private getToolDescription(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): string {
|
||||
try {
|
||||
const toolRegistry = this.runtimeContext.getToolRegistry();
|
||||
const tool = toolRegistry.getTool(toolName);
|
||||
if (!tool) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const toolInstance = tool.build(args);
|
||||
return toolInstance.getDescription() || '';
|
||||
} catch {
|
||||
// Safely ignore all runtime errors and return empty string
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private buildChatSystemPrompt(context: ContextState): string {
|
||||
if (!this.promptConfig.systemPrompt) {
|
||||
// This should ideally be caught in createChatObject, but serves as a safeguard.
|
||||
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;
|
||||
}
|
||||
}
|
||||
40
packages/core/src/subagents/types.test.ts
Normal file
40
packages/core/src/subagents/types.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SubagentError, SubagentErrorCode } from './types.js';
|
||||
|
||||
describe('SubagentError', () => {
|
||||
it('should create error with message and code', () => {
|
||||
const error = new SubagentError('Test error', SubagentErrorCode.NOT_FOUND);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe('SubagentError');
|
||||
expect(error.message).toBe('Test error');
|
||||
expect(error.code).toBe(SubagentErrorCode.NOT_FOUND);
|
||||
expect(error.subagentName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create error with subagent name', () => {
|
||||
const error = new SubagentError(
|
||||
'Test error',
|
||||
SubagentErrorCode.INVALID_CONFIG,
|
||||
'test-agent',
|
||||
);
|
||||
|
||||
expect(error.subagentName).toBe('test-agent');
|
||||
});
|
||||
|
||||
it('should have correct error codes', () => {
|
||||
expect(SubagentErrorCode.NOT_FOUND).toBe('NOT_FOUND');
|
||||
expect(SubagentErrorCode.ALREADY_EXISTS).toBe('ALREADY_EXISTS');
|
||||
expect(SubagentErrorCode.INVALID_CONFIG).toBe('INVALID_CONFIG');
|
||||
expect(SubagentErrorCode.INVALID_NAME).toBe('INVALID_NAME');
|
||||
expect(SubagentErrorCode.FILE_ERROR).toBe('FILE_ERROR');
|
||||
expect(SubagentErrorCode.VALIDATION_ERROR).toBe('VALIDATION_ERROR');
|
||||
expect(SubagentErrorCode.TOOL_NOT_FOUND).toBe('TOOL_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
257
packages/core/src/subagents/types.ts
Normal file
257
packages/core/src/subagents/types.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Content, FunctionDeclaration } from '@google/genai';
|
||||
|
||||
/**
|
||||
* Represents the storage level for a subagent configuration.
|
||||
* - 'project': Stored in `.qwen/agents/` within the project directory
|
||||
* - 'user': Stored in `~/.qwen/agents/` in the user's home directory
|
||||
* - 'builtin': Built-in agents embedded in the codebase, always available
|
||||
*/
|
||||
export type SubagentLevel = 'project' | 'user' | 'builtin';
|
||||
|
||||
/**
|
||||
* Core configuration for a subagent as stored in Markdown files.
|
||||
* This interface represents the file-based configuration that gets
|
||||
* converted to runtime configuration for SubAgentScope.
|
||||
*/
|
||||
export interface SubagentConfig {
|
||||
/** Unique name identifier for the subagent */
|
||||
name: string;
|
||||
|
||||
/** Human-readable description of when and how to use this subagent */
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Optional list of tool names that this subagent is allowed to use.
|
||||
* If omitted, the subagent inherits all available tools.
|
||||
*/
|
||||
tools?: string[];
|
||||
|
||||
/**
|
||||
* System prompt content that defines the subagent's behavior.
|
||||
* Supports ${variable} templating via ContextState.
|
||||
*/
|
||||
systemPrompt: string;
|
||||
|
||||
/** Storage level - determines where the configuration file is stored */
|
||||
level: SubagentLevel;
|
||||
|
||||
/** Absolute path to the configuration file */
|
||||
filePath: string;
|
||||
|
||||
/**
|
||||
* Optional model configuration. If not provided, uses defaults.
|
||||
* Can specify model name, temperature, and top_p values.
|
||||
*/
|
||||
modelConfig?: Partial<ModelConfig>;
|
||||
|
||||
/**
|
||||
* Optional runtime configuration. If not provided, uses defaults.
|
||||
* Can specify max_time_minutes and max_turns.
|
||||
*/
|
||||
runConfig?: Partial<RunConfig>;
|
||||
|
||||
/**
|
||||
* Optional color for runtime display.
|
||||
* If 'auto' or omitted, uses automatic color assignment.
|
||||
*/
|
||||
color?: string;
|
||||
|
||||
/**
|
||||
* Indicates whether this is a built-in agent.
|
||||
* Built-in agents cannot be modified or deleted.
|
||||
*/
|
||||
readonly isBuiltin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime configuration that converts file-based config to existing SubAgentScope.
|
||||
* This interface maps SubagentConfig to the existing runtime interfaces.
|
||||
*/
|
||||
export interface SubagentRuntimeConfig {
|
||||
/** Prompt configuration for SubAgentScope */
|
||||
promptConfig: PromptConfig;
|
||||
|
||||
/** Model configuration for SubAgentScope */
|
||||
modelConfig: ModelConfig;
|
||||
|
||||
/** Runtime execution configuration for SubAgentScope */
|
||||
runConfig: RunConfig;
|
||||
|
||||
/** Optional tool configuration for SubAgentScope */
|
||||
toolConfig?: ToolConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a validation operation on a subagent configuration.
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
/** Whether the configuration is valid */
|
||||
isValid: boolean;
|
||||
|
||||
/** Array of error messages if validation failed */
|
||||
errors: string[];
|
||||
|
||||
/** Array of warning messages (non-blocking issues) */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for listing subagents.
|
||||
*/
|
||||
export interface ListSubagentsOptions {
|
||||
/** Filter by storage level */
|
||||
level?: SubagentLevel;
|
||||
|
||||
/** Filter by tool availability */
|
||||
hasTool?: string;
|
||||
|
||||
/** Sort order for results */
|
||||
sortBy?: 'name' | 'lastModified' | 'level';
|
||||
|
||||
/** Sort direction */
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a new subagent.
|
||||
*/
|
||||
export interface CreateSubagentOptions {
|
||||
/** Storage level for the new subagent */
|
||||
level: SubagentLevel;
|
||||
|
||||
/** Whether to overwrite existing subagent with same name */
|
||||
overwrite?: boolean;
|
||||
|
||||
/** Custom directory path (overrides default level-based path) */
|
||||
customPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a subagent operation fails.
|
||||
*/
|
||||
export class SubagentError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: string,
|
||||
readonly subagentName?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SubagentError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error codes for subagent operations.
|
||||
*/
|
||||
export const SubagentErrorCode = {
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
ALREADY_EXISTS: 'ALREADY_EXISTS',
|
||||
INVALID_CONFIG: 'INVALID_CONFIG',
|
||||
INVALID_NAME: 'INVALID_NAME',
|
||||
FILE_ERROR: 'FILE_ERROR',
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
TOOL_NOT_FOUND: 'TOOL_NOT_FOUND',
|
||||
} as const;
|
||||
|
||||
export type SubagentErrorCode =
|
||||
(typeof SubagentErrorCode)[keyof typeof SubagentErrorCode];
|
||||
|
||||
/**
|
||||
* Describes the possible termination modes for a subagent.
|
||||
* This enum provides a clear indication of why a subagent's execution might have ended.
|
||||
*/
|
||||
export enum SubagentTerminateMode {
|
||||
/**
|
||||
* Indicates that the subagent's execution terminated due to an unrecoverable error.
|
||||
*/
|
||||
ERROR = 'ERROR',
|
||||
/**
|
||||
* Indicates that the subagent's execution terminated because it exceeded the maximum allowed working time.
|
||||
*/
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
/**
|
||||
* Indicates that the subagent's execution successfully completed all its defined goals.
|
||||
*/
|
||||
GOAL = 'GOAL',
|
||||
/**
|
||||
* Indicates that the subagent's execution terminated because it exceeded the maximum number of turns.
|
||||
*/
|
||||
MAX_TURNS = 'MAX_TURNS',
|
||||
/**
|
||||
* Indicates that the subagent's execution was cancelled via an abort signal.
|
||||
*/
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
426
packages/core/src/subagents/validation.test.ts
Normal file
426
packages/core/src/subagents/validation.test.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { SubagentValidator } from './validation.js';
|
||||
import { SubagentConfig, SubagentError } from './types.js';
|
||||
|
||||
describe('SubagentValidator', () => {
|
||||
let validator: SubagentValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new SubagentValidator();
|
||||
});
|
||||
|
||||
describe('validateName', () => {
|
||||
it('should accept valid names', () => {
|
||||
const validNames = [
|
||||
'test-agent',
|
||||
'code_reviewer',
|
||||
'agent123',
|
||||
'my-helper',
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
const result = validator.validateName(name);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject empty or whitespace names', () => {
|
||||
const invalidNames = ['', ' ', '\t', '\n'];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
const result = validator.validateName(name);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Name is required and cannot be empty');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject names that are too short', () => {
|
||||
const result = validator.validateName('a');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Name must be at least 2 characters long',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject names that are too long', () => {
|
||||
const longName = 'a'.repeat(51);
|
||||
const result = validator.validateName(longName);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Name must be 50 characters or less');
|
||||
});
|
||||
|
||||
it('should reject names with invalid characters', () => {
|
||||
const invalidNames = ['test@agent', 'agent.name', 'test agent', 'agent!'];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
const result = validator.validateName(name);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Name can only contain letters, numbers, hyphens, and underscores',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject names starting with special characters', () => {
|
||||
const invalidNames = ['-agent', '_agent'];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
const result = validator.validateName(name);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Name cannot start with a hyphen or underscore',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject names ending with special characters', () => {
|
||||
const invalidNames = ['agent-', 'agent_'];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
const result = validator.validateName(name);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Name cannot end with a hyphen or underscore',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject reserved names', () => {
|
||||
const reservedNames = [
|
||||
'self',
|
||||
'system',
|
||||
'user',
|
||||
'model',
|
||||
'tool',
|
||||
'config',
|
||||
'default',
|
||||
];
|
||||
|
||||
for (const name of reservedNames) {
|
||||
const result = validator.validateName(name);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
`"${name}" is a reserved name and cannot be used`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should warn about naming conventions', () => {
|
||||
const result = validator.validateName('TestAgent');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Consider using lowercase names for consistency',
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn about mixed separators', () => {
|
||||
const result = validator.validateName('test-agent_helper');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Consider using either hyphens or underscores consistently, not both',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSystemPrompt', () => {
|
||||
it('should accept valid system prompts', () => {
|
||||
const validPrompts = [
|
||||
'You are a helpful assistant.',
|
||||
'You are a code reviewer. Analyze the provided code and suggest improvements.',
|
||||
'Help the user with ${task} by using available tools.',
|
||||
];
|
||||
|
||||
for (const prompt of validPrompts) {
|
||||
const result = validator.validateSystemPrompt(prompt);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject empty prompts', () => {
|
||||
const invalidPrompts = ['', ' ', '\t\n'];
|
||||
|
||||
for (const prompt of invalidPrompts) {
|
||||
const result = validator.validateSystemPrompt(prompt);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'System prompt is required and cannot be empty',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject prompts that are too short', () => {
|
||||
const result = validator.validateSystemPrompt('Short');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'System prompt must be at least 10 characters long',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject prompts that are too long', () => {
|
||||
const longPrompt = 'a'.repeat(10001);
|
||||
const result = validator.validateSystemPrompt(longPrompt);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'System prompt is too long (>10,000 characters)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn about long prompts', () => {
|
||||
const longPrompt = 'a'.repeat(5001);
|
||||
const result = validator.validateSystemPrompt(longPrompt);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'System prompt is quite long (>5,000 characters), consider shortening',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTools', () => {
|
||||
it('should accept valid tool arrays', () => {
|
||||
const result = validator.validateTools(['read_file', 'write_file']);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject non-array inputs', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = validator.validateTools('not-an-array' as any);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Tools must be an array of strings');
|
||||
});
|
||||
|
||||
it('should warn about empty arrays', () => {
|
||||
const result = validator.validateTools([]);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Empty tools array - subagent will inherit all available tools',
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn about duplicate tools', () => {
|
||||
const result = validator.validateTools([
|
||||
'read_file',
|
||||
'read_file',
|
||||
'write_file',
|
||||
]);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Duplicate tool names found in tools array',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-string tool names', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = validator.validateTools([123, 'read_file'] as any);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Tool name must be a string, got: number',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject empty tool names', () => {
|
||||
const result = validator.validateTools(['', 'read_file']);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Tool name cannot be empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateModelConfig', () => {
|
||||
it('should accept valid model configurations', () => {
|
||||
const validConfigs = [
|
||||
{ model: 'gemini-1.5-pro', temp: 0.7, top_p: 0.9 },
|
||||
{ temp: 0.5 },
|
||||
{ top_p: 1.0 },
|
||||
{},
|
||||
];
|
||||
|
||||
for (const config of validConfigs) {
|
||||
const result = validator.validateModelConfig(config);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid model names', () => {
|
||||
const result = validator.validateModelConfig({ model: '' });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Model name must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should reject invalid temperature values', () => {
|
||||
const invalidTemps = [-0.1, 2.1, 'not-a-number'];
|
||||
|
||||
for (const temp of invalidTemps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = validator.validateModelConfig({ temp: temp as any });
|
||||
expect(result.isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should warn about high temperature', () => {
|
||||
const result = validator.validateModelConfig({ temp: 1.5 });
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'High temperature (>1) may produce very creative but unpredictable results',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid top_p values', () => {
|
||||
const invalidTopP = [-0.1, 1.1, 'not-a-number'];
|
||||
|
||||
for (const top_p of invalidTopP) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = validator.validateModelConfig({ top_p: top_p as any });
|
||||
expect(result.isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRunConfig', () => {
|
||||
it('should accept valid run configurations', () => {
|
||||
const validConfigs = [
|
||||
{ max_time_minutes: 10, max_turns: 20 },
|
||||
{ max_time_minutes: 5 },
|
||||
{ max_turns: 10 },
|
||||
{},
|
||||
];
|
||||
|
||||
for (const config of validConfigs) {
|
||||
const result = validator.validateRunConfig(config);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid max_time_minutes', () => {
|
||||
const invalidTimes = [0, -1, 'not-a-number'];
|
||||
|
||||
for (const time of invalidTimes) {
|
||||
const result = validator.validateRunConfig({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
max_time_minutes: time as any,
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should warn about very long execution times', () => {
|
||||
const result = validator.validateRunConfig({ max_time_minutes: 120 });
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Very long execution time (>60 minutes) may cause resource issues',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid max_turns', () => {
|
||||
const invalidTurns = [0, -1, 1.5, 'not-a-number'];
|
||||
|
||||
for (const turns of invalidTurns) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = validator.validateRunConfig({ max_turns: turns as any });
|
||||
expect(result.isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should warn about high turn limits', () => {
|
||||
const result = validator.validateRunConfig({ max_turns: 150 });
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Very high turn limit (>100) may cause long execution times',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
const validConfig: SubagentConfig = {
|
||||
name: 'test-agent',
|
||||
description: 'A test subagent',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'project',
|
||||
filePath: '/path/to/test-agent.md',
|
||||
};
|
||||
|
||||
it('should accept valid configurations', () => {
|
||||
const result = validator.validateConfig(validConfig);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should collect errors from all validation steps', () => {
|
||||
const invalidConfig: SubagentConfig = {
|
||||
name: '',
|
||||
description: '',
|
||||
systemPrompt: '',
|
||||
level: 'project',
|
||||
filePath: '/path/to/invalid.md',
|
||||
};
|
||||
|
||||
const result = validator.validateConfig(invalidConfig);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should collect warnings from all validation steps', () => {
|
||||
const configWithWarnings: SubagentConfig = {
|
||||
...validConfig,
|
||||
name: 'TestAgent', // Will generate warning about case
|
||||
description: 'A'.repeat(501), // Will generate warning about long description
|
||||
};
|
||||
|
||||
const result = validator.validateConfig(configWithWarnings);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOrThrow', () => {
|
||||
const validConfig: SubagentConfig = {
|
||||
name: 'test-agent',
|
||||
description: 'A test subagent',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
level: 'project',
|
||||
filePath: '/path/to/test-agent.md',
|
||||
};
|
||||
|
||||
it('should not throw for valid configurations', () => {
|
||||
expect(() => validator.validateOrThrow(validConfig)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw SubagentError for invalid configurations', () => {
|
||||
const invalidConfig: SubagentConfig = {
|
||||
...validConfig,
|
||||
name: '',
|
||||
};
|
||||
|
||||
expect(() => validator.validateOrThrow(invalidConfig)).toThrow(
|
||||
SubagentError,
|
||||
);
|
||||
expect(() => validator.validateOrThrow(invalidConfig)).toThrow(
|
||||
/Validation failed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include subagent name in error', () => {
|
||||
const invalidConfig: SubagentConfig = {
|
||||
...validConfig,
|
||||
name: '',
|
||||
};
|
||||
|
||||
try {
|
||||
validator.validateOrThrow(invalidConfig, 'custom-name');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SubagentError);
|
||||
expect((error as SubagentError).subagentName).toBe('custom-name');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
355
packages/core/src/subagents/validation.ts
Normal file
355
packages/core/src/subagents/validation.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
SubagentConfig,
|
||||
ValidationResult,
|
||||
SubagentError,
|
||||
SubagentErrorCode,
|
||||
ModelConfig,
|
||||
RunConfig,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Validates subagent configurations to ensure they are well-formed
|
||||
* and compatible with the runtime system.
|
||||
*/
|
||||
export class SubagentValidator {
|
||||
/**
|
||||
* Validates a complete subagent configuration.
|
||||
*
|
||||
* @param config - The subagent configuration to validate
|
||||
* @returns ValidationResult with errors and warnings
|
||||
*/
|
||||
validateConfig(config: SubagentConfig): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate name
|
||||
const nameValidation = this.validateName(config.name);
|
||||
if (!nameValidation.isValid) {
|
||||
errors.push(...nameValidation.errors);
|
||||
}
|
||||
|
||||
// Validate description
|
||||
if (!config.description || config.description.trim().length === 0) {
|
||||
errors.push('Description is required and cannot be empty');
|
||||
} else if (config.description.length > 500) {
|
||||
warnings.push(
|
||||
'Description is quite long (>500 chars), consider shortening for better readability',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate system prompt
|
||||
const promptValidation = this.validateSystemPrompt(config.systemPrompt);
|
||||
if (!promptValidation.isValid) {
|
||||
errors.push(...promptValidation.errors);
|
||||
}
|
||||
warnings.push(...promptValidation.warnings);
|
||||
|
||||
// Validate tools if specified
|
||||
if (config.tools) {
|
||||
const toolsValidation = this.validateTools(config.tools);
|
||||
if (!toolsValidation.isValid) {
|
||||
errors.push(...toolsValidation.errors);
|
||||
}
|
||||
warnings.push(...toolsValidation.warnings);
|
||||
}
|
||||
|
||||
// Validate model config if specified
|
||||
if (config.modelConfig) {
|
||||
const modelValidation = this.validateModelConfig(config.modelConfig);
|
||||
if (!modelValidation.isValid) {
|
||||
errors.push(...modelValidation.errors);
|
||||
}
|
||||
warnings.push(...modelValidation.warnings);
|
||||
}
|
||||
|
||||
// Validate run config if specified
|
||||
if (config.runConfig) {
|
||||
const runValidation = this.validateRunConfig(config.runConfig);
|
||||
if (!runValidation.isValid) {
|
||||
errors.push(...runValidation.errors);
|
||||
}
|
||||
warnings.push(...runValidation.warnings);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a subagent name.
|
||||
* Names must be valid identifiers that can be used in file paths and tool calls.
|
||||
*
|
||||
* @param name - The name to validate
|
||||
* @returns ValidationResult
|
||||
*/
|
||||
validateName(name: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
errors.push('Name is required and cannot be empty');
|
||||
return { isValid: false, errors, warnings };
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// Check length constraints
|
||||
if (trimmedName.length < 2) {
|
||||
errors.push('Name must be at least 2 characters long');
|
||||
}
|
||||
|
||||
if (trimmedName.length > 50) {
|
||||
errors.push('Name must be 50 characters or less');
|
||||
}
|
||||
|
||||
// Check valid characters (alphanumeric, hyphens, underscores)
|
||||
const validNameRegex = /^[a-zA-Z0-9_-]+$/;
|
||||
if (!validNameRegex.test(trimmedName)) {
|
||||
errors.push(
|
||||
'Name can only contain letters, numbers, hyphens, and underscores',
|
||||
);
|
||||
}
|
||||
|
||||
// Check that it doesn't start or end with special characters
|
||||
if (trimmedName.startsWith('-') || trimmedName.startsWith('_')) {
|
||||
errors.push('Name cannot start with a hyphen or underscore');
|
||||
}
|
||||
|
||||
if (trimmedName.endsWith('-') || trimmedName.endsWith('_')) {
|
||||
errors.push('Name cannot end with a hyphen or underscore');
|
||||
}
|
||||
|
||||
// Check for reserved names
|
||||
const reservedNames = [
|
||||
'self',
|
||||
'system',
|
||||
'user',
|
||||
'model',
|
||||
'tool',
|
||||
'config',
|
||||
'default',
|
||||
];
|
||||
if (reservedNames.includes(trimmedName.toLowerCase())) {
|
||||
errors.push(`"${trimmedName}" is a reserved name and cannot be used`);
|
||||
}
|
||||
|
||||
// Warnings for naming conventions
|
||||
if (trimmedName !== trimmedName.toLowerCase()) {
|
||||
warnings.push('Consider using lowercase names for consistency');
|
||||
}
|
||||
|
||||
if (trimmedName.includes('_') && trimmedName.includes('-')) {
|
||||
warnings.push(
|
||||
'Consider using either hyphens or underscores consistently, not both',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a system prompt.
|
||||
*
|
||||
* @param prompt - The system prompt to validate
|
||||
* @returns ValidationResult
|
||||
*/
|
||||
validateSystemPrompt(prompt: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
errors.push('System prompt is required and cannot be empty');
|
||||
return { isValid: false, errors, warnings };
|
||||
}
|
||||
|
||||
const trimmedPrompt = prompt.trim();
|
||||
|
||||
// Check minimum length for meaningful prompts
|
||||
if (trimmedPrompt.length < 10) {
|
||||
errors.push('System prompt must be at least 10 characters long');
|
||||
}
|
||||
|
||||
// Check maximum length to prevent token issues
|
||||
if (trimmedPrompt.length > 10000) {
|
||||
errors.push('System prompt is too long (>10,000 characters)');
|
||||
} else if (trimmedPrompt.length > 5000) {
|
||||
warnings.push(
|
||||
'System prompt is quite long (>5,000 characters), consider shortening',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a list of tool names.
|
||||
*
|
||||
* @param tools - Array of tool names to validate
|
||||
* @returns ValidationResult
|
||||
*/
|
||||
validateTools(tools: string[]): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!Array.isArray(tools)) {
|
||||
errors.push('Tools must be an array of strings');
|
||||
return { isValid: false, errors, warnings };
|
||||
}
|
||||
|
||||
if (tools.length === 0) {
|
||||
warnings.push(
|
||||
'Empty tools array - subagent will inherit all available tools',
|
||||
);
|
||||
return { isValid: true, errors, warnings };
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const uniqueTools = new Set(tools);
|
||||
if (uniqueTools.size !== tools.length) {
|
||||
warnings.push('Duplicate tool names found in tools array');
|
||||
}
|
||||
|
||||
// Validate each tool name
|
||||
for (const tool of tools) {
|
||||
if (typeof tool !== 'string') {
|
||||
errors.push(`Tool name must be a string, got: ${typeof tool}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tool.trim().length === 0) {
|
||||
errors.push('Tool name cannot be empty');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates model configuration.
|
||||
*
|
||||
* @param modelConfig - Partial model configuration to validate
|
||||
* @returns ValidationResult
|
||||
*/
|
||||
validateModelConfig(modelConfig: ModelConfig): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (modelConfig.model !== undefined) {
|
||||
if (
|
||||
typeof modelConfig.model !== 'string' ||
|
||||
modelConfig.model.trim().length === 0
|
||||
) {
|
||||
errors.push('Model name must be a non-empty string');
|
||||
}
|
||||
}
|
||||
|
||||
if (modelConfig.temp !== undefined) {
|
||||
if (typeof modelConfig.temp !== 'number') {
|
||||
errors.push('Temperature must be a number');
|
||||
} else if (modelConfig.temp < 0 || modelConfig.temp > 2) {
|
||||
errors.push('Temperature must be between 0 and 2');
|
||||
} else if (modelConfig.temp > 1) {
|
||||
warnings.push(
|
||||
'High temperature (>1) may produce very creative but unpredictable results',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (modelConfig.top_p !== undefined) {
|
||||
if (typeof modelConfig.top_p !== 'number') {
|
||||
errors.push('top_p must be a number');
|
||||
} else if (modelConfig.top_p < 0 || modelConfig.top_p > 1) {
|
||||
errors.push('top_p must be between 0 and 1');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates runtime configuration.
|
||||
*
|
||||
* @param runConfig - Partial run configuration to validate
|
||||
* @returns ValidationResult
|
||||
*/
|
||||
validateRunConfig(runConfig: RunConfig): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (runConfig.max_time_minutes !== undefined) {
|
||||
if (typeof runConfig.max_time_minutes !== 'number') {
|
||||
errors.push('max_time_minutes must be a number');
|
||||
} else if (runConfig.max_time_minutes <= 0) {
|
||||
errors.push('max_time_minutes must be greater than 0');
|
||||
} else if (runConfig.max_time_minutes > 60) {
|
||||
warnings.push(
|
||||
'Very long execution time (>60 minutes) may cause resource issues',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (runConfig.max_turns !== undefined) {
|
||||
if (typeof runConfig.max_turns !== 'number') {
|
||||
errors.push('max_turns must be a number');
|
||||
} else if (runConfig.max_turns <= 0) {
|
||||
errors.push('max_turns must be greater than 0');
|
||||
} else if (!Number.isInteger(runConfig.max_turns)) {
|
||||
errors.push('max_turns must be an integer');
|
||||
} else if (runConfig.max_turns > 100) {
|
||||
warnings.push(
|
||||
'Very high turn limit (>100) may cause long execution times',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a SubagentError if validation fails.
|
||||
*
|
||||
* @param config - Configuration to validate
|
||||
* @param subagentName - Name for error context
|
||||
* @throws SubagentError if validation fails
|
||||
*/
|
||||
validateOrThrow(config: SubagentConfig, subagentName?: string): void {
|
||||
const result = this.validateConfig(config);
|
||||
if (!result.isValid) {
|
||||
throw new SubagentError(
|
||||
`Validation failed: ${result.errors.join(', ')}`,
|
||||
SubagentErrorCode.VALIDATION_ERROR,
|
||||
subagentName || config.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user