feat: add AgentRouter for config-based sender/channel routing
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user