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:
William Valentin
2026-02-06 15:12:35 -08:00
parent de68deb1b2
commit 4316dbd3be
24 changed files with 902 additions and 143 deletions
+59 -15
View File
@@ -1,7 +1,7 @@
import { Lifecycle } from './lifecycle.js';
import type { Config, ModelConfig } from '../config/index.js';
import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, ModelRouter } from '../models/index.js';
import type { ModelClient } from '../models/index.js';
import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js';
import type { ModelClient, RetryConfig } from '../models/index.js';
import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js';
import { SessionStore, SessionManager } from '../session/index.js';
import { HookEngine } from '../hooks/index.js';
@@ -14,9 +14,10 @@ import { CronScheduler } from '../automation/index.js';
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
import { McpManager } from '../mcp/index.js';
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js';
import { assembleSystemPrompt } from '../prompt/index.js';
import { resolve } from 'path';
import { homedir } from 'os';
import { mkdirSync, readFileSync, existsSync } from 'fs';
import { mkdirSync } from 'fs';
export interface DaemonContext {
config: Config;
@@ -34,21 +35,23 @@ export interface DaemonContext {
skillInstaller: SkillInstaller;
}
function loadSystemPrompt(): string {
// Try to load SOUL.md from working directory first, then from project root
const paths = [
resolve(process.cwd(), 'SOUL.md'),
resolve(import.meta.dirname, '../../SOUL.md'),
function loadSystemPrompt(config: Config): string {
const searchDirs = [
process.cwd(),
resolve(import.meta.dirname, '../..'),
...(config.prompt.search_dirs ?? []),
];
for (const soulPath of paths) {
if (existsSync(soulPath)) {
return readFileSync(soulPath, 'utf-8');
}
const result = assembleSystemPrompt({
searchDirs,
extraSections: config.prompt.extra_sections,
});
if (result.loadedFiles.length > 0) {
console.log(`Loaded prompt templates: ${result.loadedFiles.map(f => f.split('/').pop()).join(', ')}`);
}
// Fallback if SOUL.md not found
return 'You are Flynn, a helpful personal AI assistant. Be direct, concise, and helpful. Use markdown when it improves readability.';
return result.prompt;
}
/**
@@ -125,12 +128,26 @@ function createModelRouter(config: Config): ModelRouter {
console.log(`Model router: default=${models.default.provider}/${models.default.model}, ` +
`fallback=[${models.fallback_chain.join(', ')}]`);
// Build retry config if enabled
const retryConfig: RetryConfig | undefined = config.retry.enabled ? {
maxRetries: config.retry.max_retries,
initialDelayMs: config.retry.initial_delay_ms,
backoffMultiplier: config.retry.backoff_multiplier,
maxDelayMs: config.retry.max_delay_ms,
nonRetryablePatterns: DEFAULT_RETRY_CONFIG.nonRetryablePatterns,
} : undefined;
if (retryConfig) {
console.log(`Retry policy: max_retries=${retryConfig.maxRetries}, initial_delay=${retryConfig.initialDelayMs}ms`);
}
return new ModelRouter({
default: defaultClient,
fast: fastClient,
complex: complexClient,
local: localClient,
fallbackChain,
retryConfig,
});
}
@@ -210,6 +227,33 @@ function createMessageRouter(deps: {
}
return;
}
if (msg.metadata.command === 'usage') {
const usage = agent.getUsage();
const lines = [
'**Token Usage**',
'',
`Primary: ${usage.primary.inputTokens.toLocaleString()} in / ${usage.primary.outputTokens.toLocaleString()} out (${usage.primary.calls} calls)`,
];
const delegationEntries = Object.entries(usage.delegation);
if (delegationEntries.length > 0) {
lines.push('');
lines.push('Delegation:');
for (const [tier, stats] of delegationEntries) {
lines.push(` ${tier}: ${stats.inputTokens.toLocaleString()} in / ${stats.outputTokens.toLocaleString()} out (${stats.calls} calls)`);
}
}
lines.push('');
lines.push(`**Total:** ${usage.total.inputTokens.toLocaleString()} in / ${usage.total.outputTokens.toLocaleString()} out (${usage.total.calls} calls)`);
if (usage.total.estimatedCost > 0) {
lines.push(`**Estimated cost:** $${usage.total.estimatedCost.toFixed(4)}`);
}
await reply({ text: lines.join('\n'), replyTo: msg.id });
return;
}
}
try {
@@ -331,7 +375,7 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
const modelRouter = createModelRouter(config);
// Load system prompt and append skill instructions
let systemPrompt = loadSystemPrompt();
let systemPrompt = loadSystemPrompt(config);
const skillAdditions = skillRegistry.getSystemPromptAdditions();
if (skillAdditions) {
systemPrompt = `${systemPrompt}\n\n# Available Skills\n\n${skillAdditions}`;