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 <noreply@anthropic.com>
This commit is contained in:
@@ -296,6 +296,7 @@ automation:
|
|||||||
channel: telegram
|
channel: telegram
|
||||||
peer: "123456789"
|
peer: "123456789"
|
||||||
enabled: false # Disabled, won't fire
|
enabled: false # Disabled, won't fire
|
||||||
|
model_tier: fast # Use fast tier for quick checks
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cron Config Fields
|
### Cron Config Fields
|
||||||
@@ -309,6 +310,7 @@ automation:
|
|||||||
| `output.peer` | yes | Peer/chat ID on the output channel |
|
| `output.peer` | yes | Peer/chat ID on the output channel |
|
||||||
| `timezone` | no | IANA timezone (defaults to system timezone) |
|
| `timezone` | no | IANA timezone (defaults to system timezone) |
|
||||||
| `enabled` | no | Whether the job is active (default: `true`) |
|
| `enabled` | no | Whether the job is active (default: `true`) |
|
||||||
|
| `model_tier` | no | Model tier for this job: `fast`, `default`, `complex`, or `local` |
|
||||||
|
|
||||||
## Inbound Webhooks
|
## Inbound Webhooks
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,32 @@ describe('CronScheduler', () => {
|
|||||||
warnSpy.mockRestore();
|
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', () => {
|
it('lists registered job names', () => {
|
||||||
const jobs = [
|
const jobs = [
|
||||||
makeCronJob({ name: 'job-a' }),
|
makeCronJob({ name: 'job-a' }),
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export class CronScheduler implements ChannelAdapter {
|
|||||||
senderName: `cron:${jobName}`,
|
senderName: `cron:${jobName}`,
|
||||||
text: job.message,
|
text: job.message,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
metadata: { cronJob: jobName, scheduled: true },
|
metadata: { cronJob: jobName, scheduled: true, modelTier: job.model_tier },
|
||||||
};
|
};
|
||||||
|
|
||||||
this.messageHandler?.(msg);
|
this.messageHandler?.(msg);
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ const mcpSchema = z.object({
|
|||||||
servers: z.array(mcpServerSchema).default([]),
|
servers: z.array(mcpServerSchema).default([]),
|
||||||
}).default({ servers: [] });
|
}).default({ servers: [] });
|
||||||
|
|
||||||
|
const modelTierEnum = z.enum(['fast', 'default', 'complex', 'local']);
|
||||||
|
|
||||||
const cronJobSchema = z.object({
|
const cronJobSchema = z.object({
|
||||||
name: z.string().min(1, 'Cron job name is required'),
|
name: z.string().min(1, 'Cron job name is required'),
|
||||||
schedule: z.string().min(1, 'Cron schedule is required'),
|
schedule: z.string().min(1, 'Cron schedule is required'),
|
||||||
@@ -131,6 +133,7 @@ const cronJobSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
timezone: z.string().optional(),
|
timezone: z.string().optional(),
|
||||||
|
model_tier: modelTierEnum.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const webhookSchema = z.object({
|
const webhookSchema = z.object({
|
||||||
@@ -349,8 +352,6 @@ const sandboxSchema = z.object({
|
|||||||
|
|
||||||
// ── Agent config + routing schemas ────────────────────────────────────
|
// ── Agent config + routing schemas ────────────────────────────────────
|
||||||
|
|
||||||
const modelTierEnum = z.enum(['fast', 'default', 'complex', 'local']);
|
|
||||||
|
|
||||||
const agentConfigEntrySchema = z.object({
|
const agentConfigEntrySchema = z.object({
|
||||||
system_prompt: z.string().optional(),
|
system_prompt: z.string().optional(),
|
||||||
model_tier: modelTierEnum.optional(),
|
model_tier: modelTierEnum.optional(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { AgentRouter } from '../agents/router.js';
|
import { AgentRouter } from '../agents/router.js';
|
||||||
import { AgentConfigRegistry } from '../agents/registry.js';
|
import { AgentConfigRegistry } from '../agents/registry.js';
|
||||||
|
import type { ModelTier } from '../models/router.js';
|
||||||
|
|
||||||
describe('daemon agent routing integration', () => {
|
describe('daemon agent routing integration', () => {
|
||||||
it('resolves agent config for channel messages', () => {
|
it('resolves agent config for channel messages', () => {
|
||||||
@@ -35,4 +36,28 @@ describe('daemon agent routing integration', () => {
|
|||||||
const router = new AgentRouter({ channels: {}, senders: {} });
|
const router = new AgentRouter({ channels: {}, senders: {} });
|
||||||
expect(router.resolve('telegram', '123')).toBeUndefined();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { Tool } from '../tools/types.js';
|
|||||||
import { createMediaSendTool } from '../tools/index.js';
|
import { createMediaSendTool } from '../tools/index.js';
|
||||||
import { createSandboxedShellTool, createSandboxedProcessStartTool, SandboxManager } from '../sandbox/index.js';
|
import { createSandboxedShellTool, createSandboxedProcessStartTool, SandboxManager } from '../sandbox/index.js';
|
||||||
import type { Config } from '../config/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 { ToolRegistry, ToolExecutor } from '../tools/index.js';
|
||||||
import { SessionManager } from '../session/index.js';
|
import { SessionManager } from '../session/index.js';
|
||||||
import { AgentConfigRegistry, AgentRouter } from '../agents/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
|
// Cache agents by session ID + agent config name to avoid recreating on every message
|
||||||
const agents = new Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }>();
|
const agents = new Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }>();
|
||||||
|
|
||||||
function getOrCreateAgent(channel: string, senderId: string): { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector } {
|
function getOrCreateAgent(channel: string, senderId: string, metadata?: Record<string, unknown>): { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector } {
|
||||||
// Resolve agent config name via routing (sender → channel → default fallback)
|
// Resolve agent config name via routing (sender → channel → default fallback)
|
||||||
const agentConfigName = deps.agentRouter?.resolve(channel, senderId);
|
const agentConfigName = deps.agentRouter?.resolve(channel, senderId);
|
||||||
const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined;
|
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
|
// Include agent config name in cache key so different agents aren't shared
|
||||||
const sessionId = agentConfigName
|
const baseSid = agentConfigName
|
||||||
? `${channel}:${senderId}:${agentConfigName}`
|
? `${channel}:${senderId}:${agentConfigName}`
|
||||||
: `${channel}:${senderId}`;
|
: `${channel}:${senderId}`;
|
||||||
|
const sessionId = tierFromMetadata ? `${baseSid}:${tierFromMetadata}` : baseSid;
|
||||||
|
|
||||||
let entry = agents.get(sessionId);
|
let entry = agents.get(sessionId);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -56,7 +60,7 @@ export function createMessageRouter(deps: {
|
|||||||
|
|
||||||
// Use agent config overrides where available, falling back to global config
|
// Use agent config overrides where available, falling back to global config
|
||||||
const effectiveSystemPrompt = agentConfig?.systemPrompt ?? deps.systemPrompt;
|
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 effectiveProvider = deps.config.models.default.provider;
|
||||||
|
|
||||||
const delegationConfig: DelegationConfig = {
|
const delegationConfig: DelegationConfig = {
|
||||||
@@ -156,7 +160,7 @@ export function createMessageRouter(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handler = async (msg: InboundMessage, reply: (response: OutboundMessage) => Promise<void>): Promise<void> => {
|
const handler = async (msg: InboundMessage, reply: (response: OutboundMessage) => Promise<void>): Promise<void> => {
|
||||||
const { orchestrator: agent, collector } = getOrCreateAgent(msg.channel, msg.senderId);
|
const { orchestrator: agent, collector } = getOrCreateAgent(msg.channel, msg.senderId, msg.metadata);
|
||||||
|
|
||||||
// Handle special commands
|
// Handle special commands
|
||||||
if (msg.metadata?.isCommand) {
|
if (msg.metadata?.isCommand) {
|
||||||
|
|||||||
Reference in New Issue
Block a user