Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0406ad0
feat(agent-core): support profileOverride for dynamic-role subagents
RealKai42 May 29, 2026
7591e67
feat(agent-core): add swarm types and pure plan-parse/concurrency hel…
RealKai42 May 29, 2026
985fd5c
feat(agent-core): add SwarmCoordinator (plan, parallel workers, synth…
RealKai42 May 29, 2026
9c309b1
feat(agent-core): add Swarm tool wired to SwarmCoordinator with recur…
RealKai42 May 29, 2026
b0b61c2
feat(tui): add /swarm command that triggers the Swarm tool
RealKai42 May 29, 2026
d6a3d91
fix(agent-core): enforce swarm worker tool allowlist and propagate abort
RealKai42 May 29, 2026
fc5e4bf
Merge remote-tracking branch 'origin/main' into kaiyi/karachi
RealKai42 May 29, 2026
8021cec
fix(agent-core): clarify swarm planner tool guidance, add profileOver…
RealKai42 May 29, 2026
adc18ad
feat(tui): add swarm dashboard model and reducer
RealKai42 May 29, 2026
3475837
feat(tui): add SwarmDashboardComponent
RealKai42 May 29, 2026
7ed20f3
feat(agent-core): emit structured swarm progress (planned/synthesizin…
RealKai42 May 29, 2026
03e49e5
feat(tui): render swarm runs as a live dashboard instead of nested to…
RealKai42 May 29, 2026
81749b9
fix(tui): count only workers in swarm dashboard, finalize on cancel, …
RealKai42 May 29, 2026
e873370
fix(tui): render swarm via the managed tool-call lifecycle to stop du…
RealKai42 May 29, 2026
0d11fbc
fix(tui): match swarm card styling to AgentGroup conventions and fix …
RealKai42 May 29, 2026
c03ba22
fix(tui): collapse multi-line swarm task to one line in header and to…
RealKai42 May 29, 2026
649596b
Merge remote-tracking branch 'origin/main' into kaiyi/karachi
RealKai42 May 29, 2026
adb6827
feat(tui): show live token counts for running swarm workers
RealKai42 May 29, 2026
e88003f
feat(agent-core): stall-detection hard-stop for swarm workers (repeat…
RealKai42 May 29, 2026
60bc6be
fix(agent-core): remove NUL byte from swarm stall-hook repeat key
RealKai42 May 29, 2026
f2cc148
feat(agent-core): swarm coordinator failure-recovery loop (retry/rege…
RealKai42 May 29, 2026
5375300
feat(tui): surface swarm recovery (retrying/dropped) in the dashboard
RealKai42 May 29, 2026
df04b8d
fix(swarm): resolve reassign orphan row, enrich stall context, decisi…
RealKai42 May 29, 2026
d6942ec
fix(agent-core): drop subagent summary-continuation re-prompt
RealKai42 May 29, 2026
cc9176b
Merge branch 'main' into kaiyi/karachi
RealKai42 May 29, 2026
38ba4b8
fix(kimi-code): route /swarm through the session-request lifecycle
RealKai42 May 29, 2026
a17cfee
fix(kimi-code): show swarm failures distinctly from cancellation
RealKai42 May 29, 2026
9d7c9a6
fix(agent-core): reject empty planner subtask fields
RealKai42 May 30, 2026
e288ffa
fix(kimi-code): show the /swarm request in the live transcript
RealKai42 May 30, 2026
f31bf2b
refactor(kimi-code): render the swarm dashboard in a dedicated SwarmCard
RealKai42 May 30, 2026
1461143
fix(kimi-code): show the swarm report when replaying a completed run
RealKai42 May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
handleInitCommand,
handleTitleCommand,
} from './session';
import { handleSwarmCommand } from './swarm';

// ---------------------------------------------------------------------------
// Re-exports — keep existing consumers working
Expand Down Expand Up @@ -254,6 +255,9 @@ async function handleBuiltInSlashCommand(
case 'plan':
await handlePlanCommand(host, args);
return;
case 'swarm':
await handleSwarmCommand(host, args);
return;
case 'compact':
await handleCompactCommand(host, args);
return;
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 100,
availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'),
},
{
name: 'swarm',
aliases: [],
description: 'Run a task as a parallel agent swarm',
priority: 100,
availability: 'idle-only',
},
{
name: 'model',
aliases: [],
Expand Down
31 changes: 31 additions & 0 deletions apps/kimi-code/src/tui/commands/swarm.ts
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));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle sessions whose active tools lack Swarm

This directly prompts the current session to call Swarm, but resumed sessions created before this commit replay their old tools.set_active_tools record from the wire, so their active tool list does not include the newly added Swarm entry from agent.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 👍 / 👎.

Comment on lines +39 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route /swarm through the normal send lifecycle

This calls session.prompt directly, so the TUI never runs the normal sendMessageInternal setup (beginSessionRequest, streaming state, transcript entry, and queue handling). During the initial model latency before any SDK event arrives, the app still considers itself idle, so another user input or idle-only slash command can be accepted and race with the swarm turn instead of being blocked/queued like a normal prompt.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Show the swarm request in the transcript

This starts a real model turn but, unlike the normal send path, never appends the user's /swarm task to the live transcript before calling session.prompt. In a live session the user sees a Swarm tool card with no preceding user request, and after resume the replayed user message comes from the internal buildSwarmPrompt(...) wrapper instead of the command/task the user actually entered; add an explicit transcript entry for the swarm request before dispatching the prompt.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the readable /swarm request for replay

Fresh evidence after the live-transcript fix: this still records the verbose buildSwarmPrompt(task) as a normal user prompt, and replay renders user-origin messages directly (apps/kimi-code/src/tui/controllers/session-replay.ts:254-256). The manual appendUserTranscriptEntry('/swarm ...') only affects the current in-memory transcript, so after resuming/exporting history the turn shows the internal “Use the Swarm tool…” wrapper instead of the command/task the user actually entered; send the framed prompt with a non-user/internal origin or persist a replayable readable request alongside it.

Useful? React with 👍 / 👎.

} catch (error) {
host.showError(`Failed to start swarm: ${formatErrorMessage(error)}`);
}
}
34 changes: 34 additions & 0 deletions apps/kimi-code/test/tui/commands/swarm.test.ts
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');
});
});
21 changes: 21 additions & 0 deletions packages/agent-core/src/agent/swarm/concurrency.ts
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()));
}
95 changes: 95 additions & 0 deletions packages/agent-core/src/agent/swarm/coordinator.ts
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})`);
}
});
}
}
52 changes: 52 additions & 0 deletions packages/agent-core/src/agent/swarm/parse.ts
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the planner's subtask cap

When the planner returns valid JSON with more than the prompted maximum of 6 subtasks, this accepts the entire array; SwarmCoordinator.runWave then iterates every entry and spawns a subagent for each one, only limiting concurrent workers to 4. In the common failure mode where the LLM ignores the cap or emits a large accidental list, /swarm can launch dozens of subagents and burn substantial time/tokens instead of retrying or rejecting the invalid plan. Please validate subtasksRaw.length <= 6 here (or truncate deliberately) before spawning workers.

Useful? React with 👍 / 👎.

Comment on lines +24 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the advertised subtask cap

The planner prompt says to keep the plan to at most 6 subtasks, but this parser accepts any non-empty subtasks array. If the model returns dozens or hundreds of items, runWithRetries will execute every one of them (bounded only by concurrency), which can turn a single /swarm call into unexpected token/tool spend and a very long run instead of retrying/rejecting the invalid plan.

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'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty planner subtask fields

When the planner returns syntactically valid JSON but leaves role, systemPrompt, or prompt as an empty string, this parser accepts the plan instead of retrying, so the coordinator can spawn a swarm: worker with no role/instructions and synthesize arbitrary or useless output. Treat trimmed-empty required fields as invalid here, matching the stricter reviser parsing, so the existing planner retry handles malformed plans.

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 };
}
54 changes: 54 additions & 0 deletions packages/agent-core/src/agent/swarm/prompts.ts
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');
}
32 changes: 32 additions & 0 deletions packages/agent-core/src/agent/swarm/types.ts
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;
}
3 changes: 3 additions & 0 deletions packages/agent-core/src/agent/tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,9 @@ export class ToolManager {
log: this.agent.log,
},
),
this.agent.subagentHost &&
this.agent.type !== 'sub' &&
new b.SwarmTool(this.agent.subagentHost, { log: this.agent.log }),
toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher),
toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher),
]
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/profile/default/agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ tools:
- Skill
- WebSearch
- Agent
- Swarm
- FetchURL
- AskUserQuestion
- EnterPlanMode
Expand Down
Loading
Loading