From 60b214e7c4d6b7860e5cbe5b7c94fc717deb4da8 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 10 Feb 2026 22:31:18 -0800 Subject: [PATCH] feat: add per-cron-job model tier selection Allow cron jobs to specify a `model_tier` field that controls which LLM tier handles the job, without needing separate agent configs. Precedence: cron job model_tier > agent config > global primary_tier > 'default'. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 ++ src/automation/cron.test.ts | 26 ++++++++++++++++++++++++++ src/automation/cron.ts | 2 +- src/config/schema.ts | 5 +++-- src/daemon/routing.test.ts | 25 +++++++++++++++++++++++++ src/daemon/routing.ts | 14 +++++++++----- 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3c5fab5..49f2682 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ automation: channel: telegram peer: "123456789" enabled: false # Disabled, won't fire + model_tier: fast # Use fast tier for quick checks ``` ### Cron Config Fields @@ -309,6 +310,7 @@ automation: | `output.peer` | yes | Peer/chat ID on the output channel | | `timezone` | no | IANA timezone (defaults to system timezone) | | `enabled` | no | Whether the job is active (default: `true`) | +| `model_tier` | no | Model tier for this job: `fast`, `default`, `complex`, or `local` | ## Inbound Webhooks diff --git a/src/automation/cron.test.ts b/src/automation/cron.test.ts index 2a3bf2e..a543e21 100644 --- a/src/automation/cron.test.ts +++ b/src/automation/cron.test.ts @@ -121,6 +121,32 @@ describe('CronScheduler', () => { warnSpy.mockRestore(); }); + it('triggerJob includes model_tier in metadata when configured', () => { + const jobs = [makeCronJob({ model_tier: 'fast' })]; + scheduler = new CronScheduler(jobs, mockChannelRegistry as any); + + const messages: InboundMessage[] = []; + scheduler.onMessage((msg: InboundMessage) => messages.push(msg)); + + scheduler.triggerJob('test-job'); + + expect(messages).toHaveLength(1); + expect(messages[0].metadata?.modelTier).toBe('fast'); + }); + + it('triggerJob metadata.modelTier is undefined when not configured', () => { + const jobs = [makeCronJob()]; + scheduler = new CronScheduler(jobs, mockChannelRegistry as any); + + const messages: InboundMessage[] = []; + scheduler.onMessage((msg: InboundMessage) => messages.push(msg)); + + scheduler.triggerJob('test-job'); + + expect(messages).toHaveLength(1); + expect(messages[0].metadata?.modelTier).toBeUndefined(); + }); + it('lists registered job names', () => { const jobs = [ makeCronJob({ name: 'job-a' }), diff --git a/src/automation/cron.ts b/src/automation/cron.ts index a34a4ee..b1c7b01 100644 --- a/src/automation/cron.ts +++ b/src/automation/cron.ts @@ -90,7 +90,7 @@ export class CronScheduler implements ChannelAdapter { senderName: `cron:${jobName}`, text: job.message, timestamp: Date.now(), - metadata: { cronJob: jobName, scheduled: true }, + metadata: { cronJob: jobName, scheduled: true, modelTier: job.model_tier }, }; this.messageHandler?.(msg); diff --git a/src/config/schema.ts b/src/config/schema.ts index 30329db..c12cfda 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -121,6 +121,8 @@ const mcpSchema = z.object({ servers: z.array(mcpServerSchema).default([]), }).default({ servers: [] }); +const modelTierEnum = z.enum(['fast', 'default', 'complex', 'local']); + const cronJobSchema = z.object({ name: z.string().min(1, 'Cron job name is required'), schedule: z.string().min(1, 'Cron schedule is required'), @@ -131,6 +133,7 @@ const cronJobSchema = z.object({ }), enabled: z.boolean().default(true), timezone: z.string().optional(), + model_tier: modelTierEnum.optional(), }); const webhookSchema = z.object({ @@ -349,8 +352,6 @@ const sandboxSchema = z.object({ // ── Agent config + routing schemas ──────────────────────────────────── -const modelTierEnum = z.enum(['fast', 'default', 'complex', 'local']); - const agentConfigEntrySchema = z.object({ system_prompt: z.string().optional(), model_tier: modelTierEnum.optional(), diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 03cf4a6..80b3278 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { AgentRouter } from '../agents/router.js'; import { AgentConfigRegistry } from '../agents/registry.js'; +import type { ModelTier } from '../models/router.js'; describe('daemon agent routing integration', () => { it('resolves agent config for channel messages', () => { @@ -35,4 +36,28 @@ describe('daemon agent routing integration', () => { const router = new AgentRouter({ channels: {}, senders: {} }); expect(router.resolve('telegram', '123')).toBeUndefined(); }); + + it('model tier precedence: metadata > agent config > global default', () => { + // This test documents the tier resolution precedence used by createMessageRouter. + // The actual resolution logic: tierFromMetadata ?? agentConfig?.modelTier ?? primary_tier ?? 'default' + function resolveTier( + metadataTier: ModelTier | undefined, + agentTier: ModelTier | undefined, + globalTier: ModelTier | undefined, + ): ModelTier { + return metadataTier ?? agentTier ?? globalTier ?? 'default'; + } + + // With all three set, metadata wins + expect(resolveTier('fast', 'complex', 'default')).toBe('fast'); + + // Without metadata, agent config wins + expect(resolveTier(undefined, 'complex', 'default')).toBe('complex'); + + // Without metadata or agent config, global wins + expect(resolveTier(undefined, undefined, 'default')).toBe('default'); + + // Without anything, falls back to 'default' + expect(resolveTier(undefined, undefined, undefined)).toBe('default'); + }); }); diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index 319dd70..657d694 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -9,7 +9,7 @@ import type { Tool } from '../tools/types.js'; import { createMediaSendTool } from '../tools/index.js'; import { createSandboxedShellTool, createSandboxedProcessStartTool, SandboxManager } from '../sandbox/index.js'; import type { Config } from '../config/index.js'; -import { ModelRouter } from '../models/index.js'; +import { ModelRouter, type ModelTier } from '../models/index.js'; import { ToolRegistry, ToolExecutor } from '../tools/index.js'; import { SessionManager } from '../session/index.js'; import { AgentConfigRegistry, AgentRouter } from '../agents/index.js'; @@ -40,15 +40,19 @@ export function createMessageRouter(deps: { // Cache agents by session ID + agent config name to avoid recreating on every message const agents = new Map(); - function getOrCreateAgent(channel: string, senderId: string): { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector } { + function getOrCreateAgent(channel: string, senderId: string, metadata?: Record): { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector } { // Resolve agent config name via routing (sender → channel → default fallback) const agentConfigName = deps.agentRouter?.resolve(channel, senderId); const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined; + // Cron job tier wins over agent config tier + const tierFromMetadata = metadata?.modelTier as ModelTier | undefined; + // Include agent config name in cache key so different agents aren't shared - const sessionId = agentConfigName + const baseSid = agentConfigName ? `${channel}:${senderId}:${agentConfigName}` : `${channel}:${senderId}`; + const sessionId = tierFromMetadata ? `${baseSid}:${tierFromMetadata}` : baseSid; let entry = agents.get(sessionId); if (!entry) { @@ -56,7 +60,7 @@ export function createMessageRouter(deps: { // Use agent config overrides where available, falling back to global config const effectiveSystemPrompt = agentConfig?.systemPrompt ?? deps.systemPrompt; - const effectiveTier = agentConfig?.modelTier ?? deps.config.agents.primary_tier ?? 'default'; + const effectiveTier = tierFromMetadata ?? agentConfig?.modelTier ?? deps.config.agents.primary_tier ?? 'default'; const effectiveProvider = deps.config.models.default.provider; const delegationConfig: DelegationConfig = { @@ -156,7 +160,7 @@ export function createMessageRouter(deps: { } const handler = async (msg: InboundMessage, reply: (response: OutboundMessage) => Promise): Promise => { - const { orchestrator: agent, collector } = getOrCreateAgent(msg.channel, msg.senderId); + const { orchestrator: agent, collector } = getOrCreateAgent(msg.channel, msg.senderId, msg.metadata); // Handle special commands if (msg.metadata?.isCommand) {