feat: add multi-model delegation (Phase 0) and context compaction (Phase 1)

Phase 0 — Multi-Model Delegation:
- AgentOrchestrator wraps NativeAgent with delegate() for stateless
  single-turn calls to any model tier (fast/default/complex/local)
- DelegationConfig maps task types (compaction, classification, etc.)
  to model tiers
- Delegation prompts for compaction, memory extraction, classification,
  and tool summarisation
- Per-tier usage tracking for cost visibility
- Config schema: agents.delegation and agents.primary_tier

Phase 1 — Context Compaction:
- Token estimation (char/4 heuristic) with context window lookup
- shouldCompact() threshold check against context window percentage
- compactHistory() splits old/recent messages, delegates summary to
  fast tier, returns CompactionResult
- Automatic compaction in AgentOrchestrator.process() when configured
- Force-compact via orchestrator.compact() with session persistence
- Session.replaceHistory() with atomic SQLite transaction
- /compact TUI command with feedback on compacted token counts
- Config schema: compaction.enabled, threshold_pct, keep_turns,
  summary_max_tokens

Tests: 385 passing across 50 files (22 new tests in 2 new test files)
This commit is contained in:
William Valentin
2026-02-06 13:17:02 -08:00
parent f7cc87a4bb
commit 306e11bd2e
22 changed files with 1562 additions and 12 deletions
+613
View File
@@ -0,0 +1,613 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AgentOrchestrator } from './orchestrator.js';
import { ModelRouter } from '../../models/router.js';
import type { ChatResponse, ModelClient } from '../../models/types.js';
import { ToolRegistry, ToolExecutor } from '../../tools/index.js';
import { HookEngine } from '../../hooks/engine.js';
import type { SubAgentRequest } from './orchestrator.js';
describe('AgentOrchestrator', () => {
let mockDefaultClient: ModelClient;
let mockFastClient: ModelClient;
let mockComplexClient: ModelClient;
let mockRouter: ModelRouter;
const createMockClient = (name: string, inputTokens = 100, outputTokens = 50): ModelClient => ({
chat: vi.fn().mockResolvedValue({
content: `${name} response`,
stopReason: 'end_turn',
usage: { inputTokens, outputTokens },
}),
});
beforeEach(() => {
mockDefaultClient = createMockClient('default', 100, 50);
mockFastClient = createMockClient('fast', 50, 25);
mockComplexClient = createMockClient('complex', 200, 100);
mockRouter = new ModelRouter({
default: mockDefaultClient,
fast: mockFastClient,
complex: mockComplexClient,
fallbackChain: [],
});
});
describe('delegate()', () => {
it('routes to the correct tier when specified', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are a helpful assistant.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
const result = await orchestrator.delegate({
tier: 'fast',
systemPrompt: 'Summarize this text',
message: 'This is a test message',
maxTokens: 1000,
});
expect(result.content).toBe('fast response');
expect(result.tier).toBe('fast');
});
it('includes tools when requested', async () => {
const mockToolRegistry = new ToolRegistry();
const hooks = new HookEngine({
confirm: ['*'],
log: [],
silent: [],
});
const mockToolExecutor = new ToolExecutor(mockToolRegistry, hooks);
const mockFastChatClient = mockRouter.getClient('fast')!;
const mockFastChatFn = vi.fn().mockResolvedValue({
content: 'response with tools',
stopReason: 'end_turn',
usage: { inputTokens: 100, outputTokens: 50 },
} as ChatResponse);
Object.assign(mockFastChatClient, { chat: mockFastChatFn });
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
toolRegistry: mockToolRegistry,
toolExecutor: mockToolExecutor,
});
await orchestrator.delegate({
tier: 'fast',
systemPrompt: 'Use available tools',
message: 'Help me analyze data',
tools: true,
});
expect(mockFastChatFn).toHaveBeenCalled();
});
it('falls back to default tier when requested tier is unavailable', async () => {
const routerWithoutComplex = new ModelRouter({
default: mockDefaultClient,
fast: mockFastClient,
fallbackChain: [],
});
const orchestrator = new AgentOrchestrator({
modelRouter: routerWithoutComplex,
systemPrompt: 'You are a helpful assistant.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
const result = await orchestrator.delegate({
tier: 'complex',
systemPrompt: 'Analyze deeply',
message: 'This is complex',
});
expect(result.content).toBe('default response');
expect(result.tier).toBe('default');
});
it('tracks cumulative usage after delegate calls', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
await orchestrator.delegate({
tier: 'fast',
systemPrompt: 'Fast task',
message: 'Fast message',
});
await orchestrator.delegate({
tier: 'complex',
systemPrompt: 'Complex task',
message: 'Complex message',
});
await orchestrator.delegate({
tier: 'fast',
systemPrompt: 'Another fast task',
message: 'Another fast message',
});
const usage = orchestrator.getDelegationUsage();
expect(usage.fast).toEqual({
inputTokens: 100,
outputTokens: 50,
calls: 2,
});
expect(usage.complex).toEqual({
inputTokens: 200,
outputTokens: 100,
calls: 1,
});
expect(usage.default).toBeUndefined();
});
it('tracks usage across tiers correctly', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
await orchestrator.delegate({
tier: 'fast',
systemPrompt: 'Fast task',
message: 'Fast message',
});
await orchestrator.delegate({
tier: 'fast',
systemPrompt: 'Another fast task',
message: 'Another fast message',
});
const usage = orchestrator.getDelegationUsage();
expect(usage.fast.inputTokens).toBe(100);
expect(usage.fast.outputTokens).toBe(50);
expect(usage.fast.calls).toBe(2);
});
it('logs delegation details with tier and token counts', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
await orchestrator.delegate({
tier: 'fast',
systemPrompt: 'Fast task',
message: 'Fast message',
});
expect(consoleSpy).toHaveBeenCalledWith(
'[Flynn:delegate] tier=fast tokens=50+25'
);
consoleSpy.mockRestore();
});
});
describe('getDelegationTier()', () => {
it('returns correct tier for each task type', () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
expect(orchestrator.getDelegationTier('compaction')).toBe('fast');
expect(orchestrator.getDelegationTier('memory_extraction')).toBe('default');
expect(orchestrator.getDelegationTier('classification')).toBe('complex');
expect(orchestrator.getDelegationTier('tool_summarisation')).toBe('default');
expect(orchestrator.getDelegationTier('complex_reasoning')).toBe('complex');
});
it('returns tier that was explicitly configured', () => {
const customDelegation = {
compaction: 'local' as const,
memory_extraction: 'fast' as const,
classification: 'complex' as const,
tool_summarisation: 'default' as const,
complex_reasoning: 'local' as const,
};
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: customDelegation,
maxDelegationDepth: 10,
});
expect(orchestrator.getDelegationTier('compaction')).toBe('local');
expect(orchestrator.getDelegationTier('memory_extraction')).toBe('fast');
expect(orchestrator.getDelegationTier('complex_reasoning')).toBe('local');
});
});
describe('process()', () => {
it('proxies to NativeAgent for user messages', async () => {
const mockDefaultChatClient = mockRouter.getClient('default')!;
const mockDefaultChatFn = vi.fn().mockResolvedValue({
content: 'Agent response',
stopReason: 'end_turn',
usage: { inputTokens: 150, outputTokens: 75 },
} as ChatResponse);
Object.assign(mockDefaultChatClient, { chat: mockDefaultChatFn });
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are a helpful agent.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
const response = await orchestrator.process('Hello, agent!');
expect(response).toBe('Agent response');
});
it('maintains conversation history through process()', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are a helpful agent.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
await orchestrator.process('Hello');
await orchestrator.process('How are you?');
await orchestrator.process('Tell me about yourself');
const history = orchestrator.getHistory();
expect(history).toHaveLength(6);
expect(history[0]).toEqual({ role: 'user', content: 'Hello' });
expect(history[1]).toEqual({ role: 'assistant', content: 'default response' });
expect(history[2]).toEqual({ role: 'user', content: 'How are you?' });
expect(history[3]).toEqual({ role: 'assistant', content: 'default response' });
expect(history[4]).toEqual({ role: 'user', content: 'Tell me about yourself' });
expect(history[5]).toEqual({ role: 'assistant', content: 'default response' });
});
});
describe('reset()', () => {
it('clears primary agent conversation history', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are a helpful agent.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
await orchestrator.process('Hello');
await orchestrator.process('How are you?');
expect(orchestrator.getHistory()).toHaveLength(4);
orchestrator.reset();
expect(orchestrator.getHistory()).toHaveLength(0);
});
it('can be called multiple times', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are a helpful agent.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
await orchestrator.process('Hello');
orchestrator.reset();
expect(orchestrator.getHistory()).toHaveLength(0);
await orchestrator.process('World');
orchestrator.reset();
expect(orchestrator.getHistory()).toHaveLength(0);
});
});
describe('getDelegationUsage()', () => {
it('returns copy of usage stats (doesn\'t expose internal map)', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
await orchestrator.delegate({
tier: 'fast',
systemPrompt: 'Fast task',
message: 'Fast message',
});
const usage1 = orchestrator.getDelegationUsage();
const usage2 = orchestrator.getDelegationUsage();
expect(usage1).toEqual(usage2);
usage1.fast.inputTokens = 999;
expect(usage2.fast.inputTokens).toBe(50);
});
it('returns empty object when no usage tracked', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
const usage = orchestrator.getDelegationUsage();
expect(usage).toEqual({});
});
});
describe('getHistory()', () => {
it('returns conversation history from primary agent', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are a helpful agent.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
await orchestrator.process('Hello');
await orchestrator.process('How are you?');
const history = orchestrator.getHistory();
expect(history).toHaveLength(4);
expect(history[0]).toEqual({ role: 'user', content: 'Hello' });
expect(history[1]).toEqual({ role: 'assistant', content: 'default response' });
expect(history[2]).toEqual({ role: 'user', content: 'How are you?' });
expect(history[3]).toEqual({ role: 'assistant', content: 'default response' });
});
it('returns empty array when no history', async () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are a helpful agent.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
const history = orchestrator.getHistory();
expect(history).toEqual([]);
});
});
describe('setModelTier()', () => {
it('sets model tier on primary agent', () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
orchestrator.setModelTier('fast');
expect(orchestrator.getModelTier()).toBe('fast');
});
it('allows tier changes after initialization', () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
expect(orchestrator.getModelTier()).toBe('default');
orchestrator.setModelTier('complex');
expect(orchestrator.getModelTier()).toBe('complex');
orchestrator.setModelTier('fast');
expect(orchestrator.getModelTier()).toBe('fast');
});
});
describe('setOnToolUse()', () => {
it('sets tool-use callback on primary agent', () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
const callback = vi.fn();
orchestrator.setOnToolUse(callback);
expect(orchestrator.getModelTier()).toBe('default');
});
it('allows callback changes', () => {
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
});
const callback1 = vi.fn();
const callback2 = vi.fn();
orchestrator.setOnToolUse(callback1);
expect(orchestrator.getModelTier()).toBe('default');
orchestrator.setOnToolUse(callback2);
expect(orchestrator.getModelTier()).toBe('default');
});
});
});