From 2b89024a71d497a7dda533f2fe2188b856482ee9 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 17 Feb 2026 15:21:11 -0800 Subject: [PATCH] feat: add /research command with sub-agent delegation --- README.md | 24 ++++++++++++++++++++++ config/default.yaml | 3 +-- docs/plans/state.json | 16 +++++++++++++++ src/commands/builtin/index.test.ts | 32 +++++++++++++++++++++++++++++- src/commands/builtin/index.ts | 24 ++++++++++++++++++++++ src/commands/types.ts | 1 + src/daemon/routing.ts | 29 +++++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a95a75a..31329d8 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,7 @@ pnpm tui:fs | `/login [provider]` | Authenticate with GitHub (OAuth device flow) | | `/reset` | Clear history | | `/status` | Show session info | +| `/research ` | Delegate a task to `agent_configs.research` | | `/compact` | Compact conversation context | | `/usage` | Show token usage and cost | | `/context` | Show estimated context-window usage | @@ -475,6 +476,29 @@ For cloud Zhipu models, ensure `ZHIPUAI_API_KEY` is set or `api_key` is configur **Note:** The `/model` command works in the TUI only. WebChat sessions inherit the active tier from the daemon. +### Research Agent Quickstart + +Add a dedicated research sub-agent and delegate to it with `/research`: + +```yaml +agent_configs: + research: + model_tier: complex + tool_profile: messaging + system_prompt: | + You are a research agent. Find, verify, and synthesize information. + Prefer primary sources. Include links and concrete dates. + Keep output structured and concise. +``` + +Then use: + +```bash +/research compare k0s vs k3s for a 3-node homelab in 2026 +``` + +If the `research` agent is not configured, Flynn returns a setup hint with available agent names. + ## Running as Service ```bash diff --git a/config/default.yaml b/config/default.yaml index 13878ad..f082607 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -466,8 +466,7 @@ hooks: # You are a research agent. Your job is to find, verify, and synthesize # information from the web. Be thorough but concise. Cite sources when # possible. Return structured findings — not conversational filler. -# Use web.search to find sources, web.fetch to read them, and file.write -# to save findings when asked. +# Use web.search to find sources and web.fetch to read them. # # code: # model_tier: complex diff --git a/docs/plans/state.json b/docs/plans/state.json index c2461b2..9cefab1 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,22 @@ "updated_at": "2026-02-17", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "research-agent-command-quickstart": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Implemented a first-class `/research` chat command that delegates to `agent_configs.research` through the command fast-path service layer, with clear fallback messages when the research agent is missing. Added README quickstart guidance and aligned default config examples for a safe research profile.", + "files_modified": [ + "src/commands/types.ts", + "src/commands/builtin/index.ts", + "src/commands/builtin/index.test.ts", + "src/daemon/routing.ts", + "README.md", + "config/default.yaml", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/commands/builtin/index.test.ts passing" + }, "verbose-only-tool-inventory-output": { "status": "completed", "date": "2026-02-17", diff --git a/src/commands/builtin/index.test.ts b/src/commands/builtin/index.test.ts index 8e3d240..f5d4cda 100644 --- a/src/commands/builtin/index.test.ts +++ b/src/commands/builtin/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { createContextCommand, createElevateCommand, createModelCommand, createQueueCommand } from './index.js'; +import { createContextCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand } from './index.js'; describe('builtin /model command', () => { it('passes through the full argument string', async () => { @@ -36,6 +36,36 @@ describe('builtin /model command', () => { }); }); +describe('builtin /research command', () => { + it('delegates to the research agent with the full task string', async () => { + const cmd = createResearchCommand(); + const delegateAgent = vi.fn(() => 'research output'); + + const result = await cmd.execute(['compare', 'k0s', 'vs', 'k3s'], { + channel: 'test', + senderId: 'user', + sessionId: 's1', + rawInput: '/research compare k0s vs k3s', + services: { delegateAgent }, + }); + + expect(delegateAgent).toHaveBeenCalledWith('research', 'compare k0s vs k3s'); + expect(result).toEqual({ handled: true, text: 'research output' }); + }); + + it('returns usage when no task is provided', async () => { + const cmd = createResearchCommand(); + const result = await cmd.execute([], { + channel: 'test', + senderId: 'user', + sessionId: 's1', + rawInput: '/research', + services: {}, + }); + expect(result).toEqual({ handled: true, text: 'Usage: /research ' }); + }); +}); + describe('builtin /elevate command', () => { it('passes through the full argument string', async () => { const cmd = createElevateCommand(); diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts index 511e076..e351e48 100644 --- a/src/commands/builtin/index.ts +++ b/src/commands/builtin/index.ts @@ -195,11 +195,35 @@ export function createQueueCommand(): CommandDefinition { }; } +export function createResearchCommand(): CommandDefinition { + return { + name: 'research', + description: 'Delegate a task to the configured research sub-agent', + execute: async (args, ctx) => { + const task = args.join(' ').trim(); + if (!task) { + return { + handled: true, + text: 'Usage: /research ', + }; + } + if (!ctx.services?.delegateAgent) { + return notAvailable('Research command'); + } + return { + handled: true, + text: await ctx.services.delegateAgent('research', task), + }; + }, + }; +} + export function registerBuiltinCommands(registry: CommandRegistry): void { registry.register(createHelpCommand(registry)); registry.register(createStatusCommand()); registry.register(createUsageCommand()); registry.register(createContextCommand()); + registry.register(createResearchCommand()); registry.register(createModelCommand()); registry.register(createCompactCommand()); registry.register(createResetCommand()); diff --git a/src/commands/types.ts b/src/commands/types.ts index 71f4287..c506e52 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -26,6 +26,7 @@ export interface CommandServices { setModel?: (tier: string) => Promise | string; compact?: () => Promise | string; reset?: () => Promise | string; + delegateAgent?: (agentName: string, task: string) => Promise | string; getElevation?: () => Promise | string; setElevation?: (input: string) => Promise | string; diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index f594494..bd1f449 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -590,6 +590,35 @@ export function createMessageRouter(deps: { return ''; }, + delegateAgent: async (agentName: string, task: string) => { + const target = agentName.trim(); + const message = task.trim(); + if (!target || !message) { + return 'Usage: /research '; + } + if (!deps.agentConfigRegistry) { + return 'No agent configurations are registered. Add agent_configs.research in config.'; + } + const agentConfig = deps.agentConfigRegistry.get(target); + if (!agentConfig) { + const available = deps.agentConfigRegistry.list().map((c) => c.name); + return `Agent "${target}" not found. Available agents: ${available.length > 0 ? available.join(', ') : 'none'}`; + } + + const tier: ModelTier = agentConfig.modelTier ?? 'default'; + const systemPrompt = agentConfig.systemPrompt + ?? `You are a sub-agent named "${target}". Complete the assigned task concisely and accurately.`; + + const result = await agent.delegate({ + tier, + systemPrompt, + message, + maxTokens: 4096, + }); + + return `[Agent: ${target} | Tier: ${result.tier} | Tokens: ${result.usage.inputTokens}+${result.usage.outputTokens}]\n\n${result.content}`; + }, + getElevation: () => { const untilRaw = session.getConfig('elevation.until_ms'); const reason = session.getConfig('elevation.reason') ?? '';