feat: Implement subagents phase 1 with file-based configuration system

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin
2025-09-02 14:02:30 +08:00
parent f024bba2ef
commit c49e4f6e8a
11 changed files with 2723 additions and 7 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ bower_components
# Editors
.idea
*.iml
.cursor
# OS metadata
.DS_Store

View File

@@ -118,15 +118,15 @@ export interface ModelConfig {
*
* TODO: In the future, this needs to support 'auto' or some other string to support routing use cases.
*/
model: string;
model?: string;
/**
* The temperature for the model's sampling process.
*/
temp: number;
temp?: number;
/**
* The top-p value for nucleus sampling.
*/
top_p: number;
top_p?: number;
}
/**
@@ -138,7 +138,7 @@ export interface ModelConfig {
*/
export interface RunConfig {
/** The maximum execution time for the subagent in minutes. */
max_time_minutes: number;
max_time_minutes?: number;
/**
* The maximum number of conversational turns (a user message + model response)
* before the execution is terminated. Helps prevent infinite loops.
@@ -387,7 +387,10 @@ export class SubAgentScope {
break;
}
let durationMin = (Date.now() - startTime) / (1000 * 60);
if (durationMin >= this.runConfig.max_time_minutes) {
if (
this.runConfig.max_time_minutes &&
durationMin >= this.runConfig.max_time_minutes
) {
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT;
break;
}
@@ -413,7 +416,10 @@ export class SubAgentScope {
}
durationMin = (Date.now() - startTime) / (1000 * 60);
if (durationMin >= this.runConfig.max_time_minutes) {
if (
this.runConfig.max_time_minutes &&
durationMin >= this.runConfig.max_time_minutes
) {
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT;
break;
}
@@ -588,7 +594,9 @@ export class SubAgentScope {
this.runtimeContext.getSessionId(),
);
this.runtimeContext.setModel(this.modelConfig.model);
if (this.modelConfig.model) {
this.runtimeContext.setModel(this.modelConfig.model);
}
return new GeminiChat(
this.runtimeContext,

View File

@@ -67,6 +67,9 @@ export * from './tools/tools.js';
export * from './tools/tool-error.js';
export * from './tools/tool-registry.js';
// Export subagents (Phase 1)
export * from './subagents/index.js';
// Export prompt logic
export * from './prompts/mcp-prompts.js';

View File

@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2025 Google LLC
* 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 specified
* in the Claude Code product manual, with storage at both project and user levels.
*/
// Core types and interfaces
export type {
SubagentConfig,
SubagentMetadata,
SubagentLevel,
SubagentRuntimeConfig,
ValidationResult,
ListSubagentsOptions,
CreateSubagentOptions,
SubagentErrorCode,
} from './types.js';
export { SubagentError } from './types.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,
OutputObject,
} from '../core/subagent.js';
export { SubAgentScope } from '../core/subagent.js';

View File

@@ -0,0 +1,800 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { SubagentManager } from './subagent-manager.js';
import { SubagentConfig, SubagentError } from './types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
// Mock file system operations
vi.mock('fs/promises');
vi.mock('os');
// Mock yaml parser - use vi.hoisted for proper hoisting
const mockParseYaml = vi.hoisted(() => vi.fn());
const mockStringifyYaml = vi.hoisted(() => vi.fn());
vi.mock('../utils/yaml-parser.js', () => ({
parse: mockParseYaml,
stringify: mockStringifyYaml,
}));
// Mock dependencies - create mock functions at the top level
const mockValidateConfig = vi.hoisted(() => vi.fn());
const mockValidateOrThrow = vi.hoisted(() => vi.fn());
vi.mock('./validation.js', () => ({
SubagentValidator: class MockSubagentValidator {
validateConfig = mockValidateConfig;
validateOrThrow = mockValidateOrThrow;
},
}));
vi.mock('../core/subagent.js');
describe('SubagentManager', () => {
let manager: SubagentManager;
let mockToolRegistry: ToolRegistry;
const projectRoot = '/test/project';
beforeEach(() => {
mockToolRegistry = {} as ToolRegistry;
// Mock os.homedir
vi.mocked(os.homedir).mockReturnValue('/home/user');
// Reset and setup mocks
vi.clearAllMocks();
mockValidateConfig.mockReturnValue({
isValid: true,
errors: [],
warnings: [],
});
mockValidateOrThrow.mockImplementation(() => {});
// Setup yaml parser mocks with sophisticated behavior
mockParseYaml.mockImplementation((yamlString: string) => {
// Handle different test cases based on YAML content
if (yamlString.includes('tools:')) {
return {
name: 'test-agent',
description: 'A test subagent',
tools: ['read_file', 'write_file'],
};
}
if (yamlString.includes('modelConfig:')) {
return {
name: 'test-agent',
description: 'A test subagent',
modelConfig: { model: 'custom-model', temp: 0.5 },
};
}
if (yamlString.includes('runConfig:')) {
return {
name: 'test-agent',
description: 'A test subagent',
runConfig: { max_time_minutes: 5, max_turns: 10 },
};
}
if (yamlString.includes('name: agent1')) {
return { name: 'agent1', description: 'First agent' };
}
if (yamlString.includes('name: agent2')) {
return { name: 'agent2', description: 'Second agent' };
}
if (yamlString.includes('name: agent3')) {
return { name: 'agent3', description: 'Third agent' };
}
if (!yamlString.includes('name:')) {
return { description: 'A test subagent' }; // Missing name case
}
if (!yamlString.includes('description:')) {
return { name: 'test-agent' }; // Missing description case
}
// Default case
return {
name: 'test-agent',
description: 'A test subagent',
};
});
mockStringifyYaml.mockImplementation((obj: Record<string, unknown>) => {
let yaml = '';
for (const [key, value] of Object.entries(obj)) {
if (key === 'tools' && Array.isArray(value)) {
yaml += `tools:\n${value.map((tool) => ` - ${tool}`).join('\n')}\n`;
} else if (
key === 'modelConfig' &&
typeof value === 'object' &&
value
) {
yaml += `modelConfig:\n`;
for (const [k, v] of Object.entries(
value as Record<string, unknown>,
)) {
yaml += ` ${k}: ${v}\n`;
}
} else if (key === 'runConfig' && typeof value === 'object' && value) {
yaml += `runConfig:\n`;
for (const [k, v] of Object.entries(
value as Record<string, unknown>,
)) {
yaml += ` ${k}: ${v}\n`;
}
} else {
yaml += `${key}: ${value}\n`;
}
}
return yaml.trim();
});
manager = new SubagentManager(projectRoot, mockToolRegistry);
});
afterEach(() => {
vi.restoreAllMocks();
});
const validConfig: SubagentConfig = {
name: 'test-agent',
description: 'A test subagent',
systemPrompt: 'You are a helpful assistant.',
level: 'project',
filePath: '/test/project/.qwen/agents/test-agent.md',
};
const validMarkdown = `---
name: test-agent
description: A test subagent
---
You are a helpful assistant.
`;
describe('parseSubagentContent', () => {
it('should parse valid markdown content', () => {
const config = manager.parseSubagentContent(
validMarkdown,
validConfig.filePath,
);
expect(config.name).toBe('test-agent');
expect(config.description).toBe('A test subagent');
expect(config.systemPrompt).toBe('You are a helpful assistant.');
expect(config.level).toBe('project');
expect(config.filePath).toBe(validConfig.filePath);
});
it('should parse content with tools', () => {
const markdownWithTools = `---
name: test-agent
description: A test subagent
tools:
- read_file
- write_file
---
You are a helpful assistant.
`;
const config = manager.parseSubagentContent(
markdownWithTools,
validConfig.filePath,
);
expect(config.tools).toEqual(['read_file', 'write_file']);
});
it('should parse content with model config', () => {
const markdownWithModel = `---
name: test-agent
description: A test subagent
modelConfig:
model: custom-model
temp: 0.5
---
You are a helpful assistant.
`;
const config = manager.parseSubagentContent(
markdownWithModel,
validConfig.filePath,
);
expect(config.modelConfig).toEqual({ model: 'custom-model', temp: 0.5 });
});
it('should parse content with run config', () => {
const markdownWithRun = `---
name: test-agent
description: A test subagent
runConfig:
max_time_minutes: 5
max_turns: 10
---
You are a helpful assistant.
`;
const config = manager.parseSubagentContent(
markdownWithRun,
validConfig.filePath,
);
expect(config.runConfig).toEqual({ max_time_minutes: 5, max_turns: 10 });
});
it('should determine level from file path', () => {
const projectPath = '/test/project/.qwen/agents/test-agent.md';
const userPath = '/home/user/.qwen/agents/test-agent.md';
const projectConfig = manager.parseSubagentContent(
validMarkdown,
projectPath,
);
const userConfig = manager.parseSubagentContent(validMarkdown, userPath);
expect(projectConfig.level).toBe('project');
expect(userConfig.level).toBe('user');
});
it('should throw error for invalid frontmatter format', () => {
const invalidMarkdown = `No frontmatter here
Just content`;
expect(() =>
manager.parseSubagentContent(invalidMarkdown, validConfig.filePath),
).toThrow(SubagentError);
});
it('should throw error for missing name', () => {
const markdownWithoutName = `---
description: A test subagent
---
You are a helpful assistant.
`;
expect(() =>
manager.parseSubagentContent(markdownWithoutName, validConfig.filePath),
).toThrow(SubagentError);
});
it('should throw error for missing description', () => {
const markdownWithoutDescription = `---
name: test-agent
---
You are a helpful assistant.
`;
expect(() =>
manager.parseSubagentContent(
markdownWithoutDescription,
validConfig.filePath,
),
).toThrow(SubagentError);
});
});
describe('serializeSubagent', () => {
it('should serialize basic configuration', () => {
const serialized = manager.serializeSubagent(validConfig);
expect(serialized).toContain('name: test-agent');
expect(serialized).toContain('description: A test subagent');
expect(serialized).toContain('You are a helpful assistant.');
expect(serialized).toMatch(/^---\n[\s\S]*\n---\n\n[\s\S]*\n$/);
});
it('should serialize configuration with tools', () => {
const configWithTools: SubagentConfig = {
...validConfig,
tools: ['read_file', 'write_file'],
};
const serialized = manager.serializeSubagent(configWithTools);
expect(serialized).toContain('tools:');
expect(serialized).toContain('- read_file');
expect(serialized).toContain('- write_file');
});
it('should serialize configuration with model config', () => {
const configWithModel: SubagentConfig = {
...validConfig,
modelConfig: { model: 'custom-model', temp: 0.5 },
};
const serialized = manager.serializeSubagent(configWithModel);
expect(serialized).toContain('modelConfig:');
expect(serialized).toContain('model: custom-model');
expect(serialized).toContain('temp: 0.5');
});
it('should not include empty optional fields', () => {
const serialized = manager.serializeSubagent(validConfig);
expect(serialized).not.toContain('tools:');
expect(serialized).not.toContain('modelConfig:');
expect(serialized).not.toContain('runConfig:');
});
});
describe('createSubagent', () => {
beforeEach(() => {
// Mock successful file operations
vi.mocked(fs.access).mockRejectedValue(new Error('File not found'));
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should create subagent successfully', async () => {
await manager.createSubagent(validConfig, { level: 'project' });
expect(fs.mkdir).toHaveBeenCalledWith(
path.dirname(validConfig.filePath),
{ recursive: true },
);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('test-agent.md'),
expect.stringContaining('name: test-agent'),
'utf8',
);
});
it('should throw error if file already exists and overwrite is false', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined); // File exists
await expect(
manager.createSubagent(validConfig, { level: 'project' }),
).rejects.toThrow(SubagentError);
await expect(
manager.createSubagent(validConfig, { level: 'project' }),
).rejects.toThrow(/already exists/);
});
it('should overwrite file when overwrite is true', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined); // File exists
await manager.createSubagent(validConfig, {
level: 'project',
overwrite: true,
});
expect(fs.writeFile).toHaveBeenCalled();
});
it('should use custom path when provided', async () => {
const customPath = '/custom/path/agent.md';
await manager.createSubagent(validConfig, {
level: 'project',
customPath,
});
expect(fs.writeFile).toHaveBeenCalledWith(
customPath,
expect.any(String),
'utf8',
);
});
it('should throw error on file write failure', async () => {
vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write failed'));
await expect(
manager.createSubagent(validConfig, { level: 'project' }),
).rejects.toThrow(SubagentError);
await expect(
manager.createSubagent(validConfig, { level: 'project' }),
).rejects.toThrow(/Failed to write subagent file/);
});
});
describe('loadSubagent', () => {
it('should load subagent from project level first', async () => {
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
const config = await manager.loadSubagent('test-agent');
expect(config).toBeDefined();
expect(config!.name).toBe('test-agent');
expect(fs.readFile).toHaveBeenCalledWith(
'/test/project/.qwen/agents/test-agent.md',
'utf8',
);
});
it('should fall back to user level if project level fails', async () => {
vi.mocked(fs.readFile)
.mockRejectedValueOnce(new Error('Not found'))
.mockResolvedValueOnce(validMarkdown);
const config = await manager.loadSubagent('test-agent');
expect(config).toBeDefined();
expect(config!.name).toBe('test-agent');
expect(fs.readFile).toHaveBeenCalledWith(
'/home/user/.qwen/agents/test-agent.md',
'utf8',
);
});
it('should return null if not found at either level', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Not found'));
const config = await manager.loadSubagent('nonexistent');
expect(config).toBeNull();
});
});
describe('updateSubagent', () => {
beforeEach(() => {
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
});
it('should update existing subagent', async () => {
const updates = { description: 'Updated description' };
await manager.updateSubagent('test-agent', updates);
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('test-agent.md'),
expect.stringContaining('Updated description'),
'utf8',
);
});
it('should throw error if subagent not found', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Not found'));
await expect(manager.updateSubagent('nonexistent', {})).rejects.toThrow(
SubagentError,
);
await expect(manager.updateSubagent('nonexistent', {})).rejects.toThrow(
/not found/,
);
});
it('should throw error on write failure', async () => {
vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write failed'));
await expect(manager.updateSubagent('test-agent', {})).rejects.toThrow(
SubagentError,
);
await expect(manager.updateSubagent('test-agent', {})).rejects.toThrow(
/Failed to update subagent file/,
);
});
});
describe('deleteSubagent', () => {
it('should delete subagent from specified level', async () => {
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await manager.deleteSubagent('test-agent', 'project');
expect(fs.unlink).toHaveBeenCalledWith(
'/test/project/.qwen/agents/test-agent.md',
);
});
it('should delete from both levels if no level specified', async () => {
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await manager.deleteSubagent('test-agent');
expect(fs.unlink).toHaveBeenCalledTimes(2);
expect(fs.unlink).toHaveBeenCalledWith(
'/test/project/.qwen/agents/test-agent.md',
);
expect(fs.unlink).toHaveBeenCalledWith(
'/home/user/.qwen/agents/test-agent.md',
);
});
it('should throw error if subagent not found', async () => {
vi.mocked(fs.unlink).mockRejectedValue(new Error('Not found'));
await expect(manager.deleteSubagent('nonexistent')).rejects.toThrow(
SubagentError,
);
await expect(manager.deleteSubagent('nonexistent')).rejects.toThrow(
/not found/,
);
});
it('should succeed if deleted from at least one level', async () => {
vi.mocked(fs.unlink)
.mockRejectedValueOnce(new Error('Not found'))
.mockResolvedValueOnce(undefined);
await expect(manager.deleteSubagent('test-agent')).resolves.not.toThrow();
});
});
describe('listSubagents', () => {
beforeEach(() => {
// Mock directory listing
vi.mocked(fs.readdir)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce(['agent1.md', 'agent2.md', 'not-md.txt'] as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce(['agent3.md', 'agent1.md'] as any); // user level
// Mock file reading for valid agents
vi.mocked(fs.readFile).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('agent1.md')) {
return Promise.resolve(`---
name: agent1
description: First agent
---
System prompt 1`);
} else if (pathStr.includes('agent2.md')) {
return Promise.resolve(`---
name: agent2
description: Second agent
---
System prompt 2`);
} else if (pathStr.includes('agent3.md')) {
return Promise.resolve(`---
name: agent3
description: Third agent
---
System prompt 3`);
}
return Promise.reject(new Error('File not found'));
});
});
it('should list subagents from both levels', async () => {
const subagents = await manager.listSubagents();
expect(subagents).toHaveLength(3); // agent1 (project takes precedence), agent2, agent3
expect(subagents.map((s) => s.name)).toEqual([
'agent1',
'agent2',
'agent3',
]);
});
it('should prioritize project level over user level', async () => {
const subagents = await manager.listSubagents();
const agent1 = subagents.find((s) => s.name === 'agent1');
expect(agent1!.level).toBe('project');
});
it('should filter by level', async () => {
const projectSubagents = await manager.listSubagents({
level: 'project',
});
expect(projectSubagents).toHaveLength(2); // agent1, agent2
expect(projectSubagents.every((s) => s.level === 'project')).toBe(true);
});
it('should sort by name', async () => {
const subagents = await manager.listSubagents({
sortBy: 'name',
sortOrder: 'asc',
});
const names = subagents.map((s) => s.name);
expect(names).toEqual(['agent1', 'agent2', 'agent3']);
});
it('should handle empty directories', async () => {
// Reset all mocks for this specific test
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.readdir).mockResolvedValue([] as any);
vi.mocked(fs.readFile).mockRejectedValue(new Error('No files'));
const subagents = await manager.listSubagents();
expect(subagents).toHaveLength(0);
});
it('should handle directory read errors', async () => {
// Reset all mocks for this specific test
vi.mocked(fs.readdir).mockRejectedValue(new Error('Directory not found'));
vi.mocked(fs.readFile).mockRejectedValue(new Error('No files'));
const subagents = await manager.listSubagents();
expect(subagents).toHaveLength(0);
});
it('should skip invalid subagent files', async () => {
// Reset all mocks for this specific test
vi.mocked(fs.readdir)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce(['valid.md', 'invalid.md'] as any)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce(['valid.md', 'invalid.md'] as any); // user level
vi.mocked(fs.readFile)
.mockResolvedValueOnce(validMarkdown) // valid.md project level
.mockRejectedValueOnce(new Error('Invalid YAML')) // invalid.md project level
.mockRejectedValueOnce(new Error('Not found')) // valid.md user level (already found)
.mockRejectedValueOnce(new Error('Invalid YAML')); // invalid.md user level
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const subagents = await manager.listSubagents();
expect(subagents).toHaveLength(1);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Skipping invalid subagent file'),
);
consoleSpy.mockRestore();
});
});
describe('findSubagentByName', () => {
it('should find existing subagent', async () => {
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
const metadata = await manager.findSubagentByName('test-agent');
expect(metadata).toBeDefined();
expect(metadata!.name).toBe('test-agent');
expect(metadata!.description).toBe('A test subagent');
});
it('should return null for non-existent subagent', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Not found'));
const metadata = await manager.findSubagentByName('nonexistent');
expect(metadata).toBeNull();
});
});
describe('isNameAvailable', () => {
it('should return true for available names', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Not found'));
const available = await manager.isNameAvailable('new-agent');
expect(available).toBe(true);
});
it('should return false for existing names', async () => {
vi.mocked(fs.readFile).mockResolvedValue(validMarkdown);
const available = await manager.isNameAvailable('test-agent');
expect(available).toBe(false);
});
it('should check specific level when provided', async () => {
// The isNameAvailable method loads from both levels and checks if found subagent is at different level
// First call: loads subagent (found at user level), checks if it's at project level (different) -> available
vi.mocked(fs.readFile)
.mockRejectedValueOnce(new Error('Not found')) // project level
.mockResolvedValueOnce(validMarkdown); // user level - found here
const availableAtProject = await manager.isNameAvailable(
'test-agent',
'project',
);
expect(availableAtProject).toBe(true); // Available at project because found at user level
// Second call: loads subagent (found at user level), checks if it's at user level (same) -> not available
vi.mocked(fs.readFile)
.mockRejectedValueOnce(new Error('Not found')) // project level
.mockResolvedValueOnce(validMarkdown); // user level - found here
const availableAtUser = await manager.isNameAvailable(
'test-agent',
'user',
);
expect(availableAtUser).toBe(false); // Not available at user because found at user level
});
});
describe('Runtime Configuration Methods', () => {
describe('convertToRuntimeConfig', () => {
it('should convert basic configuration', () => {
const runtimeConfig = manager.convertToRuntimeConfig(validConfig);
expect(runtimeConfig.promptConfig.systemPrompt).toBe(
validConfig.systemPrompt,
);
expect(runtimeConfig.modelConfig).toEqual({});
expect(runtimeConfig.runConfig).toEqual({});
expect(runtimeConfig.toolConfig).toBeUndefined();
});
it('should include tool configuration when tools are specified', () => {
const configWithTools: SubagentConfig = {
...validConfig,
tools: ['read_file', 'write_file'],
};
const runtimeConfig = manager.convertToRuntimeConfig(configWithTools);
expect(runtimeConfig.toolConfig).toBeDefined();
expect(runtimeConfig.toolConfig!.tools).toEqual([
'read_file',
'write_file',
]);
});
it('should merge custom model and run configurations', () => {
const configWithCustom: SubagentConfig = {
...validConfig,
modelConfig: { model: 'custom-model', temp: 0.5 },
runConfig: { max_time_minutes: 5 },
};
const runtimeConfig = manager.convertToRuntimeConfig(configWithCustom);
expect(runtimeConfig.modelConfig.model).toBe('custom-model');
expect(runtimeConfig.modelConfig.temp).toBe(0.5);
expect(runtimeConfig.runConfig.max_time_minutes).toBe(5);
// No default values are provided anymore
expect(Object.keys(runtimeConfig.modelConfig)).toEqual([
'model',
'temp',
]);
expect(Object.keys(runtimeConfig.runConfig)).toEqual([
'max_time_minutes',
]);
});
});
describe('mergeConfigurations', () => {
it('should merge basic properties', () => {
const updates = {
description: 'Updated description',
systemPrompt: 'Updated prompt',
};
const merged = manager.mergeConfigurations(validConfig, updates);
expect(merged.description).toBe('Updated description');
expect(merged.systemPrompt).toBe('Updated prompt');
expect(merged.name).toBe(validConfig.name); // Should keep original
});
it('should merge nested configurations', () => {
const configWithNested: SubagentConfig = {
...validConfig,
modelConfig: { model: 'original-model', temp: 0.7 },
runConfig: { max_time_minutes: 10, max_turns: 20 },
};
const updates = {
modelConfig: { temp: 0.5 },
runConfig: { max_time_minutes: 5 },
};
const merged = manager.mergeConfigurations(configWithNested, updates);
expect(merged.modelConfig!.model).toBe('original-model'); // Should keep original
expect(merged.modelConfig!.temp).toBe(0.5); // Should update
expect(merged.runConfig!.max_time_minutes).toBe(5); // Should update
expect(merged.runConfig!.max_turns).toBe(20); // Should keep original
});
});
});
});

View File

@@ -0,0 +1,647 @@
/**
* @license
* Copyright 2025 Google LLC
* 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,
SubagentMetadata,
SubagentLevel,
ListSubagentsOptions,
CreateSubagentOptions,
SubagentError,
SubagentErrorCode,
} from './types.js';
import { SubagentValidator } from './validation.js';
import {
SubAgentScope,
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from '../core/subagent.js';
import { Config } from '../config/config.js';
import { ToolRegistry } from '../tools/tool-registry.js';
const QWEN_CONFIG_DIR = '.qwen';
const AGENT_CONFIG_DIR = 'agents';
/**
* 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 projectRoot: string,
private readonly toolRegistry?: ToolRegistry,
) {
this.validator = new SubagentValidator(toolRegistry);
}
/**
* 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> {
// Validate the configuration
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.
* Searches project-level first, then user-level.
*
* @param name - Name of the subagent to load
* @returns SubagentConfig or null if not found
*/
async loadSubagent(name: string): Promise<SubagentConfig | null> {
// Try project level first
const projectPath = this.getSubagentPath(name, 'project');
try {
const config = await this.parseSubagentFile(projectPath);
return config;
} catch (_error) {
// Continue to user level
}
// Try user level
const userPath = this.getSubagentPath(name, 'user');
try {
const config = await this.parseSubagentFile(userPath);
return config;
} catch (_error) {
// Not found at either level
return null;
}
}
/**
* 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>,
): Promise<void> {
const existing = await this.loadSubagent(name);
if (!existing) {
throw new SubagentError(
`Subagent "${name}" not found`,
SubagentErrorCode.NOT_FOUND,
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> {
const levelsToCheck: SubagentLevel[] = level
? [level]
: ['project', 'user'];
let deleted = false;
for (const currentLevel of levelsToCheck) {
const filePath = this.getSubagentPath(name, currentLevel);
try {
await fs.unlink(filePath);
deleted = true;
} catch (_error) {
// File might not exist at this level, 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<SubagentMetadata[]> {
const subagents: SubagentMetadata[] = [];
const seenNames = new Set<string>();
const levelsToCheck: SubagentLevel[] = options.level
? [options.level]
: ['project', 'user'];
// Collect subagents from each level (project takes precedence)
for (const level of levelsToCheck) {
const levelSubagents = await this.listSubagentsAtLevel(level);
for (const subagent of levelSubagents) {
// Skip if we've already seen this name (project takes precedence)
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 'lastModified': {
const aTime = a.lastModified?.getTime() || 0;
const bTime = b.lastModified?.getTime() || 0;
comparison = aTime - bTime;
break;
}
case 'level':
// Project comes before user
comparison =
a.level === 'project' ? -1 : b.level === 'project' ? 1 : 0;
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 SubagentMetadata or null if not found
*/
async findSubagentByName(name: string): Promise<SubagentMetadata | null> {
const config = await this.loadSubagent(name);
if (!config) {
return null;
}
return this.configToMetadata(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
const name = frontmatter['name'] as string;
const description = frontmatter['description'] as string;
if (!name || typeof name !== 'string') {
throw new Error('Missing or invalid "name" in frontmatter');
}
if (!description || typeof description !== 'string') {
throw new Error('Missing or invalid "description" in frontmatter');
}
// 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;
// Determine level from file path
// Project level paths contain the project root, user level paths are in home directory
const isProjectLevel =
filePath.includes(this.projectRoot) &&
filePath.includes(`/${QWEN_CONFIG_DIR}/${AGENT_CONFIG_DIR}/`);
const level: SubagentLevel = isProjectLevel ? 'project' : 'user';
const config: SubagentConfig = {
name,
description,
tools,
systemPrompt: systemPrompt.trim(),
level,
filePath,
modelConfig: modelConfig as Partial<
import('../core/subagent.js').ModelConfig
>,
runConfig: runConfig as Partial<
import('../core/subagent.js').RunConfig
>,
};
// Validate the parsed configuration
const validation = this.validator.validateConfig(config);
if (!validation.isValid) {
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
}
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;
}
if (config.modelConfig) {
frontmatter['modelConfig'] = config.modelConfig;
}
if (config.runConfig) {
frontmatter['runConfig'] = config.runConfig;
}
// 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,
): Promise<SubAgentScope> {
try {
const runtimeConfig = this.convertToRuntimeConfig(config);
return await SubAgentScope.create(
config.name,
runtimeContext,
runtimeConfig.promptConfig,
runtimeConfig.modelConfig,
runtimeConfig.runConfig,
runtimeConfig.toolConfig,
);
} 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) {
toolConfig = {
tools: config.tools,
};
}
return {
promptConfig,
modelConfig,
runConfig,
toolConfig,
};
}
/**
* 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
*/
private getSubagentPath(name: string, level: SubagentLevel): string {
const baseDir =
level === 'project'
? path.join(this.projectRoot, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR)
: path.join(os.homedir(), QWEN_CONFIG_DIR, AGENT_CONFIG_DIR);
return path.join(baseDir, `${name}.md`);
}
/**
* Lists subagents at a specific level.
*
* @param level - Storage level to check
* @returns Array of subagent metadata
*/
private async listSubagentsAtLevel(
level: SubagentLevel,
): Promise<SubagentMetadata[]> {
const baseDir =
level === 'project'
? path.join(this.projectRoot, 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: SubagentMetadata[] = [];
for (const file of files) {
if (!file.endsWith('.md')) continue;
const filePath = path.join(baseDir, file);
try {
const config = await this.parseSubagentFile(filePath);
const metadata = this.configToMetadata(config);
subagents.push(metadata);
} catch (error) {
// Skip invalid files but log the error
console.warn(
`Skipping invalid subagent file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
return subagents;
} catch (_error) {
// Directory doesn't exist or can't be read
return [];
}
}
/**
* Converts a SubagentConfig to SubagentMetadata.
*
* @param config - Full configuration
* @returns Metadata object
*/
private configToMetadata(config: SubagentConfig): SubagentMetadata {
return {
name: config.name,
description: config.description,
tools: config.tools,
level: config.level,
filePath: config.filePath,
// Add file stats if available
lastModified: undefined, // Would need to stat the file
};
}
/**
* 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);
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
}
/**
* Gets available tools from the tool registry.
* Useful for validation and UI purposes.
*
* @returns Array of available tool names
*/
getAvailableTools(): string[] {
if (!this.toolRegistry) {
return [];
}
// This would need to be implemented in ToolRegistry
// For now, return empty array
return [];
}
}

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* 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');
});
});

View File

@@ -0,0 +1,182 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
PromptConfig,
ModelConfig,
RunConfig,
ToolConfig,
} from '../core/subagent.js';
/**
* 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
*/
export type SubagentLevel = 'project' | 'user';
/**
* 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>;
}
/**
* Metadata extracted from a subagent configuration file.
* Used for listing and discovery without loading full configuration.
*/
export interface SubagentMetadata {
/** Unique name identifier */
name: string;
/** Human-readable description */
description: string;
/** Optional list of allowed tools */
tools?: string[];
/** Storage level */
level: SubagentLevel;
/** File path */
filePath: string;
/** File modification time for sorting */
lastModified?: Date;
/** Additional metadata from YAML frontmatter */
[key: string]: unknown;
}
/**
* 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];

View File

@@ -0,0 +1,448 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { SubagentValidator } from './validation.js';
import { SubagentConfig, SubagentError } from './types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
describe('SubagentValidator', () => {
let validator: SubagentValidator;
let mockToolRegistry: ToolRegistry;
beforeEach(() => {
mockToolRegistry = {
getTool: vi.fn(),
} as unknown as ToolRegistry;
validator = new SubagentValidator(mockToolRegistry);
});
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', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(mockToolRegistry.getTool).mockReturnValue({} as any);
const result = validator.validateTools(['read_file', 'write_file']);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
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', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(mockToolRegistry.getTool).mockReturnValue({} as any);
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');
});
it('should reject unknown tools when registry is available', () => {
vi.mocked(mockToolRegistry.getTool).mockReturnValue(undefined);
const result = validator.validateTools(['unknown_tool']);
expect(result.isValid).toBe(false);
expect(result.errors).toContain(
'Tool "unknown_tool" not found in tool registry',
);
});
});
describe('validateModelConfig', () => {
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');
}
});
});
});

View File

@@ -0,0 +1,368 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
SubagentConfig,
ValidationResult,
SubagentError,
SubagentErrorCode,
} from './types.js';
import { ToolRegistry } from '../tools/tool-registry.js';
/**
* Validates subagent configurations to ensure they are well-formed
* and compatible with the runtime system.
*/
export class SubagentValidator {
constructor(private readonly toolRegistry?: ToolRegistry) {}
/**
* Validates a complete subagent configuration.
*
* @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;
}
// Check if tool exists in registry (if available)
if (this.toolRegistry) {
const toolInstance = this.toolRegistry.getTool(tool);
if (!toolInstance) {
errors.push(`Tool "${tool}" not found in tool registry`);
}
}
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validates model configuration.
*
* @param modelConfig - Partial model configuration to validate
* @returns ValidationResult
*/
validateModelConfig(
modelConfig: Partial<import('../core/subagent.js').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: Partial<import('../core/subagent.js').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,
);
}
}
}

View File

@@ -0,0 +1,166 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Simple YAML parser for subagent frontmatter.
* This is a minimal implementation that handles the basic YAML structures
* needed for subagent configuration files.
*/
/**
* Parses a simple YAML string into a JavaScript object.
* Supports basic key-value pairs, arrays, and nested objects.
*
* @param yamlString - YAML string to parse
* @returns Parsed object
*/
export function parse(yamlString: string): Record<string, unknown> {
const lines = yamlString
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
const result: Record<string, unknown> = {};
let currentKey = '';
let currentArray: string[] = [];
let inArray = false;
let currentObject: Record<string, unknown> = {};
let inObject = false;
let objectKey = '';
for (const line of lines) {
// Handle array items
if (line.startsWith('- ')) {
if (!inArray) {
inArray = true;
currentArray = [];
}
currentArray.push(line.substring(2).trim());
continue;
}
// End of array
if (inArray && !line.startsWith('- ')) {
result[currentKey] = currentArray;
inArray = false;
currentArray = [];
}
// Handle nested object items (simple indentation)
if (line.startsWith(' ') && inObject) {
const [key, ...valueParts] = line.trim().split(':');
const value = valueParts.join(':').trim();
currentObject[key.trim()] = parseValue(value);
continue;
}
// End of object
if (inObject && !line.startsWith(' ')) {
result[objectKey] = currentObject;
inObject = false;
currentObject = {};
}
// Handle key-value pairs
if (line.includes(':')) {
const [key, ...valueParts] = line.split(':');
const value = valueParts.join(':').trim();
if (value === '') {
// This might be the start of an object or array
currentKey = key.trim();
// Check if next lines are indented (object) or start with - (array)
continue;
} else {
result[key.trim()] = parseValue(value);
}
} else if (currentKey && !inArray && !inObject) {
// This might be the start of an object
inObject = true;
objectKey = currentKey;
currentObject = {};
currentKey = '';
}
}
// Handle remaining array or object
if (inArray) {
result[currentKey] = currentArray;
}
if (inObject) {
result[objectKey] = currentObject;
}
return result;
}
/**
* Converts a JavaScript object to a simple YAML string.
*
* @param obj - Object to stringify
* @param options - Stringify options
* @returns YAML string
*/
export function stringify(
obj: Record<string, unknown>,
_options?: { lineWidth?: number; minContentWidth?: number },
): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${item}`);
}
} else if (typeof value === 'object' && value !== null) {
lines.push(`${key}:`);
for (const [subKey, subValue] of Object.entries(
value as Record<string, unknown>,
)) {
lines.push(` ${subKey}: ${formatValue(subValue)}`);
}
} else {
lines.push(`${key}: ${formatValue(value)}`);
}
}
return lines.join('\n');
}
/**
* Parses a value string into appropriate JavaScript type.
*/
function parseValue(value: string): unknown {
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'null') return null;
if (value === '') return '';
// Try to parse as number
const num = Number(value);
if (!isNaN(num) && isFinite(num)) {
return num;
}
// Return as string
return value;
}
/**
* Formats a value for YAML output.
*/
function formatValue(value: unknown): string {
if (typeof value === 'string') {
// Quote strings that might be ambiguous
if (value.includes(':') || value.includes('#') || value.trim() !== value) {
return `"${value.replace(/"/g, '\\"')}"`;
}
return value;
}
return String(value);
}