From 70bb14a6f66e232f22c8f4a9d0399e119908900f Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 17 Feb 2026 10:39:47 -0800 Subject: [PATCH] feat(routing): add auto_escalate retry to complex tier --- docs/plans/state.json | 12 +++++++ src/daemon/routing.test.ts | 71 ++++++++++++++++++++++++++++++++++++++ src/daemon/routing.ts | 15 +++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 8a9c591..d8ebe16 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3672,6 +3672,18 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/daemon/routing.test.ts + pnpm typecheck passing" + }, + "auto-escalate-native-retry-path": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Implemented `agents.auto_escalate` runtime behavior in channel routing: on primary-tier processing failure, Flynn now escalates to `complex` tier once and retries before surfacing an error. Added regression coverage for retry success path.", + "files_modified": [ + "src/daemon/routing.ts", + "src/daemon/routing.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/daemon/routing.test.ts + pnpm typecheck passing" } }, "overall_progress": { diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 5e5c516..42a112d 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -974,6 +974,77 @@ describe('daemon audio routing integration', () => { }); }); +describe('daemon auto-escalate integration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('retries on complex tier when auto_escalate is enabled', async () => { + const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process') + .mockRejectedValueOnce(new Error('primary tier failed')) + .mockResolvedValueOnce('complex-tier response'); + const setModelTierSpy = vi.spyOn(AgentOrchestrator.prototype, 'setModelTier'); + + const session = { + id: 'telegram:auto-escalate', + addMessage: vi.fn(), + getHistory: vi.fn(() => []), + clear: vi.fn(), + replaceHistory: vi.fn(), + getConfig: vi.fn(() => undefined), + setConfig: vi.fn(), + deleteConfig: vi.fn(), + }; + + const router = createMessageRouter({ + sessionManager: { + getSession: vi.fn(() => session), + } as unknown as MessageRouterDeps['sessionManager'], + modelRouter: { + getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], + getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), + getLabel: (tier: string) => tier, + } as unknown as MessageRouterDeps['modelRouter'], + systemPrompt: 'test prompt', + toolRegistry: { + clone() { return this; }, + register: vi.fn(), + } as unknown as MessageRouterDeps['toolRegistry'], + toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'], + config: { + agents: { + primary_tier: 'default', + auto_escalate: true, + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + max_iterations: 10, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + } as unknown as MessageRouterDeps['config'], + }); + + const reply = vi.fn(async (_message: OutboundMessage) => {}); + await router.handler({ + id: 'm-auto-escalate', + channel: 'telegram', + senderId: 'auto-escalate', + text: 'do something hard', + timestamp: Date.now(), + } as MessageRouterInput, reply); + + expect(processSpy).toHaveBeenCalledTimes(2); + expect(setModelTierSpy).toHaveBeenCalledWith('complex'); + expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'complex-tier response' })); + }); +}); + describe('daemon talk mode (voice wake) integration', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index 757f2d9..cca97f0 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -888,7 +888,20 @@ export function createMessageRouter(deps: { } } - const response = await agent.process(messageText, attachments); + let response: string; + try { + response = await agent.process(messageText, attachments); + } catch (error) { + const currentTier = agent.getModelTier(); + const canEscalate = deps.config.agents.auto_escalate && currentTier !== 'complex'; + if (!canEscalate) { + throw error; + } + + console.warn(`Auto-escalating session ${msg.channel}:${msg.senderId} from ${currentTier} to complex after processing failure.`); + agent.setModelTier('complex'); + response = await agent.process(messageText, attachments); + } const outboundAttachments = collector.drain(); await reply({ text: response,