From d67cfa64a699b6b1d5a9a7368de21289087e1112 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 17 Feb 2026 10:38:56 -0800 Subject: [PATCH] feat(routing): honor models.for via metadata modelFor --- docs/plans/state.json | 12 ++++++ src/daemon/routing.test.ts | 82 ++++++++++++++++++++++++++++++++++---- src/daemon/routing.ts | 35 +++++++++++++++- 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 47d6132..8a9c591 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3660,6 +3660,18 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/channels/line/adapter.test.ts src/channels/zalo/adapter.test.ts + pnpm typecheck passing" + }, + "model-for-tier-routing-consumption": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Implemented runtime consumption of `models.*.for` via `metadata.modelFor` tier selection in channel routing (including audio capability resolution path), and added routing precedence/behavior regression coverage.", + "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 f8860d6..5e5c516 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -51,28 +51,96 @@ describe('daemon agent routing integration', () => { expect(router.resolve('telegram', '123')).toBeUndefined(); }); - it('model tier precedence: metadata > agent config > global default', () => { + it('model tier precedence: metadata > metadata modelFor > agent config > global default', () => { // This test documents the tier resolution precedence used by createMessageRouter. - // The actual resolution logic: tierFromMetadata ?? agentConfig?.modelTier ?? primary_tier ?? 'default' + // The actual resolution logic: + // tierFromMetadata ?? tierFromMetadataModelFor ?? agentConfig?.modelTier ?? primary_tier ?? 'default' function resolveTier( metadataTier: ModelTier | undefined, + metadataForTier: ModelTier | undefined, agentTier: ModelTier | undefined, globalTier: ModelTier | undefined, ): ModelTier { - return metadataTier ?? agentTier ?? globalTier ?? 'default'; + return metadataTier ?? metadataForTier ?? agentTier ?? globalTier ?? 'default'; } // With all three set, metadata wins - expect(resolveTier('fast', 'complex', 'default')).toBe('fast'); + expect(resolveTier('fast', 'default', 'complex', 'default')).toBe('fast'); + + // Without explicit metadata tier, modelFor-resolved tier wins + expect(resolveTier(undefined, 'complex', 'default', 'fast')).toBe('complex'); // Without metadata, agent config wins - expect(resolveTier(undefined, 'complex', 'default')).toBe('complex'); + expect(resolveTier(undefined, undefined, 'complex', 'default')).toBe('complex'); // Without metadata or agent config, global wins - expect(resolveTier(undefined, undefined, 'default')).toBe('default'); + expect(resolveTier(undefined, undefined, undefined, 'default')).toBe('default'); // Without anything, falls back to 'default' - expect(resolveTier(undefined, undefined, undefined)).toBe('default'); + expect(resolveTier(undefined, undefined, undefined, undefined)).toBe('default'); + }); + + it('uses metadata.modelFor tags to select tier', async () => { + const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process').mockResolvedValue('ok'); + const session = { + id: 'telegram:model-for', + 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', + 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', for: ['chat'] }, + fast: { provider: 'anthropic', model: 'haiku', for: ['search'] }, + }, + } as unknown as MessageRouterDeps['config'], + }); + + await router.handler({ + id: 'm-model-for', + channel: 'telegram', + senderId: 'model-for', + text: 'find this quickly', + timestamp: Date.now(), + metadata: { modelFor: 'search' }, + } as MessageRouterInput, vi.fn(async () => {})); + + const keys = Array.from(router.agents.keys()); + expect(keys.some((key) => key.endsWith(':fast'))).toBe(true); + expect(processSpy).toHaveBeenCalled(); }); }); diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index ba47d45..757f2d9 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -44,6 +44,34 @@ function buildProviderConfigMap(config: Config): Partial = [ + { tier: 'fast', tags: config.models.fast?.for }, + { tier: 'default', tags: config.models.default.for }, + { tier: 'complex', tags: config.models.complex?.for }, + { tier: 'local', tags: config.models.local?.for }, + ]; + + for (const { tier, tags } of mappings) { + if (!tags || tags.length === 0) { + continue; + } + if (tags.some((tag) => tag.trim().toLowerCase() === normalized)) { + return tier; + } + } + + return undefined; +} + /** * Create the unified message handler for the channel registry. * Each channel+sender pair gets its own AgentOrchestrator backed by a persistent session. @@ -83,6 +111,7 @@ export function createMessageRouter(deps: { // Cron job tier wins over agent config tier const tierFromMetadata = metadata?.modelTier as ModelTier | undefined; + const tierFromUseCaseMetadata = tierFromUseCase(deps.config, metadata?.modelFor); // Include agent config name in cache key so different agents aren't shared let skillOverride = metadata?.skillOverride as string | undefined; @@ -100,8 +129,9 @@ export function createMessageRouter(deps: { // Read per-session model tier override (persisted in SQLite) const sessionTierOverride = session.getConfig('modelTier') as ModelTier | undefined; - // Resolution chain: metadata (cron) → session override → agent config → global default + // Resolution chain: metadata (explicit tier) → metadata modelFor -> session override -> agent config -> global default const effectiveTier = tierFromMetadata + ?? tierFromUseCaseMetadata ?? sessionTierOverride ?? agentConfig?.modelTier ?? deps.config.agents.primary_tier @@ -766,9 +796,12 @@ export function createMessageRouter(deps: { let effectiveTier: string = deps.config.agents.primary_tier ?? 'default'; const session = deps.sessionManager.getSession(msg.channel, msg.senderId); const sessionTierOverride = session.getConfig('modelTier'); + const tierFromUseCaseMetadata = tierFromUseCase(deps.config, msg.metadata?.modelFor); if (msg.metadata?.modelTier) { effectiveTier = msg.metadata.modelTier as string; + } else if (tierFromUseCaseMetadata) { + effectiveTier = tierFromUseCaseMetadata; } else if (sessionTierOverride) { effectiveTier = sessionTierOverride; } else if (deps.agentRouter && deps.agentConfigRegistry) {