mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat: Implement subagents phase 1 with file-based configuration system
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ bower_components
|
||||
# Editors
|
||||
.idea
|
||||
*.iml
|
||||
.cursor
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
if (this.modelConfig.model) {
|
||||
this.runtimeContext.setModel(this.modelConfig.model);
|
||||
}
|
||||
|
||||
return new GeminiChat(
|
||||
this.runtimeContext,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
53
packages/core/src/subagents/index.ts
Normal file
53
packages/core/src/subagents/index.ts
Normal 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';
|
||||
800
packages/core/src/subagents/subagent-manager.test.ts
Normal file
800
packages/core/src/subagents/subagent-manager.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
647
packages/core/src/subagents/subagent-manager.ts
Normal file
647
packages/core/src/subagents/subagent-manager.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
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 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');
|
||||
});
|
||||
});
|
||||
182
packages/core/src/subagents/types.ts
Normal file
182
packages/core/src/subagents/types.ts
Normal 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];
|
||||
448
packages/core/src/subagents/validation.test.ts
Normal file
448
packages/core/src/subagents/validation.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
368
packages/core/src/subagents/validation.ts
Normal file
368
packages/core/src/subagents/validation.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
166
packages/core/src/utils/yaml-parser.ts
Normal file
166
packages/core/src/utils/yaml-parser.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user