From a055f4d338f4e21ea3c4caaf5648b0f20b26a692 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 17 Feb 2026 15:23:04 -0800 Subject: [PATCH] feat: auto-route research-prefixed prompts to research agent --- README.md | 7 ++++ docs/plans/state.json | 13 +++++++ src/daemon/routing.test.ts | 78 ++++++++++++++++++++++++++++++++++++++ src/daemon/routing.ts | 20 ++++++++++ 4 files changed, 118 insertions(+) diff --git a/README.md b/README.md index 31329d8..11adc5d 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,13 @@ Then use: /research compare k0s vs k3s for a 3-node homelab in 2026 ``` +You can also trigger this without the slash command: + +```text +research compare k0s vs k3s for a 3-node homelab in 2026 +look up best practices for k8s backup retention +``` + If the `research` agent is not configured, Flynn returns a setup hint with available agent names. ## Running as Service diff --git a/docs/plans/state.json b/docs/plans/state.json index 9cefab1..1868fa0 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,19 @@ "updated_at": "2026-02-17", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "research-prefix-auto-routing": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added automatic research routing: when intents are disabled but a `research` agent exists, messages prefixed with `research ...` or `look up ...` now route to the research agent and strip the prefix before processing. Added routing regression tests and README usage examples.", + "files_modified": [ + "src/daemon/routing.ts", + "src/daemon/routing.test.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/daemon/routing.test.ts passing" + }, "research-agent-command-quickstart": { "status": "completed", "date": "2026-02-17", diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 42a112d..907cb3c 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -510,6 +510,84 @@ describe('daemon command fast-path integration', () => { expect(keys.some(key => key.includes(':coder'))).toBe(true); }); + it('auto-routes research-prefixed messages to research agent when configured', async () => { + const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process').mockResolvedValue('ok'); + const session = { + id: 'telegram:user-research', + addMessage: vi.fn(), + getHistory: vi.fn(() => []), + clear: vi.fn(), + replaceHistory: vi.fn(), + getConfig: vi.fn(() => undefined), + setConfig: vi.fn(), + deleteConfig: vi.fn(), + }; + + const commandRegistry = new CommandRegistry(); + registerBuiltinCommands(commandRegistry); + + const agentConfigRegistry = new AgentConfigRegistry(); + agentConfigRegistry.loadFromConfig({ + assistant: { model_tier: 'default', sandbox: false }, + research: { model_tier: 'complex', sandbox: false }, + }); + + const agentRouter = new AgentRouter({ + default_agent: 'assistant', + channels: {}, + senders: {}, + }); + + 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: { + intents: { enabled: false }, + 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' } }, + } as unknown as MessageRouterDeps['config'], + commandRegistry, + agentConfigRegistry, + agentRouter, + }); + + await router.handler({ + id: 'm-research', + channel: 'telegram', + senderId: 'user-research', + text: 'research compare k0s vs k3s for a homelab', + timestamp: Date.now(), + } as MessageRouterInput, vi.fn(async () => {})); + + const keys = Array.from(router.agents.keys()); + expect(keys.some(key => key.includes(':research'))).toBe(true); + expect(processSpy).toHaveBeenCalledWith('compare k0s vs k3s for a homelab', undefined); + }); + it('falls back to llm path when confidence is below fast threshold', async () => { const session = { id: 'telegram:user-3', diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index bd1f449..3174d8e 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -72,6 +72,19 @@ function tierFromUseCase(config: Config, useCaseRaw: unknown): ModelTier | undef return undefined; } +function parseResearchPrefix(text: string): string | undefined { + const trimmed = text.trim(); + const researchMatch = trimmed.match(/^research(?:\s*[:,-])?\s+(.+)$/i); + if (researchMatch?.[1]) { + return researchMatch[1].trim(); + } + const lookupMatch = trimmed.match(/^(?:look\s+up|lookup)(?:\s*[:,-])?\s+(.+)$/i); + if (lookupMatch?.[1]) { + return lookupMatch[1].trim(); + } + return undefined; +} + /** * Create the unified message handler for the channel registry. * Each channel+sender pair gets its own AgentOrchestrator backed by a persistent session. @@ -362,6 +375,13 @@ export function createMessageRouter(deps: { let intentAgentOverride: string | undefined; let intentSkillOverride: string | undefined; + if (!deps.config.intents?.enabled && deps.agentConfigRegistry?.get('research')) { + const researchTask = parseResearchPrefix(incomingText); + if (researchTask) { + intentAgentOverride = 'research'; + incomingText = researchTask; + } + } if (deps.config.intents?.enabled && deps.intentRegistry) { const intentMatch = deps.intentRegistry.match(incomingText);