-
Notifications
You must be signed in to change notification settings - Fork 190
feat: add /swarm parallel agent-swarm orchestration #208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
0406ad0
7591e67
985fd5c
9c309b1
b0b61c2
d6a3d91
fc5e4bf
8021cec
adc18ad
3475837
7ed20f3
03e49e5
81749b9
e873370
0d11fbc
c03ba22
649596b
adb6827
e88003f
60bc6be
f2cc148
5375300
df04b8d
d6942ec
cc9176b
38ba4b8
a17cfee
9d7c9a6
e288ffa
f31bf2b
1461143
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; | ||
| import { formatErrorMessage } from '../utils/event-payload'; | ||
| import type { SlashCommandHost } from './dispatch'; | ||
|
|
||
| export function buildSwarmPrompt(task: string): string { | ||
| return [ | ||
| 'Use the Swarm tool to accomplish the following task.', | ||
| 'Call the Swarm tool exactly once with this task as its `task` argument; do not do the work yourself.', | ||
| '', | ||
| 'Task:', | ||
| task, | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| export async function handleSwarmCommand(host: SlashCommandHost, args: string): Promise<void> { | ||
| const session = host.session; | ||
| if (session === undefined) { | ||
| host.showError(NO_ACTIVE_SESSION_MESSAGE); | ||
| return; | ||
| } | ||
| const task = args.trim(); | ||
| if (task.length === 0) { | ||
| host.showError('Usage: /swarm <task>'); | ||
| return; | ||
| } | ||
| try { | ||
| await session.prompt(buildSwarmPrompt(task)); | ||
|
Comment on lines
+39
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This calls Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This starts a real model turn but, unlike the normal send path, never appends the user's Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence after the live-transcript fix: this still records the verbose Useful? React with 👍 / 👎. |
||
| } catch (error) { | ||
| host.showError(`Failed to start swarm: ${formatErrorMessage(error)}`); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { buildSwarmPrompt, handleSwarmCommand } from '#/tui/commands/swarm'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| describe('buildSwarmPrompt', () => { | ||
| it('frames the task to force the Swarm tool', () => { | ||
| const p = buildSwarmPrompt('compare three libraries'); | ||
| expect(p).toContain('Swarm'); | ||
| expect(p).toContain('compare three libraries'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('handleSwarmCommand', () => { | ||
| it('errors when there is no active session', async () => { | ||
| const showError = vi.fn(); | ||
| await handleSwarmCommand({ session: undefined, showError } as never, 'do it'); | ||
| expect(showError).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('errors when args are empty', async () => { | ||
| const showError = vi.fn(); | ||
| const prompt = vi.fn(); | ||
| await handleSwarmCommand({ session: { prompt }, showError } as never, ' '); | ||
| expect(showError).toHaveBeenCalled(); | ||
| expect(prompt).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('sends a framed prompt to the session', async () => { | ||
| const prompt = vi.fn<(text: string) => Promise<void>>(async () => undefined); | ||
| const showError = vi.fn(); | ||
| await handleSwarmCommand({ session: { prompt }, showError } as never, 'compare libs'); | ||
| expect(prompt).toHaveBeenCalledTimes(1); | ||
| expect(String(prompt.mock.calls[0]?.[0])).toContain('compare libs'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| export async function mapWithConcurrency<T>( | ||
| items: readonly T[], | ||
| limit: number, | ||
| fn: (item: T, index: number) => Promise<void>, | ||
| ): Promise<void> { | ||
| const max = Math.max(1, Math.floor(limit)); | ||
| let cursor = 0; | ||
|
|
||
| async function worker(): Promise<void> { | ||
| while (cursor < items.length) { | ||
| const index = cursor; | ||
| cursor += 1; | ||
| const item = items[index]; | ||
| if (item === undefined) continue; | ||
| await fn(item, index); | ||
| } | ||
| } | ||
|
|
||
| const count = Math.min(max, items.length); | ||
| await Promise.all(Array.from({ length: count }, () => worker())); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { mapWithConcurrency } from './concurrency'; | ||
| import { parsePlan } from './parse'; | ||
| import { | ||
| ALLOWED_WORKER_TOOLS, | ||
| DEFAULT_WORKER_TOOLS, | ||
| PLANNER_SYSTEM_PROMPT, | ||
| SYNTHESIZER_SYSTEM_PROMPT, | ||
| renderPlannerPrompt, | ||
| renderPlannerRetryPrompt, | ||
| renderSynthesizerPrompt, | ||
| } from './prompts'; | ||
| import type { SwarmCoordinatorDeps, SwarmPlan } from './types'; | ||
|
|
||
| export class SwarmCoordinator { | ||
| constructor(private readonly deps: SwarmCoordinatorDeps) {} | ||
|
|
||
| private progress(text: string): void { | ||
| this.deps.onProgress?.(text); | ||
| } | ||
|
|
||
| async run(rootTask: string): Promise<string> { | ||
| this.deps.signal.throwIfAborted(); | ||
| this.progress('Planning subtasks…'); | ||
| const plan = await this.decompose(rootTask); | ||
| this.progress(`Planned ${String(plan.subtasks.length)} subtasks`); | ||
|
|
||
| await this.runWave(plan); | ||
|
|
||
| this.progress('Synthesizing results…'); | ||
| const result = await this.deps.spawnSubagent({ | ||
| profileName: 'swarm-synthesizer', | ||
| systemPrompt: SYNTHESIZER_SYSTEM_PROMPT, | ||
| tools: [], | ||
| prompt: renderSynthesizerPrompt(plan), | ||
| description: 'Swarm synthesizer', | ||
| signal: this.deps.signal, | ||
| }); | ||
| return result.result; | ||
| } | ||
|
|
||
| private async decompose(rootTask: string): Promise<SwarmPlan> { | ||
| const first = await this.deps.spawnSubagent({ | ||
| profileName: 'swarm-planner', | ||
| systemPrompt: PLANNER_SYSTEM_PROMPT, | ||
| tools: [], | ||
| prompt: renderPlannerPrompt(rootTask), | ||
| description: 'Swarm planner', | ||
| signal: this.deps.signal, | ||
| }); | ||
| const plan = parsePlan(rootTask, first.result); | ||
| if (plan !== null) return plan; | ||
|
|
||
| const retry = await this.deps.spawnSubagent({ | ||
| profileName: 'swarm-planner', | ||
| systemPrompt: PLANNER_SYSTEM_PROMPT, | ||
| tools: [], | ||
| prompt: renderPlannerRetryPrompt(rootTask, first.result), | ||
| description: 'Swarm planner (retry)', | ||
| signal: this.deps.signal, | ||
| }); | ||
| const retried = parsePlan(rootTask, retry.result); | ||
| if (retried !== null) return retried; | ||
|
|
||
| throw new Error('Swarm planner failed to produce a valid plan after one retry'); | ||
| } | ||
|
|
||
| private async runWave(plan: SwarmPlan): Promise<void> { | ||
| const limit = this.deps.maxConcurrency ?? 4; | ||
| await mapWithConcurrency(plan.subtasks, limit, async (st) => { | ||
| this.deps.signal.throwIfAborted(); | ||
| st.status = 'running'; | ||
| this.progress(`▸ ${st.role}: started`); | ||
| try { | ||
| const out = await this.deps.spawnSubagent({ | ||
| profileName: `swarm:${st.role}`, | ||
| systemPrompt: st.systemPrompt, | ||
| tools: (st.toolAllowlist ?? DEFAULT_WORKER_TOOLS).filter((t) => | ||
| ALLOWED_WORKER_TOOLS.includes(t), | ||
| ), | ||
| prompt: st.prompt, | ||
| description: st.role, | ||
| signal: this.deps.signal, | ||
| }); | ||
| st.result = out.result; | ||
| st.status = 'done'; | ||
| this.progress(`✓ ${st.role}: done`); | ||
| } catch (err) { | ||
| if (this.deps.signal.aborted) throw err; | ||
| st.status = 'failed'; | ||
| st.error = err instanceof Error ? err.message : String(err); | ||
| this.progress(`✗ ${st.role}: failed (${st.error})`); | ||
| } | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import type { SwarmPlan, Subtask } from './types'; | ||
|
|
||
| export function extractJsonObject(text: string): string | null { | ||
| const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text); | ||
| const candidate = fence?.[1] ?? text; | ||
| const start = candidate.indexOf('{'); | ||
| const end = candidate.lastIndexOf('}'); | ||
| if (start === -1 || end === -1 || end < start) return null; | ||
| return candidate.slice(start, end + 1); | ||
| } | ||
|
|
||
| export function parsePlan(rootTask: string, text: string): SwarmPlan | null { | ||
| const json = extractJsonObject(text); | ||
| if (json === null) return null; | ||
|
|
||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(json); | ||
| } catch { | ||
| return null; | ||
| } | ||
| if (typeof parsed !== 'object' || parsed === null) return null; | ||
|
|
||
| const subtasksRaw = (parsed as { subtasks?: unknown }).subtasks; | ||
| if (!Array.isArray(subtasksRaw) || subtasksRaw.length === 0) return null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the planner returns valid JSON with more than the prompted maximum of 6 subtasks, this accepts the entire array; Useful? React with 👍 / 👎.
Comment on lines
+24
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The planner prompt says to keep the plan to at most 6 subtasks, but this parser accepts any non-empty Useful? React with 👍 / 👎. |
||
|
|
||
| const subtasks: Subtask[] = []; | ||
| for (let i = 0; i < subtasksRaw.length; i += 1) { | ||
| const raw = subtasksRaw[i]; | ||
| if (typeof raw !== 'object' || raw === null) return null; | ||
| const o = raw as Record<string, unknown>; | ||
| if ( | ||
| typeof o['role'] !== 'string' || | ||
| typeof o['systemPrompt'] !== 'string' || | ||
| typeof o['prompt'] !== 'string' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the planner returns syntactically valid JSON but leaves Useful? React with 👍 / 👎. |
||
| ) { | ||
| return null; | ||
| } | ||
| const toolAllowlist = Array.isArray(o['toolAllowlist']) | ||
| ? o['toolAllowlist'].filter((t): t is string => typeof t === 'string') | ||
| : undefined; | ||
| subtasks.push({ | ||
| id: typeof o['id'] === 'string' && o['id'].length > 0 ? o['id'] : `task-${String(i + 1)}`, | ||
| role: o['role'], | ||
| systemPrompt: o['systemPrompt'], | ||
| prompt: o['prompt'], | ||
| toolAllowlist, | ||
| status: 'pending', | ||
| }); | ||
| } | ||
| return { rootTask, subtasks }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import type { SwarmPlan } from './types'; | ||
|
|
||
| /** Read-only default tool set for workers; planner may widen via toolAllowlist within the allowlist. */ | ||
| export const DEFAULT_WORKER_TOOLS: readonly string[] = ['Read', 'Grep', 'Glob', 'WebSearch', 'FetchURL']; | ||
|
|
||
| /** Tool names a worker is allowed to request. Read-only for Phase 1 (no Write/Edit/Bash, no dispatch tools). */ | ||
| export const ALLOWED_WORKER_TOOLS: readonly string[] = [ | ||
| 'Read', | ||
| 'Grep', | ||
| 'Glob', | ||
| 'WebSearch', | ||
| 'FetchURL', | ||
| 'ReadMediaFile', | ||
| ]; | ||
|
|
||
| export const PLANNER_SYSTEM_PROMPT = [ | ||
| 'You are a swarm planner. Decompose the user task into independent subtasks that can run in parallel.', | ||
| 'For each subtask invent a short role name, a focused system prompt for that role, and a concrete prompt.', | ||
| 'Optionally specify toolAllowlist (a subset of the allowed tools) when a subtask needs more than read-only access.', | ||
| `Allowed tools: ${ALLOWED_WORKER_TOOLS.join(', ')}.`, | ||
| 'Output ONLY a JSON object, no prose, matching exactly:', | ||
| '{"subtasks":[{"id":"task-1","role":"...","systemPrompt":"...","prompt":"...","toolAllowlist":["Read"]}]}', | ||
| 'Keep it to at most 6 subtasks. Each subtask must be self-contained (workers cannot see each other).', | ||
| ].join('\n'); | ||
|
|
||
| export function renderPlannerPrompt(rootTask: string): string { | ||
| return `Task to decompose:\n${rootTask}\n\nReturn only the JSON plan.`; | ||
| } | ||
|
|
||
| export function renderPlannerRetryPrompt(rootTask: string, previous: string): string { | ||
| return [ | ||
| `Task to decompose:\n${rootTask}`, | ||
| '', | ||
| 'Your previous response was not valid JSON in the required shape:', | ||
| previous.slice(0, 1000), | ||
| '', | ||
| 'Return ONLY the JSON object, with a non-empty "subtasks" array. No prose, no code fences.', | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| export const SYNTHESIZER_SYSTEM_PROMPT = [ | ||
| 'You are a swarm synthesizer. You are given the original task and the outputs of several worker subagents.', | ||
| 'Merge them into one coherent, complete answer for the user.', | ||
| 'If a subtask failed, note the gap explicitly instead of inventing its content.', | ||
| ].join('\n'); | ||
|
|
||
| export function renderSynthesizerPrompt(plan: SwarmPlan): string { | ||
| const blocks = plan.subtasks.map((st) => { | ||
| const body = | ||
| st.status === 'done' ? (st.result ?? '') : `[FAILED: ${st.error ?? 'unknown error'}]`; | ||
| return `### ${st.role} (${st.status})\n${body}`; | ||
| }); | ||
| return [`Original task:\n${plan.rootTask}`, '', 'Worker outputs:', '', ...blocks].join('\n'); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| export interface Subtask { | ||
| id: string; | ||
| role: string; | ||
| systemPrompt: string; | ||
| prompt: string; | ||
| toolAllowlist?: string[] | undefined; | ||
| status: 'pending' | 'running' | 'done' | 'failed'; | ||
| result?: string | undefined; | ||
| error?: string | undefined; | ||
| } | ||
|
|
||
| export interface SwarmPlan { | ||
| rootTask: string; | ||
| subtasks: Subtask[]; | ||
| } | ||
|
|
||
| /** What the coordinator needs to run one subagent to completion. */ | ||
| export type SpawnSubagentFn = (args: { | ||
| profileName: string; | ||
| systemPrompt: string; | ||
| tools: string[]; | ||
| prompt: string; | ||
| description: string; | ||
| signal: AbortSignal; | ||
| }) => Promise<{ result: string }>; | ||
|
|
||
| export interface SwarmCoordinatorDeps { | ||
| spawnSubagent: SpawnSubagentFn; | ||
| signal: AbortSignal; | ||
| onProgress?: ((text: string) => void) | undefined; | ||
| maxConcurrency?: number | undefined; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ tools: | |
| - Skill | ||
| - WebSearch | ||
| - Agent | ||
| - Swarm | ||
| - FetchURL | ||
| - AskUserQuestion | ||
| - EnterPlanMode | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This directly prompts the current session to call
Swarm, but resumed sessions created before this commit replay their oldtools.set_active_toolsrecord from the wire, so their active tool list does not include the newly addedSwarmentry fromagent.yaml. In that context/swarm <task>is accepted by the TUI but the model is asked to use a tool that is not exposed, so the command fails or devolves into normal chat; migrate old agent tool lists or check tool availability before sending this framed prompt.Useful? React with 👍 / 👎.