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:
260
dexto/packages/core/src/systemPrompt/contributors.test.ts
Normal file
260
dexto/packages/core/src/systemPrompt/contributors.test.ts
Normal 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>');
|
||||
});
|
||||
});
|
||||
293
dexto/packages/core/src/systemPrompt/contributors.ts
Normal file
293
dexto/packages/core/src/systemPrompt/contributors.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
14
dexto/packages/core/src/systemPrompt/error-codes.ts
Normal file
14
dexto/packages/core/src/systemPrompt/error-codes.ts
Normal 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',
|
||||
}
|
||||
75
dexto/packages/core/src/systemPrompt/errors.ts
Normal file
75
dexto/packages/core/src/systemPrompt/errors.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
102
dexto/packages/core/src/systemPrompt/in-built-prompts.ts
Normal file
102
dexto/packages/core/src/systemPrompt/in-built-prompts.ts
Normal 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>`;
|
||||
}
|
||||
13
dexto/packages/core/src/systemPrompt/index.ts
Normal file
13
dexto/packages/core/src/systemPrompt/index.ts
Normal 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';
|
||||
666
dexto/packages/core/src/systemPrompt/manager.test.ts
Normal file
666
dexto/packages/core/src/systemPrompt/manager.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
146
dexto/packages/core/src/systemPrompt/manager.ts
Normal file
146
dexto/packages/core/src/systemPrompt/manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
dexto/packages/core/src/systemPrompt/registry.ts
Normal file
26
dexto/packages/core/src/systemPrompt/registry.ts
Normal 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];
|
||||
}
|
||||
363
dexto/packages/core/src/systemPrompt/schemas.test.ts
Normal file
363
dexto/packages/core/src/systemPrompt/schemas.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
163
dexto/packages/core/src/systemPrompt/schemas.ts
Normal file
163
dexto/packages/core/src/systemPrompt/schemas.ts
Normal 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)
|
||||
13
dexto/packages/core/src/systemPrompt/types.ts
Normal file
13
dexto/packages/core/src/systemPrompt/types.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user