diff --git a/src/agents/router.test.ts b/src/agents/router.test.ts new file mode 100644 index 0000000..6f255c8 --- /dev/null +++ b/src/agents/router.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { AgentRouter } from './router.js'; +import type { RoutingConfig } from '../config/schema.js'; + +describe('AgentRouter', () => { + describe('resolve()', () => { + it('returns default_agent when no specific match', () => { + const router = new AgentRouter({ + default_agent: 'assistant', + channels: {}, + senders: {}, + }); + expect(router.resolve('telegram', '12345')).toBe('assistant'); + }); + + it('returns undefined when no default and no match', () => { + const router = new AgentRouter({ + channels: {}, + senders: {}, + }); + expect(router.resolve('telegram', '12345')).toBeUndefined(); + }); + + it('matches exact sender', () => { + const router = new AgentRouter({ + default_agent: 'assistant', + channels: {}, + senders: { 'telegram:12345': 'coder' }, + }); + expect(router.resolve('telegram', '12345')).toBe('coder'); + }); + + it('matches sender with glob pattern', () => { + const router = new AgentRouter({ + default_agent: 'assistant', + channels: {}, + senders: { 'slack:U0*': 'coder' }, + }); + expect(router.resolve('slack', 'U0ABC')).toBe('coder'); + }); + + it('matches channel when no sender match', () => { + const router = new AgentRouter({ + default_agent: 'assistant', + channels: { discord: 'coder' }, + senders: {}, + }); + expect(router.resolve('discord', 'any-user')).toBe('coder'); + }); + + it('sender match takes priority over channel match', () => { + const router = new AgentRouter({ + default_agent: 'assistant', + channels: { discord: 'coder' }, + senders: { 'discord:special-user': 'vip' }, + }); + expect(router.resolve('discord', 'special-user')).toBe('vip'); + expect(router.resolve('discord', 'normal-user')).toBe('coder'); + }); + + it('falls through: sender -> channel -> default', () => { + const router = new AgentRouter({ + default_agent: 'fallback', + channels: { discord: 'guild-agent' }, + senders: { 'discord:admin': 'admin-agent' }, + }); + expect(router.resolve('discord', 'admin')).toBe('admin-agent'); + expect(router.resolve('discord', 'regular')).toBe('guild-agent'); + expect(router.resolve('telegram', 'someone')).toBe('fallback'); + }); + }); +}); diff --git a/src/agents/router.ts b/src/agents/router.ts new file mode 100644 index 0000000..a57509a --- /dev/null +++ b/src/agents/router.ts @@ -0,0 +1,61 @@ +/** + * AgentRouter resolves which agent config to use for a given channel+sender. + * + * Resolution order (first match wins): + * 1. Exact sender match (channel:senderId) + * 2. Glob pattern sender match + * 3. Channel match + * 4. default_agent fallback + */ + +import type { RoutingConfig } from '../config/schema.js'; + +export type { RoutingConfig }; + +/** + * Convert a simple glob pattern to a RegExp. + * Supports `*` (match any sequence of characters). + * All other regex-special characters are escaped. + */ +function patternToRegex(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`); +} + +export class AgentRouter { + private config: RoutingConfig; + + constructor(config: RoutingConfig) { + this.config = config; + } + + /** + * Resolve the agent config name for a channel + sender pair. + * Returns undefined if no match and no default is configured. + */ + resolve(channel: string, senderId: string): string | undefined { + const senderKey = `${channel}:${senderId}`; + + // 1. Exact sender match + if (this.config.senders[senderKey]) { + return this.config.senders[senderKey]; + } + + // 2. Glob pattern sender match + for (const [pattern, agentName] of Object.entries(this.config.senders)) { + if (pattern.includes('*') && patternToRegex(pattern).test(senderKey)) { + return agentName; + } + } + + // 3. Channel match + if (this.config.channels[channel]) { + return this.config.channels[channel]; + } + + // 4. Default fallback + return this.config.default_agent; + } +}