feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,260 @@
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
import { FileContributor } from './contributors.js';
import { writeFile, mkdir, rm } from 'fs/promises';
import { join } from 'path';
import { DynamicContributorContext } from './types.js';
import { DextoRuntimeError } from '../errors/DextoRuntimeError.js';
import { SystemPromptErrorCode } from './error-codes.js';
import { ErrorScope, ErrorType } from '../errors/types.js';
import { createMockLogger } from '../logger/v2/test-utils.js';
const mockLogger = createMockLogger();
describe('FileContributor', () => {
const testDir = join(process.cwd(), 'test-files');
const mockContext: DynamicContributorContext = {
mcpManager: {} as any,
};
beforeEach(async () => {
// Create test directory and files
await mkdir(testDir, { recursive: true });
// Create test files
await writeFile(
join(testDir, 'test1.md'),
'# Test Document 1\n\nThis is the first test document.'
);
await writeFile(join(testDir, 'test2.txt'), 'This is a plain text file.\nSecond line.');
await writeFile(join(testDir, 'large.md'), 'x'.repeat(200000)); // Large file for testing
await writeFile(join(testDir, 'invalid.json'), '{"key": "value"}'); // Invalid file type
});
afterEach(async () => {
// Clean up test files
await rm(testDir, { recursive: true, force: true });
});
test('should read single markdown file with default options', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'test1.md')],
{},
mockLogger
);
const result = await contributor.getContent(mockContext);
expect(result).toContain('<fileContext>');
expect(result).toContain('test-files/test1.md');
expect(result).toContain('# Test Document 1');
expect(result).toContain('This is the first test document.');
expect(result).toContain('</fileContext>');
});
test('should read multiple files with separator', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'test1.md'), join(testDir, 'test2.txt')],
{
separator: '\n\n===\n\n',
},
mockLogger
);
const result = await contributor.getContent(mockContext);
expect(result).toContain('# Test Document 1');
expect(result).toContain('This is a plain text file.');
expect(result).toContain('===');
});
test('should handle missing files with skip mode', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'missing.md'), join(testDir, 'test1.md')],
{
errorHandling: 'skip',
},
mockLogger
);
const result = await contributor.getContent(mockContext);
expect(result).toContain('# Test Document 1');
expect(result).not.toContain('missing.md');
});
test('should handle missing files with error mode', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'missing.md'), join(testDir, 'test1.md')],
{
errorHandling: 'error',
},
mockLogger
);
const error = (await contributor
.getContent(mockContext)
.catch((e) => e)) as DextoRuntimeError;
expect(error).toBeInstanceOf(DextoRuntimeError);
expect(error.code).toBe(SystemPromptErrorCode.FILE_READ_FAILED);
expect(error.scope).toBe(ErrorScope.SYSTEM_PROMPT);
expect(error.type).toBe(ErrorType.SYSTEM);
});
test('should throw error for missing files with single file error mode', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'missing.md')],
{
errorHandling: 'error',
},
mockLogger
);
const error = (await contributor
.getContent(mockContext)
.catch((e) => e)) as DextoRuntimeError;
expect(error).toBeInstanceOf(DextoRuntimeError);
expect(error.code).toBe(SystemPromptErrorCode.FILE_READ_FAILED);
expect(error.scope).toBe(ErrorScope.SYSTEM_PROMPT);
expect(error.type).toBe(ErrorType.SYSTEM);
});
test('should skip large files with skip mode', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'large.md'), join(testDir, 'test1.md')],
{
maxFileSize: 1000,
errorHandling: 'skip',
},
mockLogger
);
const result = await contributor.getContent(mockContext);
expect(result).toContain('# Test Document 1');
expect(result).not.toContain('large.md');
});
test('should handle invalid file types with skip mode', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'invalid.json'), join(testDir, 'test1.md')],
{
errorHandling: 'skip',
},
mockLogger
);
const result = await contributor.getContent(mockContext);
expect(result).toContain('# Test Document 1');
expect(result).not.toContain('invalid.json');
});
test('should throw error for invalid file types with error mode', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'invalid.json')],
{
errorHandling: 'error',
},
mockLogger
);
await expect(contributor.getContent(mockContext)).rejects.toThrow(
'is not a .md or .txt file'
);
});
test('should exclude filenames when configured', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'test1.md')],
{
includeFilenames: false,
},
mockLogger
);
const result = await contributor.getContent(mockContext);
expect(result).toContain('# Test Document 1');
expect(result).not.toContain('test-files/test1.md');
});
test('should include metadata when configured', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'test1.md')],
{
includeMetadata: true,
},
mockLogger
);
const result = await contributor.getContent(mockContext);
expect(result).toContain('*File size:');
expect(result).toContain('Modified:');
});
test('should return empty context when no files can be loaded', async () => {
const contributor = new FileContributor(
'test',
0,
[join(testDir, 'missing.md')],
{
errorHandling: 'skip',
},
mockLogger
);
const result = await contributor.getContent(mockContext);
expect(result).toBe('<fileContext>No files could be loaded</fileContext>');
});
test('should read files using absolute paths from arbitrary directories', async () => {
const configDir = join(testDir, 'config');
await mkdir(configDir, { recursive: true });
const absolutePath = join(configDir, 'config-file.md');
await writeFile(absolutePath, '# Config File\n\nThis is a config file.');
const contributor = new FileContributor('test', 0, [absolutePath], {}, mockLogger);
const result = await contributor.getContent(mockContext);
expect(result).toContain('<fileContext>');
expect(result).toContain('config-file.md');
expect(result).toContain('# Config File');
expect(result).toContain('This is a config file.');
expect(result).toContain('</fileContext>');
});
test('should read files from nested directories when given absolute paths', async () => {
const configDir = join(testDir, 'config');
const docsDir = join(configDir, 'docs');
await mkdir(docsDir, { recursive: true });
const nestedPath = join(docsDir, 'readme.md');
await writeFile(nestedPath, '# Documentation\n\nThis is documentation.');
const contributor = new FileContributor('test', 0, [nestedPath], {}, mockLogger);
const result = await contributor.getContent(mockContext);
expect(result).toContain('<fileContext>');
expect(result).toContain('readme.md');
expect(result).toContain('# Documentation');
expect(result).toContain('This is documentation.');
expect(result).toContain('</fileContext>');
});
});

View File

@@ -0,0 +1,293 @@
import { SystemPromptContributor, DynamicContributorContext } from './types.js';
import { readFile, stat } from 'fs/promises';
import { resolve, extname } from 'path';
import type { IDextoLogger } from '../logger/v2/types.js';
import { SystemPromptError } from './errors.js';
import { DextoRuntimeError } from '../errors/DextoRuntimeError.js';
import type { MemoryManager } from '../memory/index.js';
import type { PromptManager } from '../prompts/prompt-manager.js';
export class StaticContributor implements SystemPromptContributor {
constructor(
public id: string,
public priority: number,
private content: string
) {}
async getContent(_context: DynamicContributorContext): Promise<string> {
return this.content;
}
}
export class DynamicContributor implements SystemPromptContributor {
constructor(
public id: string,
public priority: number,
private promptGenerator: (context: DynamicContributorContext) => Promise<string>
) {}
async getContent(context: DynamicContributorContext): Promise<string> {
return this.promptGenerator(context);
}
}
export interface FileContributorOptions {
includeFilenames?: boolean | undefined;
separator?: string | undefined;
errorHandling?: 'skip' | 'error' | undefined;
maxFileSize?: number | undefined;
includeMetadata?: boolean | undefined;
cache?: boolean | undefined;
}
export class FileContributor implements SystemPromptContributor {
// Basic in-memory cache to avoid reading files on every prompt build
private cache: Map<string, string> = new Map();
private logger: IDextoLogger;
constructor(
public id: string,
public priority: number,
private files: string[],
private options: FileContributorOptions = {},
logger: IDextoLogger
) {
this.logger = logger;
this.logger.debug(`[FileContributor] Created "${id}" with files: ${JSON.stringify(files)}`);
}
async getContent(_context: DynamicContributorContext): Promise<string> {
const {
includeFilenames = true,
separator = '\n\n---\n\n',
errorHandling = 'skip',
maxFileSize = 100000,
includeMetadata = false,
cache = true,
} = this.options;
// If caching is enabled, check if we have cached content
if (cache) {
const cacheKey = JSON.stringify({ files: this.files, options: this.options });
const cached = this.cache.get(cacheKey);
if (cached) {
this.logger.debug(`[FileContributor] Using cached content for "${this.id}"`);
return cached;
}
}
const fileParts: string[] = [];
for (const filePath of this.files) {
try {
const resolvedPath = resolve(filePath);
this.logger.debug(
`[FileContributor] Resolving path: ${filePath}${resolvedPath}`
);
// Check if file is .md or .txt
const ext = extname(resolvedPath).toLowerCase();
if (ext !== '.md' && ext !== '.txt') {
if (errorHandling === 'error') {
throw SystemPromptError.invalidFileType(filePath, ['.md', '.txt']);
}
continue;
}
// Check file size
const stats = await stat(resolvedPath);
if (stats.size > maxFileSize) {
if (errorHandling === 'error') {
throw SystemPromptError.fileTooLarge(filePath, stats.size, maxFileSize);
}
continue;
}
// Read file content (always utf-8)
const content = await readFile(resolvedPath, { encoding: 'utf-8' });
// Build file part
let filePart = '';
if (includeFilenames) {
filePart += `## ${filePath}\n\n`;
}
if (includeMetadata) {
filePart += `*File size: ${stats.size} bytes, Modified: ${stats.mtime.toISOString()}*\n\n`;
}
filePart += content;
fileParts.push(filePart);
} catch (error: unknown) {
if (errorHandling === 'error') {
// Preserve previously constructed structured errors
if (error instanceof DextoRuntimeError) {
throw error;
}
const reason = error instanceof Error ? error.message : String(error);
throw SystemPromptError.fileReadFailed(filePath, reason);
}
// 'skip' mode - do nothing, continue to next file
}
}
if (fileParts.length === 0) {
return '<fileContext>No files could be loaded</fileContext>';
}
const combinedContent = fileParts.join(separator);
const result = `<fileContext>\n${combinedContent}\n</fileContext>`;
// Cache the result if caching is enabled
if (cache) {
const cacheKey = JSON.stringify({ files: this.files, options: this.options });
this.cache.set(cacheKey, result);
this.logger.debug(`[FileContributor] Cached content for "${this.id}"`);
}
return result;
}
}
export interface MemoryContributorOptions {
/** Whether to include timestamps in memory display */
includeTimestamps?: boolean | undefined;
/** Whether to include tags in memory display */
includeTags?: boolean | undefined;
/** Maximum number of memories to include */
limit?: number | undefined;
/** Only include pinned memories (for hybrid approach) */
pinnedOnly?: boolean | undefined;
}
/**
* MemoryContributor loads user memories from the database and formats them
* for inclusion in the system prompt.
*
* This enables memories to be automatically available in every conversation.
*/
export class MemoryContributor implements SystemPromptContributor {
private logger: IDextoLogger;
constructor(
public id: string,
public priority: number,
private memoryManager: MemoryManager,
private options: MemoryContributorOptions = {},
logger: IDextoLogger
) {
this.logger = logger;
this.logger.debug(
`[MemoryContributor] Created "${id}" with options: ${JSON.stringify(options)}`
);
}
async getContent(_context: DynamicContributorContext): Promise<string> {
const {
includeTimestamps = false,
includeTags = true,
limit,
pinnedOnly = false,
} = this.options;
try {
// Fetch memories from the database
const memories = await this.memoryManager.list({
...(limit !== undefined && { limit }),
...(pinnedOnly && { pinned: true }),
});
if (memories.length === 0) {
return '';
}
// Format memories for system prompt
const formattedMemories = memories.map((memory) => {
let formatted = `- ${memory.content}`;
if (includeTags && memory.tags && memory.tags.length > 0) {
formatted += ` [Tags: ${memory.tags.join(', ')}]`;
}
if (includeTimestamps) {
const date = new Date(memory.updatedAt).toLocaleDateString();
formatted += ` (Updated: ${date})`;
}
return formatted;
});
const header = '## User Memories';
const memoryList = formattedMemories.join('\n');
const result = `${header}\n${memoryList}`;
this.logger.debug(
`[MemoryContributor] Loaded ${memories.length} memories into system prompt`
);
return result;
} catch (error) {
this.logger.error(
`[MemoryContributor] Failed to load memories: ${error instanceof Error ? error.message : String(error)}`
);
// Return empty string on error to not break system prompt generation
return '';
}
}
}
/**
* SkillsContributor lists available skills that the LLM can invoke via the invoke_skill tool.
* This enables the LLM to know what skills are available without hardcoding them.
*/
export class SkillsContributor implements SystemPromptContributor {
private logger: IDextoLogger;
constructor(
public id: string,
public priority: number,
private promptManager: PromptManager,
logger: IDextoLogger
) {
this.logger = logger;
this.logger.debug(`[SkillsContributor] Created "${id}"`);
}
async getContent(_context: DynamicContributorContext): Promise<string> {
try {
const skills = await this.promptManager.listAutoInvocablePrompts();
const skillEntries = Object.entries(skills);
if (skillEntries.length === 0) {
return '';
}
const skillsList = skillEntries
.map(([_key, info]) => {
const name = info.displayName || info.name;
const desc = info.description ? ` - ${info.description}` : '';
return `- ${name}${desc}`;
})
.join('\n');
const result = `## Available Skills
You can invoke the following skills using the \`invoke_skill\` tool when they are relevant to the task:
${skillsList}
To use a skill, call invoke_skill with the skill name. The skill will provide specialized instructions for the task.`;
this.logger.debug(
`[SkillsContributor] Listed ${skillEntries.length} skills in system prompt`
);
return result;
} catch (error) {
this.logger.error(
`[SkillsContributor] Failed to list skills: ${error instanceof Error ? error.message : String(error)}`
);
return '';
}
}
}

View File

@@ -0,0 +1,14 @@
/**
* SystemPrompt-specific error codes
* Includes file processing and configuration errors
*/
export enum SystemPromptErrorCode {
// File processing
FILE_INVALID_TYPE = 'systemprompt_file_invalid_type',
FILE_TOO_LARGE = 'systemprompt_file_too_large',
FILE_READ_FAILED = 'systemprompt_file_read_failed',
// Configuration
CONTRIBUTOR_SOURCE_UNKNOWN = 'systemprompt_contributor_source_unknown',
CONTRIBUTOR_CONFIG_INVALID = 'systemprompt_contributor_config_invalid',
}

View File

@@ -0,0 +1,75 @@
import { DextoRuntimeError } from '@core/errors/DextoRuntimeError.js';
import { ErrorScope, ErrorType } from '@core/errors/types.js';
import { SystemPromptErrorCode } from './error-codes.js';
import { safeStringify } from '../utils/safe-stringify.js';
/**
* SystemPrompt error factory with typed methods for creating systemPrompt-specific errors
* Each method creates a properly typed DextoRuntimeError with SYSTEM_PROMPT scope
*/
export class SystemPromptError {
/**
* Invalid file type error
*/
static invalidFileType(filePath: string, allowedExtensions: string[]) {
return new DextoRuntimeError(
SystemPromptErrorCode.FILE_INVALID_TYPE,
ErrorScope.SYSTEM_PROMPT,
ErrorType.USER,
`File ${filePath} is not a ${allowedExtensions.join(' or ')} file`,
{ filePath, allowedExtensions }
);
}
/**
* File too large error
*/
static fileTooLarge(filePath: string, fileSize: number, maxSize: number) {
return new DextoRuntimeError(
SystemPromptErrorCode.FILE_TOO_LARGE,
ErrorScope.SYSTEM_PROMPT,
ErrorType.USER,
`File ${filePath} exceeds maximum size of ${maxSize} bytes`,
{ filePath, fileSize, maxSize }
);
}
/**
* File read failed error
*/
static fileReadFailed(filePath: string, reason: string) {
return new DextoRuntimeError(
SystemPromptErrorCode.FILE_READ_FAILED,
ErrorScope.SYSTEM_PROMPT,
ErrorType.SYSTEM,
`Failed to read file ${filePath}: ${reason}`,
{ filePath, reason }
);
}
/**
* Unknown contributor source error
*/
static unknownContributorSource(source: string) {
return new DextoRuntimeError(
SystemPromptErrorCode.CONTRIBUTOR_SOURCE_UNKNOWN,
ErrorScope.SYSTEM_PROMPT,
ErrorType.USER,
`No generator registered for dynamic contributor source: ${source}`,
{ source }
);
}
/**
* Invalid contributor config error (for exhaustive type checking)
*/
static invalidContributorConfig(config: unknown): DextoRuntimeError {
return new DextoRuntimeError(
SystemPromptErrorCode.CONTRIBUTOR_CONFIG_INVALID,
ErrorScope.SYSTEM_PROMPT,
ErrorType.USER,
`Invalid contributor config: ${safeStringify(config)}`,
{ config }
);
}
}

View File

@@ -0,0 +1,102 @@
import { DynamicContributorContext } from './types.js';
/**
* Dynamic Prompt Generators
*
* This module contains functions for generating dynamic system prompts for the AI agent.
* Each function should return a string (or Promise<string>) representing a prompt, possibly using the provided context.
*
* ---
* Guidelines for Adding Prompt Functions:
* - Place all dynamic prompt-generating functions in this file.
* - Also update the `registry.ts` file to register the new function.
* - Use XML tags to indicate the start and end of the dynamic prompt - they are known to improve performance
* - Each function should be named clearly to reflect its purpose (e.g., getCurrentDate, getEnvironmentInfo).
*/
/**
* Returns the current date (without time to prevent KV-cache invalidation).
*/
export async function getCurrentDate(_context: DynamicContributorContext): Promise<string> {
const date = new Date().toISOString().split('T')[0];
return `<date>Current date: ${date}</date>`;
}
/**
* Returns environment information to help agents understand their execution context.
* This is kept separate from date to optimize caching (env info rarely changes).
*
* Includes:
* - Working directory (cwd)
* - Platform (os)
* - Whether the cwd is a git repository
* - Default shell
*
* Note: This function uses dynamic imports for Node.js modules to maintain browser compatibility.
* In browser environments, it returns a placeholder message.
*/
export async function getEnvironmentInfo(_context: DynamicContributorContext): Promise<string> {
// Check if we're in a Node.js environment
if (typeof process === 'undefined' || !process.cwd) {
return '<environment>Environment info not available in browser context</environment>';
}
try {
// Dynamic imports for Node.js modules (browser-safe)
const [{ existsSync }, { platform }, { join }] = await Promise.all([
import('fs'),
import('os'),
import('path'),
]);
const cwd = process.cwd();
const os = platform();
const isGitRepo = existsSync(join(cwd, '.git'));
const shell = process.env.SHELL || (os === 'win32' ? 'cmd.exe' : '/bin/sh');
return `<environment>
<cwd>${cwd}</cwd>
<platform>${os}</platform>
<is_git_repo>${isGitRepo}</is_git_repo>
<shell>${shell}</shell>
</environment>`;
} catch {
return '<environment>Environment info not available</environment>';
}
}
// TODO: This needs to be optimized to only fetch resources when needed. Currently this runs every time the prompt is generated.
export async function getResourceData(context: DynamicContributorContext): Promise<string> {
const resources = await context.mcpManager.listAllResources();
if (!resources || resources.length === 0) {
return '<resources></resources>';
}
const parts = await Promise.all(
resources.map(async (resource) => {
try {
const response = await context.mcpManager.readResource(resource.key);
const first = response?.contents?.[0];
let content: string;
if (first && 'text' in first && first.text && typeof first.text === 'string') {
content = first.text;
} else if (
first &&
'blob' in first &&
first.blob &&
typeof first.blob === 'string'
) {
content = first.blob;
} else {
content = JSON.stringify(response, null, 2);
}
const label = resource.summary.name || resource.summary.uri;
return `<resource uri="${resource.key}" name="${label}">${content}</resource>`;
} catch (error: any) {
return `<resource uri="${resource.key}">Error loading resource: ${
error.message || error
}</resource>`;
}
})
);
return `<resources>\n${parts.join('\n')}\n</resources>`;
}

View File

@@ -0,0 +1,13 @@
export * from './types.js';
export * from './manager.js';
export * from './registry.js';
export * from './contributors.js';
export * from './in-built-prompts.js';
export {
type ContributorConfig,
type SystemPromptConfig,
type ValidatedContributorConfig,
type ValidatedSystemPromptConfig,
ContributorConfigSchema,
SystemPromptConfigSchema,
} from './schemas.js';

View File

@@ -0,0 +1,666 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SystemPromptManager } from './manager.js';
import { SystemPromptConfigSchema } from './schemas.js';
import type { DynamicContributorContext } from './types.js';
import * as registry from './registry.js';
import { DextoRuntimeError } from '../errors/DextoRuntimeError.js';
import { SystemPromptErrorCode } from './error-codes.js';
import { ErrorScope, ErrorType } from '../errors/types.js';
import * as path from 'path';
// Mock the registry functions
vi.mock('./registry.js', () => ({
getPromptGenerator: vi.fn(),
PROMPT_GENERATOR_SOURCES: ['date', 'env', 'resources'],
}));
const mockGetPromptGenerator = vi.mocked(registry.getPromptGenerator);
describe('SystemPromptManager', () => {
let mockContext: DynamicContributorContext;
let mockLogger: any;
let mockMemoryManager: any;
beforeEach(() => {
vi.clearAllMocks();
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
trackException: vi.fn(),
createChild: vi.fn(function (this: any) {
return this;
}),
destroy: vi.fn(),
} as any;
mockMemoryManager = {
getMemories: vi.fn().mockResolvedValue([]),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as any;
// Set up default mock generators to prevent "No generator registered" errors
mockGetPromptGenerator.mockImplementation((source) => {
const mockGenerators: Record<string, any> = {
date: vi.fn().mockResolvedValue('Mock DateTime'),
env: vi.fn().mockResolvedValue('Mock Environment'),
resources: vi.fn().mockResolvedValue('Mock Resources'),
};
return mockGenerators[source];
});
mockContext = {
mcpManager: {} as any, // Mock MCPManager
};
});
describe('Initialization', () => {
it('should initialize with string config and create static contributor', () => {
const config = SystemPromptConfigSchema.parse('You are a helpful assistant');
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const contributors = manager.getContributors();
expect(contributors).toHaveLength(1);
expect(contributors[0]?.id).toBe('inline');
expect(contributors[0]?.priority).toBe(0);
});
it('should initialize with empty object config and apply defaults', () => {
const config = SystemPromptConfigSchema.parse({});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const contributors = manager.getContributors();
expect(contributors).toHaveLength(2); // date and env are enabled by default
// Should have date and env (resources is disabled by default)
expect(contributors[0]?.id).toBe('date'); // priority 10, enabled: true
expect(contributors[1]?.id).toBe('env'); // priority 15, enabled: true
});
it('should initialize with custom contributors config', () => {
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'main',
type: 'static',
priority: 0,
content: 'You are Dexto',
enabled: true,
},
{
id: 'date',
type: 'dynamic',
priority: 10,
source: 'date',
enabled: true,
},
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const contributors = manager.getContributors();
expect(contributors).toHaveLength(2);
expect(contributors[0]?.id).toBe('main');
expect(contributors[1]?.id).toBe('date');
});
it('should filter out disabled contributors', () => {
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'enabled',
type: 'static',
priority: 0,
content: 'Enabled contributor',
enabled: true,
},
{
id: 'disabled',
type: 'static',
priority: 5,
content: 'Disabled contributor',
enabled: false,
},
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const contributors = manager.getContributors();
expect(contributors).toHaveLength(1);
expect(contributors[0]?.id).toBe('enabled');
});
it('should sort contributors by priority (lower number = higher priority)', () => {
const config = SystemPromptConfigSchema.parse({
contributors: [
{ id: 'low', type: 'static', priority: 20, content: 'Low priority' },
{ id: 'high', type: 'static', priority: 0, content: 'High priority' },
{ id: 'medium', type: 'static', priority: 10, content: 'Medium priority' },
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const contributors = manager.getContributors();
expect(contributors).toHaveLength(3);
expect(contributors[0]?.id).toBe('high'); // priority 0
expect(contributors[1]?.id).toBe('medium'); // priority 10
expect(contributors[2]?.id).toBe('low'); // priority 20
});
});
describe('Static Contributors', () => {
it('should create static contributors with correct content', async () => {
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'greeting',
type: 'static',
priority: 0,
content: 'Hello, I am Dexto!',
},
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const result = await manager.build(mockContext);
expect(result).toBe('Hello, I am Dexto!');
});
it('should handle multiline static content', async () => {
const multilineContent = `You are Dexto, an AI assistant.
You can help with:
- Coding tasks
- Analysis
- General questions`;
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'main',
type: 'static',
priority: 0,
content: multilineContent,
},
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const result = await manager.build(mockContext);
expect(result).toBe(multilineContent);
});
});
describe('Dynamic Contributors', () => {
it('should create dynamic contributors and call generators', async () => {
const mockGenerator = vi.fn().mockResolvedValue('Current time: 2023-01-01');
mockGetPromptGenerator.mockReturnValue(mockGenerator);
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'date',
type: 'dynamic',
priority: 10,
source: 'date',
},
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const result = await manager.build(mockContext);
expect(mockGetPromptGenerator).toHaveBeenCalledWith('date');
expect(mockGenerator).toHaveBeenCalledWith(mockContext);
expect(result).toBe('Current time: 2023-01-01');
});
it('should throw error if generator is not found', () => {
mockGetPromptGenerator.mockReturnValue(undefined);
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'unknownSource',
type: 'dynamic',
priority: 10,
source: 'date', // valid enum but mock returns undefined
},
],
});
const error = (() => {
try {
new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
return null;
} catch (e) {
return e;
}
})() as DextoRuntimeError;
expect(error).toBeInstanceOf(DextoRuntimeError);
expect(error.code).toBe(SystemPromptErrorCode.CONTRIBUTOR_SOURCE_UNKNOWN);
expect(error.scope).toBe(ErrorScope.SYSTEM_PROMPT);
expect(error.type).toBe(ErrorType.USER);
});
it('should handle multiple dynamic contributors', async () => {
const dateTimeGenerator = vi.fn().mockResolvedValue('Time: 2023-01-01');
const resourcesGenerator = vi.fn().mockResolvedValue('Resources: file1.md, file2.md');
mockGetPromptGenerator.mockImplementation((source) => {
if (source === 'date') return dateTimeGenerator;
if (source === 'resources') return resourcesGenerator;
return undefined;
});
const config = SystemPromptConfigSchema.parse({
contributors: [
{ id: 'time', type: 'dynamic', priority: 10, source: 'date' },
{ id: 'files', type: 'dynamic', priority: 20, source: 'resources' },
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const result = await manager.build(mockContext);
expect(result).toBe('Time: 2023-01-01\nResources: file1.md, file2.md');
expect(dateTimeGenerator).toHaveBeenCalledWith(mockContext);
expect(resourcesGenerator).toHaveBeenCalledWith(mockContext);
});
});
describe('File Contributors', () => {
it('should create file contributors with correct configuration', () => {
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'docs',
type: 'file',
priority: 5,
files: [
path.join(process.cwd(), 'README.md'),
path.join(process.cwd(), 'GUIDELINES.md'),
],
options: {
includeFilenames: true,
separator: '\n\n---\n\n',
},
},
],
});
const manager = new SystemPromptManager(
config,
'/custom/config/dir',
mockMemoryManager,
undefined,
mockLogger
);
const contributors = manager.getContributors();
expect(contributors).toHaveLength(1);
expect(contributors[0]?.id).toBe('docs');
expect(contributors[0]?.priority).toBe(5);
});
it('should use custom config directory', () => {
const customConfigDir = '/custom/project/path';
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'docs',
type: 'file',
priority: 5,
files: [path.join(customConfigDir, 'context.md')],
},
],
});
const manager = new SystemPromptManager(
config,
customConfigDir,
mockMemoryManager,
undefined,
mockLogger
);
// The FileContributor should receive the custom config directory
expect(manager.getContributors()).toHaveLength(1);
});
});
describe('Mixed Contributors', () => {
it('should handle mixed contributor types and build correctly', async () => {
const mockGenerator = vi.fn().mockResolvedValue('Dynamic content');
mockGetPromptGenerator.mockReturnValue(mockGenerator);
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'static',
type: 'static',
priority: 0,
content: 'Static content',
},
{
id: 'dynamic',
type: 'dynamic',
priority: 10,
source: 'date',
},
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const result = await manager.build(mockContext);
expect(result).toBe('Static content\nDynamic content');
});
it('should respect priority ordering with mixed types', async () => {
const mockGenerator = vi.fn().mockResolvedValue('Dynamic priority 5');
mockGetPromptGenerator.mockReturnValue(mockGenerator);
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'static-low',
type: 'static',
priority: 20,
content: 'Static priority 20',
},
{ id: 'dynamic-high', type: 'dynamic', priority: 5, source: 'date' },
{
id: 'static-high',
type: 'static',
priority: 0,
content: 'Static priority 0',
},
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const result = await manager.build(mockContext);
// Should be ordered by priority: 0, 5, 20
expect(result).toBe('Static priority 0\nDynamic priority 5\nStatic priority 20');
});
});
describe('Build Process', () => {
it('should join multiple contributors with newlines', async () => {
const config = SystemPromptConfigSchema.parse({
contributors: [
{ id: 'first', type: 'static', priority: 0, content: 'First line' },
{ id: 'second', type: 'static', priority: 10, content: 'Second line' },
{ id: 'third', type: 'static', priority: 20, content: 'Third line' },
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const result = await manager.build(mockContext);
expect(result).toBe('First line\nSecond line\nThird line');
});
it('should handle empty contributor content', async () => {
const config = SystemPromptConfigSchema.parse({
contributors: [
{ id: 'empty', type: 'static', priority: 0, content: '' },
{ id: 'content', type: 'static', priority: 10, content: 'Has content' },
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const result = await manager.build(mockContext);
expect(result).toBe('\nHas content');
});
it('should pass context correctly to all contributors', async () => {
const mockGenerator1 = vi.fn().mockResolvedValue('Gen1');
const mockGenerator2 = vi.fn().mockResolvedValue('Gen2');
mockGetPromptGenerator.mockImplementation((source) => {
if (source === 'date') return mockGenerator1;
if (source === 'resources') return mockGenerator2;
return undefined;
});
const config = SystemPromptConfigSchema.parse({
contributors: [
{ id: 'gen1', type: 'dynamic', priority: 0, source: 'date' },
{ id: 'gen2', type: 'dynamic', priority: 10, source: 'resources' },
],
});
const customContext = {
mcpManager: {} as any, // Mock MCPManager
};
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
await manager.build(customContext);
expect(mockGenerator1).toHaveBeenCalledWith(customContext);
expect(mockGenerator2).toHaveBeenCalledWith(customContext);
});
});
describe('Error Handling', () => {
it('should handle async errors in contributors gracefully', async () => {
const mockGenerator = vi.fn().mockRejectedValue(new Error('Generator failed'));
mockGetPromptGenerator.mockReturnValue(mockGenerator);
const config = SystemPromptConfigSchema.parse({
contributors: [{ id: 'failing', type: 'dynamic', priority: 0, source: 'date' }],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
await expect(manager.build(mockContext)).rejects.toThrow('Generator failed');
});
it('should use correct config directory default', () => {
const config = SystemPromptConfigSchema.parse('Simple prompt');
// Mock process.cwd() to test default behavior
const originalCwd = process.cwd;
process.cwd = vi.fn().mockReturnValue('/mocked/cwd');
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
expect(manager.getContributors()).toHaveLength(1);
process.cwd = originalCwd;
});
});
describe('Real-world Scenarios', () => {
it('should handle default configuration (empty object)', async () => {
const mockDateTimeGenerator = vi.fn().mockResolvedValue('2023-01-01 12:00:00');
const mockEnvGenerator = vi.fn().mockResolvedValue('<environment>mock</environment>');
const mockResourcesGenerator = vi.fn().mockResolvedValue('Available files: config.yml');
mockGetPromptGenerator.mockImplementation((source) => {
if (source === 'date') return mockDateTimeGenerator;
if (source === 'env') return mockEnvGenerator;
if (source === 'resources') return mockResourcesGenerator;
return undefined;
});
const config = SystemPromptConfigSchema.parse({});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
// date and env should be enabled by default, resources is disabled
const contributors = manager.getContributors();
expect(contributors).toHaveLength(2);
expect(contributors[0]?.id).toBe('date');
expect(contributors[1]?.id).toBe('env');
const result = await manager.build(mockContext);
expect(result).toBe('2023-01-01 12:00:00\n<environment>mock</environment>');
expect(mockDateTimeGenerator).toHaveBeenCalledWith(mockContext);
expect(mockEnvGenerator).toHaveBeenCalledWith(mockContext);
expect(mockResourcesGenerator).not.toHaveBeenCalled();
});
it('should handle complex configuration with all contributor types', async () => {
const mockGenerator = vi.fn().mockResolvedValue('2023-01-01');
mockGetPromptGenerator.mockReturnValue(mockGenerator);
const config = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'intro',
type: 'static',
priority: 0,
content: 'You are Dexto, an advanced AI assistant.',
},
{
id: 'context',
type: 'file',
priority: 5,
files: [path.join(process.cwd(), 'context.md')],
options: { includeFilenames: true },
},
{
id: 'datetime',
type: 'dynamic',
priority: 10,
source: 'date',
},
],
});
const manager = new SystemPromptManager(
config,
process.cwd(),
mockMemoryManager,
undefined,
mockLogger
);
const contributors = manager.getContributors();
expect(contributors).toHaveLength(3);
expect(contributors[0]?.id).toBe('intro');
expect(contributors[1]?.id).toBe('context');
expect(contributors[2]?.id).toBe('datetime');
});
});
});

View File

@@ -0,0 +1,146 @@
import type { ValidatedSystemPromptConfig, ValidatedContributorConfig } from './schemas.js';
import { StaticContributor, FileContributor, MemoryContributor } from './contributors.js';
import { getPromptGenerator } from './registry.js';
import type { MemoryManager, ValidatedMemoriesConfig } from '../memory/index.js';
import type { SystemPromptContributor, DynamicContributorContext } from './types.js';
import { DynamicContributor } from './contributors.js';
import type { IDextoLogger } from '../logger/v2/types.js';
import { DextoLogComponent } from '../logger/v2/types.js';
import { SystemPromptError } from './errors.js';
/**
* SystemPromptManager orchestrates registration, loading, and composition
* of both static and dynamic system-prompt contributors.
*/
export class SystemPromptManager {
private contributors: SystemPromptContributor[];
private configDir: string;
private memoryManager: MemoryManager;
private logger: IDextoLogger;
// TODO: move config dir logic somewhere else
constructor(
config: ValidatedSystemPromptConfig,
configDir: string,
memoryManager: MemoryManager,
memoriesConfig: ValidatedMemoriesConfig | undefined,
logger: IDextoLogger
) {
this.configDir = configDir;
this.memoryManager = memoryManager;
this.logger = logger.createChild(DextoLogComponent.SYSTEM_PROMPT);
this.logger.debug(`[SystemPromptManager] Initializing with configDir: ${configDir}`);
// Filter enabled contributors and create contributor instances
const enabledContributors = config.contributors.filter((c) => c.enabled !== false);
const contributors: SystemPromptContributor[] = enabledContributors.map((config) =>
this.createContributor(config)
);
// Add memory contributor if enabled via top-level memories config
if (memoriesConfig?.enabled) {
this.logger.debug(
`[SystemPromptManager] Creating MemoryContributor with options: ${JSON.stringify(memoriesConfig)}`
);
contributors.push(
new MemoryContributor(
'memories',
memoriesConfig.priority,
this.memoryManager,
{
includeTimestamps: memoriesConfig.includeTimestamps,
includeTags: memoriesConfig.includeTags,
limit: memoriesConfig.limit,
pinnedOnly: memoriesConfig.pinnedOnly,
},
this.logger
)
);
}
this.contributors = contributors.sort((a, b) => a.priority - b.priority); // Lower priority number = higher priority
}
private createContributor(config: ValidatedContributorConfig): SystemPromptContributor {
switch (config.type) {
case 'static':
return new StaticContributor(config.id, config.priority, config.content);
case 'dynamic': {
const promptGenerator = getPromptGenerator(config.source);
if (!promptGenerator) {
throw SystemPromptError.unknownContributorSource(config.source);
}
return new DynamicContributor(config.id, config.priority, promptGenerator);
}
case 'file': {
this.logger.debug(
`[SystemPromptManager] Creating FileContributor "${config.id}" with files: ${JSON.stringify(config.files)}`
);
return new FileContributor(
config.id,
config.priority,
config.files,
config.options,
this.logger
);
}
default: {
// Exhaustive check - TypeScript will error if we miss a case
const _exhaustive: never = config;
throw SystemPromptError.invalidContributorConfig(_exhaustive);
}
}
}
/**
* Build the full system prompt by invoking each contributor and concatenating.
*/
async build(ctx: DynamicContributorContext): Promise<string> {
const parts = await Promise.all(
this.contributors.map(async (contributor) => {
const content = await contributor.getContent(ctx);
this.logger.debug(
`[SystemPrompt] Contributor "${contributor.id}" provided content: ${content.substring(0, 50)}${content.length > 50 ? '...' : ''}`
);
return content;
})
);
return parts.join('\n');
}
/**
* Expose current list of contributors (for inspection or testing).
*/
getContributors(): SystemPromptContributor[] {
return this.contributors;
}
/**
* Add a contributor dynamically after construction.
* The contributor will be inserted in priority order.
*/
addContributor(contributor: SystemPromptContributor): void {
this.contributors.push(contributor);
this.contributors.sort((a, b) => a.priority - b.priority);
this.logger.debug(
`Added contributor: ${contributor.id} (priority: ${contributor.priority})`
);
}
/**
* Remove a contributor by ID.
* Returns true if removed, false if not found.
*/
removeContributor(id: string): boolean {
const index = this.contributors.findIndex((c) => c.id === id);
if (index === -1) return false;
this.contributors.splice(index, 1);
this.logger.debug(`Removed contributor: ${id}`);
return true;
}
}

View File

@@ -0,0 +1,26 @@
import * as handlers from './in-built-prompts.js';
import { DynamicContributorContext } from './types.js';
/**
* This file contains the registry of all the functions that can generate dynamic prompt pieces at runtime.
*/
export type DynamicPromptGenerator = (context: DynamicContributorContext) => Promise<string>;
// Available dynamic prompt generator sources
export const PROMPT_GENERATOR_SOURCES = ['date', 'env', 'resources'] as const;
export type PromptGeneratorSource = (typeof PROMPT_GENERATOR_SOURCES)[number];
// Registry mapping sources to their generator functions
export const PROMPT_GENERATOR_REGISTRY: Record<PromptGeneratorSource, DynamicPromptGenerator> = {
date: handlers.getCurrentDate,
env: handlers.getEnvironmentInfo,
resources: handlers.getResourceData,
};
// To fetch a prompt generator function from its source
export function getPromptGenerator(
source: PromptGeneratorSource
): DynamicPromptGenerator | undefined {
return PROMPT_GENERATOR_REGISTRY[source];
}

View File

@@ -0,0 +1,363 @@
import { describe, it, expect } from 'vitest';
import * as path from 'path';
import {
SystemPromptConfigSchema,
type SystemPromptConfig,
type ValidatedSystemPromptConfig,
} from './schemas.js';
describe('SystemPromptConfigSchema', () => {
describe('String Input Transform', () => {
it('should transform string to contributors object', () => {
const result = SystemPromptConfigSchema.parse('You are a helpful assistant');
expect(result.contributors).toHaveLength(1);
const contributor = result.contributors[0];
expect(contributor).toEqual({
id: 'inline',
type: 'static',
content: 'You are a helpful assistant',
priority: 0,
enabled: true,
});
});
it('should handle empty string', () => {
const result = SystemPromptConfigSchema.parse('');
expect(result.contributors).toHaveLength(1);
const contributor = result.contributors[0];
if (contributor?.type === 'static') {
expect(contributor.content).toBe('');
}
});
it('should handle multiline string', () => {
const multilinePrompt = `You are Dexto, an AI assistant.
You can help with:
- Coding tasks
- Analysis
- General questions`;
const result = SystemPromptConfigSchema.parse(multilinePrompt);
expect(result.contributors).toHaveLength(1);
const contributor = result.contributors[0];
if (contributor?.type === 'static') {
expect((contributor! as any).content).toBe(multilinePrompt);
}
});
});
describe('Object Input Validation', () => {
it('should apply default contributors for empty object', () => {
const result = SystemPromptConfigSchema.parse({});
expect(result.contributors).toHaveLength(3);
expect(result.contributors[0]).toEqual({
id: 'date',
type: 'dynamic',
priority: 10,
source: 'date',
enabled: true,
});
expect(result.contributors[1]).toEqual({
id: 'env',
type: 'dynamic',
priority: 15,
source: 'env',
enabled: true,
});
expect(result.contributors[2]).toEqual({
id: 'resources',
type: 'dynamic',
priority: 20,
source: 'resources',
enabled: false,
});
});
it('should allow overriding default contributors', () => {
const result = SystemPromptConfigSchema.parse({
contributors: [
{
id: 'custom',
type: 'static',
priority: 0,
content: 'Custom prompt',
enabled: true,
},
],
});
expect(result.contributors).toHaveLength(1);
expect(result.contributors[0]?.id).toBe('custom');
});
it('should pass through valid contributors object', () => {
const contributorsConfig = {
contributors: [
{
id: 'main',
type: 'static' as const,
priority: 0,
content: 'You are Dexto',
enabled: true,
},
{
id: 'date',
type: 'dynamic' as const,
priority: 10,
source: 'date',
enabled: true,
},
],
};
const result = SystemPromptConfigSchema.parse(contributorsConfig);
expect(result).toEqual(contributorsConfig);
});
});
describe('Contributor Type Validation', () => {
it('should validate static contributors', () => {
const validStatic = {
contributors: [{ id: 'test', type: 'static', priority: 0, content: 'hello world' }],
};
const validResult = SystemPromptConfigSchema.parse(validStatic);
expect(validResult.contributors[0]?.type).toBe('static');
const invalidStatic = {
contributors: [
{ id: 'test', type: 'static', priority: 0 }, // Missing content
],
};
const result = SystemPromptConfigSchema.safeParse(invalidStatic);
expect(result.success).toBe(false);
// For union schemas, the actual error is in unionErrors[1] (second branch - the object branch)
// TODO: Fix typing - unionErrors not properly typed in Zod
const unionError = result.error?.issues[0] as any;
expect(unionError?.code).toBe('invalid_union');
const objectErrors = unionError?.unionErrors?.[1]?.issues;
expect(objectErrors?.[0]?.path).toEqual(['contributors', 0, 'content']);
expect(objectErrors?.[0]?.code).toBe('invalid_type');
});
it('should validate dynamic contributors', () => {
const validDynamic = {
contributors: [{ id: 'date', type: 'dynamic', priority: 10, source: 'date' }],
};
const validResult = SystemPromptConfigSchema.parse(validDynamic);
expect(validResult.contributors[0]?.type).toBe('dynamic');
const invalidDynamic = {
contributors: [
{ id: 'date', type: 'dynamic', priority: 10 }, // Missing source
],
};
const result = SystemPromptConfigSchema.safeParse(invalidDynamic);
expect(result.success).toBe(false);
// For union schemas, the actual error is in unionErrors[1] (second branch - the object branch)
// TODO: Fix typing - unionErrors not properly typed in Zod
const unionError = result.error?.issues[0] as any;
expect(unionError?.code).toBe('invalid_union');
const objectErrors = unionError?.unionErrors?.[1]?.issues;
expect(objectErrors?.[0]?.path).toEqual(['contributors', 0, 'source']);
expect(objectErrors?.[0]?.code).toBe('invalid_type');
});
it('should validate dynamic contributor source enum', () => {
const validSources = ['date', 'env', 'resources'];
for (const source of validSources) {
const validConfig = {
contributors: [{ id: 'test', type: 'dynamic', priority: 10, source }],
};
const result = SystemPromptConfigSchema.parse(validConfig);
expect(result.contributors[0]?.type).toBe('dynamic');
}
const invalidSource = {
contributors: [
{ id: 'test', type: 'dynamic', priority: 10, source: 'invalidSource' }, // Invalid enum value
],
};
const result = SystemPromptConfigSchema.safeParse(invalidSource);
expect(result.success).toBe(false);
// For union schemas, the actual error is in unionErrors[1] (second branch - the object branch)
// TODO: Fix typing - unionErrors not properly typed in Zod
const unionError = result.error?.issues[0] as any;
expect(unionError?.code).toBe('invalid_union');
const objectErrors = unionError?.unionErrors?.[1]?.issues;
expect(objectErrors?.[0]?.path).toEqual(['contributors', 0, 'source']);
expect(objectErrors?.[0]?.code).toBe('invalid_enum_value');
});
it('should validate file contributors', () => {
const validFile = {
contributors: [
{
id: 'docs',
type: 'file',
priority: 5,
files: [path.join(process.cwd(), 'README.md')],
},
],
};
const validResult = SystemPromptConfigSchema.parse(validFile);
expect(validResult.contributors[0]?.type).toBe('file');
const invalidFile = {
contributors: [
{ id: 'docs', type: 'file', priority: 5, files: [] }, // Empty files array
],
};
const result = SystemPromptConfigSchema.safeParse(invalidFile);
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.path).toEqual(['contributors', 0, 'files']);
const relativePathFile = {
contributors: [
{ id: 'docs', type: 'file', priority: 5, files: ['relative/path.md'] },
],
};
const relativeResult = SystemPromptConfigSchema.safeParse(relativePathFile);
expect(relativeResult.success).toBe(false);
});
it('should reject invalid contributor types', () => {
const result = SystemPromptConfigSchema.safeParse({
contributors: [
{ id: 'invalid', type: 'invalid', priority: 0 }, // Invalid type
],
});
expect(result.success).toBe(false);
// For union schemas, the actual error is in unionErrors[1] (second branch - the object branch)
// TODO: Fix typing - unionErrors not properly typed in Zod
const unionError = result.error?.issues[0] as any;
expect(unionError?.code).toBe('invalid_union');
const objectErrors = unionError?.unionErrors?.[1]?.issues;
expect(objectErrors?.[0]?.path).toEqual(['contributors', 0, 'type']);
expect(objectErrors?.[0]?.code).toBe('invalid_union_discriminator');
});
it('should reject extra fields with strict validation', () => {
const result = SystemPromptConfigSchema.safeParse({
contributors: [{ id: 'test', type: 'static', priority: 0, content: 'test' }],
unknownField: 'should fail',
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe('unrecognized_keys');
});
});
describe('Type Safety', () => {
it('should handle input and output types correctly', () => {
// Input can be string or object
const stringInput: SystemPromptConfig = 'Hello world';
const objectInput: SystemPromptConfig = {
contributors: [{ id: 'test', type: 'static', priority: 0, content: 'test' }],
};
const stringResult = SystemPromptConfigSchema.parse(stringInput);
const objectResult = SystemPromptConfigSchema.parse(objectInput);
// Both should produce ValidatedSystemPromptConfig (object only)
expect(stringResult.contributors).toBeDefined();
expect(objectResult.contributors).toBeDefined();
});
it('should produce consistent output type', () => {
const stringResult: ValidatedSystemPromptConfig =
SystemPromptConfigSchema.parse('test');
const objectResult: ValidatedSystemPromptConfig = SystemPromptConfigSchema.parse({
contributors: [{ id: 'test', type: 'static', priority: 0, content: 'test' }],
});
// Both results should have the same type structure
expect(typeof stringResult.contributors).toBe('object');
expect(typeof objectResult.contributors).toBe('object');
expect(Array.isArray(stringResult.contributors)).toBe(true);
expect(Array.isArray(objectResult.contributors)).toBe(true);
});
});
describe('Real-world Scenarios', () => {
it('should handle simple string prompt', () => {
const result = SystemPromptConfigSchema.parse('You are a coding assistant');
expect(result.contributors).toHaveLength(1);
const contributor = result.contributors[0];
expect(contributor?.type).toBe('static');
if (contributor?.type === 'static') {
expect((contributor! as any).content).toBe('You are a coding assistant');
}
});
it('should handle complex contributors configuration', () => {
const complexConfig = {
contributors: [
{
id: 'main',
type: 'static' as const,
priority: 0,
content: 'You are Dexto, an advanced AI assistant.',
enabled: true,
},
{
id: 'context',
type: 'file' as const,
priority: 5,
files: [
path.join(process.cwd(), 'context.md'),
path.join(process.cwd(), 'guidelines.md'),
],
enabled: true,
options: {
includeFilenames: true,
separator: '\n\n---\n\n',
},
},
{
id: 'date',
type: 'dynamic' as const,
priority: 10,
source: 'date',
enabled: true,
},
],
};
const result = SystemPromptConfigSchema.parse(complexConfig);
expect(result.contributors).toHaveLength(3);
expect(result.contributors.map((c) => c.id)).toEqual(['main', 'context', 'date']);
});
it('should handle template-style configuration', () => {
const templateConfig = {
contributors: [
{
id: 'primary',
type: 'static' as const,
priority: 0,
content:
"You are a helpful AI assistant demonstrating Dexto's capabilities.",
},
{
id: 'date',
type: 'dynamic' as const,
priority: 10,
source: 'date',
enabled: true,
},
],
};
const result = SystemPromptConfigSchema.parse(templateConfig);
expect(result.contributors).toHaveLength(2);
expect(result.contributors[0]?.id).toBe('primary');
expect(result.contributors[1]?.id).toBe('date');
});
});
});

View File

@@ -0,0 +1,163 @@
import { z } from 'zod';
import * as path from 'path';
import { PROMPT_GENERATOR_SOURCES } from './registry.js';
// Define a base schema for common fields
const BaseContributorSchema = z
.object({
id: z.string().describe('Unique identifier for the contributor'),
priority: z
.number()
.int()
.nonnegative()
.describe('Execution priority of the contributor (lower numbers run first)'),
enabled: z
.boolean()
.optional()
.default(true)
.describe('Whether this contributor is currently active'),
})
.strict();
// Schema for 'static' contributors - only includes relevant fields
const StaticContributorSchema = BaseContributorSchema.extend({
type: z.literal('static'),
content: z.string().describe("Static content for the contributor (REQUIRED for 'static')"),
// No 'source' field here, as it's not relevant to static contributors
}).strict();
// Schema for 'dynamic' contributors - only includes relevant fields
const DynamicContributorSchema = BaseContributorSchema.extend({
type: z.literal('dynamic'),
source: z
.enum(PROMPT_GENERATOR_SOURCES)
.describe("Source identifier for dynamic content (REQUIRED for 'dynamic')"),
// No 'content' field here, as it's not relevant to dynamic contributors (source provides the content)
}).strict();
// Schema for 'file' contributors - includes file-specific configuration
const FileContributorSchema = BaseContributorSchema.extend({
type: z.literal('file'),
files: z
.array(
z.string().superRefine((filePath, ctx) => {
if (!path.isAbsolute(filePath)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'FileContributor paths must be absolute after template expansion (use ${{dexto.agent_dir}} or provide an absolute path).',
});
}
})
)
.min(1)
.describe('Array of file paths to include as context (.md and .txt files)'),
options: z
.object({
includeFilenames: z
.boolean()
.optional()
.default(true)
.describe('Whether to include the filename as a header for each file'),
separator: z
.string()
.optional()
.default('\n\n---\n\n')
.describe('Separator to use between multiple files'),
errorHandling: z
.enum(['skip', 'error'])
.optional()
.default('skip')
.describe(
'How to handle missing or unreadable files: skip (ignore) or error (throw)'
),
maxFileSize: z
.number()
.int()
.positive()
.optional()
.default(100000)
.describe('Maximum file size in bytes (default: 100KB)'),
includeMetadata: z
.boolean()
.optional()
.default(false)
.describe(
'Whether to include file metadata (size, modification time) in the context'
),
})
.strict()
.optional()
.default({}),
}).strict();
export const ContributorConfigSchema = z
.discriminatedUnion(
'type', // The field to discriminate on
[StaticContributorSchema, DynamicContributorSchema, FileContributorSchema],
{
// Optional: Custom error message for invalid discriminator
errorMap: (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_union_discriminator) {
return {
message: `Invalid contributor type. Expected 'static', 'dynamic', or 'file'. Note: memory contributors are now configured via the top-level 'memories' config.`,
};
}
return { message: ctx.defaultError };
},
}
)
.describe(
"Configuration for a system prompt contributor. Type 'static' requires 'content', type 'dynamic' requires 'source', type 'file' requires 'files'."
);
// Input type for user-facing API (pre-parsing)
export type ContributorConfig = z.input<typeof ContributorConfigSchema>;
// Validated type for internal use (post-parsing)
export type ValidatedContributorConfig = z.output<typeof ContributorConfigSchema>;
export const SystemPromptContributorsSchema = z
.object({
contributors: z
.array(ContributorConfigSchema)
.min(1)
.default([
{
id: 'date',
type: 'dynamic',
priority: 10,
source: 'date',
enabled: true,
},
{
id: 'env',
type: 'dynamic',
priority: 15,
source: 'env',
enabled: true,
},
{
id: 'resources',
type: 'dynamic',
priority: 20,
source: 'resources',
enabled: false,
},
] as const)
.describe('An array of contributor configurations that make up the system prompt'),
})
.strict();
// Add the union with transform - handles string | object input
export const SystemPromptConfigSchema = z
.union([
z.string().transform((str) => ({
contributors: [
{ id: 'inline', type: 'static' as const, content: str, priority: 0, enabled: true },
],
})),
SystemPromptContributorsSchema,
])
.describe('Plain string or structured contributors object')
.brand<'ValidatedSystemPromptConfig'>();
// Type definitions
export type SystemPromptConfig = z.input<typeof SystemPromptConfigSchema>; // string | object (user input)
export type ValidatedSystemPromptConfig = z.output<typeof SystemPromptConfigSchema>; // object only (parsed output)

View File

@@ -0,0 +1,13 @@
import { MCPManager } from '../mcp/manager.js';
// Context passed to dynamic contributors
export interface DynamicContributorContext {
mcpManager: MCPManager;
}
// Interface for all system prompt contributors
export interface SystemPromptContributor {
id: string;
priority: number;
getContent(context: DynamicContributorContext): Promise<string>;
}