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
|
# Editors
|
||||||
.idea
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
|
.cursor
|
||||||
|
|
||||||
# OS metadata
|
# OS metadata
|
||||||
.DS_Store
|
.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.
|
* 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.
|
* The temperature for the model's sampling process.
|
||||||
*/
|
*/
|
||||||
temp: number;
|
temp?: number;
|
||||||
/**
|
/**
|
||||||
* The top-p value for nucleus sampling.
|
* The top-p value for nucleus sampling.
|
||||||
*/
|
*/
|
||||||
top_p: number;
|
top_p?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,7 +138,7 @@ export interface ModelConfig {
|
|||||||
*/
|
*/
|
||||||
export interface RunConfig {
|
export interface RunConfig {
|
||||||
/** The maximum execution time for the subagent in minutes. */
|
/** 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)
|
* The maximum number of conversational turns (a user message + model response)
|
||||||
* before the execution is terminated. Helps prevent infinite loops.
|
* before the execution is terminated. Helps prevent infinite loops.
|
||||||
@@ -387,7 +387,10 @@ export class SubAgentScope {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let durationMin = (Date.now() - startTime) / (1000 * 60);
|
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;
|
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -413,7 +416,10 @@ export class SubAgentScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
durationMin = (Date.now() - startTime) / (1000 * 60);
|
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;
|
this.output.terminate_reason = SubagentTerminateMode.TIMEOUT;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -588,7 +594,9 @@ export class SubAgentScope {
|
|||||||
this.runtimeContext.getSessionId(),
|
this.runtimeContext.getSessionId(),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.runtimeContext.setModel(this.modelConfig.model);
|
if (this.modelConfig.model) {
|
||||||
|
this.runtimeContext.setModel(this.modelConfig.model);
|
||||||
|
}
|
||||||
|
|
||||||
return new GeminiChat(
|
return new GeminiChat(
|
||||||
this.runtimeContext,
|
this.runtimeContext,
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export * from './tools/tools.js';
|
|||||||
export * from './tools/tool-error.js';
|
export * from './tools/tool-error.js';
|
||||||
export * from './tools/tool-registry.js';
|
export * from './tools/tool-registry.js';
|
||||||
|
|
||||||
|
// Export subagents (Phase 1)
|
||||||
|
export * from './subagents/index.js';
|
||||||
|
|
||||||
// Export prompt logic
|
// Export prompt logic
|
||||||
export * from './prompts/mcp-prompts.js';
|
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