diff --git a/.gitignore b/.gitignore index 30ed32ae..b10814b4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ bower_components # Editors .idea *.iml +.cursor # OS metadata .DS_Store diff --git a/packages/core/src/core/subagent.ts b/packages/core/src/core/subagent.ts index 3abe4816..cd9d9181 100644 --- a/packages/core/src/core/subagent.ts +++ b/packages/core/src/core/subagent.ts @@ -118,15 +118,15 @@ export interface ModelConfig { * * TODO: In the future, this needs to support 'auto' or some other string to support routing use cases. */ - model: string; + model?: string; /** * The temperature for the model's sampling process. */ - temp: number; + temp?: number; /** * The top-p value for nucleus sampling. */ - top_p: number; + top_p?: number; } /** @@ -138,7 +138,7 @@ export interface ModelConfig { */ export interface RunConfig { /** The maximum execution time for the subagent in minutes. */ - max_time_minutes: number; + max_time_minutes?: number; /** * The maximum number of conversational turns (a user message + model response) * before the execution is terminated. Helps prevent infinite loops. @@ -387,7 +387,10 @@ export class SubAgentScope { break; } let durationMin = (Date.now() - startTime) / (1000 * 60); - if (durationMin >= this.runConfig.max_time_minutes) { + if ( + this.runConfig.max_time_minutes && + durationMin >= this.runConfig.max_time_minutes + ) { this.output.terminate_reason = SubagentTerminateMode.TIMEOUT; break; } @@ -413,7 +416,10 @@ export class SubAgentScope { } durationMin = (Date.now() - startTime) / (1000 * 60); - if (durationMin >= this.runConfig.max_time_minutes) { + if ( + this.runConfig.max_time_minutes && + durationMin >= this.runConfig.max_time_minutes + ) { this.output.terminate_reason = SubagentTerminateMode.TIMEOUT; break; } @@ -588,7 +594,9 @@ export class SubAgentScope { this.runtimeContext.getSessionId(), ); - this.runtimeContext.setModel(this.modelConfig.model); + if (this.modelConfig.model) { + this.runtimeContext.setModel(this.modelConfig.model); + } return new GeminiChat( this.runtimeContext, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f7c89c64..53bb4602 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -67,6 +67,9 @@ export * from './tools/tools.js'; export * from './tools/tool-error.js'; export * from './tools/tool-registry.js'; +// Export subagents (Phase 1) +export * from './subagents/index.js'; + // Export prompt logic export * from './prompts/mcp-prompts.js'; diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts new file mode 100644 index 00000000..3aa869cb --- /dev/null +++ b/packages/core/src/subagents/index.ts @@ -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'; diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts new file mode 100644 index 00000000..c40b3541 --- /dev/null +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -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) => { + 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, + )) { + 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, + )) { + 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 + }); + }); + }); +}); diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts new file mode 100644 index 00000000..75470939 --- /dev/null +++ b/packages/core/src/subagents/subagent-manager.ts @@ -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 { + // 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 { + // 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, + ): Promise { + 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 { + 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 { + const subagents: SubagentMetadata[] = []; + const seenNames = new Set(); + + 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 { + 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 { + 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; + + // 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 + | undefined; + const runConfig = frontmatter['runConfig'] as + | Record + | 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 = { + 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 { + 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 { + 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 { + 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 { + 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 []; + } +} diff --git a/packages/core/src/subagents/types.test.ts b/packages/core/src/subagents/types.test.ts new file mode 100644 index 00000000..ecf2d717 --- /dev/null +++ b/packages/core/src/subagents/types.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts new file mode 100644 index 00000000..5e61fccf --- /dev/null +++ b/packages/core/src/subagents/types.ts @@ -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; + + /** + * Optional runtime configuration. If not provided, uses defaults. + * Can specify max_time_minutes and max_turns. + */ + runConfig?: Partial; +} + +/** + * 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]; diff --git a/packages/core/src/subagents/validation.test.ts b/packages/core/src/subagents/validation.test.ts new file mode 100644 index 00000000..7d01564d --- /dev/null +++ b/packages/core/src/subagents/validation.test.ts @@ -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'); + } + }); + }); +}); diff --git a/packages/core/src/subagents/validation.ts b/packages/core/src/subagents/validation.ts new file mode 100644 index 00000000..b271d1d7 --- /dev/null +++ b/packages/core/src/subagents/validation.ts @@ -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, + ): 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, + ): 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, + ); + } + } +} diff --git a/packages/core/src/utils/yaml-parser.ts b/packages/core/src/utils/yaml-parser.ts new file mode 100644 index 00000000..eb561068 --- /dev/null +++ b/packages/core/src/utils/yaml-parser.ts @@ -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 { + const lines = yamlString + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + const result: Record = {}; + + let currentKey = ''; + let currentArray: string[] = []; + let inArray = false; + let currentObject: Record = {}; + 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, + _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, + )) { + 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); +}