feat: add P2 features — retry policy, prompt templating, usage tracking, tech debt cleanup
- Extract shared splitMessage() into channels/utils.ts (dedup 4 adapters) - Add Slack user name resolution with caching (users.info API) - Add withRetry() with exponential backoff + jitter, isRetryable() filter - Wire retry config into ModelRouter.chat() (non-streaming only) - Add assembleSystemPrompt() multi-file template system (SOUL/AGENTS/IDENTITY/USER/TOOLS.md) - Add usage tracking accumulators in NativeAgent + AgentOrchestrator - Add estimateCost() with per-model pricing table - Add /usage TUI command with full usage report formatting - Add retrySchema and promptSchema to config schema Tests: 569 passing, typecheck clean
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { assembleSystemPrompt, type PromptTemplateConfig, type PromptTemplateResult } from './template.js';
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { assembleSystemPrompt } from './template.js';
|
||||
|
||||
describe('assembleSystemPrompt', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'flynn-prompt-test-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('returns fallback when no files found', () => {
|
||||
const dir = makeTempDir();
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir] });
|
||||
|
||||
expect(result.prompt).toBe(
|
||||
'You are Flynn, a helpful personal AI assistant. Be direct, concise, and helpful. Use markdown when it improves readability.',
|
||||
);
|
||||
expect(result.loadedFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('loads SOUL.md without section header', () => {
|
||||
const dir = makeTempDir();
|
||||
writeFileSync(join(dir, 'SOUL.md'), 'You are Flynn.');
|
||||
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir] });
|
||||
|
||||
expect(result.prompt).toBe('You are Flynn.');
|
||||
expect(result.loadedFiles).toHaveLength(1);
|
||||
expect(result.loadedFiles[0]).toContain('SOUL.md');
|
||||
});
|
||||
|
||||
it('loads AGENTS.md with "# Agent Instructions" section header', () => {
|
||||
const dir = makeTempDir();
|
||||
writeFileSync(join(dir, 'AGENTS.md'), 'Follow these rules.');
|
||||
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir] });
|
||||
|
||||
expect(result.prompt).toBe('# Agent Instructions\n\nFollow these rules.');
|
||||
expect(result.loadedFiles).toHaveLength(1);
|
||||
expect(result.loadedFiles[0]).toContain('AGENTS.md');
|
||||
});
|
||||
|
||||
it('loads multiple files in correct order', () => {
|
||||
const dir = makeTempDir();
|
||||
writeFileSync(join(dir, 'SOUL.md'), 'I am Flynn.');
|
||||
writeFileSync(join(dir, 'AGENTS.md'), 'Be helpful.');
|
||||
writeFileSync(join(dir, 'USER.md'), 'User likes cats.');
|
||||
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir] });
|
||||
|
||||
expect(result.loadedFiles).toHaveLength(3);
|
||||
// Verify correct ordering: SOUL → AGENTS → USER
|
||||
expect(result.prompt).toBe(
|
||||
'I am Flynn.\n\n# Agent Instructions\n\nBe helpful.\n\n# User Context\n\nUser likes cats.',
|
||||
);
|
||||
});
|
||||
|
||||
it('first search dir takes precedence over later dirs', () => {
|
||||
const dir1 = makeTempDir();
|
||||
const dir2 = makeTempDir();
|
||||
writeFileSync(join(dir1, 'SOUL.md'), 'Primary identity.');
|
||||
writeFileSync(join(dir2, 'SOUL.md'), 'Secondary identity.');
|
||||
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir1, dir2] });
|
||||
|
||||
expect(result.prompt).toBe('Primary identity.');
|
||||
expect(result.loadedFiles).toHaveLength(1);
|
||||
expect(result.loadedFiles[0]).toContain(dir1);
|
||||
});
|
||||
|
||||
it('falls through to later dir if file missing in first dir', () => {
|
||||
const dir1 = makeTempDir();
|
||||
const dir2 = makeTempDir();
|
||||
writeFileSync(join(dir2, 'SOUL.md'), 'Fallback identity.');
|
||||
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir1, dir2] });
|
||||
|
||||
expect(result.prompt).toBe('Fallback identity.');
|
||||
expect(result.loadedFiles[0]).toContain(dir2);
|
||||
});
|
||||
|
||||
it('extra sections are appended', () => {
|
||||
const dir = makeTempDir();
|
||||
writeFileSync(join(dir, 'SOUL.md'), 'Base identity.');
|
||||
|
||||
const result = assembleSystemPrompt({
|
||||
searchDirs: [dir],
|
||||
extraSections: [
|
||||
{ name: 'Custom Rules', content: 'Always be polite.' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.prompt).toBe(
|
||||
'Base identity.\n\n# Custom Rules\n\nAlways be polite.',
|
||||
);
|
||||
});
|
||||
|
||||
it('empty files are skipped', () => {
|
||||
const dir = makeTempDir();
|
||||
writeFileSync(join(dir, 'SOUL.md'), '');
|
||||
writeFileSync(join(dir, 'AGENTS.md'), ' ');
|
||||
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir] });
|
||||
|
||||
expect(result.prompt).toBe(
|
||||
'You are Flynn, a helpful personal AI assistant. Be direct, concise, and helpful. Use markdown when it improves readability.',
|
||||
);
|
||||
expect(result.loadedFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('empty extra sections are skipped', () => {
|
||||
const dir = makeTempDir();
|
||||
writeFileSync(join(dir, 'SOUL.md'), 'Base identity.');
|
||||
|
||||
const result = assembleSystemPrompt({
|
||||
searchDirs: [dir],
|
||||
extraSections: [
|
||||
{ name: 'Empty', content: ' ' },
|
||||
{ name: 'Populated', content: 'Has content.' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.prompt).toBe(
|
||||
'Base identity.\n\n# Populated\n\nHas content.',
|
||||
);
|
||||
});
|
||||
|
||||
it('attempts all PROMPT_FILES', () => {
|
||||
const dir = makeTempDir();
|
||||
writeFileSync(join(dir, 'SOUL.md'), 'Soul.');
|
||||
writeFileSync(join(dir, 'AGENTS.md'), 'Agents.');
|
||||
writeFileSync(join(dir, 'IDENTITY.md'), 'Identity.');
|
||||
writeFileSync(join(dir, 'USER.md'), 'User.');
|
||||
writeFileSync(join(dir, 'TOOLS.md'), 'Tools.');
|
||||
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir] });
|
||||
|
||||
expect(result.loadedFiles).toHaveLength(5);
|
||||
expect(result.prompt).toContain('Soul.');
|
||||
expect(result.prompt).toContain('# Agent Instructions\n\nAgents.');
|
||||
expect(result.prompt).toContain('# Identity Customization\n\nIdentity.');
|
||||
expect(result.prompt).toContain('# User Context\n\nUser.');
|
||||
expect(result.prompt).toContain('# Tool Instructions\n\nTools.');
|
||||
});
|
||||
|
||||
it('trims whitespace from loaded file content', () => {
|
||||
const dir = makeTempDir();
|
||||
writeFileSync(join(dir, 'SOUL.md'), '\n I am Flynn. \n\n');
|
||||
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir] });
|
||||
|
||||
expect(result.prompt).toBe('I am Flynn.');
|
||||
});
|
||||
|
||||
it('mixes files from different search directories', () => {
|
||||
const dir1 = makeTempDir();
|
||||
const dir2 = makeTempDir();
|
||||
writeFileSync(join(dir1, 'SOUL.md'), 'Primary soul.');
|
||||
writeFileSync(join(dir2, 'AGENTS.md'), 'Agent rules.');
|
||||
|
||||
const result = assembleSystemPrompt({ searchDirs: [dir1, dir2] });
|
||||
|
||||
expect(result.loadedFiles).toHaveLength(2);
|
||||
expect(result.prompt).toBe('Primary soul.\n\n# Agent Instructions\n\nAgent rules.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/** Ordered list of prompt template files to look for. */
|
||||
const PROMPT_FILES = [
|
||||
{ name: 'SOUL.md', section: 'Identity', required: false },
|
||||
{ name: 'AGENTS.md', section: 'Agent Instructions', required: false },
|
||||
{ name: 'IDENTITY.md', section: 'Identity Customization', required: false },
|
||||
{ name: 'USER.md', section: 'User Context', required: false },
|
||||
{ name: 'TOOLS.md', section: 'Tool Instructions', required: false },
|
||||
] as const;
|
||||
|
||||
export interface PromptTemplateConfig {
|
||||
/** Directories to search for template files, in priority order. */
|
||||
searchDirs: string[];
|
||||
/** Additional sections to inject (e.g., from config). */
|
||||
extraSections?: Array<{ name: string; content: string }>;
|
||||
}
|
||||
|
||||
export interface PromptTemplateResult {
|
||||
/** The assembled system prompt. */
|
||||
prompt: string;
|
||||
/** Which files were loaded. */
|
||||
loadedFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a system prompt from multiple template files.
|
||||
*
|
||||
* Searches `searchDirs` in order for each template file.
|
||||
* First match wins — a file found in an earlier directory takes precedence.
|
||||
* Sections are assembled in the order defined in PROMPT_FILES.
|
||||
*/
|
||||
export function assembleSystemPrompt(config: PromptTemplateConfig): PromptTemplateResult {
|
||||
const sections: string[] = [];
|
||||
const loadedFiles: string[] = [];
|
||||
|
||||
for (const { name, section } of PROMPT_FILES) {
|
||||
for (const dir of config.searchDirs) {
|
||||
const filePath = resolve(dir, name);
|
||||
if (existsSync(filePath)) {
|
||||
const content = readFileSync(filePath, 'utf-8').trim();
|
||||
if (content) {
|
||||
// SOUL.md is special — it's the base identity, no section header
|
||||
if (name === 'SOUL.md') {
|
||||
sections.push(content);
|
||||
} else {
|
||||
sections.push(`# ${section}\n\n${content}`);
|
||||
}
|
||||
loadedFiles.push(filePath);
|
||||
}
|
||||
break; // First match wins for this file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add extra sections
|
||||
if (config.extraSections) {
|
||||
for (const { name, content } of config.extraSections) {
|
||||
if (content.trim()) {
|
||||
sections.push(`# ${name}\n\n${content.trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback if nothing was loaded
|
||||
if (sections.length === 0) {
|
||||
return {
|
||||
prompt: 'You are Flynn, a helpful personal AI assistant. Be direct, concise, and helpful. Use markdown when it improves readability.',
|
||||
loadedFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: sections.join('\n\n'),
|
||||
loadedFiles,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user