From 0406ad0a9a41e6dc4406cd552a2f4e899e9c0f5b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 15:07:24 +0800 Subject: [PATCH 01/28] feat(agent-core): support profileOverride for dynamic-role subagents --- .../agent-core/src/session/subagent-host.ts | 16 +++++++++++- .../test/session/subagent-override.test.ts | 26 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 packages/agent-core/test/session/subagent-override.test.ts diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 49861e9d..25ac5e0d 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -24,6 +24,17 @@ const HOOK_TEXT_PREVIEW_LENGTH = 500; const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; +export function buildOverrideProfile( + name: string, + override: { systemPrompt: string; tools: string[] }, +): ResolvedAgentProfile { + return { + name, + systemPrompt: () => override.systemPrompt, + tools: override.tools, + }; +} + type RunSubagentOptions = { readonly parentToolCallId: string; readonly parentToolCallUuid?: string | undefined; @@ -32,6 +43,7 @@ type RunSubagentOptions = { readonly runInBackground: boolean; readonly origin?: PromptOrigin | undefined; readonly signal: AbortSignal; + readonly profileOverride?: { readonly systemPrompt: string; readonly tools: string[] } | undefined; }; type SubagentCompletion = { @@ -68,7 +80,9 @@ export class SessionSubagentHost { throw new Error(`Parent agent "${this.ownerAgentId}" was not found`); } - const profile = this.resolveProfile(parent, profileName); + const profile = options.profileOverride + ? buildOverrideProfile(profileName, options.profileOverride) + : this.resolveProfile(parent, profileName); const { id, agent } = await this.session.createAgent( { type: 'sub', generate: parent.rawGenerate }, undefined, diff --git a/packages/agent-core/test/session/subagent-override.test.ts b/packages/agent-core/test/session/subagent-override.test.ts new file mode 100644 index 00000000..656156ab --- /dev/null +++ b/packages/agent-core/test/session/subagent-override.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { buildOverrideProfile } from '../../src/session/subagent-host'; +import { TEST_OS_ENV } from '../fixtures/test-kaos'; + +describe('buildOverrideProfile', () => { + it('builds a profile whose renderer returns the literal system prompt', () => { + const profile = buildOverrideProfile('swarm:Auditor', { + systemPrompt: 'You are a dependency auditor.', + tools: ['Read', 'Grep'], + }); + + expect(profile.name).toBe('swarm:Auditor'); + expect(profile.tools).toEqual(['Read', 'Grep']); + expect(profile.systemPrompt({ osEnv: TEST_OS_ENV, cwd: '/tmp' })).toBe( + 'You are a dependency auditor.', + ); + }); + + it('ignores render context and always returns the override text', () => { + const profile = buildOverrideProfile('swarm:X', { systemPrompt: 'fixed', tools: [] }); + expect(profile.systemPrompt({ osEnv: TEST_OS_ENV, cwd: '/a', cwdListing: 'noise' })).toBe( + 'fixed', + ); + }); +}); From 7591e679f583b528ac3d4cdd32fdba97e2b85ce3 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 15:12:03 +0800 Subject: [PATCH 02/28] feat(agent-core): add swarm types and pure plan-parse/concurrency helpers --- .../agent-core/src/agent/swarm/concurrency.ts | 21 ++++++++ packages/agent-core/src/agent/swarm/parse.ts | 52 +++++++++++++++++++ packages/agent-core/src/agent/swarm/types.ts | 32 ++++++++++++ .../agent-core/test/swarm/concurrency.test.ts | 33 ++++++++++++ packages/agent-core/test/swarm/parse.test.ts | 47 +++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 packages/agent-core/src/agent/swarm/concurrency.ts create mode 100644 packages/agent-core/src/agent/swarm/parse.ts create mode 100644 packages/agent-core/src/agent/swarm/types.ts create mode 100644 packages/agent-core/test/swarm/concurrency.test.ts create mode 100644 packages/agent-core/test/swarm/parse.test.ts diff --git a/packages/agent-core/src/agent/swarm/concurrency.ts b/packages/agent-core/src/agent/swarm/concurrency.ts new file mode 100644 index 00000000..0cde561e --- /dev/null +++ b/packages/agent-core/src/agent/swarm/concurrency.ts @@ -0,0 +1,21 @@ +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const max = Math.max(1, Math.floor(limit)); + let cursor = 0; + + async function worker(): Promise { + 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())); +} diff --git a/packages/agent-core/src/agent/swarm/parse.ts b/packages/agent-core/src/agent/swarm/parse.ts new file mode 100644 index 00000000..82c903ff --- /dev/null +++ b/packages/agent-core/src/agent/swarm/parse.ts @@ -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; + + 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; + if ( + typeof o['role'] !== 'string' || + typeof o['systemPrompt'] !== 'string' || + typeof o['prompt'] !== 'string' + ) { + 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 }; +} diff --git a/packages/agent-core/src/agent/swarm/types.ts b/packages/agent-core/src/agent/swarm/types.ts new file mode 100644 index 00000000..e15c3668 --- /dev/null +++ b/packages/agent-core/src/agent/swarm/types.ts @@ -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; +} diff --git a/packages/agent-core/test/swarm/concurrency.test.ts b/packages/agent-core/test/swarm/concurrency.test.ts new file mode 100644 index 00000000..bd786610 --- /dev/null +++ b/packages/agent-core/test/swarm/concurrency.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { mapWithConcurrency } from '../../src/agent/swarm/concurrency'; + +describe('mapWithConcurrency', () => { + it('processes every item', async () => { + const seen: number[] = []; + await mapWithConcurrency([1, 2, 3, 4], 2, async (n) => { + seen.push(n); + }); + expect(seen.sort((a, b) => a - b)).toEqual([1, 2, 3, 4]); + }); + + it('never exceeds the concurrency limit', async () => { + let active = 0; + let peak = 0; + await mapWithConcurrency([1, 2, 3, 4, 5, 6], 2, async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise((r) => setTimeout(r, 5)); + active -= 1; + }); + expect(peak).toBeLessThanOrEqual(2); + }); + + it('treats a limit below 1 as 1', async () => { + const seen: number[] = []; + await mapWithConcurrency([1, 2], 0, async (n) => { + seen.push(n); + }); + expect(seen.sort((a, b) => a - b)).toEqual([1, 2]); + }); +}); diff --git a/packages/agent-core/test/swarm/parse.test.ts b/packages/agent-core/test/swarm/parse.test.ts new file mode 100644 index 00000000..b2146527 --- /dev/null +++ b/packages/agent-core/test/swarm/parse.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { extractJsonObject, parsePlan } from '../../src/agent/swarm/parse'; + +describe('extractJsonObject', () => { + it('extracts a fenced json block', () => { + expect(extractJsonObject('blah\n```json\n{"a":1}\n```\ntail')).toBe('{"a":1}'); + }); + it('extracts a bare object from surrounding prose', () => { + expect(extractJsonObject('here you go: {"a":1} done')).toBe('{"a":1}'); + }); + it('returns null when no object is present', () => { + expect(extractJsonObject('no json here')).toBeNull(); + }); +}); + +describe('parsePlan', () => { + const good = JSON.stringify({ + subtasks: [ + { role: 'Researcher', systemPrompt: 'be a researcher', prompt: 'research X' }, + { id: 'b', role: 'Writer', systemPrompt: 'be a writer', prompt: 'write Y', toolAllowlist: ['Read'] }, + ], + }); + + it('parses a valid plan and fills default ids', () => { + const plan = parsePlan('root', '```json\n' + good + '\n```'); + expect(plan).not.toBeNull(); + expect(plan?.rootTask).toBe('root'); + expect(plan?.subtasks).toHaveLength(2); + expect(plan?.subtasks[0]?.id).toBe('task-1'); + expect(plan?.subtasks[0]?.status).toBe('pending'); + expect(plan?.subtasks[1]?.id).toBe('b'); + expect(plan?.subtasks[1]?.toolAllowlist).toEqual(['Read']); + }); + + it('returns null for empty subtasks', () => { + expect(parsePlan('root', '{"subtasks":[]}')).toBeNull(); + }); + + it('returns null when a subtask misses required fields', () => { + expect(parsePlan('root', '{"subtasks":[{"role":"R"}]}')).toBeNull(); + }); + + it('returns null for non-json garbage', () => { + expect(parsePlan('root', 'totally not json')).toBeNull(); + }); +}); From 985fd5c6f697f4fcf74c9d19badbc3d4072c625f Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 15:14:23 +0800 Subject: [PATCH 03/28] feat(agent-core): add SwarmCoordinator (plan, parallel workers, synthesize) --- .../agent-core/src/agent/swarm/coordinator.ts | 91 +++++++++++++++++++ .../agent-core/src/agent/swarm/prompts.ts | 57 ++++++++++++ .../agent-core/test/swarm/coordinator.test.ts | 83 +++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 packages/agent-core/src/agent/swarm/coordinator.ts create mode 100644 packages/agent-core/src/agent/swarm/prompts.ts create mode 100644 packages/agent-core/test/swarm/coordinator.test.ts diff --git a/packages/agent-core/src/agent/swarm/coordinator.ts b/packages/agent-core/src/agent/swarm/coordinator.ts new file mode 100644 index 00000000..a80605b3 --- /dev/null +++ b/packages/agent-core/src/agent/swarm/coordinator.ts @@ -0,0 +1,91 @@ +import { mapWithConcurrency } from './concurrency'; +import { parsePlan } from './parse'; +import { + 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 { + 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 { + 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 { + 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, + prompt: st.prompt, + description: st.role, + signal: this.deps.signal, + }); + st.result = out.result; + st.status = 'done'; + this.progress(`✓ ${st.role}: done`); + } catch (err) { + st.status = 'failed'; + st.error = err instanceof Error ? err.message : String(err); + this.progress(`✗ ${st.role}: failed (${st.error})`); + } + }); + } +} diff --git a/packages/agent-core/src/agent/swarm/prompts.ts b/packages/agent-core/src/agent/swarm/prompts.ts new file mode 100644 index 00000000..1d5e9bd2 --- /dev/null +++ b/packages/agent-core/src/agent/swarm/prompts.ts @@ -0,0 +1,57 @@ +import type { SwarmPlan } from './types'; + +/** Read-only default tool set for workers; planner may widen via toolAllowlist. */ +export const DEFAULT_WORKER_TOOLS: string[] = ['Read', 'Grep', 'Glob', 'WebSearch', 'FetchURL']; + +/** Tool names a worker is allowed to request (excludes Agent/Swarm dispatch tools). */ +export const ALLOWED_WORKER_TOOLS: string[] = [ + 'Read', + 'Write', + 'Edit', + 'Grep', + 'Glob', + 'Bash', + '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'); +} diff --git a/packages/agent-core/test/swarm/coordinator.test.ts b/packages/agent-core/test/swarm/coordinator.test.ts new file mode 100644 index 00000000..55e796d2 --- /dev/null +++ b/packages/agent-core/test/swarm/coordinator.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SwarmCoordinator } from '../../src/agent/swarm/coordinator'; +import type { SpawnSubagentFn } from '../../src/agent/swarm/types'; + +const PLAN_JSON = JSON.stringify({ + subtasks: [ + { role: 'Researcher', systemPrompt: 'sp-research', prompt: 'p-research' }, + { role: 'Analyst', systemPrompt: 'sp-analyst', prompt: 'p-analyst', toolAllowlist: ['Read'] }, + ], +}); + +function makeSpawner(byProfile: Record): SpawnSubagentFn { + return vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: '```json\n' + PLAN_JSON + '\n```' }; + if (args.profileName === 'swarm-synthesizer') return { result: 'FINAL ANSWER' }; + const key = args.profileName; + return { result: byProfile[key] ?? `done:${args.description}` }; + }); +} + +describe('SwarmCoordinator.run', () => { + it('plans, runs workers concurrently, and synthesizes', async () => { + const spawn = makeSpawner({}); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + maxConcurrency: 4, + }); + + const result = await coordinator.run('do a thing'); + + expect(result).toBe('FINAL ANSWER'); + const calls = (spawn as ReturnType).mock.calls.map((c) => c[0]); + expect(calls).toHaveLength(4); + expect(calls[0].profileName).toBe('swarm-planner'); + expect(calls.some((c) => c.profileName === 'swarm:Researcher' && c.systemPrompt === 'sp-research')).toBe(true); + expect(calls.some((c) => c.profileName === 'swarm:Analyst' && c.tools.includes('Read'))).toBe(true); + expect(calls[calls.length - 1].profileName).toBe('swarm-synthesizer'); + }); + + it('retries planning once on invalid JSON, then succeeds', async () => { + let first = true; + const spawn: SpawnSubagentFn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') { + if (first) { + first = false; + return { result: 'not json at all' }; + } + return { result: PLAN_JSON }; + } + if (args.profileName === 'swarm-synthesizer') return { result: 'OK' }; + return { result: 'worker-done' }; + }); + const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: new AbortController().signal }); + const result = await coordinator.run('x'); + expect(result).toBe('OK'); + }); + + it('throws when planning fails twice', async () => { + const spawn: SpawnSubagentFn = vi.fn(async () => ({ result: 'never json' })); + const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: new AbortController().signal }); + await expect(coordinator.run('x')).rejects.toThrow(/valid plan/i); + }); + + it('records a failed worker and still synthesizes', async () => { + const spawn: SpawnSubagentFn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: PLAN_JSON }; + if (args.profileName === 'swarm-synthesizer') return { result: 'SYNTH' }; + if (args.profileName === 'swarm:Researcher') throw new Error('boom'); + return { result: 'analyst-done' }; + }); + const onProgress = vi.fn(); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + onProgress, + }); + const result = await coordinator.run('x'); + expect(result).toBe('SYNTH'); + expect(onProgress.mock.calls.some((c) => /failed/i.test(String(c[0])))).toBe(true); + }); +}); From 9c309b19efbf4c4c4e5149dad9be3c7cd0bbbb9d Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 15:18:44 +0800 Subject: [PATCH 04/28] feat(agent-core): add Swarm tool wired to SwarmCoordinator with recursion guard --- packages/agent-core/src/agent/tool/index.ts | 3 + .../agent-core/src/profile/default/agent.yaml | 1 + .../src/tools/builtin/collaboration/swarm.ts | 90 +++++++++++++++++++ .../agent-core/src/tools/builtin/index.ts | 1 + .../agent-core/test/swarm/swarm-tool.test.ts | 47 ++++++++++ 5 files changed, 142 insertions(+) create mode 100644 packages/agent-core/src/tools/builtin/collaboration/swarm.ts create mode 100644 packages/agent-core/test/swarm/swarm-tool.test.ts diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 550cfeba..5038b2a5 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -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), ] diff --git a/packages/agent-core/src/profile/default/agent.yaml b/packages/agent-core/src/profile/default/agent.yaml index 82b81bd3..072904eb 100644 --- a/packages/agent-core/src/profile/default/agent.yaml +++ b/packages/agent-core/src/profile/default/agent.yaml @@ -23,6 +23,7 @@ tools: - Skill - WebSearch - Agent + - Swarm - FetchURL - AskUserQuestion - EnterPlanMode diff --git a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts new file mode 100644 index 00000000..2ab1f0c4 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts @@ -0,0 +1,90 @@ +/** + * SwarmTool — collaboration tool that runs a task as a self-directed agent + * swarm. + * + * Like {@link AgentTool}, this is a "collaboration tool": it uses + * `SessionSubagentHost` (injected via the constructor) to create in-process + * subagents. The {@link SwarmCoordinator} dynamically decomposes the task into + * parallel role-specialized workers, then synthesizes their outputs into one + * answer. + * + * Workers are spawned with an ad-hoc `profileOverride`, and the tool is + * registered only on non-sub agents so a swarm worker can never launch another + * swarm (recursion guard). + */ + +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import type { Logger } from '../../../logging'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import type { SessionSubagentHost } from '../../../session/subagent-host'; +import { toInputJsonSchema } from '../../support/input-schema'; +import { SwarmCoordinator } from '../../../agent/swarm/coordinator'; + +export const SwarmToolInputSchema = z.object({ + task: z.string().describe('The high-level task to decompose and run as a parallel agent swarm.'), +}); + +export type SwarmToolInput = z.infer; + +const SWARM_DESCRIPTION = + 'Run a task as a self-directed agent swarm: dynamically decompose it into parallel ' + + 'role-specialized subagents, then synthesize their outputs into one answer. ' + + 'Use for broad, parallelizable tasks (research, multi-file analysis). ' + + 'Subagents run in isolated contexts and cannot themselves launch swarms.'; + +const DEFAULT_MAX_CONCURRENCY = 4; + +export class SwarmTool implements BuiltinTool { + readonly name: string = 'Swarm'; + readonly description: string = SWARM_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(SwarmToolInputSchema); + private readonly log: Logger | undefined; + + constructor( + private readonly subagentHost: SessionSubagentHost, + options?: { log?: Logger }, + ) { + this.log = options?.log; + } + + resolveExecution(args: SwarmToolInput): ToolExecution { + return { + description: `Running swarm: ${args.task.slice(0, 60)}`, + approvalRule: 'Swarm', + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: SwarmToolInput, + ctx: ExecutableToolContext, + ): Promise { + const coordinator = new SwarmCoordinator({ + signal: ctx.signal, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + onProgress: (text) => ctx.onUpdate?.({ kind: 'status', text }), + spawnSubagent: async ({ profileName, systemPrompt, tools, prompt, description, signal }) => { + const handle = await this.subagentHost.spawn(profileName, { + parentToolCallId: ctx.toolCallId, + prompt, + description, + runInBackground: false, + signal, + profileOverride: { systemPrompt, tools }, + }); + return handle.completion; + }, + }); + + try { + const output = await coordinator.run(args.task); + return { output }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log?.error(`swarm failed: ${message}`); + return { output: `Swarm failed: ${message}`, isError: true }; + } + } +} diff --git a/packages/agent-core/src/tools/builtin/index.ts b/packages/agent-core/src/tools/builtin/index.ts index ebbe0dc7..70c21875 100644 --- a/packages/agent-core/src/tools/builtin/index.ts +++ b/packages/agent-core/src/tools/builtin/index.ts @@ -8,6 +8,7 @@ export * from '../cron/cron-list'; export * from './collaboration/agent'; export * from './collaboration/ask-user'; export * from './collaboration/skill-tool'; +export * from './collaboration/swarm'; export * from './file/edit'; export * from './file/glob'; export * from './file/grep'; diff --git a/packages/agent-core/test/swarm/swarm-tool.test.ts b/packages/agent-core/test/swarm/swarm-tool.test.ts new file mode 100644 index 00000000..f6b2af8e --- /dev/null +++ b/packages/agent-core/test/swarm/swarm-tool.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SwarmTool } from '../../src/tools/builtin/collaboration/swarm'; +import type { SessionSubagentHost } from '../../src/session/subagent-host'; + +const PLAN_JSON = JSON.stringify({ + subtasks: [{ role: 'R', systemPrompt: 'sp', prompt: 'p' }], +}); + +function fakeHost(): SessionSubagentHost { + const spawn = vi.fn(async (profileName: string) => { + const result = + profileName === 'swarm-planner' + ? PLAN_JSON + : profileName === 'swarm-synthesizer' + ? 'FINAL' + : 'worker-out'; + return { agentId: 'a', profileName, resumed: false, completion: Promise.resolve({ result }) }; + }); + return { spawn } as unknown as SessionSubagentHost; +} + +describe('SwarmTool', () => { + it('exposes a task parameter and an approval rule', () => { + const tool = new SwarmTool(fakeHost()); + expect(tool.name).toBe('Swarm'); + const exec = tool.resolveExecution({ task: 'hello' }); + expect('approvalRule' in exec && exec.approvalRule).toBe('Swarm'); + }); + + it('runs the coordinator and returns the synthesized output', async () => { + const tool = new SwarmTool(fakeHost()); + const exec = tool.resolveExecution({ task: 'do it' }); + if (!('execute' in exec)) throw new Error('expected runnable execution'); + const updates: string[] = []; + const result = await exec.execute({ + turnId: 't1', + toolCallId: 'tc1', + signal: new AbortController().signal, + onUpdate: (u) => { + if (u.text !== undefined) updates.push(u.text); + }, + }); + expect('output' in result && result.output).toBe('FINAL'); + expect(updates.length).toBeGreaterThan(0); + }); +}); From b0b61c27cac6a5d3e40e92421c3129500114c078 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 15:22:55 +0800 Subject: [PATCH 05/28] feat(tui): add /swarm command that triggers the Swarm tool --- apps/kimi-code/src/tui/commands/dispatch.ts | 4 +++ apps/kimi-code/src/tui/commands/registry.ts | 7 ++++ apps/kimi-code/src/tui/commands/swarm.ts | 31 +++++++++++++++++ .../kimi-code/test/tui/commands/swarm.test.ts | 34 +++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 apps/kimi-code/src/tui/commands/swarm.ts create mode 100644 apps/kimi-code/test/tui/commands/swarm.test.ts diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 28ccf8bc..bd8c04bf 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -42,6 +42,7 @@ import { handleInitCommand, handleTitleCommand, } from './session'; +import { handleSwarmCommand } from './swarm'; // --------------------------------------------------------------------------- // Re-exports — keep existing consumers working @@ -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; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index faf76b57..ab771269 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -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: [], diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts new file mode 100644 index 00000000..975b9803 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -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 { + 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 '); + return; + } + try { + await session.prompt(buildSwarmPrompt(task)); + } catch (error) { + host.showError(`Failed to start swarm: ${formatErrorMessage(error)}`); + } +} diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts new file mode 100644 index 00000000..c75b545c --- /dev/null +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -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>(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'); + }); +}); From d6a3d91c72023b4b896b1058c6dc8707362e128f Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 15:33:41 +0800 Subject: [PATCH 06/28] fix(agent-core): enforce swarm worker tool allowlist and propagate abort --- .../agent-core/src/agent/swarm/coordinator.ts | 6 +++- .../agent-core/src/agent/swarm/prompts.ts | 11 +++---- .../agent-core/test/swarm/coordinator.test.ts | 33 +++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/agent-core/src/agent/swarm/coordinator.ts b/packages/agent-core/src/agent/swarm/coordinator.ts index a80605b3..6b9016e6 100644 --- a/packages/agent-core/src/agent/swarm/coordinator.ts +++ b/packages/agent-core/src/agent/swarm/coordinator.ts @@ -1,6 +1,7 @@ import { mapWithConcurrency } from './concurrency'; import { parsePlan } from './parse'; import { + ALLOWED_WORKER_TOOLS, DEFAULT_WORKER_TOOLS, PLANNER_SYSTEM_PROMPT, SYNTHESIZER_SYSTEM_PROMPT, @@ -73,7 +74,9 @@ export class SwarmCoordinator { const out = await this.deps.spawnSubagent({ profileName: `swarm:${st.role}`, systemPrompt: st.systemPrompt, - tools: st.toolAllowlist ?? DEFAULT_WORKER_TOOLS, + tools: (st.toolAllowlist ?? DEFAULT_WORKER_TOOLS).filter((t) => + ALLOWED_WORKER_TOOLS.includes(t), + ), prompt: st.prompt, description: st.role, signal: this.deps.signal, @@ -82,6 +85,7 @@ export class SwarmCoordinator { 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})`); diff --git a/packages/agent-core/src/agent/swarm/prompts.ts b/packages/agent-core/src/agent/swarm/prompts.ts index 1d5e9bd2..3d708760 100644 --- a/packages/agent-core/src/agent/swarm/prompts.ts +++ b/packages/agent-core/src/agent/swarm/prompts.ts @@ -1,16 +1,13 @@ import type { SwarmPlan } from './types'; -/** Read-only default tool set for workers; planner may widen via toolAllowlist. */ -export const DEFAULT_WORKER_TOOLS: string[] = ['Read', 'Grep', 'Glob', 'WebSearch', 'FetchURL']; +/** 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 (excludes Agent/Swarm dispatch tools). */ -export const ALLOWED_WORKER_TOOLS: string[] = [ +/** 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', - 'Write', - 'Edit', 'Grep', 'Glob', - 'Bash', 'WebSearch', 'FetchURL', 'ReadMediaFile', diff --git a/packages/agent-core/test/swarm/coordinator.test.ts b/packages/agent-core/test/swarm/coordinator.test.ts index 55e796d2..756fb069 100644 --- a/packages/agent-core/test/swarm/coordinator.test.ts +++ b/packages/agent-core/test/swarm/coordinator.test.ts @@ -80,4 +80,37 @@ describe('SwarmCoordinator.run', () => { expect(result).toBe('SYNTH'); expect(onProgress.mock.calls.some((c) => /failed/i.test(String(c[0])))).toBe(true); }); + + it('strips disallowed tools (Agent/Bash) from a planner-supplied allowlist', async () => { + const planWithBadTools = JSON.stringify({ + subtasks: [{ role: 'X', systemPrompt: 's', prompt: 'p', toolAllowlist: ['Agent', 'Read', 'Bash'] }], + }); + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: planWithBadTools }; + if (args.profileName === 'swarm-synthesizer') return { result: 'S' }; + return { result: 'w' }; + }); + const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: new AbortController().signal }); + await coordinator.run('x'); + const worker = (spawn as ReturnType).mock.calls + .map((c) => c[0]) + .find((c) => c.profileName === 'swarm:X'); + expect(worker?.tools).toEqual(['Read']); + }); + + it('propagates abort instead of swallowing it (no synthesis after cancel)', async () => { + const controller = new AbortController(); + const PLAN = JSON.stringify({ subtasks: [{ role: 'A', systemPrompt: 's', prompt: 'p' }] }); + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: PLAN }; + controller.abort(); + const e = new Error('aborted'); + e.name = 'AbortError'; + throw e; + }); + const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: controller.signal }); + await expect(coordinator.run('x')).rejects.toThrow(); + const profiles = (spawn as ReturnType).mock.calls.map((c) => c[0].profileName); + expect(profiles).not.toContain('swarm-synthesizer'); + }); }); From 8021cecfab55159bf1ddd956bd6488421c78aef8 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 17:36:18 +0800 Subject: [PATCH 07/28] fix(agent-core): clarify swarm planner tool guidance, add profileOverride test and changeset --- .changeset/swarm-agent-orchestration.md | 6 ++++ .../agent-core/src/agent/swarm/prompts.ts | 2 +- .../test/session/subagent-host.test.ts | 31 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .changeset/swarm-agent-orchestration.md diff --git a/.changeset/swarm-agent-orchestration.md b/.changeset/swarm-agent-orchestration.md new file mode 100644 index 00000000..8ad07664 --- /dev/null +++ b/.changeset/swarm-agent-orchestration.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Add `/swarm` command and Swarm tool: decompose a task into parallel role-specialized subagents and synthesize their results. diff --git a/packages/agent-core/src/agent/swarm/prompts.ts b/packages/agent-core/src/agent/swarm/prompts.ts index 3d708760..9e44b58c 100644 --- a/packages/agent-core/src/agent/swarm/prompts.ts +++ b/packages/agent-core/src/agent/swarm/prompts.ts @@ -16,7 +16,7 @@ export const ALLOWED_WORKER_TOOLS: readonly string[] = [ 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.', + 'All workers are read-only. Optionally specify toolAllowlist to RESTRICT a subtask to a subset of the allowed tools; you cannot grant tools beyond the allowed list (anything else is ignored).', `Allowed tools: ${ALLOWED_WORKER_TOOLS.join(', ')}.`, 'Output ONLY a JSON object, no prose, matching exactly:', '{"subtasks":[{"id":"task-1","role":"...","systemPrompt":"...","prompt":"...","toolAllowlist":["Read"]}]}', diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index faf62052..dfb92d70 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -262,6 +262,37 @@ describe('SessionSubagentHost', () => { ]); }); + it('uses the profileOverride system prompt and tools instead of a registry profile', async () => { + const parent = testAgent(); + parent.configure(); + parent.newEvents(); + + const summary = + 'Researched the requested topic thoroughly and returned a complete, detailed summary that gives the parent agent everything it needs to continue without repeating the investigation work that was already finished here.'; + const child = testAgent(); + child.mockNextResponse({ type: 'text', text: summary }); + const session = fakeSession(parent.agent, child.agent); + const host = new SessionSubagentHost(session, 'main'); + + const handle = await host.spawn('swarm:Researcher', { + parentToolCallId: 'call_agent', + prompt: 'Research the topic', + description: 'Research', + runInBackground: false, + signal, + profileOverride: { + systemPrompt: 'You are a researcher.', + tools: ['Read', 'Grep'], + }, + }); + + await expect(handle.completion).resolves.toMatchObject({ result: summary }); + expect(handle.profileName).toBe('swarm:Researcher'); + expect(child.agent.config.systemPrompt).toBe('You are a researcher.'); + expect(child.llmCalls[0]?.systemPrompt).toBe('You are a researcher.'); + expect(child.llmCalls[0]?.tools.map((tool) => tool.name).toSorted()).toEqual(['Grep', 'Read']); + }); + it('rejects unknown subagent types before creating a child agent', async () => { const parent = testAgent(); parent.configure(); From adc18ad5127ebc73d878aac0cf291043c58e3243 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 18:26:57 +0800 Subject: [PATCH 08/28] feat(tui): add swarm dashboard model and reducer --- .../messages/swarm-dashboard-model.ts | 114 ++++++++++++++++++ .../messages/swarm-dashboard-model.test.ts | 85 +++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts create mode 100644 apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts diff --git a/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts new file mode 100644 index 00000000..e0da8966 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts @@ -0,0 +1,114 @@ +export type SwarmPhase = 'planning' | 'working' | 'synthesizing' | 'done' | 'cancelled'; +export type WorkerStatus = 'running' | 'done' | 'failed' | 'retrying'; + +export interface WorkerRow { + id: string; + role: string; + status: WorkerStatus; + toolCount: number; + latestActivity?: string; + tokens?: number; + error?: string; +} + +export interface SwarmModel { + task: string; + phase: SwarmPhase; + total: number; + doneCount: number; + failedCount: number; + workers: Map; +} + +export type SwarmEvent = + | { t: 'planned'; total: number } + | { t: 'synthesizing' } + | { t: 'done'; succeeded: number; failed: number } + | { t: 'cancelled' } + | { t: 'worker.spawned'; id: string; role: string } + | { t: 'worker.toolcall'; id: string; activity: string } + | { t: 'worker.done'; id: string; tokens?: number } + | { t: 'worker.failed'; id: string; error: string }; + +export function initialSwarmModel(task: string): SwarmModel { + return { task, phase: 'planning', total: 0, doneCount: 0, failedCount: 0, workers: new Map() }; +} + +export function applySwarmEvent(model: SwarmModel, event: SwarmEvent): SwarmModel { + switch (event.t) { + case 'planned': + return { ...model, phase: 'working', total: event.total }; + case 'synthesizing': + return { ...model, phase: 'synthesizing' }; + case 'done': + return { ...model, phase: 'done' }; + case 'cancelled': + return { ...model, phase: 'cancelled' }; + case 'worker.spawned': { + const workers = new Map(model.workers); + if (!workers.has(event.id)) { + workers.set(event.id, { id: event.id, role: event.role, status: 'running', toolCount: 0 }); + } + return { ...model, workers }; + } + case 'worker.toolcall': { + const workers = new Map(model.workers); + const w = workers.get(event.id); + if (w !== undefined) { + workers.set(event.id, { ...w, toolCount: w.toolCount + 1, latestActivity: event.activity }); + } + return { ...model, workers }; + } + case 'worker.done': { + const workers = new Map(model.workers); + const w = workers.get(event.id); + if (w === undefined) return model; + const wasTerminal = w.status === 'done' || w.status === 'failed'; + workers.set(event.id, { + ...w, + status: 'done', + latestActivity: undefined, + ...(event.tokens !== undefined ? { tokens: event.tokens } : {}), + }); + return { ...model, workers, doneCount: wasTerminal ? model.doneCount : model.doneCount + 1 }; + } + case 'worker.failed': { + const workers = new Map(model.workers); + const w = workers.get(event.id); + if (w === undefined) return model; + const wasTerminal = w.status === 'done' || w.status === 'failed'; + workers.set(event.id, { ...w, status: 'failed', latestActivity: undefined, error: event.error }); + return { ...model, workers, failedCount: wasTerminal ? model.failedCount : model.failedCount + 1 }; + } + default: + return model; + } +} + +export function workerActivityFromTool(name: string, args: Record): string { + const s = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); + switch (name) { + case 'Read': { + const p = s(args['path']); + return p !== undefined ? `read ${p}` : 'read'; + } + case 'Grep': { + const p = s(args['pattern']); + return p !== undefined ? `grep "${p}"` : 'grep'; + } + case 'Glob': { + const p = s(args['pattern']); + return p !== undefined ? `glob ${p}` : 'glob'; + } + case 'WebSearch': { + const q = s(args['query']); + return q !== undefined ? `search "${q}"` : 'search'; + } + case 'FetchURL': { + const u = s(args['url']); + return u !== undefined ? `fetch ${u}` : 'fetch'; + } + default: + return name; + } +} diff --git a/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts new file mode 100644 index 00000000..f5665727 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { + applySwarmEvent, + initialSwarmModel, + workerActivityFromTool, + type SwarmModel, +} from '#/tui/components/messages/swarm-dashboard-model'; + +function reduce(events: Parameters[1][]): SwarmModel { + return events.reduce((m, e) => applySwarmEvent(m, e), initialSwarmModel('do a task')); +} + +describe('applySwarmEvent', () => { + it('starts in planning phase with the task', () => { + const m = initialSwarmModel('my task'); + expect(m.task).toBe('my task'); + expect(m.phase).toBe('planning'); + expect(m.workers.size).toBe(0); + }); + + it('planned sets total and moves to working', () => { + const m = reduce([{ t: 'planned', total: 5 }]); + expect(m.phase).toBe('working'); + expect(m.total).toBe(5); + }); + + it('builds a worker row on spawn and tracks activity + count', () => { + const m = reduce([ + { t: 'planned', total: 2 }, + { t: 'worker.spawned', id: 'a1', role: 'Researcher' }, + { t: 'worker.toolcall', id: 'a1', activity: 'read foo.ts' }, + { t: 'worker.toolcall', id: 'a1', activity: 'grep "x"' }, + ]); + const w = m.workers.get('a1'); + expect(w?.role).toBe('Researcher'); + expect(w?.status).toBe('running'); + expect(w?.toolCount).toBe(2); + expect(w?.latestActivity).toBe('grep "x"'); + }); + + it('marks workers done/failed and counts them', () => { + const m = reduce([ + { t: 'planned', total: 2 }, + { t: 'worker.spawned', id: 'a1', role: 'R' }, + { t: 'worker.spawned', id: 'a2', role: 'A' }, + { t: 'worker.done', id: 'a1', tokens: 2100 }, + { t: 'worker.failed', id: 'a2', error: 'timeout' }, + ]); + expect(m.workers.get('a1')?.status).toBe('done'); + expect(m.workers.get('a1')?.tokens).toBe(2100); + expect(m.workers.get('a2')?.status).toBe('failed'); + expect(m.workers.get('a2')?.error).toBe('timeout'); + expect(m.doneCount).toBe(1); + expect(m.failedCount).toBe(1); + }); + + it('synthesizing then done set the phase', () => { + const m = reduce([{ t: 'planned', total: 1 }, { t: 'synthesizing' }, { t: 'done', succeeded: 1, failed: 0 }]); + expect(m.phase).toBe('done'); + }); + + it('cancelled sets the phase', () => { + const m = reduce([{ t: 'planned', total: 1 }, { t: 'cancelled' }]); + expect(m.phase).toBe('cancelled'); + }); + + it('clamps a worker that finishes without an explicit running transition', () => { + const m = reduce([{ t: 'worker.spawned', id: 'a1', role: 'R' }, { t: 'worker.done', id: 'a1' }]); + expect(m.workers.get('a1')?.status).toBe('done'); + }); +}); + +describe('workerActivityFromTool', () => { + it('formats common tools compactly', () => { + expect(workerActivityFromTool('Read', { path: 'a/b.ts' })).toBe('read a/b.ts'); + expect(workerActivityFromTool('Grep', { pattern: 'foo' })).toBe('grep "foo"'); + expect(workerActivityFromTool('Glob', { pattern: '*.ts' })).toBe('glob *.ts'); + expect(workerActivityFromTool('WebSearch', { query: 'kimi' })).toBe('search "kimi"'); + expect(workerActivityFromTool('FetchURL', { url: 'http://x' })).toBe('fetch http://x'); + }); + it('falls back to the tool name', () => { + expect(workerActivityFromTool('Mystery', {})).toBe('Mystery'); + }); +}); From 3475837c9df466db987afdd3d016118252d297f6 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 18:32:35 +0800 Subject: [PATCH 09/28] feat(tui): add SwarmDashboardComponent --- .../components/messages/swarm-dashboard.ts | 136 ++++++++++++++++++ .../messages/swarm-dashboard.test.ts | 50 +++++++ 2 files changed, 186 insertions(+) create mode 100644 apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts create mode 100644 apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts diff --git a/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts b/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts new file mode 100644 index 00000000..77911cdf --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts @@ -0,0 +1,136 @@ +import type { TUI } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { BRAILLE_SPINNER_FRAMES } from '#/tui/constant/rendering'; +import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; + +import { + applySwarmEvent, + initialSwarmModel, + type SwarmEvent, + type SwarmModel, + type WorkerRow, +} from './swarm-dashboard-model'; + +const THROTTLE_MS = 200; + +export class SwarmDashboardComponent extends Container { + private model: SwarmModel; + private readonly headerText: Text; + private readonly bodyContainer: Container; + private throttleTimer: ReturnType | null = null; + private pendingFlush = false; + private spinnerFrame = 0; + + constructor( + task: string, + private readonly colors: ColorPalette, + private readonly ui: TUI | undefined, + ) { + super(); + this.model = initialSwarmModel(task); + this.addChild(new Spacer(1)); + this.headerText = new Text('', 0, 0); + this.addChild(this.headerText); + this.bodyContainer = new Container(); + this.addChild(this.bodyContainer); + this.flush(); + } + + apply(event: SwarmEvent): void { + const prevPhase = this.model.phase; + this.model = applySwarmEvent(this.model, event); + if (this.model.phase !== prevPhase) { + this.flush(); + return; + } + this.schedule(); + } + + private schedule(): void { + this.pendingFlush = true; + if (this.throttleTimer !== null) return; + this.throttleTimer = setTimeout(() => { + this.throttleTimer = null; + this.flush(); + }, THROTTLE_MS); + } + + /** + * Always renders the current model. A throttled update may be queued for the + * live UI; render must not show stale rows, so flush any pending state first. + */ + override render(width: number): string[] { + if (this.pendingFlush) this.flush(); + return super.render(width); + } + + private flush(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + this.pendingFlush = false; + this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + this.headerText.setText(this.buildHeader()); + this.bodyContainer.clear(); + for (const w of this.model.workers.values()) { + this.bodyContainer.addChild(new Text(this.buildWorkerLine(w), 0, 0)); + } + this.invalidate(); + this.ui?.requestRender(); + } + + private spinner(): string { + return BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]!; + } + + private buildHeader(): string { + const c = this.colors; + const m = this.model; + const title = m.task.length > 56 ? `${m.task.slice(0, 56)}…` : m.task; + if (m.phase === 'done' || m.phase === 'cancelled') { + const bullet = chalk.hex(c.success)(STATUS_BULLET); + const tag = m.phase === 'cancelled' ? ' · cancelled' : ''; + const summary = `${String(m.workers.size)} workers · ${String(m.doneCount)}✓ ${String(m.failedCount)}✗${tag}`; + return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)} ${chalk.dim(`· ${summary}`)}`; + } + const phases = [ + `Plan ${m.phase === 'planning' ? this.spinner() : '✓'}`, + `Workers ${String(m.doneCount + m.failedCount)}/${String(m.total)}`, + `Synthesize ${m.phase === 'synthesizing' ? this.spinner() : m.phase === 'working' || m.phase === 'planning' ? '·' : '✓'}`, + ].join(' '); + const bullet = chalk.hex(c.roleAssistant)(STATUS_BULLET); + return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)}\n ${chalk.dim(phases)}`; + } + + private buildWorkerLine(w: WorkerRow): string { + const c = this.colors; + const role = chalk.hex(c.primary)(w.role); + if (w.status === 'failed') { + return ` ${chalk.hex(c.error)(FAILURE_MARK)}${role} ${chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`)}`; + } + if (w.status === 'done') { + const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : ''; + return ` ${chalk.hex(c.success)('✓ ')}${role} ${chalk.dim(`${String(w.toolCount)} calls${tok}`)}`; + } + if (w.status === 'retrying') { + return ` ${chalk.hex(c.roleAssistant)('⟳ ')}${role} ${chalk.dim('retrying…')}`; + } + const activity = w.latestActivity ?? 'starting…'; + return ` ${chalk.hex(c.roleAssistant)(this.spinner())} ${role} ${chalk.dim(`now: ${activity}`)}`; + } + + dispose(): void { + if (this.throttleTimer !== null) { + clearTimeout(this.throttleTimer); + this.throttleTimer = null; + } + } +} + +function formatTokens(n: number): string { + return n >= 1000 ? `${(n / 1000).toFixed(1)}k tok` : `${String(n)} tok`; +} diff --git a/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts new file mode 100644 index 00000000..a6dfee46 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { SwarmDashboardComponent } from '#/tui/components/messages/swarm-dashboard'; +import { darkColors } from '#/tui/theme/colors'; + +const ESC = String.fromCodePoint(0x1b); +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, '').replaceAll(new RegExp(`${ESC}\\][0-9];;[^\\u0007]*\\u0007`, 'g'), ''); +} + +describe('SwarmDashboardComponent', () => { + it('renders phase header and worker rows', () => { + const c = new SwarmDashboardComponent('compare error handling', darkColors, undefined); + c.apply({ t: 'planned', total: 2 }); + c.apply({ t: 'worker.spawned', id: 'a1', role: 'Researcher' }); + c.apply({ t: 'worker.toolcall', id: 'a1', activity: 'read foo.ts' }); + c.apply({ t: 'worker.spawned', id: 'a2', role: 'Analyst' }); + c.apply({ t: 'worker.done', id: 'a2', tokens: 1800 }); + + const out = strip(c.render(80).join('\n')); + expect(out).toContain('Swarm'); + expect(out).toContain('compare error handling'); + expect(out).toContain('Researcher'); + expect(out).toContain('read foo.ts'); + expect(out).toContain('Analyst'); + }); + + it('shows a failed worker with its error', () => { + const c = new SwarmDashboardComponent('t', darkColors, undefined); + c.apply({ t: 'planned', total: 1 }); + c.apply({ t: 'worker.spawned', id: 'a1', role: 'Scan' }); + c.apply({ t: 'worker.failed', id: 'a1', error: 'timeout' }); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('Scan'); + expect(out).toContain('timeout'); + }); + + it('finalizes to a summary header on done', () => { + const c = new SwarmDashboardComponent('t', darkColors, undefined); + c.apply({ t: 'planned', total: 2 }); + c.apply({ t: 'worker.spawned', id: 'a1', role: 'R' }); + c.apply({ t: 'worker.done', id: 'a1' }); + c.apply({ t: 'worker.spawned', id: 'a2', role: 'A' }); + c.apply({ t: 'worker.failed', id: 'a2', error: 'x' }); + c.apply({ t: 'done', succeeded: 1, failed: 1 }); + const out = strip(c.render(80).join('\n')); + expect(out).toMatch(/2 workers/); + expect(out).toContain('1'); + }); +}); From 7ed20f374905b04b9937061423533614251bd096 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 18:35:18 +0800 Subject: [PATCH 10/28] feat(agent-core): emit structured swarm progress (planned/synthesizing/done) --- .../agent-core/src/agent/swarm/coordinator.ts | 11 ++++++++++- packages/agent-core/src/agent/swarm/types.ts | 6 ++++++ .../src/tools/builtin/collaboration/swarm.ts | 2 ++ .../agent-core/test/swarm/coordinator.test.ts | 15 +++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/agent/swarm/coordinator.ts b/packages/agent-core/src/agent/swarm/coordinator.ts index 6b9016e6..4d2d4fff 100644 --- a/packages/agent-core/src/agent/swarm/coordinator.ts +++ b/packages/agent-core/src/agent/swarm/coordinator.ts @@ -9,7 +9,7 @@ import { renderPlannerRetryPrompt, renderSynthesizerPrompt, } from './prompts'; -import type { SwarmCoordinatorDeps, SwarmPlan } from './types'; +import type { SwarmCoordinatorDeps, SwarmPlan, SwarmProgress } from './types'; export class SwarmCoordinator { constructor(private readonly deps: SwarmCoordinatorDeps) {} @@ -18,14 +18,20 @@ export class SwarmCoordinator { this.deps.onProgress?.(text); } + private emit(progress: SwarmProgress): void { + this.deps.onProgressCustom?.(progress); + } + async run(rootTask: string): Promise { this.deps.signal.throwIfAborted(); this.progress('Planning subtasks…'); const plan = await this.decompose(rootTask); this.progress(`Planned ${String(plan.subtasks.length)} subtasks`); + this.emit({ phase: 'planned', total: plan.subtasks.length }); await this.runWave(plan); + this.emit({ phase: 'synthesizing' }); this.progress('Synthesizing results…'); const result = await this.deps.spawnSubagent({ profileName: 'swarm-synthesizer', @@ -35,6 +41,9 @@ export class SwarmCoordinator { description: 'Swarm synthesizer', signal: this.deps.signal, }); + const succeeded = plan.subtasks.filter((s) => s.status === 'done').length; + const failed = plan.subtasks.filter((s) => s.status === 'failed').length; + this.emit({ phase: 'done', succeeded, failed }); return result.result; } diff --git a/packages/agent-core/src/agent/swarm/types.ts b/packages/agent-core/src/agent/swarm/types.ts index e15c3668..23b40227 100644 --- a/packages/agent-core/src/agent/swarm/types.ts +++ b/packages/agent-core/src/agent/swarm/types.ts @@ -24,9 +24,15 @@ export type SpawnSubagentFn = (args: { signal: AbortSignal; }) => Promise<{ result: string }>; +export type SwarmProgress = + | { phase: 'planned'; total: number } + | { phase: 'synthesizing' } + | { phase: 'done'; succeeded: number; failed: number }; + export interface SwarmCoordinatorDeps { spawnSubagent: SpawnSubagentFn; signal: AbortSignal; onProgress?: ((text: string) => void) | undefined; + onProgressCustom?: ((progress: SwarmProgress) => void) | undefined; maxConcurrency?: number | undefined; } diff --git a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts index 2ab1f0c4..d1efed42 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts @@ -65,6 +65,8 @@ export class SwarmTool implements BuiltinTool { signal: ctx.signal, maxConcurrency: DEFAULT_MAX_CONCURRENCY, onProgress: (text) => ctx.onUpdate?.({ kind: 'status', text }), + onProgressCustom: (progress) => + ctx.onUpdate?.({ kind: 'custom', customKind: 'swarm', customData: progress }), spawnSubagent: async ({ profileName, systemPrompt, tools, prompt, description, signal }) => { const handle = await this.subagentHost.spawn(profileName, { parentToolCallId: ctx.toolCallId, diff --git a/packages/agent-core/test/swarm/coordinator.test.ts b/packages/agent-core/test/swarm/coordinator.test.ts index 756fb069..ac9d512f 100644 --- a/packages/agent-core/test/swarm/coordinator.test.ts +++ b/packages/agent-core/test/swarm/coordinator.test.ts @@ -98,6 +98,21 @@ describe('SwarmCoordinator.run', () => { expect(worker?.tools).toEqual(['Read']); }); + it('emits structured progress: planned(total) → synthesizing → done', async () => { + const spawn = makeSpawner({}); + const onProgressCustom = vi.fn(); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + onProgressCustom, + }); + await coordinator.run('do a thing'); + const payloads = (onProgressCustom as ReturnType).mock.calls.map((c) => c[0]); + expect(payloads).toContainEqual({ phase: 'planned', total: 2 }); + expect(payloads).toContainEqual({ phase: 'synthesizing' }); + expect(payloads.some((p) => p.phase === 'done' && p.succeeded === 2 && p.failed === 0)).toBe(true); + }); + it('propagates abort instead of swallowing it (no synthesis after cancel)', async () => { const controller = new AbortController(); const PLAN = JSON.stringify({ subtasks: [{ role: 'A', systemPrompt: 's', prompt: 'p' }] }); From 03e49e564081931f280dca22b9e6d6b4e589955c Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 18:43:18 +0800 Subject: [PATCH 11/28] feat(tui): render swarm runs as a live dashboard instead of nested tool calls --- .../tui/controllers/session-event-handler.ts | 47 +++++++++ .../src/tui/controllers/streaming-ui.ts | 15 +++ .../components/messages/swarm-routing.test.ts | 96 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 0e310ba9..ab05e3d9 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -33,6 +33,7 @@ import type { import { MoonLoader } from '../components/chrome/moon-loader'; import { StatusMessageComponent } from '../components/messages/status-message'; +import { workerActivityFromTool } from '../components/messages/swarm-dashboard-model'; import { MAIN_AGENT_ID, OAUTH_LOGIN_REQUIRED_CODE, @@ -232,6 +233,19 @@ export class SessionEventHandler { if (info === undefined || info.parentToolCallId.length === 0) return true; const { parentToolCallId } = info; const sourceName = info.name; + + const swarmDash = streamingUI.getSwarmDashboard(parentToolCallId); + if (swarmDash !== undefined) { + if (event.type === 'tool.call.started') { + swarmDash.apply({ + t: 'worker.toolcall', + id: subagentId, + activity: workerActivityFromTool(event.name, argsRecord(event.args)), + }); + } + return true; // swarm worker events never fall through to appendSubToolCall + } + const toolCall = streamingUI.getToolComponent(parentToolCallId); if (toolCall === undefined) return true; toolCall.setSubagentMeta(subagentId, sourceName); @@ -483,6 +497,15 @@ export class SessionEventHandler { } private handleToolProgress(event: ToolProgressEvent): void { + if (event.update.kind === 'custom' && event.update.customKind === 'swarm') { + const dash = this.host.streamingUI.getSwarmDashboard(event.toolCallId); + if (dash === undefined) return; + const p = event.update.customData as { phase?: string; total?: number }; + if (p.phase === 'planned' && typeof p.total === 'number') dash.apply({ t: 'planned', total: p.total }); + else if (p.phase === 'synthesizing') dash.apply({ t: 'synthesizing' }); + else if (p.phase === 'done') dash.apply({ t: 'done', succeeded: 0, failed: 0 }); + return; + } if (event.update.kind !== 'status') return; const text = event.update.text; if (text === undefined || text.length === 0) return; @@ -695,6 +718,16 @@ export class SessionEventHandler { name: event.subagentName, }); + const swarmDash = streamingUI.getSwarmDashboard(event.parentToolCallId); + if (swarmDash !== undefined) { + swarmDash.apply({ + t: 'worker.spawned', + id: event.subagentId, + role: event.description ?? event.subagentName, + }); + return; + } + if (event.runInBackground) { const meta = this.buildBackgroundAgentMetadata(event); this.backgroundAgentMetadata.set(event.subagentId, meta); @@ -738,6 +771,15 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); return; } + const swarmDashC = streamingUI.getSwarmDashboard(event.parentToolCallId); + if (swarmDashC !== undefined) { + swarmDashC.apply({ + t: 'worker.done', + id: event.subagentId, + ...(event.contextTokens !== undefined ? { tokens: event.contextTokens } : {}), + }); + return; + } const tc = streamingUI.getToolComponent(event.parentToolCallId); if (tc === undefined) return; tc.onSubagentCompleted({ @@ -764,6 +806,11 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); return; } + const swarmDashF = streamingUI.getSwarmDashboard(event.parentToolCallId); + if (swarmDashF !== undefined) { + swarmDashF.apply({ t: 'worker.failed', id: event.subagentId, error: event.error }); + return; + } const tc = streamingUI.getToolComponent(event.parentToolCallId); if (tc === undefined) return; tc.onSubagentFailed({ error: event.error }); diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 769df376..31e485fb 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -4,6 +4,7 @@ import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; import { CompactionComponent } from '../components/dialogs/compaction'; import { ReadGroupComponent } from '../components/messages/read-group'; +import { SwarmDashboardComponent } from '../components/messages/swarm-dashboard'; import { ThinkingComponent } from '../components/messages/thinking'; import { ToolCallComponent } from '../components/messages/tool-call'; import { STREAMING_UI_FLUSH_MS } from '../constant/streaming'; @@ -60,6 +61,7 @@ export class StreamingUIController { { name?: string; argumentsText: string; startedAtMs: number } >(); private _pendingToolComponents = new Map(); + private readonly _swarmDashboards = new Map(); private _pendingAgentGroup: { readonly turnId: string | undefined; readonly step: number; @@ -156,6 +158,10 @@ export class StreamingUIController { return this._pendingToolComponents.get(id); } + getSwarmDashboard(toolCallId: string): SwarmDashboardComponent | undefined { + return this._swarmDashboards.get(toolCallId); + } + removeToolComponent(id: string): void { this._pendingToolComponents.delete(id); } @@ -503,6 +509,15 @@ export class StreamingUIController { onToolCallStart(toolCall: ToolCallBlockData): void { if (toolCall.name === 'AskUserQuestion') return; + if (toolCall.name === 'Swarm') { + const task = typeof toolCall.args['task'] === 'string' ? toolCall.args['task'] : ''; + const dash = new SwarmDashboardComponent(task, this.host.state.theme.colors, this.host.state.ui); + this._swarmDashboards.set(toolCall.id, dash); + this.host.state.transcriptContainer.addChild(dash); + this.host.state.ui.requestRender(); + return; + } + const { state } = this.host; const tc = new ToolCallComponent( toolCall, diff --git a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts new file mode 100644 index 00000000..a1e5e2d8 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import type { Event } from '@moonshot-ai/kimi-code-sdk'; + +import { SessionEventHandler, type SessionEventHost } from '#/tui/controllers/session-event-handler'; +import { SwarmDashboardComponent } from '#/tui/components/messages/swarm-dashboard'; +import { workerActivityFromTool } from '#/tui/components/messages/swarm-dashboard-model'; +import { darkColors } from '#/tui/theme/colors'; + +const strip = (t: string): string => t.replaceAll(/\[[0-9;]*m/g, ''); + +describe('swarm dashboard wiring (translation)', () => { + it('produces the expected dashboard from a worker lifecycle sequence', () => { + const dash = new SwarmDashboardComponent('task', darkColors, undefined); + dash.apply({ t: 'planned', total: 2 }); + dash.apply({ t: 'worker.spawned', id: 's1', role: 'Researcher' }); + dash.apply({ t: 'worker.toolcall', id: 's1', activity: workerActivityFromTool('Read', { path: 'a.ts' }) }); + dash.apply({ t: 'worker.done', id: 's1', tokens: 2100 }); + dash.apply({ t: 'worker.spawned', id: 's2', role: 'Analyst' }); + dash.apply({ t: 'worker.failed', id: 's2', error: 'timeout' }); + dash.apply({ t: 'done', succeeded: 1, failed: 1 }); + const out = strip(dash.render(80).join('\n')); + expect(out).toContain('Researcher'); + expect(out).toContain('Analyst'); + expect(out).toContain('timeout'); + expect(out).toMatch(/2 workers/); + }); + + it('routes live swarm events through SessionEventHandler into the dashboard', () => { + const parentToolCallId = 'tc-swarm'; + const dash = new SwarmDashboardComponent('task', darkColors, undefined); + const mockHost = { + streamingUI: { + setTurnId: (): void => {}, + getSwarmDashboard: (id: string): SwarmDashboardComponent | undefined => + id === parentToolCallId ? dash : undefined, + getToolComponent: (): undefined => undefined, + }, + } as unknown as SessionEventHost; + const handler = new SessionEventHandler(mockHost); + const noop = (): void => {}; + + handler.handleEvent( + { + type: 'tool.progress', + agentId: 'main', + sessionId: 's', + turnId: 1, + toolCallId: parentToolCallId, + update: { kind: 'custom', customKind: 'swarm', customData: { phase: 'planned', total: 1 } }, + } as unknown as Event, + noop, + ); + handler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 's', + subagentId: 'w1', + subagentName: 'explore', + parentToolCallId, + description: 'Researcher', + runInBackground: false, + } as unknown as Event, + noop, + ); + handler.handleEvent( + { + type: 'tool.call.started', + agentId: 'w1', + sessionId: 's', + turnId: 1, + toolCallId: 'inner-1', + name: 'Read', + args: { path: 'x.ts' }, + } as unknown as Event, + noop, + ); + handler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 's', + subagentId: 'w1', + parentToolCallId, + error: 'boom', + } as unknown as Event, + noop, + ); + + const out = strip(dash.render(80).join('\n')); + expect(out).toContain('Researcher'); + expect(out).toContain('boom'); + expect(out).toMatch(/Workers 1\/1/); + }); +}); From 81749b9c51ee1d96b883db2022f2f7857ba40e3e Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 18:54:54 +0800 Subject: [PATCH 12/28] fix(tui): count only workers in swarm dashboard, finalize on cancel, clean up on reset --- .../components/messages/swarm-dashboard.ts | 5 +- .../tui/controllers/session-event-handler.ts | 19 +++-- .../src/tui/controllers/streaming-ui.ts | 29 ++++++- .../messages/swarm-dashboard.test.ts | 11 +++ .../components/messages/swarm-routing.test.ts | 78 ++++++++++++++++++- 5 files changed, 132 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts b/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts index 77911cdf..c066bac1 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts @@ -15,6 +15,8 @@ import { } from './swarm-dashboard-model'; const THROTTLE_MS = 200; +/** Keeps a running worker's activity to a single dashboard line. */ +const ACTIVITY_MAX_LENGTH = 48; export class SwarmDashboardComponent extends Container { private model: SwarmModel; @@ -119,7 +121,8 @@ export class SwarmDashboardComponent extends Container { if (w.status === 'retrying') { return ` ${chalk.hex(c.roleAssistant)('⟳ ')}${role} ${chalk.dim('retrying…')}`; } - const activity = w.latestActivity ?? 'starting…'; + const raw = w.latestActivity ?? 'starting…'; + const activity = raw.length > ACTIVITY_MAX_LENGTH ? `${raw.slice(0, ACTIVITY_MAX_LENGTH)}…` : raw; return ` ${chalk.hex(c.roleAssistant)(this.spinner())} ${role} ${chalk.dim(`now: ${activity}`)}`; } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index ab05e3d9..32a6f12b 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -720,11 +720,20 @@ export class SessionEventHandler { const swarmDash = streamingUI.getSwarmDashboard(event.parentToolCallId); if (swarmDash !== undefined) { - swarmDash.apply({ - t: 'worker.spawned', - id: event.subagentId, - role: event.description ?? event.subagentName, - }); + // Only real workers (profile `swarm:`) become dashboard rows. The + // planner (`swarm-planner`, plus a possible retry) and synthesizer + // (`swarm-synthesizer`) share the same parent tool-call id but must not + // appear as workers or inflate the worker counts. They still have a + // ToolCallComponent-less parent, so any non-worker subagent under a swarm + // dashboard returns without falling through to the foreground path. + const workerPrefix = 'swarm:'; + if (event.subagentName.startsWith(workerPrefix)) { + swarmDash.apply({ + t: 'worker.spawned', + id: event.subagentId, + role: event.description ?? event.subagentName.slice(workerPrefix.length), + }); + } return; } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 31e485fb..6136b0d7 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -252,6 +252,7 @@ export class StreamingUIController { for (const toolCallId of completedToolCallIds) { this._pendingToolComponents.delete(toolCallId); } + this.disposeAndClearSwarmDashboards(); this._pendingAgentGroup = null; this._pendingReadGroup = null; this._currentTurnId = undefined; @@ -399,10 +400,18 @@ export class StreamingUIController { this.clearFlushTimerIfIdle(); this._streamingToolCallArguments.clear(); this.disposeAndClearPendingToolComponents(); + this.disposeAndClearSwarmDashboards(); this._pendingAgentGroup = null; this._pendingReadGroup = null; } + private disposeAndClearSwarmDashboards(): void { + for (const dash of this._swarmDashboards.values()) { + dash.dispose(); + } + this._swarmDashboards.clear(); + } + resetToolCallState(): void { this._activeToolCalls.clear(); } @@ -509,6 +518,12 @@ export class StreamingUIController { onToolCallStart(toolCall: ToolCallBlockData): void { if (toolCall.name === 'AskUserQuestion') return; + // A tool call of any other kind breaks an in-flight Agent/Read run, so the + // pending groups are reset here — before the Swarm early-return — to avoid + // a Swarm call between Agent/Read calls leaving a stale pending group. + if (toolCall.name !== 'Agent') this._pendingAgentGroup = null; + if (toolCall.name !== 'Read') this._pendingReadGroup = null; + if (toolCall.name === 'Swarm') { const task = typeof toolCall.args['task'] === 'string' ? toolCall.args['task'] : ''; const dash = new SwarmDashboardComponent(task, this.host.state.theme.colors, this.host.state.ui); @@ -531,9 +546,6 @@ export class StreamingUIController { if (state.planExpanded) tc.setPlanExpanded(true); this._pendingToolComponents.set(toolCall.id, tc); - if (toolCall.name !== 'Agent') this._pendingAgentGroup = null; - if (toolCall.name !== 'Read') this._pendingReadGroup = null; - let handled = this.tryAttachAgentToolCall(toolCall, tc); if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc); if (!handled) { @@ -557,6 +569,17 @@ export class StreamingUIController { onToolCallEnd(toolCallId: string, result: ToolResultBlockData): void { const { state } = this.host; const matchedCall = this._activeToolCalls.get(toolCallId); + + // A Swarm call that ends in error (abort/throw) never emits the `done` + // custom progress, so its dashboard would otherwise stay stuck on a + // spinner. Finalize it as cancelled. A non-error end means the coordinator + // already emitted `done`, leaving the dashboard terminal — leave it alone. + const swarmDash = this._swarmDashboards.get(toolCallId); + if (swarmDash !== undefined) { + if (result.is_error === true) swarmDash.apply({ t: 'cancelled' }); + return; + } + const tc = this._pendingToolComponents.get(toolCallId); if (tc) { tc.setResult(result); diff --git a/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts index a6dfee46..cc1bc863 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts @@ -35,6 +35,17 @@ describe('SwarmDashboardComponent', () => { expect(out).toContain('timeout'); }); + it('finalizes to a cancelled header on cancel', () => { + const c = new SwarmDashboardComponent('t', darkColors, undefined); + c.apply({ t: 'planned', total: 2 }); + c.apply({ t: 'worker.spawned', id: 'a1', role: 'R' }); + c.apply({ t: 'worker.done', id: 'a1' }); + c.apply({ t: 'worker.spawned', id: 'a2', role: 'A' }); + c.apply({ t: 'cancelled' }); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('cancelled'); + }); + it('finalizes to a summary header on done', () => { const c = new SwarmDashboardComponent('t', darkColors, undefined); c.apply({ t: 'planned', total: 2 }); diff --git a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts index a1e5e2d8..df9a290f 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts @@ -57,7 +57,7 @@ describe('swarm dashboard wiring (translation)', () => { agentId: 'main', sessionId: 's', subagentId: 'w1', - subagentName: 'explore', + subagentName: 'swarm:Researcher', parentToolCallId, description: 'Researcher', runInBackground: false, @@ -93,4 +93,80 @@ describe('swarm dashboard wiring (translation)', () => { expect(out).toContain('boom'); expect(out).toMatch(/Workers 1\/1/); }); + + it('counts only real workers — planner/synthesizer/retry never become rows', () => { + const parentToolCallId = 'tc-swarm'; + const dash = new SwarmDashboardComponent('task', darkColors, undefined); + const mockHost = { + streamingUI: { + setTurnId: (): void => {}, + getSwarmDashboard: (id: string): SwarmDashboardComponent | undefined => + id === parentToolCallId ? dash : undefined, + getToolComponent: (): undefined => undefined, + }, + } as unknown as SessionEventHost; + const handler = new SessionEventHandler(mockHost); + const noop = (): void => {}; + + const spawn = (subagentId: string, subagentName: string, description: string): void => { + handler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 's', + subagentId, + subagentName, + parentToolCallId, + description, + runInBackground: false, + } as unknown as Event, + noop, + ); + }; + const complete = (subagentId: string): void => { + handler.handleEvent( + { + type: 'subagent.completed', + agentId: 'main', + sessionId: 's', + subagentId, + parentToolCallId, + resultSummary: 'ok', + } as unknown as Event, + noop, + ); + }; + + // Coordinator order: planner, two workers, synthesizer — all under the + // same parent tool-call id. Only the two `swarm:` workers are rows. + spawn('p1', 'swarm-planner', 'Swarm planner'); + spawn('w1', 'swarm:Researcher', 'Researcher'); + spawn('w2', 'swarm:Analyst', 'Analyst'); + spawn('synth', 'swarm-synthesizer', 'Swarm synthesizer'); + + complete('p1'); + complete('w1'); + complete('w2'); + complete('synth'); + + // The Swarm tool's custom `done` progress finalizes the dashboard. + handler.handleEvent( + { + type: 'tool.progress', + agentId: 'main', + sessionId: 's', + turnId: 1, + toolCallId: parentToolCallId, + update: { kind: 'custom', customKind: 'swarm', customData: { phase: 'done', succeeded: 2, failed: 0 } }, + } as unknown as Event, + noop, + ); + + const out = strip(dash.render(80).join('\n')); + expect(out).toContain('Researcher'); + expect(out).toContain('Analyst'); + expect(out).not.toContain('planner'); + expect(out).not.toContain('synthesizer'); + expect(out).toContain('2 workers · 2✓ 0✗'); + }); }); From e8733709202b893a08ee08a1f3f7c4844bd25117 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 19:24:46 +0800 Subject: [PATCH 13/28] fix(tui): render swarm via the managed tool-call lifecycle to stop duplicate cards --- .../components/messages/swarm-dashboard.ts | 139 ---------------- .../src/tui/components/messages/tool-call.ts | 149 +++++++++++++++++- .../tui/controllers/session-event-handler.ts | 54 +++---- .../src/tui/controllers/streaming-ui.ts | 40 +---- .../messages/swarm-dashboard.test.ts | 61 ------- .../components/messages/swarm-routing.test.ts | 36 +++-- .../messages/tool-call-swarm.test.ts | 113 +++++++++++++ 7 files changed, 312 insertions(+), 280 deletions(-) delete mode 100644 apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts delete mode 100644 apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts create mode 100644 apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts diff --git a/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts b/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts deleted file mode 100644 index c066bac1..00000000 --- a/apps/kimi-code/src/tui/components/messages/swarm-dashboard.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import { BRAILLE_SPINNER_FRAMES } from '#/tui/constant/rendering'; -import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; -import type { ColorPalette } from '#/tui/theme/colors'; - -import { - applySwarmEvent, - initialSwarmModel, - type SwarmEvent, - type SwarmModel, - type WorkerRow, -} from './swarm-dashboard-model'; - -const THROTTLE_MS = 200; -/** Keeps a running worker's activity to a single dashboard line. */ -const ACTIVITY_MAX_LENGTH = 48; - -export class SwarmDashboardComponent extends Container { - private model: SwarmModel; - private readonly headerText: Text; - private readonly bodyContainer: Container; - private throttleTimer: ReturnType | null = null; - private pendingFlush = false; - private spinnerFrame = 0; - - constructor( - task: string, - private readonly colors: ColorPalette, - private readonly ui: TUI | undefined, - ) { - super(); - this.model = initialSwarmModel(task); - this.addChild(new Spacer(1)); - this.headerText = new Text('', 0, 0); - this.addChild(this.headerText); - this.bodyContainer = new Container(); - this.addChild(this.bodyContainer); - this.flush(); - } - - apply(event: SwarmEvent): void { - const prevPhase = this.model.phase; - this.model = applySwarmEvent(this.model, event); - if (this.model.phase !== prevPhase) { - this.flush(); - return; - } - this.schedule(); - } - - private schedule(): void { - this.pendingFlush = true; - if (this.throttleTimer !== null) return; - this.throttleTimer = setTimeout(() => { - this.throttleTimer = null; - this.flush(); - }, THROTTLE_MS); - } - - /** - * Always renders the current model. A throttled update may be queued for the - * live UI; render must not show stale rows, so flush any pending state first. - */ - override render(width: number): string[] { - if (this.pendingFlush) this.flush(); - return super.render(width); - } - - private flush(): void { - if (this.throttleTimer !== null) { - clearTimeout(this.throttleTimer); - this.throttleTimer = null; - } - this.pendingFlush = false; - this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; - this.headerText.setText(this.buildHeader()); - this.bodyContainer.clear(); - for (const w of this.model.workers.values()) { - this.bodyContainer.addChild(new Text(this.buildWorkerLine(w), 0, 0)); - } - this.invalidate(); - this.ui?.requestRender(); - } - - private spinner(): string { - return BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]!; - } - - private buildHeader(): string { - const c = this.colors; - const m = this.model; - const title = m.task.length > 56 ? `${m.task.slice(0, 56)}…` : m.task; - if (m.phase === 'done' || m.phase === 'cancelled') { - const bullet = chalk.hex(c.success)(STATUS_BULLET); - const tag = m.phase === 'cancelled' ? ' · cancelled' : ''; - const summary = `${String(m.workers.size)} workers · ${String(m.doneCount)}✓ ${String(m.failedCount)}✗${tag}`; - return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)} ${chalk.dim(`· ${summary}`)}`; - } - const phases = [ - `Plan ${m.phase === 'planning' ? this.spinner() : '✓'}`, - `Workers ${String(m.doneCount + m.failedCount)}/${String(m.total)}`, - `Synthesize ${m.phase === 'synthesizing' ? this.spinner() : m.phase === 'working' || m.phase === 'planning' ? '·' : '✓'}`, - ].join(' '); - const bullet = chalk.hex(c.roleAssistant)(STATUS_BULLET); - return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)}\n ${chalk.dim(phases)}`; - } - - private buildWorkerLine(w: WorkerRow): string { - const c = this.colors; - const role = chalk.hex(c.primary)(w.role); - if (w.status === 'failed') { - return ` ${chalk.hex(c.error)(FAILURE_MARK)}${role} ${chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`)}`; - } - if (w.status === 'done') { - const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : ''; - return ` ${chalk.hex(c.success)('✓ ')}${role} ${chalk.dim(`${String(w.toolCount)} calls${tok}`)}`; - } - if (w.status === 'retrying') { - return ` ${chalk.hex(c.roleAssistant)('⟳ ')}${role} ${chalk.dim('retrying…')}`; - } - const raw = w.latestActivity ?? 'starting…'; - const activity = raw.length > ACTIVITY_MAX_LENGTH ? `${raw.slice(0, ACTIVITY_MAX_LENGTH)}…` : raw; - return ` ${chalk.hex(c.roleAssistant)(this.spinner())} ${role} ${chalk.dim(`now: ${activity}`)}`; - } - - dispose(): void { - if (this.throttleTimer !== null) { - clearTimeout(this.throttleTimer); - this.throttleTimer = null; - } - } -} - -function formatTokens(n: number): string { - return n >= 1000 ? `${(n / 1000).toFixed(1)}k tok` : `${String(n)} tok`; -} diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 6887070b..eca1eef8 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -11,12 +11,12 @@ import chalk from 'chalk'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; -import { COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering'; +import { BRAILLE_SPINNER_FRAMES, COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering'; import { STREAMING_ARGS_FIELD_RE, STREAMING_ARGS_PREVIEW_MAX_CHARS, } from '#/tui/constant/streaming'; -import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; @@ -25,10 +25,19 @@ import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; import { PlanBoxComponent } from './plan-box'; import { ShellExecutionComponent } from './shell-execution'; +import { + applySwarmEvent, + initialSwarmModel, + type SwarmEvent, + type SwarmModel, + type WorkerRow, +} from './swarm-dashboard-model'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { pickResultRenderer } from './tool-renderers/registry'; const MAX_ARG_LENGTH = 60; +/** Keeps a running swarm worker's activity to a single dashboard line. */ +const SWARM_ACTIVITY_MAX_LENGTH = 48; const MAX_SUB_TOOL_CALLS_SHOWN = 4; const MAX_SINGLE_SUBAGENT_TOOL_ROWS = 4; const APPROVED_PLAN_MARKER = '## Approved Plan:'; @@ -494,6 +503,16 @@ export class ToolCallComponent extends Container { private progressLines: string[] = []; private static readonly MAX_PROGRESS_LINES = 24; + // ── Swarm dashboard state ──────────────────────────────────────── + // + // Populated only when this tool call is the `Swarm` coordinator. The pure + // reducer in `swarm-dashboard-model` folds `applySwarm(event)` into this + // model; the body-building path renders the dashboard (phase line + one row + // per worker) instead of the normal progress/sub-tool/subagent blocks. A + // static spinner frame keeps the rendered lines stable so pi-tui's + // differential renderer never re-emits the card into scrollback. + private swarmModel: SwarmModel | undefined; + /** * Registered by a group container (`AgentGroupComponent` or * `ReadGroupComponent`) when this component is borrowed as a hidden state @@ -518,6 +537,9 @@ export class ToolCallComponent extends Container { this.colors = colors; this.ui = ui; this.markdownTheme = markdownTheme; + if (toolCall.name === 'Swarm') { + this.swarmModel = initialSwarmModel(str(toolCall.args['task'])); + } this.applySubagentReplay(toolCall.subagent); this.addChild(new Spacer(1)); @@ -560,6 +582,7 @@ export class ToolCallComponent extends Container { // authoritative final state. Without this clear, a finished tool would // show both the streamed status lines and the final output stacked. this.progressLines = []; + this.finalizeSwarmModelIfNeeded(result); this.finalizeSubagentElapsedIfNeeded(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); @@ -602,6 +625,47 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + /** True when this tool call drives the `Swarm` coordinator dashboard. */ + isSwarm(): boolean { + return this.toolCall.name === 'Swarm'; + } + + /** + * Fold a swarm dashboard event into the model and re-render in place. + * No-ops for non-swarm tool calls so callers can route blindly. Mirrors + * {@link ToolCallComponent.appendProgress} so the swarm card stays a single, + * stable component managed by the normal tool-call lifecycle. + */ + applySwarm(event: SwarmEvent): void { + if (this.swarmModel === undefined) return; + this.swarmModel = applySwarmEvent(this.swarmModel, event); + this.headerText.setText(this.buildHeader()); + this.rebuildBody(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + + /** + * Drives the swarm dashboard to its terminal state when the tool result + * lands. An error result (abort/throw) never emits the coordinator's `done` + * progress, so finalize it as cancelled; otherwise ensure the header shows + * the summary even if the `done` progress event was missed. + */ + private finalizeSwarmModelIfNeeded(result: ToolResultBlockData): void { + if (this.swarmModel === undefined) return; + if (result.is_error === true) { + this.swarmModel = applySwarmEvent(this.swarmModel, { t: 'cancelled' }); + return; + } + if (this.swarmModel.phase !== 'done' && this.swarmModel.phase !== 'cancelled') { + this.swarmModel = applySwarmEvent(this.swarmModel, { + t: 'done', + succeeded: this.swarmModel.doneCount, + failed: this.swarmModel.failedCount, + }); + } + } + dispose(): void { this.stopStreamingProgressTimer(); this.stopSubagentElapsedTimer(); @@ -1039,6 +1103,10 @@ export class ToolCallComponent extends Container { bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); } + if (this.swarmModel !== undefined) { + return this.buildSwarmHeader(); + } + if (toolCall.name === 'ExitPlanMode') { const label = chalk.hex(colors.primary).bold('Current plan'); if (!isFinished || result === undefined || result.is_error === true) { @@ -1094,6 +1162,74 @@ export class ToolCallComponent extends Container { return tone(` · ${text}`); } + // ── Swarm dashboard rendering ──────────────────────────────────── + // + // A static spinner frame is used (rather than an animated, per-render frame) + // so the rendered lines stay identical across consecutive renders — the + // property that lets pi-tui's differential renderer keep one stable card. + + private swarmSpinner(): string { + return BRAILLE_SPINNER_FRAMES[0]!; + } + + /** Single-line header for the Swarm card (carried by `headerText`). */ + private buildSwarmHeader(): string { + const c = this.colors; + const m = this.swarmModel; + if (m === undefined) return ''; + const title = m.task.length > 56 ? `${m.task.slice(0, 56)}…` : m.task; + if (m.phase === 'done' || m.phase === 'cancelled') { + const bullet = chalk.hex(c.success)(STATUS_BULLET); + const tag = m.phase === 'cancelled' ? ' · cancelled' : ''; + const summary = `${String(m.workers.size)} workers · ${String(m.doneCount)}✓ ${String(m.failedCount)}✗${tag}`; + return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)} ${chalk.dim(`· ${summary}`)}`; + } + const bullet = chalk.hex(c.roleAssistant)(STATUS_BULLET); + return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)}`; + } + + /** Renders the swarm phase line and one row per worker into the body. */ + private buildSwarmBody(): void { + const m = this.swarmModel; + if (m === undefined) return; + if (m.phase !== 'done' && m.phase !== 'cancelled') { + const phases = [ + `Plan ${m.phase === 'planning' ? this.swarmSpinner() : '✓'}`, + `Workers ${String(m.doneCount + m.failedCount)}/${String(m.total)}`, + `Synthesize ${ + m.phase === 'synthesizing' + ? this.swarmSpinner() + : m.phase === 'working' || m.phase === 'planning' + ? '·' + : '✓' + }`, + ].join(' '); + this.addChild(new Text(` ${chalk.dim(phases)}`, 0, 0)); + } + for (const w of m.workers.values()) { + this.addChild(new Text(this.buildSwarmWorkerLine(w), 0, 0)); + } + } + + private buildSwarmWorkerLine(w: WorkerRow): string { + const c = this.colors; + const role = chalk.hex(c.primary)(w.role); + if (w.status === 'failed') { + return ` ${chalk.hex(c.error)(FAILURE_MARK)}${role} ${chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`)}`; + } + if (w.status === 'done') { + const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : ''; + return ` ${chalk.hex(c.success)('✓ ')}${role} ${chalk.dim(`${String(w.toolCount)} calls${tok}`)}`; + } + if (w.status === 'retrying') { + return ` ${chalk.hex(c.roleAssistant)('⟳ ')}${role} ${chalk.dim('retrying…')}`; + } + const raw = w.latestActivity ?? 'starting…'; + const activity = + raw.length > SWARM_ACTIVITY_MAX_LENGTH ? `${raw.slice(0, SWARM_ACTIVITY_MAX_LENGTH)}…` : raw; + return ` ${chalk.hex(c.roleAssistant)(this.swarmSpinner())} ${role} ${chalk.dim(`now: ${activity}`)}`; + } + private rebuildContent(): void { while (this.children.length > this.callPreviewEndIndex) { this.children.pop(); @@ -1125,6 +1261,7 @@ export class ToolCallComponent extends Container { * styled individually so surrounding prose keeps its default dim tone. */ private buildProgressBlock(): void { + if (this.swarmModel !== undefined) return; if (this.progressLines.length === 0) return; if (this.result !== undefined) return; for (const raw of this.progressLines) { @@ -1145,6 +1282,7 @@ export class ToolCallComponent extends Container { } private buildSubagentBlock(): void { + if (this.swarmModel !== undefined) return; if ( this.subagentAgentId === undefined && this.ongoingSubCalls.size === 0 && @@ -1415,6 +1553,10 @@ export class ToolCallComponent extends Container { private buildCallPreview(): void { const name = this.toolCall.name; + if (this.swarmModel !== undefined) { + this.buildSwarmBody(); + return; + } if (name === 'ExitPlanMode') { this.buildPlanPreview(); return; @@ -1604,6 +1746,9 @@ export class ToolCallComponent extends Container { private buildContent(): void { const { result } = this; + // Swarm renders its dashboard via buildSwarmBody; the result output is the + // synthesized report which is surfaced elsewhere, not in this card. + if (this.swarmModel !== undefined) return; if (result === undefined || !result.output) return; if (this.isSingleSubagentView()) { diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 32a6f12b..7e33f0f6 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -234,20 +234,22 @@ export class SessionEventHandler { const { parentToolCallId } = info; const sourceName = info.name; - const swarmDash = streamingUI.getSwarmDashboard(parentToolCallId); - if (swarmDash !== undefined) { + const toolCall = streamingUI.getToolComponent(parentToolCallId); + if (toolCall === undefined) return true; + + // Swarm worker events drive the swarm dashboard, not the subagent block, and + // never fall through to the regular Agent appendSubToolCall path. + if (toolCall.isSwarm()) { if (event.type === 'tool.call.started') { - swarmDash.apply({ + toolCall.applySwarm({ t: 'worker.toolcall', id: subagentId, activity: workerActivityFromTool(event.name, argsRecord(event.args)), }); } - return true; // swarm worker events never fall through to appendSubToolCall + return true; } - const toolCall = streamingUI.getToolComponent(parentToolCallId); - if (toolCall === undefined) return true; toolCall.setSubagentMeta(subagentId, sourceName); switch (event.type) { @@ -498,12 +500,12 @@ export class SessionEventHandler { private handleToolProgress(event: ToolProgressEvent): void { if (event.update.kind === 'custom' && event.update.customKind === 'swarm') { - const dash = this.host.streamingUI.getSwarmDashboard(event.toolCallId); - if (dash === undefined) return; + const tc = this.host.streamingUI.getToolComponent(event.toolCallId); + if (tc === undefined || !tc.isSwarm()) return; const p = event.update.customData as { phase?: string; total?: number }; - if (p.phase === 'planned' && typeof p.total === 'number') dash.apply({ t: 'planned', total: p.total }); - else if (p.phase === 'synthesizing') dash.apply({ t: 'synthesizing' }); - else if (p.phase === 'done') dash.apply({ t: 'done', succeeded: 0, failed: 0 }); + if (p.phase === 'planned' && typeof p.total === 'number') tc.applySwarm({ t: 'planned', total: p.total }); + else if (p.phase === 'synthesizing') tc.applySwarm({ t: 'synthesizing' }); + else if (p.phase === 'done') tc.applySwarm({ t: 'done', succeeded: 0, failed: 0 }); return; } if (event.update.kind !== 'status') return; @@ -718,17 +720,17 @@ export class SessionEventHandler { name: event.subagentName, }); - const swarmDash = streamingUI.getSwarmDashboard(event.parentToolCallId); - if (swarmDash !== undefined) { + const swarmTc = streamingUI.getToolComponent(event.parentToolCallId); + if (swarmTc?.isSwarm() === true) { // Only real workers (profile `swarm:`) become dashboard rows. The // planner (`swarm-planner`, plus a possible retry) and synthesizer // (`swarm-synthesizer`) share the same parent tool-call id but must not - // appear as workers or inflate the worker counts. They still have a - // ToolCallComponent-less parent, so any non-worker subagent under a swarm - // dashboard returns without falling through to the foreground path. + // appear as workers or inflate the worker counts. Any non-worker subagent + // under a swarm coordinator returns without falling through to the + // foreground path. const workerPrefix = 'swarm:'; if (event.subagentName.startsWith(workerPrefix)) { - swarmDash.apply({ + swarmTc.applySwarm({ t: 'worker.spawned', id: event.subagentId, role: event.description ?? event.subagentName.slice(workerPrefix.length), @@ -780,17 +782,16 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); return; } - const swarmDashC = streamingUI.getSwarmDashboard(event.parentToolCallId); - if (swarmDashC !== undefined) { - swarmDashC.apply({ + const tc = streamingUI.getToolComponent(event.parentToolCallId); + if (tc === undefined) return; + if (tc.isSwarm()) { + tc.applySwarm({ t: 'worker.done', id: event.subagentId, ...(event.contextTokens !== undefined ? { tokens: event.contextTokens } : {}), }); return; } - const tc = streamingUI.getToolComponent(event.parentToolCallId); - if (tc === undefined) return; tc.onSubagentCompleted({ contextTokens: event.contextTokens, usage: event.usage, @@ -815,13 +816,12 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); return; } - const swarmDashF = streamingUI.getSwarmDashboard(event.parentToolCallId); - if (swarmDashF !== undefined) { - swarmDashF.apply({ t: 'worker.failed', id: event.subagentId, error: event.error }); - return; - } const tc = streamingUI.getToolComponent(event.parentToolCallId); if (tc === undefined) return; + if (tc.isSwarm()) { + tc.applySwarm({ t: 'worker.failed', id: event.subagentId, error: event.error }); + return; + } tc.onSubagentFailed({ error: event.error }); streamingUI.removeToolComponentIfInactive(event.parentToolCallId); } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 6136b0d7..f552618e 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -4,7 +4,6 @@ import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; import { CompactionComponent } from '../components/dialogs/compaction'; import { ReadGroupComponent } from '../components/messages/read-group'; -import { SwarmDashboardComponent } from '../components/messages/swarm-dashboard'; import { ThinkingComponent } from '../components/messages/thinking'; import { ToolCallComponent } from '../components/messages/tool-call'; import { STREAMING_UI_FLUSH_MS } from '../constant/streaming'; @@ -61,7 +60,6 @@ export class StreamingUIController { { name?: string; argumentsText: string; startedAtMs: number } >(); private _pendingToolComponents = new Map(); - private readonly _swarmDashboards = new Map(); private _pendingAgentGroup: { readonly turnId: string | undefined; readonly step: number; @@ -158,10 +156,6 @@ export class StreamingUIController { return this._pendingToolComponents.get(id); } - getSwarmDashboard(toolCallId: string): SwarmDashboardComponent | undefined { - return this._swarmDashboards.get(toolCallId); - } - removeToolComponent(id: string): void { this._pendingToolComponents.delete(id); } @@ -252,7 +246,6 @@ export class StreamingUIController { for (const toolCallId of completedToolCallIds) { this._pendingToolComponents.delete(toolCallId); } - this.disposeAndClearSwarmDashboards(); this._pendingAgentGroup = null; this._pendingReadGroup = null; this._currentTurnId = undefined; @@ -400,18 +393,10 @@ export class StreamingUIController { this.clearFlushTimerIfIdle(); this._streamingToolCallArguments.clear(); this.disposeAndClearPendingToolComponents(); - this.disposeAndClearSwarmDashboards(); this._pendingAgentGroup = null; this._pendingReadGroup = null; } - private disposeAndClearSwarmDashboards(): void { - for (const dash of this._swarmDashboards.values()) { - dash.dispose(); - } - this._swarmDashboards.clear(); - } - resetToolCallState(): void { this._activeToolCalls.clear(); } @@ -519,20 +504,13 @@ export class StreamingUIController { if (toolCall.name === 'AskUserQuestion') return; // A tool call of any other kind breaks an in-flight Agent/Read run, so the - // pending groups are reset here — before the Swarm early-return — to avoid - // a Swarm call between Agent/Read calls leaving a stale pending group. + // pending groups are reset here to avoid a non-Agent/Read call (e.g. Swarm) + // between Agent/Read calls leaving a stale pending group. Swarm itself flows + // through the normal ToolCallComponent path below — it renders its dashboard + // via the managed tool-call lifecycle (one stable component per tool id). if (toolCall.name !== 'Agent') this._pendingAgentGroup = null; if (toolCall.name !== 'Read') this._pendingReadGroup = null; - if (toolCall.name === 'Swarm') { - const task = typeof toolCall.args['task'] === 'string' ? toolCall.args['task'] : ''; - const dash = new SwarmDashboardComponent(task, this.host.state.theme.colors, this.host.state.ui); - this._swarmDashboards.set(toolCall.id, dash); - this.host.state.transcriptContainer.addChild(dash); - this.host.state.ui.requestRender(); - return; - } - const { state } = this.host; const tc = new ToolCallComponent( toolCall, @@ -570,16 +548,6 @@ export class StreamingUIController { const { state } = this.host; const matchedCall = this._activeToolCalls.get(toolCallId); - // A Swarm call that ends in error (abort/throw) never emits the `done` - // custom progress, so its dashboard would otherwise stay stuck on a - // spinner. Finalize it as cancelled. A non-error end means the coordinator - // already emitted `done`, leaving the dashboard terminal — leave it alone. - const swarmDash = this._swarmDashboards.get(toolCallId); - if (swarmDash !== undefined) { - if (result.is_error === true) swarmDash.apply({ t: 'cancelled' }); - return; - } - const tc = this._pendingToolComponents.get(toolCallId); if (tc) { tc.setResult(result); diff --git a/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts deleted file mode 100644 index cc1bc863..00000000 --- a/apps/kimi-code/test/tui/components/messages/swarm-dashboard.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { SwarmDashboardComponent } from '#/tui/components/messages/swarm-dashboard'; -import { darkColors } from '#/tui/theme/colors'; - -const ESC = String.fromCodePoint(0x1b); -function strip(text: string): string { - return text.replaceAll(/\[[0-9;]*m/g, '').replaceAll(new RegExp(`${ESC}\\][0-9];;[^\\u0007]*\\u0007`, 'g'), ''); -} - -describe('SwarmDashboardComponent', () => { - it('renders phase header and worker rows', () => { - const c = new SwarmDashboardComponent('compare error handling', darkColors, undefined); - c.apply({ t: 'planned', total: 2 }); - c.apply({ t: 'worker.spawned', id: 'a1', role: 'Researcher' }); - c.apply({ t: 'worker.toolcall', id: 'a1', activity: 'read foo.ts' }); - c.apply({ t: 'worker.spawned', id: 'a2', role: 'Analyst' }); - c.apply({ t: 'worker.done', id: 'a2', tokens: 1800 }); - - const out = strip(c.render(80).join('\n')); - expect(out).toContain('Swarm'); - expect(out).toContain('compare error handling'); - expect(out).toContain('Researcher'); - expect(out).toContain('read foo.ts'); - expect(out).toContain('Analyst'); - }); - - it('shows a failed worker with its error', () => { - const c = new SwarmDashboardComponent('t', darkColors, undefined); - c.apply({ t: 'planned', total: 1 }); - c.apply({ t: 'worker.spawned', id: 'a1', role: 'Scan' }); - c.apply({ t: 'worker.failed', id: 'a1', error: 'timeout' }); - const out = strip(c.render(80).join('\n')); - expect(out).toContain('Scan'); - expect(out).toContain('timeout'); - }); - - it('finalizes to a cancelled header on cancel', () => { - const c = new SwarmDashboardComponent('t', darkColors, undefined); - c.apply({ t: 'planned', total: 2 }); - c.apply({ t: 'worker.spawned', id: 'a1', role: 'R' }); - c.apply({ t: 'worker.done', id: 'a1' }); - c.apply({ t: 'worker.spawned', id: 'a2', role: 'A' }); - c.apply({ t: 'cancelled' }); - const out = strip(c.render(80).join('\n')); - expect(out).toContain('cancelled'); - }); - - it('finalizes to a summary header on done', () => { - const c = new SwarmDashboardComponent('t', darkColors, undefined); - c.apply({ t: 'planned', total: 2 }); - c.apply({ t: 'worker.spawned', id: 'a1', role: 'R' }); - c.apply({ t: 'worker.done', id: 'a1' }); - c.apply({ t: 'worker.spawned', id: 'a2', role: 'A' }); - c.apply({ t: 'worker.failed', id: 'a2', error: 'x' }); - c.apply({ t: 'done', succeeded: 1, failed: 1 }); - const out = strip(c.render(80).join('\n')); - expect(out).toMatch(/2 workers/); - expect(out).toContain('1'); - }); -}); diff --git a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts index df9a290f..70c7ae53 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts @@ -3,22 +3,30 @@ import { describe, expect, it } from 'vitest'; import type { Event } from '@moonshot-ai/kimi-code-sdk'; import { SessionEventHandler, type SessionEventHost } from '#/tui/controllers/session-event-handler'; -import { SwarmDashboardComponent } from '#/tui/components/messages/swarm-dashboard'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { workerActivityFromTool } from '#/tui/components/messages/swarm-dashboard-model'; import { darkColors } from '#/tui/theme/colors'; const strip = (t: string): string => t.replaceAll(/\[[0-9;]*m/g, ''); +function makeSwarm(): ToolCallComponent { + return new ToolCallComponent( + { id: 'tc-swarm', name: 'Swarm', args: { task: 'task' } }, + undefined, + darkColors, + ); +} + describe('swarm dashboard wiring (translation)', () => { it('produces the expected dashboard from a worker lifecycle sequence', () => { - const dash = new SwarmDashboardComponent('task', darkColors, undefined); - dash.apply({ t: 'planned', total: 2 }); - dash.apply({ t: 'worker.spawned', id: 's1', role: 'Researcher' }); - dash.apply({ t: 'worker.toolcall', id: 's1', activity: workerActivityFromTool('Read', { path: 'a.ts' }) }); - dash.apply({ t: 'worker.done', id: 's1', tokens: 2100 }); - dash.apply({ t: 'worker.spawned', id: 's2', role: 'Analyst' }); - dash.apply({ t: 'worker.failed', id: 's2', error: 'timeout' }); - dash.apply({ t: 'done', succeeded: 1, failed: 1 }); + const dash = makeSwarm(); + dash.applySwarm({ t: 'planned', total: 2 }); + dash.applySwarm({ t: 'worker.spawned', id: 's1', role: 'Researcher' }); + dash.applySwarm({ t: 'worker.toolcall', id: 's1', activity: workerActivityFromTool('Read', { path: 'a.ts' }) }); + dash.applySwarm({ t: 'worker.done', id: 's1', tokens: 2100 }); + dash.applySwarm({ t: 'worker.spawned', id: 's2', role: 'Analyst' }); + dash.applySwarm({ t: 'worker.failed', id: 's2', error: 'timeout' }); + dash.applySwarm({ t: 'done', succeeded: 1, failed: 1 }); const out = strip(dash.render(80).join('\n')); expect(out).toContain('Researcher'); expect(out).toContain('Analyst'); @@ -28,13 +36,12 @@ describe('swarm dashboard wiring (translation)', () => { it('routes live swarm events through SessionEventHandler into the dashboard', () => { const parentToolCallId = 'tc-swarm'; - const dash = new SwarmDashboardComponent('task', darkColors, undefined); + const dash = makeSwarm(); const mockHost = { streamingUI: { setTurnId: (): void => {}, - getSwarmDashboard: (id: string): SwarmDashboardComponent | undefined => + getToolComponent: (id: string): ToolCallComponent | undefined => id === parentToolCallId ? dash : undefined, - getToolComponent: (): undefined => undefined, }, } as unknown as SessionEventHost; const handler = new SessionEventHandler(mockHost); @@ -96,13 +103,12 @@ describe('swarm dashboard wiring (translation)', () => { it('counts only real workers — planner/synthesizer/retry never become rows', () => { const parentToolCallId = 'tc-swarm'; - const dash = new SwarmDashboardComponent('task', darkColors, undefined); + const dash = makeSwarm(); const mockHost = { streamingUI: { setTurnId: (): void => {}, - getSwarmDashboard: (id: string): SwarmDashboardComponent | undefined => + getToolComponent: (id: string): ToolCallComponent | undefined => id === parentToolCallId ? dash : undefined, - getToolComponent: (): undefined => undefined, }, } as unknown as SessionEventHost; const handler = new SessionEventHandler(mockHost); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts new file mode 100644 index 00000000..1fc1fba1 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { darkColors } from '#/tui/theme/colors'; + +const ESC = String.fromCodePoint(0x1b); +function strip(text: string): string { + return text + .replaceAll(/\[[0-9;]*m/g, '') + .replaceAll(new RegExp(`${ESC}\\][0-9];;[^\\u0007]*\\u0007`, 'g'), ''); +} + +function makeSwarm(task: string): ToolCallComponent { + return new ToolCallComponent( + { id: 'tc-swarm', name: 'Swarm', args: { task } }, + undefined, + darkColors, + ); +} + +describe('ToolCallComponent swarm mode', () => { + it('identifies swarm tool calls and no-ops applySwarm on non-swarm tools', () => { + const swarm = makeSwarm('t'); + expect(swarm.isSwarm()).toBe(true); + + const read = new ToolCallComponent( + { id: 'tc-read', name: 'Read', args: { path: 'foo.ts' } }, + undefined, + darkColors, + ); + expect(read.isSwarm()).toBe(false); + const before = read.render(80).join('\n'); + // applySwarm must be a safe no-op on non-swarm tools. + read.applySwarm({ t: 'planned', total: 2 }); + expect(read.render(80).join('\n')).toBe(before); + }); + + it('renders phase header and worker rows', () => { + const c = makeSwarm('compare error handling'); + c.applySwarm({ t: 'planned', total: 2 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Researcher' }); + c.applySwarm({ t: 'worker.toolcall', id: 'a1', activity: 'read foo.ts' }); + c.applySwarm({ t: 'worker.spawned', id: 'a2', role: 'Analyst' }); + c.applySwarm({ t: 'worker.done', id: 'a2', tokens: 1800 }); + + const out = strip(c.render(80).join('\n')); + expect(out).toContain('Swarm'); + expect(out).toContain('compare error handling'); + expect(out).toContain('Researcher'); + expect(out).toContain('read foo.ts'); + expect(out).toContain('Analyst'); + }); + + it('produces byte-identical output across consecutive renders (stability)', () => { + const c = makeSwarm('stable task'); + c.applySwarm({ t: 'planned', total: 2 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Researcher' }); + c.applySwarm({ t: 'worker.toolcall', id: 'a1', activity: 'read foo.ts' }); + c.applySwarm({ t: 'worker.spawned', id: 'a2', role: 'Analyst' }); + + // The root-cause property: a stable component renders the same lines each + // time, so pi-tui's differential renderer never re-emits it to scrollback. + expect(c.render(80).join('\n')).toBe(c.render(80).join('\n')); + }); + + it('shows a failed worker with its error', () => { + const c = makeSwarm('t'); + c.applySwarm({ t: 'planned', total: 1 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Scan' }); + c.applySwarm({ t: 'worker.failed', id: 'a1', error: 'timeout' }); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('Scan'); + expect(out).toContain('timeout'); + }); + + it('finalizes to a cancelled header on an error result', () => { + const c = makeSwarm('t'); + c.applySwarm({ t: 'planned', total: 2 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'R' }); + c.applySwarm({ t: 'worker.done', id: 'a1' }); + c.applySwarm({ t: 'worker.spawned', id: 'a2', role: 'A' }); + c.setResult({ tool_call_id: 'tc-swarm', output: 'aborted', is_error: true }); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('cancelled'); + }); + + it('finalizes to a summary header after done + success result', () => { + const c = makeSwarm('t'); + c.applySwarm({ t: 'planned', total: 2 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'R' }); + c.applySwarm({ t: 'worker.done', id: 'a1' }); + c.applySwarm({ t: 'worker.spawned', id: 'a2', role: 'A' }); + c.applySwarm({ t: 'worker.failed', id: 'a2', error: 'x' }); + c.applySwarm({ t: 'done', succeeded: 1, failed: 1 }); + c.setResult({ tool_call_id: 'tc-swarm', output: 'final report', is_error: false }); + const out = strip(c.render(80).join('\n')); + expect(out).toMatch(/2 workers/); + expect(out).toContain('1✓'); + expect(out).toContain('1✗'); + }); + + it('synthesizes a done header when a success result arrives before the done event', () => { + const c = makeSwarm('t'); + c.applySwarm({ t: 'planned', total: 1 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'R' }); + c.applySwarm({ t: 'worker.done', id: 'a1' }); + // No explicit {t:'done'} — setResult must finalize the header to a summary. + c.setResult({ tool_call_id: 'tc-swarm', output: 'final report', is_error: false }); + const out = strip(c.render(80).join('\n')); + expect(out).toMatch(/1 workers/); + expect(out).toContain('1✓'); + }); +}); From 0d11fbc097329d28b500157bd92b660830c8db02 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 19:42:33 +0800 Subject: [PATCH 14/28] fix(tui): match swarm card styling to AgentGroup conventions and fix empty task --- .../src/tui/components/messages/tool-call.ts | 133 +++++++++++------- .../components/messages/swarm-routing.test.ts | 3 +- .../messages/tool-call-swarm.test.ts | 38 ++++- 3 files changed, 120 insertions(+), 54 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index eca1eef8..f09141a3 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -11,12 +11,12 @@ import chalk from 'chalk'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; -import { BRAILLE_SPINNER_FRAMES, COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering'; +import { COMMAND_PREVIEW_LINES } from '#/tui/constant/rendering'; import { STREAMING_ARGS_FIELD_RE, STREAMING_ARGS_PREVIEW_MAX_CHARS, } from '#/tui/constant/streaming'; -import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; @@ -507,10 +507,11 @@ export class ToolCallComponent extends Container { // // Populated only when this tool call is the `Swarm` coordinator. The pure // reducer in `swarm-dashboard-model` folds `applySwarm(event)` into this - // model; the body-building path renders the dashboard (phase line + one row - // per worker) instead of the normal progress/sub-tool/subagent blocks. A - // static spinner frame keeps the rendered lines stable so pi-tui's - // differential renderer never re-emits the card into scrollback. + // model; the body-building path renders the dashboard (one or two gutter + // lines per worker, mirroring `AgentGroupComponent`) instead of the normal + // progress/sub-tool/subagent blocks. No animated, per-render content is used + // so the rendered lines stay stable and pi-tui's differential renderer never + // re-emits the card into scrollback. private swarmModel: SwarmModel | undefined; /** @@ -1164,70 +1165,104 @@ export class ToolCallComponent extends Container { // ── Swarm dashboard rendering ──────────────────────────────────── // - // A static spinner frame is used (rather than an animated, per-render frame) - // so the rendered lines stay identical across consecutive renders — the - // property that lets pi-tui's differential renderer keep one stable card. + // The swarm card mirrors `AgentGroupComponent`'s gutter/indent/color + // vocabulary. No animated, per-render content is used so the rendered lines + // stay identical across consecutive renders — the property that lets + // pi-tui's differential renderer keep one stable card. - private swarmSpinner(): string { - return BRAILLE_SPINNER_FRAMES[0]!; - } - - /** Single-line header for the Swarm card (carried by `headerText`). */ + /** + * Single-line header for the Swarm card (carried by `headerText`). Mirrors + * `AgentGroupComponent.buildHeader`: a status bullet (roleAssistant while + * active, success when terminal), the bold `Swarm` label, a dim `· title` + * segment (omitted when empty so no dangling `·`), and a dim phase/summary + * tail. The displayed task is sourced live from the tool-call args rather + * than the stale model so it reflects the fully-streamed task string. + */ private buildSwarmHeader(): string { const c = this.colors; const m = this.swarmModel; if (m === undefined) return ''; - const title = m.task.length > 56 ? `${m.task.slice(0, 56)}…` : m.task; - if (m.phase === 'done' || m.phase === 'cancelled') { - const bullet = chalk.hex(c.success)(STATUS_BULLET); + const rawTask = str(this.toolCall.args['task']); + const title = rawTask.length > 56 ? `${rawTask.slice(0, 56)}…` : rawTask; + const label = chalk.hex(c.primary).bold('Swarm'); + const titlePart = title.length > 0 ? chalk.dim(` · ${title}`) : ''; + const terminal = m.phase === 'done' || m.phase === 'cancelled'; + const bullet = terminal + ? chalk.hex(c.success)(STATUS_BULLET) + : chalk.hex(c.roleAssistant)(STATUS_BULLET); + let tail: string; + if (terminal) { const tag = m.phase === 'cancelled' ? ' · cancelled' : ''; - const summary = `${String(m.workers.size)} workers · ${String(m.doneCount)}✓ ${String(m.failedCount)}✗${tag}`; - return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)} ${chalk.dim(`· ${summary}`)}`; + tail = chalk.dim( + ` · ${String(m.workers.size)} workers · ${String(m.doneCount)}✓ ${String(m.failedCount)}✗${tag}`, + ); + } else if (m.phase === 'planning') { + tail = chalk.dim(' · planning…'); + } else if (m.phase === 'synthesizing') { + tail = chalk.dim(' · synthesizing…'); + } else { + tail = chalk.dim(` · ${String(m.doneCount + m.failedCount)}/${String(m.total)} workers`); } - const bullet = chalk.hex(c.roleAssistant)(STATUS_BULLET); - return `${bullet}${chalk.hex(c.primary).bold('Swarm')} ${chalk.dim(`· ${title}`)}`; + return `${bullet}${label}${titlePart}${tail}`; } - /** Renders the swarm phase line and one row per worker into the body. */ + /** + * Renders one or two gutter lines per worker into the body, mirroring + * `AgentGroupComponent.appendLines` (the `├─`/`└─`/`│` vocabulary, the + * 2-space lead, and the dim/primary/error coloring). While still planning + * with no workers yet, a single dim placeholder line keeps the card from + * rendering blank. + */ private buildSwarmBody(): void { const m = this.swarmModel; if (m === undefined) return; - if (m.phase !== 'done' && m.phase !== 'cancelled') { - const phases = [ - `Plan ${m.phase === 'planning' ? this.swarmSpinner() : '✓'}`, - `Workers ${String(m.doneCount + m.failedCount)}/${String(m.total)}`, - `Synthesize ${ - m.phase === 'synthesizing' - ? this.swarmSpinner() - : m.phase === 'working' || m.phase === 'planning' - ? '·' - : '✓' - }`, - ].join(' '); - this.addChild(new Text(` ${chalk.dim(phases)}`, 0, 0)); - } - for (const w of m.workers.values()) { - this.addChild(new Text(this.buildSwarmWorkerLine(w), 0, 0)); - } - } - - private buildSwarmWorkerLine(w: WorkerRow): string { + const workers = [...m.workers.values()]; + if (m.phase === 'planning' && workers.length === 0) { + this.addChild(new Text(` ${chalk.dim('└─ planning subtasks…')}`, 0, 0)); + return; + } + workers.forEach((w, idx) => { + const isLast = idx === workers.length - 1; + for (const line of this.buildSwarmWorkerLine(w, isLast)) { + this.addChild(new Text(line, 0, 0)); + } + }); + } + + /** + * Builds the gutter lines for one worker. Line 1 carries the branch, the + * role, and a dim stats tail; line 2 (omitted once the worker is done) + * carries the latest activity or the failure reason. Matches + * `AgentGroupComponent`'s two-line gutter format. + */ + private buildSwarmWorkerLine(w: WorkerRow, isLast: boolean): string[] { const c = this.colors; + const branch1 = isLast ? '└─' : '├─'; + const branch2 = isLast ? ' ' : '│ '; const role = chalk.hex(c.primary)(w.role); - if (w.status === 'failed') { - return ` ${chalk.hex(c.error)(FAILURE_MARK)}${role} ${chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`)}`; - } + + let statsPart = ''; if (w.status === 'done') { const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : ''; - return ` ${chalk.hex(c.success)('✓ ')}${role} ${chalk.dim(`${String(w.toolCount)} calls${tok}`)}`; + statsPart = chalk.dim(` · ${String(w.toolCount)} calls${tok}`); + } else if (w.status === 'retrying') { + statsPart = chalk.dim(' · retrying…'); + } else if (w.status === 'running' && w.toolCount > 0) { + statsPart = chalk.dim(` · ${String(w.toolCount)} calls`); } - if (w.status === 'retrying') { - return ` ${chalk.hex(c.roleAssistant)('⟳ ')}${role} ${chalk.dim('retrying…')}`; + const line1 = ` ${branch1} ${role}${statsPart}`; + + if (w.status === 'done') { + return [line1]; + } + if (w.status === 'failed') { + const errLine = chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`); + return [line1, ` ${branch2} ${errLine}`]; } const raw = w.latestActivity ?? 'starting…'; const activity = raw.length > SWARM_ACTIVITY_MAX_LENGTH ? `${raw.slice(0, SWARM_ACTIVITY_MAX_LENGTH)}…` : raw; - return ` ${chalk.hex(c.roleAssistant)(this.swarmSpinner())} ${role} ${chalk.dim(`now: ${activity}`)}`; + return [line1, ` ${branch2} ${chalk.dim(`now: ${activity}`)}`]; } private rebuildContent(): void { diff --git a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts index 70c7ae53..6bf41ae8 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts @@ -98,7 +98,8 @@ describe('swarm dashboard wiring (translation)', () => { const out = strip(dash.render(80).join('\n')); expect(out).toContain('Researcher'); expect(out).toContain('boom'); - expect(out).toMatch(/Workers 1\/1/); + // Active header tail reports worker progress (1 of 1 terminal). + expect(out).toMatch(/1\/1 workers/); }); it('counts only real workers — planner/synthesizer/retry never become rows', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts index 1fc1fba1..b46d0cc7 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts @@ -35,7 +35,7 @@ describe('ToolCallComponent swarm mode', () => { expect(read.render(80).join('\n')).toBe(before); }); - it('renders phase header and worker rows', () => { + it('renders the header and worker rows with the AgentGroup gutter style', () => { const c = makeSwarm('compare error handling'); c.applySwarm({ t: 'planned', total: 2 }); c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Researcher' }); @@ -49,6 +49,21 @@ describe('ToolCallComponent swarm mode', () => { expect(out).toContain('Researcher'); expect(out).toContain('read foo.ts'); expect(out).toContain('Analyst'); + // Active header tail reports worker progress (1 of 2 terminal). + expect(out).toContain('1/2 workers'); + // Mirrors AgentGroup's gutter vocabulary: branch glyphs + "now:" activity. + expect(out).toContain('├─ Researcher'); + expect(out).toContain('now: read foo.ts'); + // The last worker (done) uses the closing branch and shows its call stats. + expect(out).toContain('└─ Analyst'); + expect(out).toContain('1.8k tok'); + }); + + it('shows a dim planning placeholder before any workers spawn', () => { + const c = makeSwarm('explore the repo'); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('planning…'); + expect(out).toContain('└─ planning subtasks…'); }); it('produces byte-identical output across consecutive renders (stability)', () => { @@ -63,14 +78,14 @@ describe('ToolCallComponent swarm mode', () => { expect(c.render(80).join('\n')).toBe(c.render(80).join('\n')); }); - it('shows a failed worker with its error', () => { + it('shows a failed worker with its error on the second gutter line', () => { const c = makeSwarm('t'); c.applySwarm({ t: 'planned', total: 1 }); c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Scan' }); c.applySwarm({ t: 'worker.failed', id: 'a1', error: 'timeout' }); const out = strip(c.render(80).join('\n')); - expect(out).toContain('Scan'); - expect(out).toContain('timeout'); + expect(out).toContain('└─ Scan'); + expect(out).toContain('failed: timeout'); }); it('finalizes to a cancelled header on an error result', () => { @@ -110,4 +125,19 @@ describe('ToolCallComponent swarm mode', () => { expect(out).toMatch(/1 workers/); expect(out).toContain('1✓'); }); + + it('reflects a task supplied via tool-call args after empty-args construction', () => { + // The coordinator's `tool.call.started` fires before the streamed args + // finish, so the task is empty at construction time. The header must read + // the live task from the tool call once `updateToolCall` syncs it. + const c = new ToolCallComponent( + { id: 'tc-swarm', name: 'Swarm', args: {} }, + undefined, + darkColors, + ); + c.updateToolCall({ id: 'tc-swarm', name: 'Swarm', args: { task: 'explore the repo' } }); + c.applySwarm({ t: 'planned', total: 2 }); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('explore the repo'); + }); }); From c03ba22f05c69e2c8b6c1ad92a8f8ee7084209b9 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 19:49:21 +0800 Subject: [PATCH 15/28] fix(tui): collapse multi-line swarm task to one line in header and tool description --- apps/kimi-code/src/tui/components/messages/tool-call.ts | 6 +++--- .../agent-core/src/tools/builtin/collaboration/swarm.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index f09141a3..a2c78078 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -1182,7 +1182,7 @@ export class ToolCallComponent extends Container { const c = this.colors; const m = this.swarmModel; if (m === undefined) return ''; - const rawTask = str(this.toolCall.args['task']); + const rawTask = str(this.toolCall.args['task']).replace(/\s+/g, ' ').trim(); const title = rawTask.length > 56 ? `${rawTask.slice(0, 56)}…` : rawTask; const label = chalk.hex(c.primary).bold('Swarm'); const titlePart = title.length > 0 ? chalk.dim(` · ${title}`) : ''; @@ -1244,11 +1244,11 @@ export class ToolCallComponent extends Container { let statsPart = ''; if (w.status === 'done') { const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : ''; - statsPart = chalk.dim(` · ${String(w.toolCount)} calls${tok}`); + statsPart = chalk.dim(` · ${String(w.toolCount)} call${w.toolCount === 1 ? '' : 's'}${tok}`); } else if (w.status === 'retrying') { statsPart = chalk.dim(' · retrying…'); } else if (w.status === 'running' && w.toolCount > 0) { - statsPart = chalk.dim(` · ${String(w.toolCount)} calls`); + statsPart = chalk.dim(` · ${String(w.toolCount)} call${w.toolCount === 1 ? '' : 's'}`); } const line1 = ` ${branch1} ${role}${statsPart}`; diff --git a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts index d1efed42..c2c440e4 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts @@ -51,7 +51,7 @@ export class SwarmTool implements BuiltinTool { resolveExecution(args: SwarmToolInput): ToolExecution { return { - description: `Running swarm: ${args.task.slice(0, 60)}`, + description: `Running swarm: ${args.task.replace(/\s+/g, ' ').trim().slice(0, 60)}`, approvalRule: 'Swarm', execute: (ctx) => this.execution(args, ctx), }; From adb68270f6cc2a9291417d139eb077d80945a1fd Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 20:06:11 +0800 Subject: [PATCH 16/28] feat(tui): show live token counts for running swarm workers --- .../messages/swarm-dashboard-model.ts | 8 +++++ .../src/tui/components/messages/tool-call.ts | 10 ++++-- .../tui/controllers/session-event-handler.ts | 32 +++++++++++++++++++ .../messages/swarm-dashboard-model.test.ts | 24 ++++++++++++++ .../messages/tool-call-swarm.test.ts | 15 +++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts index e0da8966..8bcc3fd4 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts @@ -27,6 +27,7 @@ export type SwarmEvent = | { t: 'cancelled' } | { t: 'worker.spawned'; id: string; role: string } | { t: 'worker.toolcall'; id: string; activity: string } + | { t: 'worker.tokens'; id: string; tokens: number } | { t: 'worker.done'; id: string; tokens?: number } | { t: 'worker.failed'; id: string; error: string }; @@ -59,6 +60,13 @@ export function applySwarmEvent(model: SwarmModel, event: SwarmEvent): SwarmMode } return { ...model, workers }; } + case 'worker.tokens': { + const w = model.workers.get(event.id); + if (w === undefined) return model; + const workers = new Map(model.workers); + workers.set(event.id, { ...w, tokens: event.tokens }); + return { ...model, workers }; + } case 'worker.done': { const workers = new Map(model.workers); const w = workers.get(event.id); diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 264e3e30..d6a8eb54 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -1358,14 +1358,18 @@ export class ToolCallComponent extends Container { const branch2 = isLast ? ' ' : '│ '; const role = chalk.hex(c.primary)(w.role); + // Live token counts are shown for every worker (running, retrying, done) so + // the dashboard stays consistent with `AgentGroupComponent`, which renders + // live tokens for all subagents from `agent.status.updated`. Running workers + // get their figure from `worker.tokens`; done workers from `worker.done`. + const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : ''; let statsPart = ''; if (w.status === 'done') { - const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : ''; statsPart = chalk.dim(` · ${String(w.toolCount)} call${w.toolCount === 1 ? '' : 's'}${tok}`); } else if (w.status === 'retrying') { - statsPart = chalk.dim(' · retrying…'); + statsPart = chalk.dim(` · retrying…${tok}`); } else if (w.status === 'running' && w.toolCount > 0) { - statsPart = chalk.dim(` · ${String(w.toolCount)} call${w.toolCount === 1 ? '' : 's'}`); + statsPart = chalk.dim(` · ${String(w.toolCount)} call${w.toolCount === 1 ? '' : 's'}${tok}`); } const line1 = ` ${branch1} ${role}${statsPart}`; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 7c0f014f..02866357 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -73,6 +73,28 @@ import type { } from '../types'; import type { TUIState } from '../tui-state'; +/** + * Live token figure for a swarm worker from its `agent.status.updated` event, + * computed identically to the non-swarm grouped-subagent path so a running + * worker shows the same `· X tok` the grouped card would: prefer the per-agent + * `contextTokens` when positive, otherwise fall back to the usage grand total + * (`total ?? currentTurn`, summing input + output). Returns `undefined` when no + * positive figure is available so the dashboard line stays unchanged. + */ +function liveSwarmWorkerTokens(event: AgentStatusUpdatedEvent): number | undefined { + if (event.contextTokens !== undefined && event.contextTokens > 0) { + return event.contextTokens; + } + const usage = event.usage?.total ?? event.usage?.currentTurn; + if (usage === undefined) return undefined; + const total = + (usage.inputOther ?? 0) + + (usage.inputCacheRead ?? 0) + + (usage.inputCacheCreation ?? 0) + + usage.output; + return total > 0 ? total : undefined; +} + export interface SessionEventHost { state: TUIState; session: Session | undefined; @@ -246,6 +268,16 @@ export class SessionEventHandler { id: subagentId, activity: workerActivityFromTool(event.name, argsRecord(event.args)), }); + } else if (event.type === 'agent.status.updated') { + // Mirror the non-swarm `agent.status.updated` path's live-token + // computation (prefer the per-agent context tokens, fall back to the + // usage total) so a running worker shows the same live `· X tok` the + // grouped subagent card would. Matches the value `worker.done` later + // records via `SubagentCompletedEvent.contextTokens`. + const tokens = liveSwarmWorkerTokens(event); + if (tokens !== undefined) { + toolCall.applySwarm({ t: 'worker.tokens', id: subagentId, tokens }); + } } return true; } diff --git a/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts index f5665727..cbb7d79a 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts @@ -69,6 +69,30 @@ describe('applySwarmEvent', () => { const m = reduce([{ t: 'worker.spawned', id: 'a1', role: 'R' }, { t: 'worker.done', id: 'a1' }]); expect(m.workers.get('a1')?.status).toBe('done'); }); + + it('worker.tokens updates a running worker tokens without touching count/status/activity', () => { + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'Researcher' }, + { t: 'worker.toolcall', id: 'a1', activity: 'read foo.ts' }, + { t: 'worker.tokens', id: 'a1', tokens: 3200 }, + ]); + const w = m.workers.get('a1'); + expect(w?.tokens).toBe(3200); + expect(w?.status).toBe('running'); + expect(w?.toolCount).toBe(1); + expect(w?.latestActivity).toBe('read foo.ts'); + }); + + it('worker.tokens is a no-op for an unknown worker id', () => { + const before = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'R' }, + ]); + const after = applySwarmEvent(before, { t: 'worker.tokens', id: 'ghost', tokens: 999 }); + expect(after).toBe(before); + expect(after.workers.get('ghost')).toBeUndefined(); + }); }); describe('workerActivityFromTool', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts index b46d0cc7..17497ebb 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts @@ -59,6 +59,21 @@ describe('ToolCallComponent swarm mode', () => { expect(out).toContain('1.8k tok'); }); + it('shows live token counts on a running worker that received worker.tokens', () => { + const c = makeSwarm('compare error handling'); + c.applySwarm({ t: 'planned', total: 1 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Researcher' }); + c.applySwarm({ t: 'worker.toolcall', id: 'a1', activity: 'read foo.ts' }); + c.applySwarm({ t: 'worker.tokens', id: 'a1', tokens: 4200 }); + + const out = strip(c.render(80).join('\n')); + // The running worker still shows its live activity line ... + expect(out).toContain('now: read foo.ts'); + // ... alongside the live token count on its stats line. + expect(out).toContain('1 call'); + expect(out).toContain('4.2k tok'); + }); + it('shows a dim planning placeholder before any workers spawn', () => { const c = makeSwarm('explore the repo'); const out = strip(c.render(80).join('\n')); From e88003f856b4947bb503b9bd9b9927a0a472adf6 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 22:31:18 +0800 Subject: [PATCH 17/28] feat(agent-core): stall-detection hard-stop for swarm workers (repeat-based) --- packages/agent-core/src/agent/index.ts | 9 ++ .../agent-core/src/agent/swarm/stall-hook.ts | Bin 0 -> 2397 bytes packages/agent-core/src/agent/swarm/types.ts | 10 ++ packages/agent-core/src/agent/turn/index.ts | 11 ++ .../agent-core/src/session/subagent-host.ts | 17 ++- .../src/tools/builtin/collaboration/swarm.ts | 67 +++++++++-- .../agent/swarm/stall-hook-turn.e2e.test.ts | 95 +++++++++++++++ .../test/agent/swarm/stall-hook.test.ts | 78 +++++++++++++ .../agent-core/test/swarm/swarm-tool.test.ts | 110 ++++++++++++++++++ 9 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 packages/agent-core/src/agent/swarm/stall-hook.ts create mode 100644 packages/agent-core/test/agent/swarm/stall-hook-turn.e2e.test.ts create mode 100644 packages/agent-core/test/agent/swarm/stall-hook.test.ts diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 5473f65a..a44ccad0 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -14,6 +14,7 @@ import { import type { EnabledPluginSessionStart } from '#/plugin'; +import type { LoopHooks } from '../loop'; import type { McpConnectionManager } from '../mcp'; import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profile'; import type { ModelProvider } from '../session/provider-manager'; @@ -114,6 +115,14 @@ export class Agent { readonly cron: CronManager | null; readonly replayBuilder: ReplayBuilder; + /** + * Loop hooks scoped to this agent when it runs as a subagent (e.g. swarm + * worker stall detection). Set by {@link SessionSubagentHost} when spawning; + * `undefined` for the main agent and regular subagents, so they run with + * identical (default) turn hooks. + */ + subagentLoopHooks?: Partial | undefined; + private lastLlmConfigLogSignature?: string; constructor(options: AgentOptions) { diff --git a/packages/agent-core/src/agent/swarm/stall-hook.ts b/packages/agent-core/src/agent/swarm/stall-hook.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c7b9edafc63cb2d867a73e234b9f440b0cb375c GIT binary patch literal 2397 zcmaJ?*>2-D6z#LW;uaksQtHT^hrT$Dr)g(^4lqlQ1StAuTDrDa%cM(EUXU8-NAwH( zB|VpvoMe{HL*S99yPS=q^Ya0n(_K(aLlp-ugSLhi){);Sx2BzSE5?7?xmvObzQA$$ z;<|qmY8A-whE?zsYNosE_irdz+fXS*Gq2W6M*e({C&n6$=cL@y=Y&tKlX7!DSW}Tg z1;@U&O-0(%TCKEMlCiXLc8M3hp!*sxYHL^BxYD$nAZQt0$|=+nbgZ0fkQTXqL(tlh|rc(T==WgV%OFevD4kfF5s11NDtA3wbN{WGNeWdvcZ zw4sPtoW=37ZB*R!gd`b8Q)$k6%cR4QFd15E2RLfA)ff%^T2t1_EV=5TzX!+(a9Z&J zKU>e99)4=HSv?|r&ke6RxWhG&KGMH`{}Ts`Y@BG2?J8bq6Ol^?YUQw;5O@*cs<>)5 z4M-EHvYT7_?&Xg;#hy|WGkabu6NJ|0AQkz~HZk0;MKZv;Y1BsWv}6r;Q!kaP8c<4h zA@pw8g#*-3v6DqdgMnUetPAw;m7VBF3uo7KRurSqIx{+Lbs^$9yX*^u!-i4i9mxF^ zy75E00esl<9y`9-bJ>b)ZrQg@cv=*7MGzzTUdj!49|zJVuv@4SQWx92?p-6rKW?NJ z*ccFv&d=#K8UqNlCQznL*|Z*PN9FHoUDhH+u?dI{4juyZ%HyGz(uLqaU|3bwG(xr5 z?7p|im`uB#v%5^q@E72}HCwwv72c~d;2Ud79!6#t+WA01!-?Y4XHG+lvWCPkaFioP zNaZT+1Dw-pU#oc|!c!gr;e!)DWn3%^n#Ezc8g~Pt`$Y6JrTyf?be4-ox^l8BL@ypw zb75Bzh`;DkrnDwFhy^EhI@It4q=2QKfnZI9eN0)JW!w#u)76x=R#%sU<6r>rMT$SS z8d0KpcbqgJ+EGi9C+%trY0frZ(8Mee=cQ7@KB`r)qD&%f_^8b&x?Hqf&~G(kKlyzo z>KE=4z_HTo6B=hjEJ|PpE}nJUb-U%W$Y0Emd1K2}mv&Jy8B#9VEoK*T#lv{AQK~P# z5}u>uF-o2=Jv~b3^`Y`Q@?3_fM&D(VD$90_;0j`0TnC7Sr^1PW)$kJGvBnvhrO#;* zhmb0|PC3_UBze1Y(gN8Q@0$|C1dhBuvr`88ZL-w(>uwtk@-f{g7qn_7CyJ-hgpd?O z|IlG_vd$2=#&$6LRo_BPTE+ZR3^ z066#%z;wm*0v?f4lTX3VdxTCqojywYBx%Y@oob#W%a@j<{fmdYm`Jf>{ void) | undefined; onProgressCustom?: ((progress: SwarmProgress) => void) | undefined; maxConcurrency?: number | undefined; + /** + * Repeat count at which a worker that keeps issuing the SAME tool call is + * treated as stalled and hard-stopped (its turn fails with a distinguishable + * reason so this wave records it as a failed subtask). Defaults to + * {@link DEFAULT_STALL_REPEAT_THRESHOLD}. + */ + stallRepeatThreshold?: number | undefined; } + +/** Default repeat threshold for swarm worker stall detection. */ +export const DEFAULT_STALL_REPEAT_THRESHOLD = 10; diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 068d5626..794fadca 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -358,6 +358,10 @@ export class TurnFlow { private async runTurn(turnId: number, signal: AbortSignal): Promise { let stopHookContinuationUsed = false; const deduper = new ToolCallDeduplicator(); + // Per-subagent loop hooks (e.g. swarm worker stall detection). Composed + // ahead of the built-in prepareToolExecution; undefined for the main agent + // and regular subagents, leaving their hook behavior unchanged. + const subagentPrepareToolExecution = this.agent.subagentLoopHooks?.prepareToolExecution; await this.agent.mcp?.waitForInitialLoad(signal); while (true) { signal.throwIfAborted(); @@ -413,6 +417,13 @@ export class TurnFlow { return { continue: false }; }, prepareToolExecution: async (ctx) => { + // Run the subagent-scoped hook first; honor its decision (a block + // ends the call) before the built-in dedup logic. Falls through + // when it returns undefined, preserving the dedup behavior. + if (subagentPrepareToolExecution !== undefined) { + const subagentResult = await subagentPrepareToolExecution(ctx); + if (subagentResult !== undefined) return subagentResult; + } const cached = deduper.checkSameStep( ctx.toolCall.id, ctx.toolCall.name, diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 25ac5e0d..2882fa2f 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -2,7 +2,7 @@ import type { TokenUsage } from '@moonshot-ai/kosong'; import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; -import type { LoopTurnStopReason } from '../loop'; +import type { LoopHooks, LoopTurnStopReason } from '../loop'; import { DEFAULT_AGENT_PROFILES, prepareSystemPromptContext, @@ -44,6 +44,13 @@ type RunSubagentOptions = { readonly origin?: PromptOrigin | undefined; readonly signal: AbortSignal; readonly profileOverride?: { readonly systemPrompt: string; readonly tools: string[] } | undefined; + /** + * Loop hooks scoped to this subagent only (e.g. swarm worker stall + * detection). Composed into the subagent's turn hooks alongside the + * built-in ones. Absent for the main agent and regular subagents, so their + * behavior is unchanged. + */ + readonly loopHooks?: Partial | undefined; }; type SubagentCompletion = { @@ -104,7 +111,7 @@ export class SessionSubagentHost { ...options, signal: controller.signal, }, - () => this.configureChild(parent, agent, profile), + () => this.configureChild(parent, agent, profile, options), ).finally(() => { unlinkAbortSignal(); this.activeChildren.delete(id); @@ -166,6 +173,7 @@ export class SessionSubagentHost { // reflected — a subagent always uses the parent agent's model. () => { child.config.update({ modelAlias: parent.config.modelAlias }); + child.subagentLoopHooks = options.loopHooks; return Promise.resolve(); }, ).finally(() => { @@ -289,6 +297,7 @@ export class SessionSubagentHost { parent: Agent, child: Agent, profile: ResolvedAgentProfile, + options: RunSubagentOptions, ): Promise { // A subagent always inherits the parent agent's model. child.config.update({ @@ -297,6 +306,10 @@ export class SessionSubagentHost { thinkingLevel: parent.config.thinkingLevel, }); + // Per-worker loop hooks (e.g. swarm stall detection) are scoped to this + // child only; absent for regular subagents, leaving them unaffected. + child.subagentLoopHooks = options.loopHooks; + const context = await prepareSystemPromptContext(child.kaos); child.useProfile(profile, context); } diff --git a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts index c2c440e4..bc4e3369 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts @@ -21,6 +21,9 @@ import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from import type { SessionSubagentHost } from '../../../session/subagent-host'; import { toInputJsonSchema } from '../../support/input-schema'; import { SwarmCoordinator } from '../../../agent/swarm/coordinator'; +import { DEFAULT_STALL_REPEAT_THRESHOLD } from '../../../agent/swarm/types'; +import { createStallDetectionHook } from '../../../agent/swarm/stall-hook'; +import { linkAbortSignal } from '../../../utils/abort'; export const SwarmToolInputSchema = z.object({ task: z.string().describe('The high-level task to decompose and run as a parallel agent swarm.'), @@ -61,22 +64,70 @@ export class SwarmTool implements BuiltinTool { args: SwarmToolInput, ctx: ExecutableToolContext, ): Promise { + const stallRepeatThreshold = DEFAULT_STALL_REPEAT_THRESHOLD; const coordinator = new SwarmCoordinator({ signal: ctx.signal, maxConcurrency: DEFAULT_MAX_CONCURRENCY, + stallRepeatThreshold, onProgress: (text) => ctx.onUpdate?.({ kind: 'status', text }), onProgressCustom: (progress) => ctx.onUpdate?.({ kind: 'custom', customKind: 'swarm', customData: progress }), spawnSubagent: async ({ profileName, systemPrompt, tools, prompt, description, signal }) => { - const handle = await this.subagentHost.spawn(profileName, { - parentToolCallId: ctx.toolCallId, - prompt, - description, - runInBackground: false, - signal, - profileOverride: { systemPrompt, tools }, + // Workers (the swarm: spawns) get stall detection. Planner and + // synthesizer make no tool calls, so the hook is harmless there but we + // scope it to workers to keep their behavior identical. + const isWorker = profileName.startsWith('swarm:'); + if (!isWorker) { + const handle = await this.subagentHost.spawn(profileName, { + parentToolCallId: ctx.toolCallId, + prompt, + description, + runInBackground: false, + signal, + profileOverride: { systemPrompt, tools }, + }); + return handle.completion; + } + + // Per-worker AbortController linked to the incoming signal: a + // coordinator cancel still propagates DOWN, but a stall aborts ONLY + // this worker — the coordinator's signal stays unaborted, so the wave + // records a single failed subtask instead of cancelling the swarm. + const workerController = new AbortController(); + const unlink = linkAbortSignal(signal, workerController); + let stallReason: string | undefined; + const loopHooks = createStallDetectionHook({ + repeatThreshold: stallRepeatThreshold, + onStall: (reason) => { + stallReason = reason; + this.log?.warn(`swarm worker stalled (${description}): ${reason}`); + workerController.abort(new Error(reason)); + }, }); - return handle.completion; + try { + const handle = await this.subagentHost.spawn(profileName, { + parentToolCallId: ctx.toolCallId, + prompt, + description, + runInBackground: false, + signal: workerController.signal, + profileOverride: { systemPrompt, tools }, + loopHooks, + }); + return await handle.completion; + } catch (error) { + // A stall aborts the worker, which surfaces as a generic cancellation + // ("Subagent turn cancelled"). Re-throw the distinguishable stalled + // reason instead so the coordinator records it on the subtask — but + // only when the incoming (coordinator) signal is NOT itself aborted, + // so a genuine swarm-wide cancel still propagates as a cancel. + if (stallReason !== undefined && !signal.aborted) { + throw new Error(stallReason, { cause: error }); + } + throw error; + } finally { + unlink(); + } }, }); diff --git a/packages/agent-core/test/agent/swarm/stall-hook-turn.e2e.test.ts b/packages/agent-core/test/agent/swarm/stall-hook-turn.e2e.test.ts new file mode 100644 index 00000000..dd978fb9 --- /dev/null +++ b/packages/agent-core/test/agent/swarm/stall-hook-turn.e2e.test.ts @@ -0,0 +1,95 @@ +/** + * Turn-level proof that the swarm stall hook + per-worker abort wiring stops a + * worker that repeats the same tool call, while distinct calls run to + * completion. This mirrors how the swarm spawnSubagent adapter wires the hook: + * a per-worker AbortController linked to the parent signal, and an onStall + * callback that aborts that controller with a distinguishable reason. + */ + +import { describe, expect, it } from 'vitest'; + +import { createStallDetectionHook } from '../../../src/agent/swarm/stall-hook'; +import type { LoopHooks } from '../../../src/loop/index'; +import { linkAbortSignal } from '../../../src/utils/abort'; +import { makeToolCall, makeToolUseResponse, makeEndTurnResponse } from '../../loop/fixtures/fake-llm'; +import { runTurn } from '../../loop/fixtures/helpers'; +import { EchoTool } from '../../loop/fixtures/tools'; + +describe('swarm stall hook — turn level', () => { + it('stops a worker that repeats the same tool call and aborts its per-worker controller', async () => { + // Parent (coordinator) signal stays unaborted; per-worker controller is + // linked to it so coordinator cancel still propagates down. + const parent = new AbortController(); + const worker = new AbortController(); + const unlink = linkAbortSignal(parent.signal, worker); + + let stallReason: string | undefined; + const hook: Partial = createStallDetectionHook({ + repeatThreshold: 3, + onStall: (reason) => { + stallReason = reason; + worker.abort(new Error(reason)); + }, + }); + + const echo = new EchoTool(); + // LLM keeps emitting the identical tool call; without a stop the loop would + // run forever. The hook must block on the 3rd repeat and the abort must end + // the turn. + const sameCall = () => makeToolUseResponse([makeToolCall('echo', { text: 'spin' })]); + const responses = [sameCall(), sameCall(), sameCall(), sameCall(), sameCall()]; + + const { result } = await runTurn({ + hooks: hook as LoopHooks, + tools: [echo], + responses, + signal: worker.signal, + }); + + unlink(); + + // Worker turn ended as aborted; the per-worker controller fired. + expect(result.stopReason).toBe('aborted'); + expect(worker.signal.aborted).toBe(true); + expect(stallReason).toMatch(/stalled/i); + expect(stallReason).toContain('echo'); + // Crucially the coordinator's signal is NOT aborted — a single worker + // failure, not a whole-swarm cancel. + expect(parent.signal.aborted).toBe(false); + // The blocked call never executed the tool. + expect(echo.calls.length).toBeLessThan(3); + }); + + it('lets distinct progressing tool calls run to completion without stalling', async () => { + const worker = new AbortController(); + let stalled = false; + const hook: Partial = createStallDetectionHook({ + repeatThreshold: 3, + onStall: () => { + stalled = true; + worker.abort(); + }, + }); + + const echo = new EchoTool(); + const responses = [ + makeToolUseResponse([makeToolCall('echo', { text: 'a' })]), + makeToolUseResponse([makeToolCall('echo', { text: 'b' })]), + makeToolUseResponse([makeToolCall('echo', { text: 'c' })]), + makeToolUseResponse([makeToolCall('echo', { text: 'd' })]), + makeEndTurnResponse('done'), + ]; + + const { result } = await runTurn({ + hooks: hook as LoopHooks, + tools: [echo], + responses, + signal: worker.signal, + }); + + expect(stalled).toBe(false); + expect(worker.signal.aborted).toBe(false); + expect(result.stopReason).toBe('end_turn'); + expect(echo.calls.length).toBe(4); + }); +}); diff --git a/packages/agent-core/test/agent/swarm/stall-hook.test.ts b/packages/agent-core/test/agent/swarm/stall-hook.test.ts new file mode 100644 index 00000000..52da0165 --- /dev/null +++ b/packages/agent-core/test/agent/swarm/stall-hook.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createStallDetectionHook } from '../../../src/agent/swarm/stall-hook'; +import type { ToolExecutionHookContext } from '../../../src/loop/index'; + +function makeCtx(name: string, args: unknown, id = 'call'): ToolExecutionHookContext { + return { + toolCall: { type: 'function', id, name, arguments: JSON.stringify(args) }, + args, + turnId: 'turn-1', + stepNumber: 1, + signal: new AbortController().signal, + // `llm` is unused by the stall hook; the cast keeps the fixture small. + } as unknown as ToolExecutionHookContext; +} + +describe('createStallDetectionHook', () => { + it('blocks and fires onStall exactly once when the same call repeats >= threshold', async () => { + const onStall = vi.fn(); + const hook = createStallDetectionHook({ repeatThreshold: 3, onStall }); + const prepare = hook.prepareToolExecution; + expect(prepare).toBeDefined(); + + const ctx = makeCtx('Read', { path: '/a' }); + + const r1 = await prepare!(ctx); + const r2 = await prepare!(ctx); + expect(r1).toBeUndefined(); + expect(r2).toBeUndefined(); + expect(onStall).not.toHaveBeenCalled(); + + const r3 = await prepare!(ctx); + expect(r3?.block).toBe(true); + expect(r3?.reason).toMatch(/stalled/i); + expect(r3?.reason).toContain('Read'); + expect(onStall).toHaveBeenCalledTimes(1); + + // Further repeats keep blocking but never re-fire onStall. + const r4 = await prepare!(ctx); + expect(r4?.block).toBe(true); + expect(onStall).toHaveBeenCalledTimes(1); + }); + + it('never triggers on distinct progressing calls', async () => { + const onStall = vi.fn(); + const hook = createStallDetectionHook({ repeatThreshold: 3, onStall }); + const prepare = hook.prepareToolExecution!; + + for (let i = 0; i < 10; i += 1) { + const r = await prepare(makeCtx('Read', { path: `/file-${String(i)}` })); + expect(r).toBeUndefined(); + } + expect(onStall).not.toHaveBeenCalled(); + }); + + it('treats canonically-equal args as the same key (key order independent)', async () => { + const onStall = vi.fn(); + const hook = createStallDetectionHook({ repeatThreshold: 2, onStall }); + const prepare = hook.prepareToolExecution!; + + const r1 = await prepare(makeCtx('Edit', { a: 1, b: 2 })); + const r2 = await prepare(makeCtx('Edit', { b: 2, a: 1 })); + expect(r1).toBeUndefined(); + expect(r2?.block).toBe(true); + expect(onStall).toHaveBeenCalledTimes(1); + }); + + it('keys on tool name too: same args under different names do not collide', async () => { + const onStall = vi.fn(); + const hook = createStallDetectionHook({ repeatThreshold: 2, onStall }); + const prepare = hook.prepareToolExecution!; + + await prepare(makeCtx('Read', { path: '/a' })); + const r = await prepare(makeCtx('Grep', { path: '/a' })); + expect(r).toBeUndefined(); + expect(onStall).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent-core/test/swarm/swarm-tool.test.ts b/packages/agent-core/test/swarm/swarm-tool.test.ts index f6b2af8e..b6c62956 100644 --- a/packages/agent-core/test/swarm/swarm-tool.test.ts +++ b/packages/agent-core/test/swarm/swarm-tool.test.ts @@ -2,11 +2,22 @@ import { describe, expect, it, vi } from 'vitest'; import { SwarmTool } from '../../src/tools/builtin/collaboration/swarm'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; +import type { ToolExecutionHookContext } from '../../src/loop/index'; const PLAN_JSON = JSON.stringify({ subtasks: [{ role: 'R', systemPrompt: 'sp', prompt: 'p' }], }); +function makeHookCtx(name: string, args: unknown): ToolExecutionHookContext { + return { + toolCall: { type: 'function', id: 'c', name, arguments: JSON.stringify(args) }, + args, + turnId: 't', + stepNumber: 1, + signal: new AbortController().signal, + } as unknown as ToolExecutionHookContext; +} + function fakeHost(): SessionSubagentHost { const spawn = vi.fn(async (profileName: string) => { const result = @@ -44,4 +55,103 @@ describe('SwarmTool', () => { expect('output' in result && result.output).toBe('FINAL'); expect(updates.length).toBeGreaterThan(0); }); + + it('injects a stall hook + per-worker signal for workers but not planner/synthesizer', async () => { + const seen: Array<{ profileName: string; hasHooks: boolean; sameAsCoordinator: boolean }> = []; + const coordinatorSignal = new AbortController().signal; + const spawn = vi.fn(async (profileName: string, options: any) => { + seen.push({ + profileName, + hasHooks: options.loopHooks !== undefined, + sameAsCoordinator: options.signal === coordinatorSignal, + }); + const result = + profileName === 'swarm-planner' + ? PLAN_JSON + : profileName === 'swarm-synthesizer' + ? 'FINAL' + : 'worker-out'; + return { agentId: 'a', profileName, resumed: false, completion: Promise.resolve({ result }) }; + }); + const host = { spawn } as unknown as SessionSubagentHost; + + const tool = new SwarmTool(host); + const exec = tool.resolveExecution({ task: 'do it' }); + if (!('execute' in exec)) throw new Error('expected runnable execution'); + await exec.execute({ turnId: 't1', toolCallId: 'tc1', signal: coordinatorSignal }); + + const planner = seen.find((s) => s.profileName === 'swarm-planner'); + const synth = seen.find((s) => s.profileName === 'swarm-synthesizer'); + const worker = seen.find((s) => s.profileName === 'swarm:R'); + expect(planner?.hasHooks).toBe(false); + expect(synth?.hasHooks).toBe(false); + // Planner/synthesizer use the coordinator signal directly. + expect(planner?.sameAsCoordinator).toBe(true); + // Worker gets the stall hook and a distinct (linked) per-worker signal. + expect(worker?.hasHooks).toBe(true); + expect(worker?.sameAsCoordinator).toBe(false); + }); + + it('translates a worker stall into a distinguishable error recorded by the coordinator, leaving the coordinator signal unaborted, and still synthesizes', async () => { + const coordinator = new AbortController(); + let synthesizerPrompt: string | undefined; + + const spawn = vi.fn(async (profileName: string, options: any) => { + if (profileName === 'swarm-planner') { + return { + agentId: 'p', + profileName, + resumed: false, + completion: Promise.resolve({ result: PLAN_JSON }), + }; + } + if (profileName === 'swarm-synthesizer') { + synthesizerPrompt = options.prompt; + return { + agentId: 's', + profileName, + resumed: false, + completion: Promise.resolve({ result: 'SYNTH' }), + }; + } + // Worker: drive the injected stall hook with a repeated tool call. The + // hook's onStall aborts the per-worker signal; we mirror the real + // subagent-host path by rejecting with the generic cancel message once + // the per-worker signal is aborted. + const hook = options.loopHooks?.prepareToolExecution; + expect(hook).toBeDefined(); + const ctx = makeHookCtx('Read', { path: '/loop' }); + const completion = (async () => { + for (let i = 0; i < 100; i += 1) { + const decision = await hook(ctx); + if (decision?.block === true) break; + } + // Per-worker signal was aborted by the stall hook. + expect(options.signal.aborted).toBe(true); + const err = new Error('Subagent turn cancelled'); + err.name = 'AbortError'; + throw err; + })(); + return { agentId: 'w', profileName, resumed: false, completion }; + }); + const host = { spawn } as unknown as SessionSubagentHost; + + const tool = new SwarmTool(host); + const exec = tool.resolveExecution({ task: 'do it' }); + if (!('execute' in exec)) throw new Error('expected runnable execution'); + const result = await exec.execute({ + turnId: 't1', + toolCallId: 'tc1', + signal: coordinator.signal, + }); + + // Swarm still completes (synthesis ran) despite the stalled worker. + expect('output' in result && result.output).toBe('SYNTH'); + // The coordinator signal was never aborted by the per-worker stall. + expect(coordinator.signal.aborted).toBe(false); + // The synthesizer prompt records the worker as failed with the + // distinguishable stalled reason. + expect(synthesizerPrompt).toMatch(/stalled/i); + expect(synthesizerPrompt).toContain('Read'); + }); }); From 60bc6beeeec13fcc343f6a6ca60ef5e6d160ee8b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 22:38:30 +0800 Subject: [PATCH 18/28] fix(agent-core): remove NUL byte from swarm stall-hook repeat key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stall-detection repeat key joined the tool name and canonical args with a literal NUL (0x00) separator. The control byte caused git to classify stall-hook.ts as binary, so diffs, blame, and code review on the file were opaque — which prevented confirming the test history for this feature. Replace the NUL with a normal space (tool names are identifiers and never contain spaces, so keys stay collision-free) so the file is plain UTF-8 text and remains reviewable. Behavior is unchanged: the key still uniquely combines tool name and canonical args. Verified by reverting the hook to a no-op stub to show the three stall-detection test files go red (the discriminating block, canonical-key, e2e turn-abort, and worker-stall-translation cases all fail), then restoring the real implementation to confirm they pass — the failing-first the prior atomic commit never recorded. Full suite: 5049 passed / 25 skipped; make typecheck clean. --- .../agent-core/src/agent/swarm/stall-hook.ts | Bin 2397 -> 2397 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/agent-core/src/agent/swarm/stall-hook.ts b/packages/agent-core/src/agent/swarm/stall-hook.ts index 7c7b9edafc63cb2d867a73e234b9f440b0cb375c..eafe52132d6020276058926011e2750838152d34 100644 GIT binary patch delta 20 ccmcaBbXRD@Lw05bmFmeC8O1jLVozoS09bto%m4rY delta 20 ccmcaBbXRD@Lw05cmFmeC8O1jLVozoS09O46tN;K2 From f2cc14889b893613bd2a3bf1eb6db283a3acd15b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 22:48:34 +0800 Subject: [PATCH 19/28] feat(agent-core): swarm coordinator failure-recovery loop (retry/regenerate/reassign/drop) --- .../agent-core/src/agent/swarm/coordinator.ts | 121 ++++++++- packages/agent-core/src/agent/swarm/parse.ts | 45 +++- .../agent-core/src/agent/swarm/prompts.ts | 44 +++- packages/agent-core/src/agent/swarm/types.ts | 41 ++- .../agent-core/test/swarm/coordinator.test.ts | 242 +++++++++++++++++- packages/agent-core/test/swarm/parse.test.ts | 62 ++++- .../agent-core/test/swarm/swarm-tool.test.ts | 10 + 7 files changed, 550 insertions(+), 15 deletions(-) diff --git a/packages/agent-core/src/agent/swarm/coordinator.ts b/packages/agent-core/src/agent/swarm/coordinator.ts index 4d2d4fff..f75b42fe 100644 --- a/packages/agent-core/src/agent/swarm/coordinator.ts +++ b/packages/agent-core/src/agent/swarm/coordinator.ts @@ -1,15 +1,25 @@ import { mapWithConcurrency } from './concurrency'; -import { parsePlan } from './parse'; +import { parsePlan, parseReviseDecision } from './parse'; import { ALLOWED_WORKER_TOOLS, DEFAULT_WORKER_TOOLS, PLANNER_SYSTEM_PROMPT, + REVISER_SYSTEM_PROMPT, SYNTHESIZER_SYSTEM_PROMPT, renderPlannerPrompt, renderPlannerRetryPrompt, + renderReviseSubtaskPrompt, renderSynthesizerPrompt, } from './prompts'; -import type { SwarmCoordinatorDeps, SwarmPlan, SwarmProgress } from './types'; +import { + DEFAULT_MAX_ATTEMPTS, + DEFAULT_MAX_WAVES, + type ReviseDecision, + type Subtask, + type SwarmCoordinatorDeps, + type SwarmPlan, + type SwarmProgress, +} from './types'; export class SwarmCoordinator { constructor(private readonly deps: SwarmCoordinatorDeps) {} @@ -29,7 +39,7 @@ export class SwarmCoordinator { this.progress(`Planned ${String(plan.subtasks.length)} subtasks`); this.emit({ phase: 'planned', total: plan.subtasks.length }); - await this.runWave(plan); + await this.runWithRetries(plan); this.emit({ phase: 'synthesizing' }); this.progress('Synthesizing results…'); @@ -43,7 +53,8 @@ export class SwarmCoordinator { }); const succeeded = plan.subtasks.filter((s) => s.status === 'done').length; const failed = plan.subtasks.filter((s) => s.status === 'failed').length; - this.emit({ phase: 'done', succeeded, failed }); + const dropped = plan.subtasks.filter((s) => s.status === 'dropped').length; + this.emit({ phase: 'done', succeeded, failed, dropped }); return result.result; } @@ -73,11 +84,56 @@ export class SwarmCoordinator { throw new Error('Swarm planner failed to produce a valid plan after one retry'); } - private async runWave(plan: SwarmPlan): Promise { + /** + * Wave loop with bounded failure recovery. Each iteration runs the pending + * subtasks; then, for every subtask still 'failed', either force-drops it + * (attempts exhausted) or asks the reviser how to recover it and re-queues it + * for the next wave. Terminates when no subtasks remain pending, or when the + * {@link DEFAULT_MAX_WAVES} safety bound is hit. + */ + private async runWithRetries(plan: SwarmPlan): Promise { + const maxAttempts = this.deps.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const maxWaves = this.deps.maxWaves ?? DEFAULT_MAX_WAVES; + + for (let wave = 0; wave < maxWaves; wave += 1) { + const pending = plan.subtasks.filter((s) => s.status === 'pending'); + if (pending.length === 0) break; + + await this.runWave(pending); + + for (const st of plan.subtasks) { + if (st.status !== 'failed') continue; + if (st.attempts >= maxAttempts) { + this.forceDrop(st, `attempts exhausted (${String(st.attempts)})`); + continue; + } + const decision = await this.reviseSubtask(st); + this.emit({ + phase: 'revising', + subtaskId: st.id, + decision: decision.kind, + attempt: st.attempts, + }); + this.applyDecision(st, decision); + } + } + + // Safety net: anything still pending after the wave bound is dropped so the + // loop is guaranteed to terminate and the subtask surfaces as a gap. + for (const st of plan.subtasks) { + if (st.status === 'pending' || st.status === 'failed') { + this.forceDrop(st, 'recovery wave limit reached'); + } + } + } + + /** Run a SUBSET of subtasks (the pending ones passed in) concurrently. */ + private async runWave(subtasks: Subtask[]): Promise { const limit = this.deps.maxConcurrency ?? 4; - await mapWithConcurrency(plan.subtasks, limit, async (st) => { + await mapWithConcurrency(subtasks, limit, async (st) => { this.deps.signal.throwIfAborted(); st.status = 'running'; + st.attempts += 1; this.progress(`▸ ${st.role}: started`); try { const out = await this.deps.spawnSubagent({ @@ -94,6 +150,7 @@ export class SwarmCoordinator { st.status = 'done'; this.progress(`✓ ${st.role}: done`); } catch (err) { + // A genuine swarm-wide cancel must propagate (and must NOT be revised). if (this.deps.signal.aborted) throw err; st.status = 'failed'; st.error = err instanceof Error ? err.message : String(err); @@ -101,4 +158,56 @@ export class SwarmCoordinator { } }); } + + /** + * Ask a reviser subagent how to recover one failed subtask. On a malformed + * response we conservatively drop (rather than burn an attempt on a confused + * reviser). + */ + private async reviseSubtask(st: Subtask): Promise { + const out = await this.deps.spawnSubagent({ + profileName: 'swarm-reviser', + systemPrompt: REVISER_SYSTEM_PROMPT, + tools: [], + prompt: renderReviseSubtaskPrompt(st, st.error), + description: `Swarm reviser (${st.role})`, + signal: this.deps.signal, + }); + return ( + parseReviseDecision(out.result) ?? { + kind: 'drop', + reason: 'reviser produced no valid decision', + } + ); + } + + /** Apply a reviser decision in place, re-queueing the subtask unless dropped. */ + private applyDecision(st: Subtask, decision: ReviseDecision): void { + switch (decision.kind) { + case 'retry': + st.status = 'pending'; + return; + case 'regenerate': + st.prompt = decision.prompt; + st.status = 'pending'; + return; + case 'reassign': + st.role = decision.role; + st.systemPrompt = decision.systemPrompt; + st.toolAllowlist = decision.toolAllowlist; + st.status = 'pending'; + return; + case 'drop': + this.forceDrop(st, decision.reason); + return; + } + } + + /** Mark a subtask dropped, record the reason, and emit a 'dropped' event. */ + private forceDrop(st: Subtask, reason: string): void { + st.status = 'dropped'; + st.error = st.error === undefined ? `dropped: ${reason}` : `${st.error} (dropped: ${reason})`; + this.progress(`x ${st.role}: dropped (${reason})`); + this.emit({ phase: 'dropped', subtaskId: st.id, reason }); + } } diff --git a/packages/agent-core/src/agent/swarm/parse.ts b/packages/agent-core/src/agent/swarm/parse.ts index 82c903ff..65ef1d5f 100644 --- a/packages/agent-core/src/agent/swarm/parse.ts +++ b/packages/agent-core/src/agent/swarm/parse.ts @@ -1,4 +1,4 @@ -import type { SwarmPlan, Subtask } from './types'; +import type { ReviseDecision, SwarmPlan, Subtask } from './types'; export function extractJsonObject(text: string): string | null { const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text); @@ -46,7 +46,50 @@ export function parsePlan(rootTask: string, text: string): SwarmPlan | null { prompt: o['prompt'], toolAllowlist, status: 'pending', + attempts: 0, }); } return { rootTask, subtasks }; } + +/** + * Parse a reviser subagent's decision about a single failed subtask. Returns + * `null` on any malformed input (missing/invalid `kind` or required per-variant + * fields) so the caller can apply a conservative fallback. + */ +export function parseReviseDecision(text: string): ReviseDecision | 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 o = parsed as Record; + + switch (o['kind']) { + case 'retry': + return { kind: 'retry' }; + case 'regenerate': + if (typeof o['prompt'] !== 'string' || o['prompt'].length === 0) return null; + return { kind: 'regenerate', prompt: o['prompt'] }; + case 'reassign': { + if (typeof o['role'] !== 'string' || o['role'].length === 0) return null; + if (typeof o['systemPrompt'] !== 'string' || o['systemPrompt'].length === 0) return null; + const toolAllowlist = Array.isArray(o['toolAllowlist']) + ? o['toolAllowlist'].filter((t): t is string => typeof t === 'string') + : undefined; + return toolAllowlist === undefined + ? { kind: 'reassign', role: o['role'], systemPrompt: o['systemPrompt'] } + : { kind: 'reassign', role: o['role'], systemPrompt: o['systemPrompt'], toolAllowlist }; + } + case 'drop': + if (typeof o['reason'] !== 'string' || o['reason'].length === 0) return null; + return { kind: 'drop', reason: o['reason'] }; + default: + return null; + } +} diff --git a/packages/agent-core/src/agent/swarm/prompts.ts b/packages/agent-core/src/agent/swarm/prompts.ts index 9e44b58c..79747ee2 100644 --- a/packages/agent-core/src/agent/swarm/prompts.ts +++ b/packages/agent-core/src/agent/swarm/prompts.ts @@ -1,4 +1,4 @@ -import type { SwarmPlan } from './types'; +import type { Subtask, 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']; @@ -41,14 +41,50 @@ export function renderPlannerRetryPrompt(rootTask: string, previous: string): st 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.', + 'If a subtask failed or was dropped, surface the gap explicitly instead of inventing its content. Never pretend a dropped or failed subtask succeeded.', ].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'}]`; + let body: string; + if (st.status === 'done') { + body = st.result ?? ''; + } else if (st.status === 'dropped') { + body = `[DROPPED: ${st.error ?? 'no reason given'}]`; + } else { + body = `[FAILED: ${st.error ?? 'unknown error'}]`; + } return `### ${st.role} (${st.status})\n${body}`; }); return [`Original task:\n${plan.rootTask}`, '', 'Worker outputs:', '', ...blocks].join('\n'); } + +export const REVISER_SYSTEM_PROMPT = [ + 'You are a swarm reviser. You are given ONE subtask that failed (a real error or a detected stall/loop) along with its error.', + 'Decide how to recover it by choosing exactly one of:', + '- retry: re-run the subtask unchanged (use only for transient/flaky errors).', + '- regenerate: re-run with a more specific, better-scoped prompt you provide.', + '- reassign: re-run under a different role with a new system prompt (and optionally a restricted toolAllowlist).', + '- drop: abandon the subtask when it is impossible or not worth retrying; give a short reason.', + 'For stalled or looping errors, prefer regenerate (with a tighter, more concrete prompt) or reassign — a plain retry will usually stall again.', + `Tools available to workers: ${ALLOWED_WORKER_TOOLS.join(', ')} (toolAllowlist may only restrict to a subset).`, + 'Output ONLY a JSON object, no prose, matching exactly one of:', + '{"kind":"retry"}', + '{"kind":"regenerate","prompt":"..."}', + '{"kind":"reassign","role":"...","systemPrompt":"...","toolAllowlist":["Read"]}', + '{"kind":"drop","reason":"..."}', +].join('\n'); + +export function renderReviseSubtaskPrompt(subtask: Subtask, error: string | undefined): string { + return [ + 'A subtask failed. Decide how to recover it.', + '', + `Role: ${subtask.role}`, + `System prompt: ${subtask.systemPrompt}`, + `Prompt: ${subtask.prompt}`, + `Attempts so far: ${String(subtask.attempts)}`, + `Error: ${error ?? 'unknown error'}`, + '', + 'Return ONLY the JSON decision object.', + ].join('\n'); +} diff --git a/packages/agent-core/src/agent/swarm/types.ts b/packages/agent-core/src/agent/swarm/types.ts index bc7cefeb..e48c7699 100644 --- a/packages/agent-core/src/agent/swarm/types.ts +++ b/packages/agent-core/src/agent/swarm/types.ts @@ -4,9 +4,11 @@ export interface Subtask { systemPrompt: string; prompt: string; toolAllowlist?: string[] | undefined; - status: 'pending' | 'running' | 'done' | 'failed'; + status: 'pending' | 'running' | 'done' | 'failed' | 'dropped'; result?: string | undefined; error?: string | undefined; + /** Number of times this subtask has actually been executed by a worker. */ + attempts: number; } export interface SwarmPlan { @@ -24,10 +26,27 @@ export type SpawnSubagentFn = (args: { signal: AbortSignal; }) => Promise<{ result: string }>; +/** + * Decision a reviser subagent makes about a single failed/stalled subtask. + * Shape mirrors the JSON the reviser emits (see {@link parseReviseDecision}). + */ +export type ReviseDecision = + | { kind: 'retry' } + | { kind: 'regenerate'; prompt: string } + | { kind: 'reassign'; role: string; systemPrompt: string; toolAllowlist?: string[] } + | { kind: 'drop'; reason: string }; + export type SwarmProgress = | { phase: 'planned'; total: number } + | { + phase: 'revising'; + subtaskId: string; + decision: 'retry' | 'regenerate' | 'reassign' | 'drop'; + attempt: number; + } + | { phase: 'dropped'; subtaskId: string; reason: string } | { phase: 'synthesizing' } - | { phase: 'done'; succeeded: number; failed: number }; + | { phase: 'done'; succeeded: number; failed: number; dropped: number }; export interface SwarmCoordinatorDeps { spawnSubagent: SpawnSubagentFn; @@ -42,7 +61,25 @@ export interface SwarmCoordinatorDeps { * {@link DEFAULT_STALL_REPEAT_THRESHOLD}. */ stallRepeatThreshold?: number | undefined; + /** + * Maximum number of times a single subtask is executed before it is + * force-dropped (counting the original run). Defaults to + * {@link DEFAULT_MAX_ATTEMPTS}. + */ + maxAttempts?: number | undefined; + /** + * Safety bound on the number of wave iterations the recovery loop performs + * before giving up, to guarantee termination. Defaults to + * {@link DEFAULT_MAX_WAVES}. + */ + maxWaves?: number | undefined; } /** Default repeat threshold for swarm worker stall detection. */ export const DEFAULT_STALL_REPEAT_THRESHOLD = 10; + +/** Default cap on per-subtask execution attempts before a force-drop. */ +export const DEFAULT_MAX_ATTEMPTS = 2; + +/** Default safety cap on recovery-loop wave iterations. */ +export const DEFAULT_MAX_WAVES = 6; diff --git a/packages/agent-core/test/swarm/coordinator.test.ts b/packages/agent-core/test/swarm/coordinator.test.ts index ac9d512f..3cc89d6f 100644 --- a/packages/agent-core/test/swarm/coordinator.test.ts +++ b/packages/agent-core/test/swarm/coordinator.test.ts @@ -110,7 +110,11 @@ describe('SwarmCoordinator.run', () => { const payloads = (onProgressCustom as ReturnType).mock.calls.map((c) => c[0]); expect(payloads).toContainEqual({ phase: 'planned', total: 2 }); expect(payloads).toContainEqual({ phase: 'synthesizing' }); - expect(payloads.some((p) => p.phase === 'done' && p.succeeded === 2 && p.failed === 0)).toBe(true); + expect( + payloads.some( + (p) => p.phase === 'done' && p.succeeded === 2 && p.failed === 0 && p.dropped === 0, + ), + ).toBe(true); }); it('propagates abort instead of swallowing it (no synthesis after cancel)', async () => { @@ -129,3 +133,239 @@ describe('SwarmCoordinator.run', () => { expect(profiles).not.toContain('swarm-synthesizer'); }); }); + +// One-subtask plan keeps wave behavior deterministic for recovery tests. +const ONE_PLAN = JSON.stringify({ + subtasks: [{ id: 'task-1', role: 'Worker', systemPrompt: 'sp', prompt: 'p-original' }], +}); + +describe('SwarmCoordinator failure recovery', () => { + it('retry: a worker fails once, reviser says retry, re-run succeeds', async () => { + let workerCalls = 0; + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: ONE_PLAN }; + if (args.profileName === 'swarm-synthesizer') return { result: 'SYNTH' }; + if (args.profileName === 'swarm-reviser') return { result: '{"kind":"retry"}' }; + // swarm:Worker + workerCalls += 1; + if (workerCalls === 1) throw new Error('boom'); + return { result: 'worker-ok' }; + }); + const onProgressCustom = vi.fn(); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + onProgressCustom, + }); + const result = await coordinator.run('x'); + expect(result).toBe('SYNTH'); + expect(workerCalls).toBe(2); + const payloads = (onProgressCustom as ReturnType).mock.calls.map((c) => c[0]); + expect(payloads).toContainEqual({ + phase: 'revising', + subtaskId: 'task-1', + decision: 'retry', + attempt: 1, + }); + expect( + payloads.some( + (p) => p.phase === 'done' && p.succeeded === 1 && p.failed === 0 && p.dropped === 0, + ), + ).toBe(true); + }); + + it('regenerate: re-run uses the new prompt from the reviser', async () => { + const workerPrompts: string[] = []; + let workerCalls = 0; + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: ONE_PLAN }; + if (args.profileName === 'swarm-synthesizer') return { result: 'SYNTH' }; + if (args.profileName === 'swarm-reviser') + return { result: '{"kind":"regenerate","prompt":"NEW PROMPT"}' }; + workerCalls += 1; + workerPrompts.push(args.prompt); + if (workerCalls === 1) throw new Error('boom'); + return { result: 'worker-ok' }; + }); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + }); + const result = await coordinator.run('x'); + expect(result).toBe('SYNTH'); + expect(workerPrompts[0]).toBe('p-original'); + expect(workerPrompts[1]).toBe('NEW PROMPT'); + }); + + it('reassign: re-run uses the new role, systemPrompt, and tools', async () => { + const seen: Array<{ profileName: string; systemPrompt: string; tools: string[] }> = []; + let workerCalls = 0; + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: ONE_PLAN }; + if (args.profileName === 'swarm-synthesizer') return { result: 'SYNTH' }; + if (args.profileName === 'swarm-reviser') + return { + result: '{"kind":"reassign","role":"R2","systemPrompt":"SP2","toolAllowlist":["Read"]}', + }; + seen.push({ + profileName: args.profileName, + systemPrompt: args.systemPrompt, + tools: args.tools, + }); + workerCalls += 1; + if (workerCalls === 1) throw new Error('boom'); + return { result: 'worker-ok' }; + }); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + }); + const result = await coordinator.run('x'); + expect(result).toBe('SYNTH'); + expect(seen[0]?.profileName).toBe('swarm:Worker'); + expect(seen[1]?.profileName).toBe('swarm:R2'); + expect(seen[1]?.systemPrompt).toBe('SP2'); + expect(seen[1]?.tools).toEqual(['Read']); + }); + + it('drop (LLM-chosen): a dropped subtask is not re-run and is surfaced as a gap', async () => { + let workerCalls = 0; + let synthesizerPrompt: string | undefined; + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: ONE_PLAN }; + if (args.profileName === 'swarm-synthesizer') { + synthesizerPrompt = args.prompt; + return { result: 'SYNTH' }; + } + if (args.profileName === 'swarm-reviser') + return { result: '{"kind":"drop","reason":"impossible"}' }; + workerCalls += 1; + throw new Error('boom'); + }); + const onProgressCustom = vi.fn(); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + onProgressCustom, + }); + const result = await coordinator.run('x'); + expect(result).toBe('SYNTH'); + expect(workerCalls).toBe(1); // ran once, then dropped — never re-run + const payloads = (onProgressCustom as ReturnType).mock.calls.map((c) => c[0]); + expect(payloads).toContainEqual({ + phase: 'dropped', + subtaskId: 'task-1', + reason: 'impossible', + }); + expect( + payloads.some( + (p) => p.phase === 'done' && p.succeeded === 0 && p.failed === 0 && p.dropped === 1, + ), + ).toBe(true); + expect(synthesizerPrompt).toMatch(/DROPPED/); + expect(synthesizerPrompt).toContain('impossible'); + }); + + it('maxAttempts: a perpetually failing subtask runs exactly maxAttempts times then force-drops', async () => { + let workerCalls = 0; + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: ONE_PLAN }; + if (args.profileName === 'swarm-synthesizer') return { result: 'SYNTH' }; + if (args.profileName === 'swarm-reviser') return { result: '{"kind":"retry"}' }; + workerCalls += 1; + throw new Error('always-boom'); + }); + const onProgressCustom = vi.fn(); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + maxAttempts: 2, + onProgressCustom, + }); + const result = await coordinator.run('x'); + expect(result).toBe('SYNTH'); + expect(workerCalls).toBe(2); // exactly maxAttempts runs + const payloads = (onProgressCustom as ReturnType).mock.calls.map((c) => c[0]); + expect(payloads.some((p) => p.phase === 'dropped' && p.subtaskId === 'task-1')).toBe(true); + expect( + payloads.some((p) => p.phase === 'done' && p.succeeded === 0 && p.dropped === 1), + ).toBe(true); + // Reviser is consulted only after attempt 1 (attempt 2 hits the cap and force-drops). + const reviserCalls = (spawn as ReturnType).mock.calls + .map((c) => c[0]) + .filter((c) => c.profileName === 'swarm-reviser'); + expect(reviserCalls).toHaveLength(1); + }); + + it('reviser parse failure falls back to a conservative drop (does not burn attempts)', async () => { + let workerCalls = 0; + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: ONE_PLAN }; + if (args.profileName === 'swarm-synthesizer') return { result: 'SYNTH' }; + if (args.profileName === 'swarm-reviser') return { result: 'I am confused, no json here' }; + workerCalls += 1; + throw new Error('boom'); + }); + const onProgressCustom = vi.fn(); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + onProgressCustom, + }); + const result = await coordinator.run('x'); + expect(result).toBe('SYNTH'); + expect(workerCalls).toBe(1); + const payloads = (onProgressCustom as ReturnType).mock.calls.map((c) => c[0]); + expect(payloads).toContainEqual({ + phase: 'revising', + subtaskId: 'task-1', + decision: 'drop', + attempt: 1, + }); + expect(payloads.some((p) => p.phase === 'dropped' && p.subtaskId === 'task-1')).toBe(true); + }); + + it('multi-wave: a revised subtask re-runs in a later wave and the loop terminates', async () => { + // Two subtasks; both fail on wave 1, retry, both succeed on wave 2. + const TWO_PLAN = JSON.stringify({ + subtasks: [ + { id: 'task-1', role: 'A', systemPrompt: 'spa', prompt: 'pa' }, + { id: 'task-2', role: 'B', systemPrompt: 'spb', prompt: 'pb' }, + ], + }); + const calls: Record = {}; + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: TWO_PLAN }; + if (args.profileName === 'swarm-synthesizer') return { result: 'SYNTH' }; + if (args.profileName === 'swarm-reviser') return { result: '{"kind":"retry"}' }; + calls[args.profileName] = (calls[args.profileName] ?? 0) + 1; + if (calls[args.profileName] === 1) throw new Error('boom'); + return { result: 'ok' }; + }); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + }); + const result = await coordinator.run('x'); + expect(result).toBe('SYNTH'); + expect(calls['swarm:A']).toBe(2); + expect(calls['swarm:B']).toBe(2); + }); + + it('does not revise on a genuine swarm-wide cancel (re-throws the abort)', async () => { + const controller = new AbortController(); + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: ONE_PLAN }; + // Worker: a real swarm-wide cancel — abort the coordinator signal and throw. + controller.abort(); + const e = new Error('aborted'); + e.name = 'AbortError'; + throw e; + }); + const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: controller.signal }); + await expect(coordinator.run('x')).rejects.toThrow(); + const profiles = (spawn as ReturnType).mock.calls.map((c) => c[0].profileName); + expect(profiles).not.toContain('swarm-reviser'); + expect(profiles).not.toContain('swarm-synthesizer'); + }); +}); diff --git a/packages/agent-core/test/swarm/parse.test.ts b/packages/agent-core/test/swarm/parse.test.ts index b2146527..62186bba 100644 --- a/packages/agent-core/test/swarm/parse.test.ts +++ b/packages/agent-core/test/swarm/parse.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { extractJsonObject, parsePlan } from '../../src/agent/swarm/parse'; +import { extractJsonObject, parsePlan, parseReviseDecision } from '../../src/agent/swarm/parse'; describe('extractJsonObject', () => { it('extracts a fenced json block', () => { @@ -45,3 +45,63 @@ describe('parsePlan', () => { expect(parsePlan('root', 'totally not json')).toBeNull(); }); }); + +describe('parseReviseDecision', () => { + it('parses a retry decision', () => { + expect(parseReviseDecision('{"kind":"retry"}')).toEqual({ kind: 'retry' }); + }); + + it('parses a retry decision from a fenced block', () => { + expect(parseReviseDecision('```json\n{"kind":"retry"}\n```')).toEqual({ kind: 'retry' }); + }); + + it('parses a regenerate decision with a new prompt', () => { + expect(parseReviseDecision('{"kind":"regenerate","prompt":"NEW"}')).toEqual({ + kind: 'regenerate', + prompt: 'NEW', + }); + }); + + it('parses a reassign decision with role, systemPrompt, and toolAllowlist', () => { + expect( + parseReviseDecision( + '{"kind":"reassign","role":"R2","systemPrompt":"SP2","toolAllowlist":["Read"]}', + ), + ).toEqual({ kind: 'reassign', role: 'R2', systemPrompt: 'SP2', toolAllowlist: ['Read'] }); + }); + + it('parses a reassign decision without a toolAllowlist', () => { + expect(parseReviseDecision('{"kind":"reassign","role":"R2","systemPrompt":"SP2"}')).toEqual({ + kind: 'reassign', + role: 'R2', + systemPrompt: 'SP2', + }); + }); + + it('parses a drop decision with a reason', () => { + expect(parseReviseDecision('{"kind":"drop","reason":"impossible"}')).toEqual({ + kind: 'drop', + reason: 'impossible', + }); + }); + + it('returns null for an unknown kind', () => { + expect(parseReviseDecision('{"kind":"explode"}')).toBeNull(); + }); + + it('returns null when a regenerate decision misses its prompt', () => { + expect(parseReviseDecision('{"kind":"regenerate"}')).toBeNull(); + }); + + it('returns null when a reassign decision misses required fields', () => { + expect(parseReviseDecision('{"kind":"reassign","role":"R2"}')).toBeNull(); + }); + + it('returns null when a drop decision misses its reason', () => { + expect(parseReviseDecision('{"kind":"drop"}')).toBeNull(); + }); + + it('returns null for non-json garbage', () => { + expect(parseReviseDecision('totally not json')).toBeNull(); + }); +}); diff --git a/packages/agent-core/test/swarm/swarm-tool.test.ts b/packages/agent-core/test/swarm/swarm-tool.test.ts index b6c62956..8fcbbc6d 100644 --- a/packages/agent-core/test/swarm/swarm-tool.test.ts +++ b/packages/agent-core/test/swarm/swarm-tool.test.ts @@ -114,6 +114,16 @@ describe('SwarmTool', () => { completion: Promise.resolve({ result: 'SYNTH' }), }; } + if (profileName === 'swarm-reviser') { + // The coordinator now consults a reviser for the stalled subtask; drop + // it so the worker is not re-run and the stall surfaces as a gap. + return { + agentId: 'r', + profileName, + resumed: false, + completion: Promise.resolve({ result: '{"kind":"drop","reason":"stall is unrecoverable"}' }), + }; + } // Worker: drive the injected stall hook with a repeated tool call. The // hook's onStall aborts the per-worker signal; we mirror the real // subagent-host path by rejecting with the generic cancel message once From 53753002b08f8fddf7c56d2e52b13f40e5ef384c Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 23:03:58 +0800 Subject: [PATCH 20/28] feat(tui): surface swarm recovery (retrying/dropped) in the dashboard --- .../messages/swarm-dashboard-model.ts | 142 ++++++++++++++++-- .../src/tui/components/messages/tool-call.ts | 16 +- .../tui/controllers/session-event-handler.ts | 23 ++- .../messages/swarm-dashboard-model.test.ts | 103 +++++++++++++ .../components/messages/swarm-routing.test.ts | 69 +++++++++ .../messages/tool-call-swarm.test.ts | 52 +++++++ .../agent-core/src/agent/swarm/coordinator.ts | 5 +- packages/agent-core/src/agent/swarm/types.ts | 8 +- .../agent-core/test/swarm/coordinator.test.ts | 15 ++ 9 files changed, 417 insertions(+), 16 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts index 8bcc3fd4..79d6d98b 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts @@ -1,5 +1,5 @@ export type SwarmPhase = 'planning' | 'working' | 'synthesizing' | 'done' | 'cancelled'; -export type WorkerStatus = 'running' | 'done' | 'failed' | 'retrying'; +export type WorkerStatus = 'running' | 'done' | 'failed' | 'retrying' | 'dropped'; export interface WorkerRow { id: string; @@ -17,22 +17,79 @@ export interface SwarmModel { total: number; doneCount: number; failedCount: number; + droppedCount: number; workers: Map; } export type SwarmEvent = | { t: 'planned'; total: number } | { t: 'synthesizing' } - | { t: 'done'; succeeded: number; failed: number } + | { t: 'done'; succeeded: number; failed: number; dropped?: number } | { t: 'cancelled' } | { t: 'worker.spawned'; id: string; role: string } | { t: 'worker.toolcall'; id: string; activity: string } | { t: 'worker.tokens'; id: string; tokens: number } | { t: 'worker.done'; id: string; tokens?: number } - | { t: 'worker.failed'; id: string; error: string }; + | { t: 'worker.failed'; id: string; error: string } + | { t: 'worker.retrying'; role: string } + | { t: 'worker.dropped'; role: string; reason: string }; export function initialSwarmModel(task: string): SwarmModel { - return { task, phase: 'planning', total: 0, doneCount: 0, failedCount: 0, workers: new Map() }; + return { + task, + phase: 'planning', + total: 0, + doneCount: 0, + failedCount: 0, + droppedCount: 0, + workers: new Map(), + }; +} + +/** + * Which summary counter (if any) a worker status contributes to. `running` and + * `retrying` are in-flight states that count toward nothing; the three terminal + * states each map to exactly one counter. Used to keep `doneCount`/ + * `failedCount`/`droppedCount` consistent as a row transitions across attempts + * (e.g. failed → retrying → running → done) without ever double-counting. + */ +function countKeyFor(status: WorkerStatus): 'doneCount' | 'failedCount' | 'droppedCount' | null { + if (status === 'done') return 'doneCount'; + if (status === 'failed') return 'failedCount'; + if (status === 'dropped') return 'droppedCount'; + // 'running' and 'retrying' are in-flight states — they count toward nothing. + return null; +} + +/** Counter adjustments to move a row from `prev` to `next` status. */ +function countAdjustments( + prev: WorkerStatus, + next: WorkerStatus, +): Partial> { + const from = countKeyFor(prev); + const to = countKeyFor(next); + if (from === to) return {}; + const adj: Partial> = {}; + if (from !== null) adj[from] = -1; + if (to !== null) adj[to] = (adj[to] ?? 0) + 1; + return adj; +} + +/** Apply count deltas onto a model, clamping at zero. */ +function withCounts( + model: SwarmModel, + adj: Partial>, +): Pick { + return { + doneCount: Math.max(0, model.doneCount + (adj.doneCount ?? 0)), + failedCount: Math.max(0, model.failedCount + (adj.failedCount ?? 0)), + droppedCount: Math.max(0, model.droppedCount + (adj.droppedCount ?? 0)), + }; +} + +/** A status the recovery loop can collapse a re-spawn onto (one row per role). */ +function isReusableForRespawn(status: WorkerStatus): boolean { + return status === 'failed' || status === 'dropped' || status === 'retrying'; } export function applySwarmEvent(model: SwarmModel, event: SwarmEvent): SwarmModel { @@ -46,10 +103,24 @@ export function applySwarmEvent(model: SwarmModel, event: SwarmEvent): SwarmMode case 'cancelled': return { ...model, phase: 'cancelled' }; case 'worker.spawned': { + if (model.workers.has(event.id)) return model; const workers = new Map(model.workers); - if (!workers.has(event.id)) { + // Recovery: if a row for this role exists in a terminal/retrying state, a + // re-spawn is the SAME subtask running again. Reuse that row (re-key it to + // the new subagent id, reset to running, clear the error) so the role keeps + // a single dashboard row across attempts instead of accumulating duplicates. + // Running rows are never reused, so single-run same-role fan-out is intact. + const prior = findReusableRoleRow(model.workers, event.role); + if (prior !== undefined) { + workers.delete(prior.id); workers.set(event.id, { id: event.id, role: event.role, status: 'running', toolCount: 0 }); + return { + ...model, + workers, + ...withCounts(model, countAdjustments(prior.status, 'running')), + }; } + workers.set(event.id, { id: event.id, role: event.role, status: 'running', toolCount: 0 }); return { ...model, workers }; } case 'worker.toolcall': { @@ -71,28 +142,81 @@ export function applySwarmEvent(model: SwarmModel, event: SwarmEvent): SwarmMode const workers = new Map(model.workers); const w = workers.get(event.id); if (w === undefined) return model; - const wasTerminal = w.status === 'done' || w.status === 'failed'; workers.set(event.id, { ...w, status: 'done', latestActivity: undefined, ...(event.tokens !== undefined ? { tokens: event.tokens } : {}), }); - return { ...model, workers, doneCount: wasTerminal ? model.doneCount : model.doneCount + 1 }; + return { ...model, workers, ...withCounts(model, countAdjustments(w.status, 'done')) }; } case 'worker.failed': { const workers = new Map(model.workers); const w = workers.get(event.id); if (w === undefined) return model; - const wasTerminal = w.status === 'done' || w.status === 'failed'; workers.set(event.id, { ...w, status: 'failed', latestActivity: undefined, error: event.error }); - return { ...model, workers, failedCount: wasTerminal ? model.failedCount : model.failedCount + 1 }; + return { ...model, workers, ...withCounts(model, countAdjustments(w.status, 'failed')) }; + } + case 'worker.retrying': { + // The coordinator decided to re-run this role's subtask. Keep its row + // visible but mark it retrying (an in-flight, uncounted state) so the + // re-spawn can collapse onto it. Carries no subagent id, so we match by + // role against the most recent terminal/retrying row. + const prior = findReusableRoleRow(model.workers, event.role); + if (prior === undefined || prior.status === 'retrying') return model; + const workers = new Map(model.workers); + const adj = countAdjustments(prior.status, 'retrying'); + workers.set(prior.id, { ...prior, status: 'retrying', latestActivity: undefined }); + return { ...model, workers, ...withCounts(model, adj) }; + } + case 'worker.dropped': { + // The coordinator gave up on this role's subtask. Mark its row dropped + // (or create a dropped row if the subtask never spawned a worker) and + // record the reason. + const prior = findReusableRoleRow(model.workers, event.role) ?? findRoleRow(model.workers, event.role); + const workers = new Map(model.workers); + if (prior === undefined) { + // No row yet (dropped before ever spawning): synthesize one keyed by the + // role so the gap is visible. A role label collides with no subagent id. + workers.set(event.role, { + id: event.role, + role: event.role, + status: 'dropped', + toolCount: 0, + error: event.reason, + }); + return { ...model, workers, ...withCounts(model, countAdjustments('running', 'dropped')) }; + } + workers.set(prior.id, { ...prior, status: 'dropped', latestActivity: undefined, error: event.reason }); + return { ...model, workers, ...withCounts(model, countAdjustments(prior.status, 'dropped')) }; } default: return model; } } +/** Most recently inserted row for a role (any status), or undefined. */ +function findRoleRow(workers: Map, role: string): WorkerRow | undefined { + let match: WorkerRow | undefined; + for (const w of workers.values()) { + if (w.role === role) match = w; + } + return match; +} + +/** + * Most recently inserted row for a role that a re-spawn or revise can collapse + * onto (terminal or retrying). Running rows are skipped so concurrent same-role + * workers in a single run keep distinct rows. + */ +function findReusableRoleRow(workers: Map, role: string): WorkerRow | undefined { + let match: WorkerRow | undefined; + for (const w of workers.values()) { + if (w.role === role && isReusableForRespawn(w.status)) match = w; + } + return match; +} + export function workerActivityFromTool(name: string, args: Record): string { const s = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); switch (name) { diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index d6a8eb54..7d540b5e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -1310,8 +1310,11 @@ export class ToolCallComponent extends Container { let tail: string; if (terminal) { const tag = m.phase === 'cancelled' ? ' · cancelled' : ''; + // Surface drops alongside ✓/✗ so a recovered-with-gaps run is honest about + // the missing subtasks; omitted when zero to keep the common run compact. + const droppedPart = m.droppedCount > 0 ? ` ${String(m.droppedCount)}⊘` : ''; tail = chalk.dim( - ` · ${String(m.workers.size)} workers · ${String(m.doneCount)}✓ ${String(m.failedCount)}✗${tag}`, + ` · ${String(m.workers.size)} workers · ${String(m.doneCount)}✓ ${String(m.failedCount)}✗${droppedPart}${tag}`, ); } else if (m.phase === 'planning') { tail = chalk.dim(' · planning…'); @@ -1376,10 +1379,21 @@ export class ToolCallComponent extends Container { if (w.status === 'done') { return [line1]; } + // Retrying is a transient in-flight state shown as a single dim line so the + // role's row stays visible (and stable) while the coordinator re-runs it. + if (w.status === 'retrying') { + return [line1]; + } if (w.status === 'failed') { const errLine = chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`); return [line1, ` ${branch2} ${errLine}`]; } + // Dropped: the coordinator gave up on this subtask. Dim the row and show the + // reason on the second gutter line so the gap is explicit, not silent. + if (w.status === 'dropped') { + const dropLine = chalk.dim(`dropped: ${w.error ?? 'no reason'}`); + return [` ${branch1} ${chalk.dim(w.role)}`, ` ${branch2} ${dropLine}`]; + } const raw = w.latestActivity ?? 'starting…'; const activity = raw.length > SWARM_ACTIVITY_MAX_LENGTH ? `${raw.slice(0, SWARM_ACTIVITY_MAX_LENGTH)}…` : raw; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 02866357..c04ec3ca 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -534,10 +534,25 @@ export class SessionEventHandler { if (event.update.kind === 'custom' && event.update.customKind === 'swarm') { const tc = this.host.streamingUI.getToolComponent(event.toolCallId); if (tc === undefined || !tc.isSwarm()) return; - const p = event.update.customData as { phase?: string; total?: number }; - if (p.phase === 'planned' && typeof p.total === 'number') tc.applySwarm({ t: 'planned', total: p.total }); - else if (p.phase === 'synthesizing') tc.applySwarm({ t: 'synthesizing' }); - else if (p.phase === 'done') tc.applySwarm({ t: 'done', succeeded: 0, failed: 0 }); + const p = event.update.customData as { + phase?: string; + total?: number; + role?: string; + reason?: string; + }; + if (p.phase === 'planned' && typeof p.total === 'number') { + tc.applySwarm({ t: 'planned', total: p.total }); + } else if (p.phase === 'synthesizing') { + tc.applySwarm({ t: 'synthesizing' }); + } else if (p.phase === 'done') { + tc.applySwarm({ t: 'done', succeeded: 0, failed: 0 }); + } else if (p.phase === 'revising' && typeof p.role === 'string') { + // The reviser decided to re-run this role's subtask — show it retrying. + tc.applySwarm({ t: 'worker.retrying', role: p.role }); + } else if (p.phase === 'dropped' && typeof p.role === 'string') { + // The subtask was given up on — show it as a dropped gap with the reason. + tc.applySwarm({ t: 'worker.dropped', role: p.role, reason: p.reason ?? '' }); + } return; } if (event.update.kind !== 'status') return; diff --git a/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts index cbb7d79a..45448b11 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts @@ -93,6 +93,109 @@ describe('applySwarmEvent', () => { expect(after).toBe(before); expect(after.workers.get('ghost')).toBeUndefined(); }); + + it('worker.retrying sets the matching role row to retrying and keeps it visible', () => { + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'Worker' }, + { t: 'worker.failed', id: 'a1', error: 'boom' }, + { t: 'worker.retrying', role: 'Worker' }, + ]); + expect(m.workers.size).toBe(1); + expect(m.workers.get('a1')?.status).toBe('retrying'); + }); + + it('a worker.spawned for a role already retrying REUSES the row (no duplicate, id updated, running)', () => { + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'Worker' }, + { t: 'worker.failed', id: 'a1', error: 'boom' }, + { t: 'worker.retrying', role: 'Worker' }, + { t: 'worker.spawned', id: 'a2', role: 'Worker' }, + ]); + // Exactly one row for the role, now keyed by the NEW subagent id, reset to running. + expect(m.workers.size).toBe(1); + expect(m.workers.get('a1')).toBeUndefined(); + const w = m.workers.get('a2'); + expect(w?.role).toBe('Worker'); + expect(w?.status).toBe('running'); + expect(w?.error).toBeUndefined(); + }); + + it('a worker.spawned for a role in a terminal failed state REUSES the row on retry', () => { + // Even without an explicit worker.retrying, a re-spawn of the same role + // collapses onto the existing terminal row (one row per role across attempts). + const m = reduce([ + { t: 'worker.spawned', id: 'a1', role: 'Worker' }, + { t: 'worker.failed', id: 'a1', error: 'boom' }, + { t: 'worker.spawned', id: 'a2', role: 'Worker' }, + ]); + expect(m.workers.size).toBe(1); + expect(m.workers.get('a2')?.status).toBe('running'); + }); + + it('worker.dropped sets an existing role row to dropped with the reason', () => { + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'Worker' }, + { t: 'worker.failed', id: 'a1', error: 'boom' }, + { t: 'worker.dropped', role: 'Worker', reason: 'impossible' }, + ]); + expect(m.workers.size).toBe(1); + const w = m.workers.get('a1'); + expect(w?.status).toBe('dropped'); + expect(w?.error).toBe('impossible'); + }); + + it('worker.dropped creates a dropped row when the subtask never spawned a worker', () => { + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.dropped', role: 'Planner', reason: 'no decision' }, + ]); + expect(m.workers.size).toBe(1); + const w = [...m.workers.values()][0]; + expect(w?.role).toBe('Planner'); + expect(w?.status).toBe('dropped'); + expect(w?.error).toBe('no decision'); + }); + + it('distinct roles still get distinct rows; same-role reuse does not collapse them', () => { + const m = reduce([ + { t: 'planned', total: 2 }, + { t: 'worker.spawned', id: 'a1', role: 'Researcher' }, + { t: 'worker.spawned', id: 'a2', role: 'Analyst' }, + { t: 'worker.failed', id: 'a1', error: 'boom' }, + { t: 'worker.retrying', role: 'Researcher' }, + { t: 'worker.spawned', id: 'a3', role: 'Researcher' }, + ]); + expect(m.workers.size).toBe(2); + expect(m.workers.get('a3')?.role).toBe('Researcher'); + expect(m.workers.get('a2')?.role).toBe('Analyst'); + }); + + it('a reassign (new role) re-spawn adds a new row (does not reuse a different role)', () => { + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'Worker' }, + { t: 'worker.failed', id: 'a1', error: 'boom' }, + { t: 'worker.retrying', role: 'Worker' }, + { t: 'worker.spawned', id: 'a2', role: 'R2' }, + ]); + expect(m.workers.size).toBe(2); + expect(m.workers.get('a1')?.role).toBe('Worker'); + expect(m.workers.get('a2')?.role).toBe('R2'); + }); + + it('single-run (no retry) leaves running rows untouched by reuse logic', () => { + const m = reduce([ + { t: 'planned', total: 2 }, + { t: 'worker.spawned', id: 'a1', role: 'Researcher' }, + { t: 'worker.spawned', id: 'a2', role: 'Researcher' }, + ]); + // Two concurrent running workers of the same role keep distinct rows; reuse + // only applies to terminal/retrying rows, so single-run fan-out is unchanged. + expect(m.workers.size).toBe(2); + }); }); describe('workerActivityFromTool', () => { diff --git a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts index 6bf41ae8..34b7db83 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts @@ -102,6 +102,75 @@ describe('swarm dashboard wiring (translation)', () => { expect(out).toMatch(/1\/1 workers/); }); + it('routes custom revising/dropped progress into retrying/dropped dashboard states', () => { + const parentToolCallId = 'tc-swarm'; + const dash = makeSwarm(); + const mockHost = { + streamingUI: { + setTurnId: (): void => {}, + getToolComponent: (id: string): ToolCallComponent | undefined => + id === parentToolCallId ? dash : undefined, + }, + } as unknown as SessionEventHost; + const handler = new SessionEventHandler(mockHost); + const noop = (): void => {}; + + const progress = (customData: Record): void => { + handler.handleEvent( + { + type: 'tool.progress', + agentId: 'main', + sessionId: 's', + turnId: 1, + toolCallId: parentToolCallId, + update: { kind: 'custom', customKind: 'swarm', customData }, + } as unknown as Event, + noop, + ); + }; + const spawn = (subagentId: string): void => { + handler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 's', + subagentId, + subagentName: 'swarm:Worker', + parentToolCallId, + description: 'Worker', + runInBackground: false, + } as unknown as Event, + noop, + ); + }; + + progress({ phase: 'planned', total: 1 }); + spawn('w1'); + handler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 's', + subagentId: 'w1', + parentToolCallId, + error: 'boom', + } as unknown as Event, + noop, + ); + // Coordinator decides to retry the Worker subtask. + progress({ phase: 'revising', subtaskId: 'task-1', role: 'Worker', decision: 'retry', attempt: 1 }); + const retrying = strip(dash.render(80).join('\n')); + expect(retrying).toContain('Worker'); + expect(retrying).toContain('retrying'); + + // Re-spawn collapses onto the same row, then the subtask is ultimately dropped. + spawn('w2'); + progress({ phase: 'dropped', subtaskId: 'task-1', role: 'Worker', reason: 'impossible' }); + const out = strip(dash.render(80).join('\n')); + expect(out.match(/Worker/g)?.length).toBe(1); + expect(out).toContain('dropped: impossible'); + }); + it('counts only real workers — planner/synthesizer/retry never become rows', () => { const parentToolCallId = 'tc-swarm'; const dash = makeSwarm(); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts index 17497ebb..756e7a6b 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts @@ -103,6 +103,58 @@ describe('ToolCallComponent swarm mode', () => { expect(out).toContain('failed: timeout'); }); + it('renders a retrying worker with a dim retrying indicator', () => { + const c = makeSwarm('t'); + c.applySwarm({ t: 'planned', total: 1 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Worker' }); + c.applySwarm({ t: 'worker.failed', id: 'a1', error: 'boom' }); + c.applySwarm({ t: 'worker.retrying', role: 'Worker' }); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('└─ Worker'); + expect(out).toContain('retrying'); + }); + + it('reuses the same row when a retried worker re-spawns (one row per role)', () => { + const c = makeSwarm('t'); + c.applySwarm({ t: 'planned', total: 1 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Worker' }); + c.applySwarm({ t: 'worker.failed', id: 'a1', error: 'boom' }); + c.applySwarm({ t: 'worker.retrying', role: 'Worker' }); + c.applySwarm({ t: 'worker.spawned', id: 'a2', role: 'Worker' }); + c.applySwarm({ t: 'worker.done', id: 'a2', tokens: 1500 }); + const out = strip(c.render(80).join('\n')); + // Only one Worker row across both attempts. + expect(out.match(/Worker/g)?.length).toBe(1); + expect(out).toContain('1.5k tok'); + expect(out).not.toContain('retrying'); + }); + + it('renders a dropped worker with its drop reason', () => { + const c = makeSwarm('t'); + c.applySwarm({ t: 'planned', total: 1 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'Worker' }); + c.applySwarm({ t: 'worker.failed', id: 'a1', error: 'boom' }); + c.applySwarm({ t: 'worker.dropped', role: 'Worker', reason: 'impossible' }); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('└─ Worker'); + expect(out).toContain('dropped: impossible'); + }); + + it('done-phase header shows the dropped count when there are drops', () => { + const c = makeSwarm('t'); + c.applySwarm({ t: 'planned', total: 2 }); + c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'R' }); + c.applySwarm({ t: 'worker.done', id: 'a1' }); + c.applySwarm({ t: 'worker.spawned', id: 'a2', role: 'A' }); + c.applySwarm({ t: 'worker.failed', id: 'a2', error: 'x' }); + c.applySwarm({ t: 'worker.dropped', role: 'A', reason: 'gave up' }); + c.applySwarm({ t: 'done', succeeded: 1, failed: 0, dropped: 1 }); + c.setResult({ tool_call_id: 'tc-swarm', output: 'final report', is_error: false }); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('1✓'); + expect(out).toContain('1⊘'); + }); + it('finalizes to a cancelled header on an error result', () => { const c = makeSwarm('t'); c.applySwarm({ t: 'planned', total: 2 }); diff --git a/packages/agent-core/src/agent/swarm/coordinator.ts b/packages/agent-core/src/agent/swarm/coordinator.ts index f75b42fe..81d1f41c 100644 --- a/packages/agent-core/src/agent/swarm/coordinator.ts +++ b/packages/agent-core/src/agent/swarm/coordinator.ts @@ -111,6 +111,9 @@ export class SwarmCoordinator { this.emit({ phase: 'revising', subtaskId: st.id, + // Capture the role BEFORE applyDecision so a `reassign` still + // correlates to the existing dashboard row keyed by the old role. + role: st.role, decision: decision.kind, attempt: st.attempts, }); @@ -208,6 +211,6 @@ export class SwarmCoordinator { st.status = 'dropped'; st.error = st.error === undefined ? `dropped: ${reason}` : `${st.error} (dropped: ${reason})`; this.progress(`x ${st.role}: dropped (${reason})`); - this.emit({ phase: 'dropped', subtaskId: st.id, reason }); + this.emit({ phase: 'dropped', subtaskId: st.id, role: st.role, reason }); } } diff --git a/packages/agent-core/src/agent/swarm/types.ts b/packages/agent-core/src/agent/swarm/types.ts index e48c7699..3d9664f9 100644 --- a/packages/agent-core/src/agent/swarm/types.ts +++ b/packages/agent-core/src/agent/swarm/types.ts @@ -41,10 +41,16 @@ export type SwarmProgress = | { phase: 'revising'; subtaskId: string; + /** + * The subtask's role at the moment the reviser decision is emitted, i.e. + * BEFORE the decision is applied. For a `reassign` this is the OLD role, + * letting the dashboard correlate the event to the existing worker row. + */ + role: string; decision: 'retry' | 'regenerate' | 'reassign' | 'drop'; attempt: number; } - | { phase: 'dropped'; subtaskId: string; reason: string } + | { phase: 'dropped'; subtaskId: string; role: string; reason: string } | { phase: 'synthesizing' } | { phase: 'done'; succeeded: number; failed: number; dropped: number }; diff --git a/packages/agent-core/test/swarm/coordinator.test.ts b/packages/agent-core/test/swarm/coordinator.test.ts index 3cc89d6f..d7a582c2 100644 --- a/packages/agent-core/test/swarm/coordinator.test.ts +++ b/packages/agent-core/test/swarm/coordinator.test.ts @@ -164,6 +164,7 @@ describe('SwarmCoordinator failure recovery', () => { expect(payloads).toContainEqual({ phase: 'revising', subtaskId: 'task-1', + role: 'Worker', decision: 'retry', attempt: 1, }); @@ -216,9 +217,11 @@ describe('SwarmCoordinator failure recovery', () => { if (workerCalls === 1) throw new Error('boom'); return { result: 'worker-ok' }; }); + const onProgressCustom = vi.fn(); const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: new AbortController().signal, + onProgressCustom, }); const result = await coordinator.run('x'); expect(result).toBe('SYNTH'); @@ -226,6 +229,16 @@ describe('SwarmCoordinator failure recovery', () => { expect(seen[1]?.profileName).toBe('swarm:R2'); expect(seen[1]?.systemPrompt).toBe('SP2'); expect(seen[1]?.tools).toEqual(['Read']); + // The 'revising' event carries the role as it was BEFORE the reassign so + // the dashboard can correlate it to the existing worker row. + const payloads = (onProgressCustom as ReturnType).mock.calls.map((c) => c[0]); + expect(payloads).toContainEqual({ + phase: 'revising', + subtaskId: 'task-1', + role: 'Worker', + decision: 'reassign', + attempt: 1, + }); }); it('drop (LLM-chosen): a dropped subtask is not re-run and is surfaced as a gap', async () => { @@ -255,6 +268,7 @@ describe('SwarmCoordinator failure recovery', () => { expect(payloads).toContainEqual({ phase: 'dropped', subtaskId: 'task-1', + role: 'Worker', reason: 'impossible', }); expect( @@ -319,6 +333,7 @@ describe('SwarmCoordinator failure recovery', () => { expect(payloads).toContainEqual({ phase: 'revising', subtaskId: 'task-1', + role: 'Worker', decision: 'drop', attempt: 1, }); From df04b8d2fb03389cd4303f27e8e342ee321d0a13 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 29 May 2026 23:24:26 +0800 Subject: [PATCH 21/28] fix(swarm): resolve reassign orphan row, enrich stall context, decision-aware recovery UI --- .../messages/swarm-dashboard-model.ts | 22 ++- .../src/tui/components/messages/tool-call.ts | 6 +- .../tui/controllers/session-event-handler.ts | 16 +- .../messages/swarm-dashboard-model.test.ts | 73 ++++++++ .../components/messages/swarm-routing.test.ts | 157 ++++++++++++++++++ .../messages/tool-call-swarm.test.ts | 2 +- packages/agent-core/src/agent/index.ts | 8 +- .../agent-core/src/agent/swarm/coordinator.ts | 3 + .../agent-core/src/agent/swarm/stall-hook.ts | 30 +++- packages/agent-core/src/agent/swarm/types.ts | 15 +- .../agent-core/src/session/subagent-host.ts | 5 +- .../src/tools/builtin/collaboration/swarm.ts | 1 - .../agent/swarm/stall-hook-turn.e2e.test.ts | 2 + .../test/agent/swarm/stall-hook.test.ts | 21 +++ .../swarm/subagent-hooks-unaffected.test.ts | 96 +++++++++++ .../agent-core/test/swarm/coordinator.test.ts | 44 ++++- 16 files changed, 476 insertions(+), 25 deletions(-) create mode 100644 packages/agent-core/test/agent/swarm/subagent-hooks-unaffected.test.ts diff --git a/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts index 79d6d98b..a1128544 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts @@ -24,7 +24,7 @@ export interface SwarmModel { export type SwarmEvent = | { t: 'planned'; total: number } | { t: 'synthesizing' } - | { t: 'done'; succeeded: number; failed: number; dropped?: number } + | { t: 'done'; succeeded: number; failed: number } | { t: 'cancelled' } | { t: 'worker.spawned'; id: string; role: string } | { t: 'worker.toolcall'; id: string; activity: string } @@ -32,6 +32,7 @@ export type SwarmEvent = | { t: 'worker.done'; id: string; tokens?: number } | { t: 'worker.failed'; id: string; error: string } | { t: 'worker.retrying'; role: string } + | { t: 'worker.reassigned'; fromRole: string; toRole: string } | { t: 'worker.dropped'; role: string; reason: string }; export function initialSwarmModel(task: string): SwarmModel { @@ -169,6 +170,25 @@ export function applySwarmEvent(model: SwarmModel, event: SwarmEvent): SwarmMode workers.set(prior.id, { ...prior, status: 'retrying', latestActivity: undefined }); return { ...model, workers, ...withCounts(model, adj) }; } + case 'worker.reassigned': { + // The reviser moved this subtask to a new role. Re-key the SAME row from + // the old role to the new one and mark it retrying so the subsequent + // worker.spawned for the new role reuses THIS row (one row per subtask) + // instead of stranding the old-role row in 'retrying' forever. If no + // old-role row exists, no-op — there is nothing to correlate. + const prior = findReusableRoleRow(model.workers, event.fromRole); + if (prior === undefined) return model; + const workers = new Map(model.workers); + const adj = countAdjustments(prior.status, 'retrying'); + workers.set(prior.id, { + ...prior, + role: event.toRole, + status: 'retrying', + latestActivity: undefined, + error: undefined, + }); + return { ...model, workers, ...withCounts(model, adj) }; + } case 'worker.dropped': { // The coordinator gave up on this role's subtask. Mark its row dropped // (or create a dropped row if the subtask never spawned a worker) and diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 7d540b5e..d62a98d6 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -1379,10 +1379,12 @@ export class ToolCallComponent extends Container { if (w.status === 'done') { return [line1]; } - // Retrying is a transient in-flight state shown as a single dim line so the + // Retrying is a transient in-flight state shown as a single line so the // role's row stays visible (and stable) while the coordinator re-runs it. + // Dim the role label to match the 'dropped' convention: non-running rows + // (retrying, dropped) use a dimmed label, running/done/failed keep primary. if (w.status === 'retrying') { - return [line1]; + return [` ${branch1} ${chalk.dim(w.role)}${statsPart}`]; } if (w.status === 'failed') { const errLine = chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`); diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index c04ec3ca..7de54c6a 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -538,6 +538,8 @@ export class SessionEventHandler { phase?: string; total?: number; role?: string; + newRole?: string; + decision?: string; reason?: string; }; if (p.phase === 'planned' && typeof p.total === 'number') { @@ -547,8 +549,18 @@ export class SessionEventHandler { } else if (p.phase === 'done') { tc.applySwarm({ t: 'done', succeeded: 0, failed: 0 }); } else if (p.phase === 'revising' && typeof p.role === 'string') { - // The reviser decided to re-run this role's subtask — show it retrying. - tc.applySwarm({ t: 'worker.retrying', role: p.role }); + // Route by the reviser's decision so each recovery path shows the right + // transient state: + // - retry/regenerate re-run the same role → mark it retrying. + // - reassign moves the subtask to a new role → re-key the existing + // row so the subtask keeps ONE row (no orphan left in retrying). + // - drop emits nothing here; the subsequent 'dropped' event fully + // describes it (and skipping this avoids a drop→retrying flash). + if (p.decision === 'reassign' && typeof p.newRole === 'string') { + tc.applySwarm({ t: 'worker.reassigned', fromRole: p.role, toRole: p.newRole }); + } else if (p.decision === 'retry' || p.decision === 'regenerate') { + tc.applySwarm({ t: 'worker.retrying', role: p.role }); + } } else if (p.phase === 'dropped' && typeof p.role === 'string') { // The subtask was given up on — show it as a dropped gap with the reason. tc.applySwarm({ t: 'worker.dropped', role: p.role, reason: p.reason ?? '' }); diff --git a/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts index 45448b11..0860a474 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts @@ -186,6 +186,79 @@ describe('applySwarmEvent', () => { expect(m.workers.get('a2')?.role).toBe('R2'); }); + it('reassign collapses to ONE row: failed(OLD) -> reassigned(OLD->NEW) -> spawned(NEW) -> done', () => { + // The reassign-orphan regression: before the fix, a reassign marked the OLD + // role row retrying then the re-spawn created a NEW role row, stranding the + // old one in 'retrying' forever. The reassigned event re-keys the SAME row. + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'OldRole' }, + { t: 'worker.failed', id: 'a1', error: 'boom' }, + { t: 'worker.reassigned', fromRole: 'OldRole', toRole: 'NewRole' }, + { t: 'worker.spawned', id: 'a2', role: 'NewRole' }, + { t: 'worker.done', id: 'a2', tokens: 1500 }, + ]); + // Exactly one row, final role NewRole, status done. + expect(m.workers.size).toBe(1); + const w = [...m.workers.values()][0]; + expect(w?.role).toBe('NewRole'); + expect(w?.status).toBe('done'); + expect(w?.tokens).toBe(1500); + // No row left dangling in 'retrying', and no stray OldRole row. + expect([...m.workers.values()].some((r) => r.status === 'retrying')).toBe(false); + expect([...m.workers.values()].some((r) => r.role === 'OldRole')).toBe(false); + expect(m.doneCount).toBe(1); + expect(m.failedCount).toBe(0); + }); + + it('worker.reassigned re-keys the failed row to the new role and marks it retrying', () => { + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'OldRole' }, + { t: 'worker.failed', id: 'a1', error: 'boom' }, + { t: 'worker.reassigned', fromRole: 'OldRole', toRole: 'NewRole' }, + ]); + expect(m.workers.size).toBe(1); + const w = m.workers.get('a1'); + expect(w?.role).toBe('NewRole'); + expect(w?.status).toBe('retrying'); + expect(w?.error).toBeUndefined(); + // The transient failed count is reversed when the row leaves the failed state. + expect(m.failedCount).toBe(0); + }); + + it('worker.reassigned is a no-op when no fromRole row exists', () => { + const before = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'Other' }, + ]); + const after = applySwarmEvent(before, { + t: 'worker.reassigned', + fromRole: 'Missing', + toRole: 'NewRole', + }); + expect(after).toBe(before); + }); + + it('full failed->retrying->respawn(running)->done on ONE role keeps counts consistent', () => { + // Locks count bookkeeping: the transient failed must be reversed, so the + // surviving row is done and the failed/dropped counts return to zero. + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'worker.spawned', id: 'a1', role: 'Worker' }, + { t: 'worker.failed', id: 'a1', error: 'boom' }, + { t: 'worker.retrying', role: 'Worker' }, + { t: 'worker.spawned', id: 'a2', role: 'Worker' }, + { t: 'worker.done', id: 'a2', tokens: 900 }, + ]); + expect(m.workers.size).toBe(1); + const w = [...m.workers.values()][0]; + expect(w?.status).toBe('done'); + expect(m.doneCount).toBe(1); + expect(m.failedCount).toBe(0); + expect(m.droppedCount).toBe(0); + }); + it('single-run (no retry) leaves running rows untouched by reuse logic', () => { const m = reduce([ { t: 'planned', total: 2 }, diff --git a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts index 34b7db83..d9c9b343 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts @@ -171,6 +171,163 @@ describe('swarm dashboard wiring (translation)', () => { expect(out).toContain('dropped: impossible'); }); + it('routes a reassign decision so the subtask keeps ONE row (no orphan)', () => { + const parentToolCallId = 'tc-swarm'; + const dash = makeSwarm(); + const mockHost = { + streamingUI: { + setTurnId: (): void => {}, + getToolComponent: (id: string): ToolCallComponent | undefined => + id === parentToolCallId ? dash : undefined, + }, + } as unknown as SessionEventHost; + const handler = new SessionEventHandler(mockHost); + const noop = (): void => {}; + + const progress = (customData: Record): void => { + handler.handleEvent( + { + type: 'tool.progress', + agentId: 'main', + sessionId: 's', + turnId: 1, + toolCallId: parentToolCallId, + update: { kind: 'custom', customKind: 'swarm', customData }, + } as unknown as Event, + noop, + ); + }; + const spawn = (subagentId: string, role: string): void => { + handler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 's', + subagentId, + subagentName: `swarm:${role}`, + parentToolCallId, + description: role, + runInBackground: false, + } as unknown as Event, + noop, + ); + }; + const fail = (subagentId: string): void => { + handler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 's', + subagentId, + parentToolCallId, + error: 'boom', + } as unknown as Event, + noop, + ); + }; + const complete = (subagentId: string): void => { + handler.handleEvent( + { + type: 'subagent.completed', + agentId: 'main', + sessionId: 's', + subagentId, + parentToolCallId, + resultSummary: 'ok', + } as unknown as Event, + noop, + ); + }; + + progress({ phase: 'planned', total: 1 }); + spawn('w1', 'OldRole'); + fail('w1'); + // Reviser reassigns OldRole -> NewRole; the re-spawn uses the NEW role. + progress({ + phase: 'revising', + subtaskId: 'task-1', + role: 'OldRole', + newRole: 'NewRole', + decision: 'reassign', + attempt: 1, + }); + spawn('w2', 'NewRole'); + complete('w2'); + + const out = strip(dash.render(80).join('\n')); + // Exactly one row, now labeled with the new role; the old role is gone. + expect(out).toContain('NewRole'); + expect(out).not.toContain('OldRole'); + // No stray retrying row left behind. + expect(out).not.toContain('retrying'); + }); + + it('a drop decision then dropped produces a single dropped row with no transient retrying', () => { + const parentToolCallId = 'tc-swarm'; + const dash = makeSwarm(); + const mockHost = { + streamingUI: { + setTurnId: (): void => {}, + getToolComponent: (id: string): ToolCallComponent | undefined => + id === parentToolCallId ? dash : undefined, + }, + } as unknown as SessionEventHost; + const handler = new SessionEventHandler(mockHost); + const noop = (): void => {}; + + const progress = (customData: Record): void => { + handler.handleEvent( + { + type: 'tool.progress', + agentId: 'main', + sessionId: 's', + turnId: 1, + toolCallId: parentToolCallId, + update: { kind: 'custom', customKind: 'swarm', customData }, + } as unknown as Event, + noop, + ); + }; + + progress({ phase: 'planned', total: 1 }); + handler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 's', + subagentId: 'w1', + subagentName: 'swarm:Worker', + parentToolCallId, + description: 'Worker', + runInBackground: false, + } as unknown as Event, + noop, + ); + handler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 's', + subagentId: 'w1', + parentToolCallId, + error: 'boom', + } as unknown as Event, + noop, + ); + // The reviser decides to DROP. The 'revising' event with decision 'drop' + // must emit NOTHING (no transient retrying flash); the subsequent 'dropped' + // event fully describes the gap. + progress({ phase: 'revising', subtaskId: 'task-1', role: 'Worker', decision: 'drop', attempt: 1 }); + const afterRevise = strip(dash.render(80).join('\n')); + expect(afterRevise).not.toContain('retrying'); + + progress({ phase: 'dropped', subtaskId: 'task-1', role: 'Worker', reason: 'impossible' }); + const out = strip(dash.render(80).join('\n')); + expect(out.match(/Worker/g)?.length).toBe(1); + expect(out).toContain('dropped: impossible'); + expect(out).not.toContain('retrying'); + }); + it('counts only real workers — planner/synthesizer/retry never become rows', () => { const parentToolCallId = 'tc-swarm'; const dash = makeSwarm(); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts index 756e7a6b..a83208d2 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts @@ -148,7 +148,7 @@ describe('ToolCallComponent swarm mode', () => { c.applySwarm({ t: 'worker.spawned', id: 'a2', role: 'A' }); c.applySwarm({ t: 'worker.failed', id: 'a2', error: 'x' }); c.applySwarm({ t: 'worker.dropped', role: 'A', reason: 'gave up' }); - c.applySwarm({ t: 'done', succeeded: 1, failed: 0, dropped: 1 }); + c.applySwarm({ t: 'done', succeeded: 1, failed: 0 }); c.setResult({ tool_call_id: 'tc-swarm', output: 'final report', is_error: false }); const out = strip(c.render(80).join('\n')); expect(out).toContain('1✓'); diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index a44ccad0..281f458c 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -14,7 +14,7 @@ import { import type { EnabledPluginSessionStart } from '#/plugin'; -import type { LoopHooks } from '../loop'; +import type { SubagentLoopHooks } from './swarm/stall-hook'; import type { McpConnectionManager } from '../mcp'; import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profile'; import type { ModelProvider } from '../session/provider-manager'; @@ -119,9 +119,11 @@ export class Agent { * Loop hooks scoped to this agent when it runs as a subagent (e.g. swarm * worker stall detection). Set by {@link SessionSubagentHost} when spawning; * `undefined` for the main agent and regular subagents, so they run with - * identical (default) turn hooks. + * identical (default) turn hooks. Narrowed to the only phase `TurnFlow` + * consumes (`prepareToolExecution`) so the unaffected-paths invariant is + * enforced by the type. */ - subagentLoopHooks?: Partial | undefined; + subagentLoopHooks?: SubagentLoopHooks | undefined; private lastLlmConfigLogSignature?: string; diff --git a/packages/agent-core/src/agent/swarm/coordinator.ts b/packages/agent-core/src/agent/swarm/coordinator.ts index 81d1f41c..d5a87c00 100644 --- a/packages/agent-core/src/agent/swarm/coordinator.ts +++ b/packages/agent-core/src/agent/swarm/coordinator.ts @@ -115,6 +115,9 @@ export class SwarmCoordinator { // correlates to the existing dashboard row keyed by the old role. role: st.role, decision: decision.kind, + // For a reassign, carry the NEW role too so the dashboard can re-key + // the existing old-role row instead of stranding it in `retrying`. + ...(decision.kind === 'reassign' ? { newRole: decision.role } : {}), attempt: st.attempts, }); this.applyDecision(st, decision); diff --git a/packages/agent-core/src/agent/swarm/stall-hook.ts b/packages/agent-core/src/agent/swarm/stall-hook.ts index eafe5213..046f90f7 100644 --- a/packages/agent-core/src/agent/swarm/stall-hook.ts +++ b/packages/agent-core/src/agent/swarm/stall-hook.ts @@ -15,13 +15,25 @@ import { canonicalTelemetryArgs } from '../turn/canonical-args'; import type { LoopHooks, PrepareToolExecutionResult } from '../../loop/types'; +/** + * The only loop-hook phase a subagent (swarm worker) overrides. `TurnFlow` + * composes just `prepareToolExecution` ahead of its built-in dedup, so a + * purpose-named subset keeps the surface honest and the main agent / regular + * subagent paths provably unaffected. + */ +export type SubagentLoopHooks = Pick; + +/** Max length of the repeated-call args snippet embedded in a stall reason. */ +const STALL_ARGS_PREVIEW_MAX_CHARS = 120; + export interface StallDetectionHookOptions { /** Repeat count (inclusive) at which a call is treated as a stall. */ readonly repeatThreshold: number; /** * Invoked exactly once, the first time the threshold is reached. Receives a - * distinguishable reason string (e.g. `stalled: repeated x`) so a - * caller can abort a per-worker controller with it. + * distinguishable reason string (e.g. + * `stalled: repeated () x`) so a caller can abort a per-worker + * controller with it. */ readonly onStall: (reason: string) => void; } @@ -37,20 +49,28 @@ export interface StallDetectionHookOptions { */ export function createStallDetectionHook( options: StallDetectionHookOptions, -): Partial { +): SubagentLoopHooks { const { repeatThreshold, onStall } = options; const counts = new Map(); let stalled = false; return { prepareToolExecution: async (ctx): Promise => { - const key = `${ctx.toolCall.name} ${canonicalTelemetryArgs(ctx.args)}`; + const canonicalArgs = canonicalTelemetryArgs(ctx.args); + const key = `${ctx.toolCall.name} ${canonicalArgs}`; const next = (counts.get(key) ?? 0) + 1; counts.set(key, next); if (next < repeatThreshold) return undefined; - const reason = `stalled: repeated ${ctx.toolCall.name} x${String(next)}`; + // Include the repeated call's canonical args (truncated) so the reviser + // can see WHAT was repeated, not just which tool — e.g. + // `stalled: repeated Read({"path":"/a"}) x10`. + const argsPreview = + canonicalArgs.length > STALL_ARGS_PREVIEW_MAX_CHARS + ? `${canonicalArgs.slice(0, STALL_ARGS_PREVIEW_MAX_CHARS)}…` + : canonicalArgs; + const reason = `stalled: repeated ${ctx.toolCall.name}(${argsPreview}) x${String(next)}`; if (!stalled) { stalled = true; onStall(reason); diff --git a/packages/agent-core/src/agent/swarm/types.ts b/packages/agent-core/src/agent/swarm/types.ts index 3d9664f9..f907374d 100644 --- a/packages/agent-core/src/agent/swarm/types.ts +++ b/packages/agent-core/src/agent/swarm/types.ts @@ -48,6 +48,14 @@ export type SwarmProgress = */ role: string; decision: 'retry' | 'regenerate' | 'reassign' | 'drop'; + /** + * For a `reassign`, the NEW role the subtask is being moved to (the + * decision's role). Lets the dashboard re-key the existing OLD-role row to + * the new role so the subtask keeps a single row across the reassign, + * rather than stranding the old row in `retrying`. Absent for other + * decisions. + */ + newRole?: string; attempt: number; } | { phase: 'dropped'; subtaskId: string; role: string; reason: string } @@ -60,13 +68,6 @@ export interface SwarmCoordinatorDeps { onProgress?: ((text: string) => void) | undefined; onProgressCustom?: ((progress: SwarmProgress) => void) | undefined; maxConcurrency?: number | undefined; - /** - * Repeat count at which a worker that keeps issuing the SAME tool call is - * treated as stalled and hard-stopped (its turn fails with a distinguishable - * reason so this wave records it as a failed subtask). Defaults to - * {@link DEFAULT_STALL_REPEAT_THRESHOLD}. - */ - stallRepeatThreshold?: number | undefined; /** * Maximum number of times a single subtask is executed before it is * force-dropped (counting the original run). Defaults to diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 2882fa2f..58ccc5c1 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -2,7 +2,8 @@ import type { TokenUsage } from '@moonshot-ai/kosong'; import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; -import type { LoopHooks, LoopTurnStopReason } from '../loop'; +import type { LoopTurnStopReason } from '../loop'; +import type { SubagentLoopHooks } from '../agent/swarm/stall-hook'; import { DEFAULT_AGENT_PROFILES, prepareSystemPromptContext, @@ -50,7 +51,7 @@ type RunSubagentOptions = { * built-in ones. Absent for the main agent and regular subagents, so their * behavior is unchanged. */ - readonly loopHooks?: Partial | undefined; + readonly loopHooks?: SubagentLoopHooks | undefined; }; type SubagentCompletion = { diff --git a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts index bc4e3369..1924a145 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts @@ -68,7 +68,6 @@ export class SwarmTool implements BuiltinTool { const coordinator = new SwarmCoordinator({ signal: ctx.signal, maxConcurrency: DEFAULT_MAX_CONCURRENCY, - stallRepeatThreshold, onProgress: (text) => ctx.onUpdate?.({ kind: 'status', text }), onProgressCustom: (progress) => ctx.onUpdate?.({ kind: 'custom', customKind: 'swarm', customData: progress }), diff --git a/packages/agent-core/test/agent/swarm/stall-hook-turn.e2e.test.ts b/packages/agent-core/test/agent/swarm/stall-hook-turn.e2e.test.ts index dd978fb9..c59c4d76 100644 --- a/packages/agent-core/test/agent/swarm/stall-hook-turn.e2e.test.ts +++ b/packages/agent-core/test/agent/swarm/stall-hook-turn.e2e.test.ts @@ -53,6 +53,8 @@ describe('swarm stall hook — turn level', () => { expect(worker.signal.aborted).toBe(true); expect(stallReason).toMatch(/stalled/i); expect(stallReason).toContain('echo'); + // The reason carries the repeated call's args so a reviser sees WHAT spun. + expect(stallReason).toContain('spin'); // Crucially the coordinator's signal is NOT aborted — a single worker // failure, not a whole-swarm cancel. expect(parent.signal.aborted).toBe(false); diff --git a/packages/agent-core/test/agent/swarm/stall-hook.test.ts b/packages/agent-core/test/agent/swarm/stall-hook.test.ts index 52da0165..6f4ee643 100644 --- a/packages/agent-core/test/agent/swarm/stall-hook.test.ts +++ b/packages/agent-core/test/agent/swarm/stall-hook.test.ts @@ -33,7 +33,12 @@ describe('createStallDetectionHook', () => { expect(r3?.block).toBe(true); expect(r3?.reason).toMatch(/stalled/i); expect(r3?.reason).toContain('Read'); + // The reason includes the repeated call's canonical args so the reviser + // can tell WHAT was repeated, not just which tool. + expect(r3?.reason).toContain('/a'); + expect(r3?.reason).toContain('"path"'); expect(onStall).toHaveBeenCalledTimes(1); + expect(onStall).toHaveBeenLastCalledWith(r3?.reason); // Further repeats keep blocking but never re-fire onStall. const r4 = await prepare!(ctx); @@ -41,6 +46,22 @@ describe('createStallDetectionHook', () => { expect(onStall).toHaveBeenCalledTimes(1); }); + it('truncates very long repeated args in the stall reason', async () => { + const onStall = vi.fn(); + const hook = createStallDetectionHook({ repeatThreshold: 2, onStall }); + const prepare = hook.prepareToolExecution!; + + const longPattern = 'x'.repeat(500); + const ctx = makeCtx('Grep', { pattern: longPattern }); + await prepare(ctx); + const r = await prepare(ctx); + expect(r?.block).toBe(true); + // Truncated with an ellipsis — the full 500-char pattern is not embedded. + expect(r?.reason).toContain('…'); + expect(r?.reason?.length).toBeLessThan(longPattern.length); + expect(r?.reason).toContain('Grep'); + }); + it('never triggers on distinct progressing calls', async () => { const onStall = vi.fn(); const hook = createStallDetectionHook({ repeatThreshold: 3, onStall }); diff --git a/packages/agent-core/test/agent/swarm/subagent-hooks-unaffected.test.ts b/packages/agent-core/test/agent/swarm/subagent-hooks-unaffected.test.ts new file mode 100644 index 00000000..8af36edf --- /dev/null +++ b/packages/agent-core/test/agent/swarm/subagent-hooks-unaffected.test.ts @@ -0,0 +1,96 @@ +/** + * Locks the "main agent / regular subagent unaffected" invariant: when + * `agent.subagentLoopHooks` is UNDEFINED (every path except a swarm worker), + * `TurnFlow.runTurn` composes ONLY the built-in `prepareToolExecution` (the + * tool-call deduplicator). This test replicates that composition exactly and + * proves the built-in same-step dedup still short-circuits identical calls, + * so non-swarm turns run with unchanged behavior. + */ + +import { describe, expect, it } from 'vitest'; + +import { ToolCallDeduplicator } from '../../../src/agent/turn/tool-dedup'; +import type { + LoopHooks, + PrepareToolExecutionHook, +} from '../../../src/loop/index'; +import { makeToolCall, makeToolUseResponse, makeEndTurnResponse } from '../../loop/fixtures/fake-llm'; +import { runTurn } from '../../loop/fixtures/helpers'; +import { EchoTool } from '../../loop/fixtures/tools'; + +/** + * Build the same `prepareToolExecution` hook `TurnFlow.runTurn` builds: run the + * subagent-scoped hook first (when present), then fall through to the built-in + * dedup. With `subagentPrepareToolExecution` undefined this is exactly the + * non-swarm composition. + */ +function buildHooks( + deduper: ToolCallDeduplicator, + subagentPrepareToolExecution: PrepareToolExecutionHook | undefined, +): LoopHooks { + return { + beforeStep: async () => { + deduper.beginStep(); + return; + }, + afterStep: async () => { + deduper.endStep(); + }, + prepareToolExecution: async (ctx) => { + if (subagentPrepareToolExecution !== undefined) { + const subagentResult = await subagentPrepareToolExecution(ctx); + if (subagentResult !== undefined) return subagentResult; + } + const cached = deduper.checkSameStep(ctx.toolCall.id, ctx.toolCall.name, ctx.args); + if (cached !== null) return { syntheticResult: cached }; + return undefined; + }, + finalizeToolResult: async (ctx) => { + return deduper.finalizeResult(ctx.toolCall.id, ctx.toolCall.name, ctx.args, ctx.result); + }, + }; +} + +describe('subagentLoopHooks undefined — non-swarm turn unaffected', () => { + it('built-in same-step dedup still short-circuits identical calls (no subagent hook)', async () => { + const deduper = new ToolCallDeduplicator(); + // subagentLoopHooks UNDEFINED — the non-swarm / regular-subagent case. + const hooks = buildHooks(deduper, undefined); + + const echo = new EchoTool(); + // One step emits the identical tool call twice; the built-in dedup must + // execute the tool only once and serve the second from the placeholder. + const responses = [ + makeToolUseResponse([ + makeToolCall('echo', { text: 'same' }, 'c1'), + makeToolCall('echo', { text: 'same' }, 'c2'), + ]), + makeEndTurnResponse('done'), + ]; + + const { result } = await runTurn({ hooks, tools: [echo], responses }); + + expect(result.stopReason).toBe('end_turn'); + // Dedup short-circuits the duplicate: the tool ran exactly once. + expect(echo.calls.length).toBe(1); + }); + + it('distinct same-step calls all execute (dedup does not over-collapse)', async () => { + const deduper = new ToolCallDeduplicator(); + const hooks = buildHooks(deduper, undefined); + + const echo = new EchoTool(); + const responses = [ + makeToolUseResponse([ + makeToolCall('echo', { text: 'a' }, 'c1'), + makeToolCall('echo', { text: 'b' }, 'c2'), + ]), + makeEndTurnResponse('done'), + ]; + + const { result } = await runTurn({ hooks, tools: [echo], responses }); + + expect(result.stopReason).toBe('end_turn'); + expect(echo.calls.length).toBe(2); + }); +}); diff --git a/packages/agent-core/test/swarm/coordinator.test.ts b/packages/agent-core/test/swarm/coordinator.test.ts index d7a582c2..b9a4fbd6 100644 --- a/packages/agent-core/test/swarm/coordinator.test.ts +++ b/packages/agent-core/test/swarm/coordinator.test.ts @@ -230,13 +230,15 @@ describe('SwarmCoordinator failure recovery', () => { expect(seen[1]?.systemPrompt).toBe('SP2'); expect(seen[1]?.tools).toEqual(['Read']); // The 'revising' event carries the role as it was BEFORE the reassign so - // the dashboard can correlate it to the existing worker row. + // the dashboard can correlate it to the existing worker row, plus the NEW + // role so the dashboard can re-key that row instead of stranding it. const payloads = (onProgressCustom as ReturnType).mock.calls.map((c) => c[0]); expect(payloads).toContainEqual({ phase: 'revising', subtaskId: 'task-1', role: 'Worker', decision: 'reassign', + newRole: 'R2', attempt: 1, }); }); @@ -367,6 +369,46 @@ describe('SwarmCoordinator failure recovery', () => { expect(calls['swarm:B']).toBe(2); }); + it('all subtasks dropped: still synthesizes with a gap-only prompt (no crash)', async () => { + const TWO_PLAN = JSON.stringify({ + subtasks: [ + { id: 'task-1', role: 'A', systemPrompt: 'spa', prompt: 'pa' }, + { id: 'task-2', role: 'B', systemPrompt: 'spb', prompt: 'pb' }, + ], + }); + let synthesizerPrompt: string | undefined; + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: TWO_PLAN }; + if (args.profileName === 'swarm-synthesizer') { + synthesizerPrompt = args.prompt; + return { result: 'SYNTH' }; + } + if (args.profileName === 'swarm-reviser') + return { result: '{"kind":"drop","reason":"impossible"}' }; + // Every worker fails on its first (only) run, then is dropped. + throw new Error('boom'); + }); + const onProgressCustom = vi.fn(); + const coordinator = new SwarmCoordinator({ + spawnSubagent: spawn, + signal: new AbortController().signal, + onProgressCustom, + }); + const result = await coordinator.run('x'); + expect(result).toBe('SYNTH'); + // Synthesizer was consulted and its prompt surfaces both subtasks as gaps, + // never inventing a success. + expect(synthesizerPrompt).toBeDefined(); + expect(synthesizerPrompt).toMatch(/DROPPED/); + expect(synthesizerPrompt).not.toMatch(/done\)/); + const payloads = (onProgressCustom as ReturnType).mock.calls.map((c) => c[0]); + expect( + payloads.some( + (p) => p.phase === 'done' && p.succeeded === 0 && p.dropped === 2 && p.failed === 0, + ), + ).toBe(true); + }); + it('does not revise on a genuine swarm-wide cancel (re-throws the abort)', async () => { const controller = new AbortController(); const spawn = vi.fn(async (args) => { From d6942ec5d50f7bf8e9cce96475c3bc47395edfdb Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 30 May 2026 00:03:44 +0800 Subject: [PATCH 22/28] fix(agent-core): drop subagent summary-continuation re-prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary-continuation pass re-prompted any subagent whose first summary was under 200 chars to "expand" it, then read back the follow-up turn — replacing the original output rather than appending. For swarm's structured-output subagents this was harmful: a reviser's compact decision JSON (e.g. {"kind":"retry"}) is always under the threshold, so the expand turn always fired and could replace the JSON with prose, silently degrading the recovery loop into conservative drops. It also taxed every short-but-complete handoff with an extra turn. Remove the heuristic entirely so a subagent's first summary is returned as-is. The max-tokens truncation guard is unaffected. --- .../agent-core/src/session/subagent-host.ts | 24 ++----------------- .../src/session/summary-continuation.md | 5 ---- .../test/session/subagent-host.test.ts | 18 +++++++------- 3 files changed, 10 insertions(+), 37 deletions(-) delete mode 100644 packages/agent-core/src/session/summary-continuation.md diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 58ccc5c1..b797ad18 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -12,15 +12,7 @@ import { import { linkAbortSignal } from '../utils/abort'; import { collectGitContext } from './git-context'; import type { Session } from './index'; -import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md'; - -/** - * A subagent summary shorter than this many characters triggers one - * follow-up turn that asks the subagent to expand it, so the parent - * agent receives a technically complete handoff. - */ -const SUMMARY_MIN_LENGTH = 200; -const SUMMARY_CONTINUATION_ATTEMPTS = 1; + const HOOK_TEXT_PREVIEW_LENGTH = 500; const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; @@ -258,19 +250,7 @@ export class SessionSubagentHost { child.turn.prompt([{ type: 'text', text: childPrompt }], origin); await runChildTurnToCompletion(child, options.signal); - // A subagent that returns an overly terse summary leaves the parent - // agent under-informed. Give it a bounded number of chances to expand - // the handoff; if it is still short after that, accept it as-is rather - // than retrying indefinitely. - let result = lastAssistantText(child); - let remainingContinuations = SUMMARY_CONTINUATION_ATTEMPTS; - while (remainingContinuations > 0 && result.length < SUMMARY_MIN_LENGTH) { - remainingContinuations -= 1; - options.signal.throwIfAborted(); - child.turn.prompt([{ type: 'text', text: SUMMARY_CONTINUATION_PROMPT }], origin); - await runChildTurnToCompletion(child, options.signal); - result = lastAssistantText(child); - } + const result = lastAssistantText(child); const usage = child.usage.data().total; parent.emitEvent({ type: 'subagent.completed', diff --git a/packages/agent-core/src/session/summary-continuation.md b/packages/agent-core/src/session/summary-continuation.md deleted file mode 100644 index 8efb589a..00000000 --- a/packages/agent-core/src/session/summary-continuation.md +++ /dev/null @@ -1,5 +0,0 @@ -Your previous response was too brief. Please provide a more comprehensive summary that includes: - -1. Specific technical details and implementations -2. Detailed findings and analysis -3. All important information that the parent agent should know \ No newline at end of file diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index dfb92d70..fde45abc 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -435,15 +435,17 @@ describe('SessionSubagentHost', () => { ); }); - it('re-prompts the child when the first summary is too short', async () => { + it('returns a short summary as-is without re-prompting the child', async () => { const parent = testAgent(); parent.configure(); parent.newEvents(); - const longSummary = 'Detailed findings: '.repeat(20); + const shortSummary = 'done'; const child = testAgent(); - child.mockNextResponse({ type: 'text', text: 'done' }); - child.mockNextResponse({ type: 'text', text: longSummary }); + child.mockNextResponse({ type: 'text', text: shortSummary }); + // A second response is queued to prove it is never consumed: a short + // summary must NOT trigger a follow-up "expand" turn. + child.mockNextResponse({ type: 'text', text: 'Detailed findings: '.repeat(20) }); const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); @@ -455,12 +457,8 @@ describe('SessionSubagentHost', () => { signal, }); - await expect(handle.completion).resolves.toMatchObject({ result: longSummary.trim() }); - expect(child.llmCalls).toHaveLength(2); - expect(child.llmCalls[1]?.history.at(-1)).toMatchObject({ - role: 'user', - content: [{ type: 'text', text: expect.stringContaining('too brief') }], - }); + await expect(handle.completion).resolves.toMatchObject({ result: shortSummary }); + expect(child.llmCalls).toHaveLength(1); }); it('fails the child instead of re-prompting when the response is truncated', async () => { From 38ba4b83d7e57373f949422824fcabacacb1a04b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 30 May 2026 00:51:29 +0800 Subject: [PATCH 23/28] fix(kimi-code): route /swarm through the session-request lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSwarmCommand called session.prompt directly, bypassing beginSessionRequest. streamingPhase therefore stayed 'idle' until the SDK turn.started event round-tripped back, leaving a startup window in which a fast follow-up message was dispatched as a second concurrent prompt and silently dropped by the core as agent_busy, and in which the UI showed no waiting state. Call beginSessionRequest() before prompting — flipping streamingPhase synchronously so the input gate closes immediately and the waiting pane shows — and failSessionRequest() on a prompt rejection, mirroring sendSkillActivation / handleInitCommand. --- apps/kimi-code/src/tui/commands/swarm.ts | 10 ++++- .../kimi-code/test/tui/commands/swarm.test.ts | 41 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index 975b9803..b5c52b6a 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -23,9 +23,17 @@ export async function handleSwarmCommand(host: SlashCommandHost, args: string): host.showError('Usage: /swarm '); return; } + // Route through the same session-request lifecycle as a normal send / + // skill activation rather than calling session.prompt raw. beginSessionRequest + // flips streamingPhase out of 'idle' synchronously, so the input gate closes + // immediately and shows the waiting pane; otherwise, during the window before + // turn.started arrives the UI still thinks it is idle and a fast follow-up + // message could be dispatched as a second concurrent prompt and be silently + // dropped as agent_busy. + host.beginSessionRequest(); try { await session.prompt(buildSwarmPrompt(task)); } catch (error) { - host.showError(`Failed to start swarm: ${formatErrorMessage(error)}`); + host.failSessionRequest(`Failed to start swarm: ${formatErrorMessage(error)}`); } } diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index c75b545c..52f8a914 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -27,8 +27,47 @@ describe('handleSwarmCommand', () => { it('sends a framed prompt to the session', async () => { const prompt = vi.fn<(text: string) => Promise>(async () => undefined); const showError = vi.fn(); - await handleSwarmCommand({ session: { prompt }, showError } as never, 'compare libs'); + const beginSessionRequest = vi.fn(); + const failSessionRequest = vi.fn(); + await handleSwarmCommand( + { session: { prompt }, showError, beginSessionRequest, failSessionRequest } as never, + 'compare libs', + ); expect(prompt).toHaveBeenCalledTimes(1); expect(String(prompt.mock.calls[0]?.[0])).toContain('compare libs'); }); + + it('begins the session request before prompting so a follow-up cannot race the swarm turn', async () => { + const prompt = vi.fn<(text: string) => Promise>(async () => undefined); + const showError = vi.fn(); + const beginSessionRequest = vi.fn(); + const failSessionRequest = vi.fn(); + await handleSwarmCommand( + { session: { prompt }, showError, beginSessionRequest, failSessionRequest } as never, + 'compare libs', + ); + expect(beginSessionRequest).toHaveBeenCalledTimes(1); + // The streamingPhase must flip out of 'idle' BEFORE the prompt is dispatched, + // otherwise the input gate stays open during turn startup. + expect(beginSessionRequest.mock.invocationCallOrder[0]).toBeLessThan( + prompt.mock.invocationCallOrder[0] ?? Infinity, + ); + expect(failSessionRequest).not.toHaveBeenCalled(); + }); + + it('fails the session request when the prompt rejects', async () => { + const prompt = vi.fn<(text: string) => Promise>(async () => { + throw new Error('boom'); + }); + const showError = vi.fn(); + const beginSessionRequest = vi.fn(); + const failSessionRequest = vi.fn(); + await handleSwarmCommand( + { session: { prompt }, showError, beginSessionRequest, failSessionRequest } as never, + 'compare libs', + ); + expect(beginSessionRequest).toHaveBeenCalledTimes(1); + expect(failSessionRequest).toHaveBeenCalledTimes(1); + expect(String(failSessionRequest.mock.calls[0]?.[0])).toContain('boom'); + }); }); From a17cfeee2d26b12952509292e803b773b75c294e Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 30 May 2026 01:05:28 +0800 Subject: [PATCH 24/28] fix(kimi-code): show swarm failures distinctly from cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swarm card finalized every is_error tool result as 'cancelled' with a success-toned bullet, and the dashboard suppresses the result body, so ordinary failures (planner produced no valid plan, synthesizer error) rendered as a clean "cancelled" with the real "Swarm failed: ..." reason hidden from the user. SwarmTool now distinguishes a genuine cancel (ctx.signal aborted) from an ordinary failure: on a real failure it emits a 'failed' swarm progress event carrying the reason before returning the error result. The TUI adds a terminal 'failed' phase (error bullet, ' · failed' tag, and a "✗ reason" body line); finalizeSwarmModelIfNeeded only forces 'cancelled' when the model is not already 'failed', so a genuine abort still shows 'cancelled'. --- .../messages/swarm-dashboard-model.ts | 7 ++- .../src/tui/components/messages/tool-call.ts | 31 +++++++--- .../tui/controllers/session-event-handler.ts | 5 ++ .../messages/swarm-dashboard-model.test.ts | 11 ++++ .../components/messages/swarm-routing.test.ts | 42 ++++++++++++++ .../messages/tool-call-swarm.test.ts | 23 +++++++- .../src/tools/builtin/collaboration/swarm.ts | 13 +++++ .../agent-core/test/swarm/swarm-tool.test.ts | 56 +++++++++++++++++++ 8 files changed, 178 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts index a1128544..0c99830b 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-dashboard-model.ts @@ -1,4 +1,4 @@ -export type SwarmPhase = 'planning' | 'working' | 'synthesizing' | 'done' | 'cancelled'; +export type SwarmPhase = 'planning' | 'working' | 'synthesizing' | 'done' | 'cancelled' | 'failed'; export type WorkerStatus = 'running' | 'done' | 'failed' | 'retrying' | 'dropped'; export interface WorkerRow { @@ -19,6 +19,8 @@ export interface SwarmModel { failedCount: number; droppedCount: number; workers: Map; + /** Set when phase is 'failed': the reason the whole swarm errored out. */ + failureMessage?: string; } export type SwarmEvent = @@ -26,6 +28,7 @@ export type SwarmEvent = | { t: 'synthesizing' } | { t: 'done'; succeeded: number; failed: number } | { t: 'cancelled' } + | { t: 'failed'; message: string } | { t: 'worker.spawned'; id: string; role: string } | { t: 'worker.toolcall'; id: string; activity: string } | { t: 'worker.tokens'; id: string; tokens: number } @@ -103,6 +106,8 @@ export function applySwarmEvent(model: SwarmModel, event: SwarmEvent): SwarmMode return { ...model, phase: 'done' }; case 'cancelled': return { ...model, phase: 'cancelled' }; + case 'failed': + return { ...model, phase: 'failed', failureMessage: event.message }; case 'worker.spawned': { if (model.workers.has(event.id)) return model; const workers = new Map(model.workers); diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index d62a98d6..8ddd135d 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -675,14 +675,18 @@ export class ToolCallComponent extends Container { /** * Drives the swarm dashboard to its terminal state when the tool result - * lands. An error result (abort/throw) never emits the coordinator's `done` - * progress, so finalize it as cancelled; otherwise ensure the header shows + * lands. An ordinary failure (planner/synthesizer error) has already driven + * the model to 'failed' via a progress event carrying the reason, so leave it + * be; only a genuine abort/cancel reaches an error result still non-terminal, + * so finalize that as cancelled. A success result ensures the header shows * the summary even if the `done` progress event was missed. */ private finalizeSwarmModelIfNeeded(result: ToolResultBlockData): void { if (this.swarmModel === undefined) return; if (result.is_error === true) { - this.swarmModel = applySwarmEvent(this.swarmModel, { t: 'cancelled' }); + if (this.swarmModel.phase !== 'failed') { + this.swarmModel = applySwarmEvent(this.swarmModel, { t: 'cancelled' }); + } return; } if (this.swarmModel.phase !== 'done' && this.swarmModel.phase !== 'cancelled') { @@ -1303,13 +1307,17 @@ export class ToolCallComponent extends Container { const title = rawTask.length > 56 ? `${rawTask.slice(0, 56)}…` : rawTask; const label = chalk.hex(c.primary).bold('Swarm'); const titlePart = title.length > 0 ? chalk.dim(` · ${title}`) : ''; - const terminal = m.phase === 'done' || m.phase === 'cancelled'; - const bullet = terminal - ? chalk.hex(c.success)(STATUS_BULLET) - : chalk.hex(c.roleAssistant)(STATUS_BULLET); + const terminal = m.phase === 'done' || m.phase === 'cancelled' || m.phase === 'failed'; + const bullet = + m.phase === 'failed' + ? chalk.hex(c.error)(STATUS_BULLET) + : terminal + ? chalk.hex(c.success)(STATUS_BULLET) + : chalk.hex(c.roleAssistant)(STATUS_BULLET); let tail: string; if (terminal) { - const tag = m.phase === 'cancelled' ? ' · cancelled' : ''; + const tag = + m.phase === 'cancelled' ? ' · cancelled' : m.phase === 'failed' ? ' · failed' : ''; // Surface drops alongside ✓/✗ so a recovered-with-gaps run is honest about // the missing subtasks; omitted when zero to keep the common run compact. const droppedPart = m.droppedCount > 0 ? ` ${String(m.droppedCount)}⊘` : ''; @@ -1347,6 +1355,13 @@ export class ToolCallComponent extends Container { this.addChild(new Text(line, 0, 0)); } }); + // A whole-swarm failure (planner/synthesizer error) surfaces its reason as + // an error line so the card is honest about what went wrong instead of + // hiding the message behind a 'cancelled'-looking header. + if (m.phase === 'failed') { + const reason = m.failureMessage ?? 'swarm failed'; + this.addChild(new Text(` ${chalk.hex(this.colors.error)(`✗ ${reason}`)}`, 0, 0)); + } } /** diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 7de54c6a..4fd3b011 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -541,6 +541,7 @@ export class SessionEventHandler { newRole?: string; decision?: string; reason?: string; + message?: string; }; if (p.phase === 'planned' && typeof p.total === 'number') { tc.applySwarm({ t: 'planned', total: p.total }); @@ -548,6 +549,10 @@ export class SessionEventHandler { tc.applySwarm({ t: 'synthesizing' }); } else if (p.phase === 'done') { tc.applySwarm({ t: 'done', succeeded: 0, failed: 0 }); + } else if (p.phase === 'failed') { + // An ordinary swarm failure (planner/synthesizer error) — show it as a + // failed dashboard with the reason, not a success-toned 'cancelled'. + tc.applySwarm({ t: 'failed', message: typeof p.message === 'string' ? p.message : '' }); } else if (p.phase === 'revising' && typeof p.role === 'string') { // Route by the reviser's decision so each recovery path shows the right // transient state: diff --git a/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts index 0860a474..5a4e993b 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-dashboard-model.test.ts @@ -11,6 +11,17 @@ function reduce(events: Parameters[1][]): SwarmModel { return events.reduce((m, e) => applySwarmEvent(m, e), initialSwarmModel('do a task')); } +describe('swarm failed phase', () => { + it('sets the failed phase and stores the failure message', () => { + const m = reduce([ + { t: 'planned', total: 1 }, + { t: 'failed', message: 'planner exploded' }, + ]); + expect(m.phase).toBe('failed'); + expect(m.failureMessage).toBe('planner exploded'); + }); +}); + describe('applySwarmEvent', () => { it('starts in planning phase with the task', () => { const m = initialSwarmModel('my task'); diff --git a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts index d9c9b343..10d6fb43 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts @@ -402,4 +402,46 @@ describe('swarm dashboard wiring (translation)', () => { expect(out).not.toContain('synthesizer'); expect(out).toContain('2 workers · 2✓ 0✗'); }); + + it('routes a failed progress event into a failed dashboard state and keeps it over the error result', () => { + const parentToolCallId = 'tc-swarm'; + const dash = makeSwarm(); + const mockHost = { + streamingUI: { + setTurnId: (): void => {}, + getToolComponent: (id: string): ToolCallComponent | undefined => + id === parentToolCallId ? dash : undefined, + }, + } as unknown as SessionEventHost; + const handler = new SessionEventHandler(mockHost); + const noop = (): void => {}; + const progress = (customData: Record): void => { + handler.handleEvent( + { + type: 'tool.progress', + agentId: 'main', + sessionId: 's', + turnId: 1, + toolCallId: parentToolCallId, + update: { kind: 'custom', customKind: 'swarm', customData }, + } as unknown as Event, + noop, + ); + }; + + progress({ phase: 'planned', total: 1 }); + progress({ phase: 'failed', message: 'planner failed to produce a valid plan' }); + // The error tool result then lands; it must NOT override 'failed' with + // a success-toned 'cancelled'. + dash.setResult({ + tool_call_id: parentToolCallId, + output: 'Swarm failed: planner failed to produce a valid plan', + is_error: true, + }); + + const out = strip(dash.render(80).join('\n')); + expect(out).toContain('· failed'); + expect(out).not.toContain('cancelled'); + expect(out).toContain('planner failed to produce a valid plan'); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts index a83208d2..feee992f 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts @@ -155,7 +155,7 @@ describe('ToolCallComponent swarm mode', () => { expect(out).toContain('1⊘'); }); - it('finalizes to a cancelled header on an error result', () => { + it('finalizes to a cancelled header on an error result (genuine abort, no failure event)', () => { const c = makeSwarm('t'); c.applySwarm({ t: 'planned', total: 2 }); c.applySwarm({ t: 'worker.spawned', id: 'a1', role: 'R' }); @@ -166,6 +166,27 @@ describe('ToolCallComponent swarm mode', () => { expect(out).toContain('cancelled'); }); + it('finalizes to a failed header showing the reason when a failure preceded the error result', () => { + const c = makeSwarm('do research'); + c.applySwarm({ t: 'planned', total: 1 }); + // An ordinary swarm failure (planner/synthesizer error) emits a failure + // event before the error result. The card must show the reason, not + // masquerade as a success-toned 'cancelled' with the message hidden. + c.applySwarm({ + t: 'failed', + message: 'Swarm planner failed to produce a valid plan after one retry', + }); + c.setResult({ + tool_call_id: 'tc-swarm', + output: 'Swarm failed: Swarm planner failed to produce a valid plan after one retry', + is_error: true, + }); + const out = strip(c.render(80).join('\n')); + expect(out).toContain('· failed'); + expect(out).not.toContain('cancelled'); + expect(out).toContain('planner failed to produce a valid plan'); + }); + it('finalizes to a summary header after done + success result', () => { const c = makeSwarm('t'); c.applySwarm({ t: 'planned', total: 2 }); diff --git a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts index 1924a145..e4b3951b 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/swarm.ts @@ -136,6 +136,19 @@ export class SwarmTool implements BuiltinTool { } catch (err) { const message = err instanceof Error ? err.message : String(err); this.log?.error(`swarm failed: ${message}`); + // Distinguish an ordinary failure (planner/synthesizer error) from a + // genuine cancel (the turn was aborted). For a real failure, drive the + // dashboard to a 'failed' state that surfaces the reason; an abort emits + // nothing here so the card still finalizes as 'cancelled' on the error + // result. Without this, ordinary failures masquerade as a success-toned + // 'cancelled' card with the reason hidden. + if (!ctx.signal.aborted) { + ctx.onUpdate?.({ + kind: 'custom', + customKind: 'swarm', + customData: { phase: 'failed', message }, + }); + } return { output: `Swarm failed: ${message}`, isError: true }; } } diff --git a/packages/agent-core/test/swarm/swarm-tool.test.ts b/packages/agent-core/test/swarm/swarm-tool.test.ts index 8fcbbc6d..bc0e644f 100644 --- a/packages/agent-core/test/swarm/swarm-tool.test.ts +++ b/packages/agent-core/test/swarm/swarm-tool.test.ts @@ -56,6 +56,62 @@ describe('SwarmTool', () => { expect(updates.length).toBeGreaterThan(0); }); + it('emits a failed dashboard event with the reason on an ordinary failure', async () => { + // Planner always returns garbage -> decompose fails after its one retry, + // so coordinator.run rejects with an ordinary (non-abort) error. + const host = { + spawn: vi.fn(async (profileName: string) => ({ + agentId: 'a', + profileName, + resumed: false, + completion: Promise.resolve({ result: 'not json at all' }), + })), + } as unknown as SessionSubagentHost; + const tool = new SwarmTool(host); + const exec = tool.resolveExecution({ task: 'do it' }); + if (!('execute' in exec)) throw new Error('expected runnable execution'); + const customData: Array<{ phase?: string; message?: string }> = []; + const result = await exec.execute({ + turnId: 't1', + toolCallId: 'tc1', + signal: new AbortController().signal, + onUpdate: (u) => { + if (u.kind === 'custom') customData.push(u.customData as { phase?: string; message?: string }); + }, + }); + expect('isError' in result && result.isError).toBe(true); + const failed = customData.find((d) => d.phase === 'failed'); + expect(failed).toBeDefined(); + expect(failed?.message).toContain('planner failed to produce a valid plan'); + }); + + it('does not emit a failed event when the swarm is aborted (genuine cancel)', async () => { + const controller = new AbortController(); + const host = { + spawn: vi.fn(async (profileName: string) => ({ + agentId: 'a', + profileName, + resumed: false, + completion: Promise.resolve({ result: 'not json at all' }), + })), + } as unknown as SessionSubagentHost; + const tool = new SwarmTool(host); + const exec = tool.resolveExecution({ task: 'do it' }); + if (!('execute' in exec)) throw new Error('expected runnable execution'); + controller.abort(); + const customData: Array<{ phase?: string }> = []; + const result = await exec.execute({ + turnId: 't1', + toolCallId: 'tc1', + signal: controller.signal, + onUpdate: (u) => { + if (u.kind === 'custom') customData.push(u.customData as { phase?: string }); + }, + }); + expect('isError' in result && result.isError).toBe(true); + expect(customData.find((d) => d.phase === 'failed')).toBeUndefined(); + }); + it('injects a stall hook + per-worker signal for workers but not planner/synthesizer', async () => { const seen: Array<{ profileName: string; hasHooks: boolean; sameAsCoordinator: boolean }> = []; const coordinatorSignal = new AbortController().signal; From 9d7c9a6e6a6d9e919558938fb33343123b7e3f9b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 30 May 2026 14:01:42 +0800 Subject: [PATCH 25/28] fix(agent-core): reject empty planner subtask fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parsePlan accepted a syntactically valid plan whose role, systemPrompt, or prompt was an empty (or whitespace-only) string, only checking the type. Such a subtask spawns a swarm worker with no identity and no instructions — a wasted run with a blank dashboard row and a low-value contribution to synthesis. Reject empty/whitespace-only required fields so decompose's existing planner retry re-prompts for a valid plan, mirroring the non-empty validation parseReviseDecision already applies to the reviser's output. --- packages/agent-core/src/agent/swarm/parse.ts | 22 ++++++++++++++------ packages/agent-core/test/swarm/parse.test.ts | 12 +++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/agent-core/src/agent/swarm/parse.ts b/packages/agent-core/src/agent/swarm/parse.ts index 65ef1d5f..13887662 100644 --- a/packages/agent-core/src/agent/swarm/parse.ts +++ b/packages/agent-core/src/agent/swarm/parse.ts @@ -29,10 +29,20 @@ export function parsePlan(rootTask: string, text: string): SwarmPlan | null { const raw = subtasksRaw[i]; if (typeof raw !== 'object' || raw === null) return null; const o = raw as Record; + const role = o['role']; + const systemPrompt = o['systemPrompt']; + const prompt = o['prompt']; + // Required fields must be present AND non-empty: an empty/whitespace-only + // role/systemPrompt/prompt would spawn a worker with no identity or + // instructions, so reject the plan and let decompose's retry re-prompt the + // planner (mirrors the reviser's non-empty validation in parseReviseDecision). if ( - typeof o['role'] !== 'string' || - typeof o['systemPrompt'] !== 'string' || - typeof o['prompt'] !== 'string' + typeof role !== 'string' || + role.trim().length === 0 || + typeof systemPrompt !== 'string' || + systemPrompt.trim().length === 0 || + typeof prompt !== 'string' || + prompt.trim().length === 0 ) { return null; } @@ -41,9 +51,9 @@ export function parsePlan(rootTask: string, text: string): SwarmPlan | null { : 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'], + role, + systemPrompt, + prompt, toolAllowlist, status: 'pending', attempts: 0, diff --git a/packages/agent-core/test/swarm/parse.test.ts b/packages/agent-core/test/swarm/parse.test.ts index 62186bba..aa60ba3c 100644 --- a/packages/agent-core/test/swarm/parse.test.ts +++ b/packages/agent-core/test/swarm/parse.test.ts @@ -41,6 +41,18 @@ describe('parsePlan', () => { expect(parsePlan('root', '{"subtasks":[{"role":"R"}]}')).toBeNull(); }); + it('returns null when a required field is empty or whitespace-only', () => { + expect( + parsePlan('root', '{"subtasks":[{"role":"","systemPrompt":"sp","prompt":"p"}]}'), + ).toBeNull(); + expect( + parsePlan('root', '{"subtasks":[{"role":"R","systemPrompt":" ","prompt":"p"}]}'), + ).toBeNull(); + expect( + parsePlan('root', '{"subtasks":[{"role":"R","systemPrompt":"sp","prompt":""}]}'), + ).toBeNull(); + }); + it('returns null for non-json garbage', () => { expect(parsePlan('root', 'totally not json')).toBeNull(); }); From e288ffa1b95b3c324cbb052a346abee81c05cea0 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 30 May 2026 14:09:57 +0800 Subject: [PATCH 26/28] fix(kimi-code): show the /swarm request in the live transcript /swarm drove a model turn via session.prompt(buildSwarmPrompt(task)) without ever putting the user's request in the transcript, so a live session showed a Swarm tool card with no preceding user line. Append a readable "/swarm " user entry before starting the turn, mirroring the normal send path. Adds appendUserTranscriptEntry to the slash-command host for framed commands that prompt the model with an internal wrapper. --- apps/kimi-code/src/tui/commands/dispatch.ts | 1 + apps/kimi-code/src/tui/commands/swarm.ts | 5 ++ apps/kimi-code/src/tui/kimi-tui.ts | 17 ++++++ .../kimi-code/test/tui/commands/swarm.test.ts | 52 +++++++++++++++++-- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index e47fe0d7..5e50f717 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -105,6 +105,7 @@ export interface SlashCommandHost { switchToSession(session: Session, message: string): Promise; beginSessionRequest(): void; failSessionRequest(message: string): void; + appendUserTranscriptEntry(content: string): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; // UI diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index b5c52b6a..6dc8e714 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -23,6 +23,11 @@ export async function handleSwarmCommand(host: SlashCommandHost, args: string): host.showError('Usage: /swarm '); return; } + // Show the readable command in the transcript before the turn starts. The + // prompt actually sent to the model is the verbose buildSwarmPrompt wrapper, + // so without this the live transcript would show a Swarm tool card with no + // preceding user request. + host.appendUserTranscriptEntry(`/swarm ${task}`); // Route through the same session-request lifecycle as a normal send / // skill activation rather than calling session.prompt raw. beginSessionRequest // flips streamingPhase out of 'idle' synchronously, so the input gate closes diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 68258aaa..3d519287 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1239,6 +1239,23 @@ export class KimiTUI { } } + /** + * Appends a plain user-request line to the live transcript. Used by framed + * slash commands (e.g. /swarm) that drive a model turn via session.prompt + * with an internal wrapper prompt: the wrapper would otherwise be the only + * record of the request, so the live transcript should show the readable + * command the user actually entered before the turn's tool card appears. + */ + appendUserTranscriptEntry(content: string): void { + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content, + }); + } + private appendApprovalTranscriptEntry(request: ApprovalRequest, response: ApprovalResponse): void { if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return; const parts: string[] = []; diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 52f8a914..dcbabaa7 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -29,8 +29,15 @@ describe('handleSwarmCommand', () => { const showError = vi.fn(); const beginSessionRequest = vi.fn(); const failSessionRequest = vi.fn(); + const appendUserTranscriptEntry = vi.fn(); await handleSwarmCommand( - { session: { prompt }, showError, beginSessionRequest, failSessionRequest } as never, + { + session: { prompt }, + showError, + beginSessionRequest, + failSessionRequest, + appendUserTranscriptEntry, + } as never, 'compare libs', ); expect(prompt).toHaveBeenCalledTimes(1); @@ -42,8 +49,15 @@ describe('handleSwarmCommand', () => { const showError = vi.fn(); const beginSessionRequest = vi.fn(); const failSessionRequest = vi.fn(); + const appendUserTranscriptEntry = vi.fn(); await handleSwarmCommand( - { session: { prompt }, showError, beginSessionRequest, failSessionRequest } as never, + { + session: { prompt }, + showError, + beginSessionRequest, + failSessionRequest, + appendUserTranscriptEntry, + } as never, 'compare libs', ); expect(beginSessionRequest).toHaveBeenCalledTimes(1); @@ -55,6 +69,31 @@ describe('handleSwarmCommand', () => { expect(failSessionRequest).not.toHaveBeenCalled(); }); + it('appends the user request to the transcript before prompting', async () => { + const prompt = vi.fn<(text: string) => Promise>(async () => undefined); + const showError = vi.fn(); + const beginSessionRequest = vi.fn(); + const failSessionRequest = vi.fn(); + const appendUserTranscriptEntry = vi.fn(); + await handleSwarmCommand( + { + session: { prompt }, + showError, + beginSessionRequest, + failSessionRequest, + appendUserTranscriptEntry, + } as never, + 'compare libs', + ); + // The user's command must appear in the live transcript (as the readable + // "/swarm ", not the verbose internal wrapper), before the turn starts. + expect(appendUserTranscriptEntry).toHaveBeenCalledTimes(1); + expect(String(appendUserTranscriptEntry.mock.calls[0]?.[0])).toBe('/swarm compare libs'); + expect(appendUserTranscriptEntry.mock.invocationCallOrder[0]).toBeLessThan( + beginSessionRequest.mock.invocationCallOrder[0] ?? Infinity, + ); + }); + it('fails the session request when the prompt rejects', async () => { const prompt = vi.fn<(text: string) => Promise>(async () => { throw new Error('boom'); @@ -62,8 +101,15 @@ describe('handleSwarmCommand', () => { const showError = vi.fn(); const beginSessionRequest = vi.fn(); const failSessionRequest = vi.fn(); + const appendUserTranscriptEntry = vi.fn(); await handleSwarmCommand( - { session: { prompt }, showError, beginSessionRequest, failSessionRequest } as never, + { + session: { prompt }, + showError, + beginSessionRequest, + failSessionRequest, + appendUserTranscriptEntry, + } as never, 'compare libs', ); expect(beginSessionRequest).toHaveBeenCalledTimes(1); From f31bf2b7ba26ac9bdd6b095c0efefa73a1b92d49 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 30 May 2026 14:28:57 +0800 Subject: [PATCH 27/28] refactor(kimi-code): render the swarm dashboard in a dedicated SwarmCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swarm dashboard was stacked as branches inside ToolCallComponent, against the guideline that new tool-result display should live behind a dedicated renderer/component boundary. The static tool-renderer registry can't host it (the dashboard needs live, per-event updates on a single stable component — folding it in was how the original duplicate-card bug was fixed), so extract it into a dedicated top-level SwarmCard selected at tool-call-start, hosted by the same managed lifecycle. - New SwarmCard (sibling to ToolCallComponent) + a narrow ManagedToolCard interface the streaming-UI registry is typed against; shared helpers (str/formatTokens/SWARM_ACTIVITY_MAX_LENGTH) moved to tool-call-shared. - streaming-ui selects SwarmCard for name==='Swarm' at the single creation point; replay routes Swarm entries to SwarmCard too. - session-event-handler narrows the generic subagent path to ToolCallComponent after the swarm guard. - ToolCallComponent loses all swarm code (isSwarm() now returns false). Behavior-preserving: same single stable card, in-place mutation, static bullet, header from live args. Full suite green; swarm tests retargeted to SwarmCard. --- .../components/messages/managed-tool-card.ts | 50 +++ .../src/tui/components/messages/swarm-card.ts | 302 ++++++++++++++++++ .../components/messages/tool-call-shared.ts | 21 ++ .../src/tui/components/messages/tool-call.ts | 233 +------------- .../tui/controllers/session-event-handler.ts | 14 +- .../src/tui/controllers/streaming-ui.ts | 29 +- apps/kimi-code/src/tui/kimi-tui.ts | 28 +- .../components/messages/swarm-routing.test.ts | 18 +- .../messages/tool-call-swarm.test.ts | 9 +- 9 files changed, 457 insertions(+), 247 deletions(-) create mode 100644 apps/kimi-code/src/tui/components/messages/managed-tool-card.ts create mode 100644 apps/kimi-code/src/tui/components/messages/swarm-card.ts create mode 100644 apps/kimi-code/src/tui/components/messages/tool-call-shared.ts diff --git a/apps/kimi-code/src/tui/components/messages/managed-tool-card.ts b/apps/kimi-code/src/tui/components/messages/managed-tool-card.ts new file mode 100644 index 00000000..c9f17017 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/managed-tool-card.ts @@ -0,0 +1,50 @@ +import type { Component } from '@earendil-works/pi-tui'; + +import type { SwarmEvent } from './swarm-dashboard-model'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +/** + * Narrow shared contract for a card that the streaming-UI managed tool-call + * lifecycle owns in `_pendingToolComponents` (keyed by tool call id). Both + * `ToolCallComponent` and `SwarmCard` implement it, so callers can hold a + * registry value and dispatch the lifecycle methods below polymorphically. + * + * The interface is deliberately minimal: it only carries members the callers + * invoke on the union without first knowing which concrete card they hold. + * Anything reached only after `isSwarm()` returns false (the whole subagent + * API, progress lines, plan info, background-task terminal status, …) is left + * off and accessed via `instanceof ToolCallComponent` narrowing at the call + * site instead. + */ +export interface ManagedToolCard extends Component { + /** True iff this card drives the `Swarm` coordinator dashboard. */ + isSwarm(): boolean; + + /** + * Fold a swarm dashboard event into the card and re-render in place. A safe + * no-op on a non-swarm card so callers can route blindly after an + * `isSwarm()` guard without re-narrowing. + */ + applySwarm(event: SwarmEvent): void; + + /** Deliver the tool result and drive the card to its terminal state. */ + setResult(result: ToolResultBlockData): void; + + /** Re-sync the live tool-call metadata (args, truncation, …). */ + updateToolCall(toolCall: ToolCallBlockData): void; + + /** Tool-output expansion toggle (Ctrl+O). No-op on cards without a body. */ + setExpanded(expanded: boolean): void; + + /** + * Plan-box expansion toggle. Returns true iff the card actually owns a plan + * preview (so the caller can decide whether to consume the keystroke). + */ + setPlanExpanded(expanded: boolean): boolean; + + /** Release any timers/resources. Must be safe to call more than once. */ + dispose(): void; + + /** Readonly view of the backing tool call (id, name, description). */ + readonly toolCallView: Readonly; +} diff --git a/apps/kimi-code/src/tui/components/messages/swarm-card.ts b/apps/kimi-code/src/tui/components/messages/swarm-card.ts new file mode 100644 index 00000000..3e49fbbd --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/swarm-card.ts @@ -0,0 +1,302 @@ +/** + * Renders the `Swarm` coordinator dashboard as a dedicated, top-level managed + * card — a sibling to `ToolCallComponent` hosted by the same streaming-UI + * managed tool-call lifecycle (one stable component per tool id). + * + * Stability invariants (a standalone dashboard component once caused a + * duplicate-card bug, fixed by folding it into the managed lifecycle; this card + * preserves the same discipline): + * 1. Created exactly once per tool id, inside streaming-ui's + * `onToolCallStart`, stored in `_pendingToolComponents`. Never re-created. + * 2. In-place mutation only: `applySwarm` / `setResult` mutate the existing + * children (header `setText` + pop-and-rebuild body past the fixed header + * index) and call `ui?.requestRender()`. Never re-attached to the + * transcript after creation. + * 3. Byte-stable render: no per-render animation, no spinner / setInterval. + * A static `STATUS_BULLET` colored by phase keeps consecutive renders + * identical so pi-tui's differential renderer never re-emits the card. + * 4. The header task is sourced from the live tool-call args, not the + * (possibly stale) model task. + */ + +import { Container, Text, Spacer } from '@earendil-works/pi-tui'; +import type { TUI } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; + +import type { ManagedToolCard } from './managed-tool-card'; +import { + SWARM_ACTIVITY_MAX_LENGTH, + formatTokens, + str, +} from './tool-call-shared'; +import { + applySwarmEvent, + initialSwarmModel, + type SwarmEvent, + type SwarmModel, + type WorkerRow, +} from './swarm-dashboard-model'; + +/** + * Index of the first body child. Children 0/1 are the leading Spacer and the + * single-line header carried by `headerText`; the swarm body lives past this + * index and is popped-and-rebuilt in place on every event. + */ +const SWARM_BODY_START_INDEX = 2; + +export class SwarmCard extends Container implements ManagedToolCard { + private toolCall: ToolCallBlockData; + private result: ToolResultBlockData | undefined; + private readonly colors: ColorPalette; + private readonly ui: TUI | undefined; + private readonly headerText: Text; + private swarmModel: SwarmModel; + + constructor( + toolCall: ToolCallBlockData, + result: ToolResultBlockData | undefined, + colors: ColorPalette, + ui?: TUI, + ) { + super(); + this.toolCall = toolCall; + this.result = result; + this.colors = colors; + this.ui = ui; + this.swarmModel = initialSwarmModel(str(toolCall.args['task'])); + + this.addChild(new Spacer(1)); + this.headerText = new Text(this.buildSwarmHeader(), 0, 0); + this.addChild(this.headerText); + this.buildSwarmBody(); + + // A result supplied at construction (replay) finalizes the dashboard the + // same way a live result would. + if (result !== undefined) { + this.finalizeSwarmModelIfNeeded(result); + this.headerText.setText(this.buildSwarmHeader()); + this.rebuildBody(); + } + } + + /** True — this card always drives the swarm dashboard. */ + isSwarm(): boolean { + return true; + } + + /** + * Fold a swarm dashboard event into the model and re-render in place. Mirrors + * `ToolCallComponent`'s prior `applySwarm` so the card stays a single, stable + * component managed by the normal tool-call lifecycle. + */ + applySwarm(event: SwarmEvent): void { + this.swarmModel = applySwarmEvent(this.swarmModel, event); + this.headerText.setText(this.buildSwarmHeader()); + this.rebuildBody(); + this.ui?.requestRender(); + } + + setResult(result: ToolResultBlockData): void { + this.result = result; + this.finalizeSwarmModelIfNeeded(result); + this.headerText.setText(this.buildSwarmHeader()); + this.rebuildBody(); + this.ui?.requestRender(); + } + + updateToolCall(toolCall: ToolCallBlockData): void { + this.toolCall = toolCall; + // The header task is sourced live from the args, so re-sync it. + this.headerText.setText(this.buildSwarmHeader()); + this.rebuildBody(); + this.ui?.requestRender(); + } + + /** No tool-output body to expand on the swarm card — no-op. */ + setExpanded(_expanded: boolean): void { + void _expanded; + } + + /** The swarm card never owns a plan preview. */ + setPlanExpanded(_expanded: boolean): boolean { + void _expanded; + return false; + } + + /** No timers to release — the swarm card uses no per-render animation. */ + dispose(): void { + // Intentionally empty: stability invariant #3 forbids any timer/spinner. + } + + // Readonly view for callers that inspect the backing tool call metadata. + get toolCallView(): Readonly { + return this.toolCall; + } + + /** + * Drives the swarm dashboard to its terminal state when the tool result + * lands. An ordinary failure (planner/synthesizer error) has already driven + * the model to 'failed' via a progress event carrying the reason, so leave it + * be; only a genuine abort/cancel reaches an error result still non-terminal, + * so finalize that as cancelled. A success result ensures the header shows + * the summary even if the `done` progress event was missed. + */ + private finalizeSwarmModelIfNeeded(result: ToolResultBlockData): void { + if (result.is_error === true) { + if (this.swarmModel.phase !== 'failed') { + this.swarmModel = applySwarmEvent(this.swarmModel, { t: 'cancelled' }); + } + return; + } + if (this.swarmModel.phase !== 'done' && this.swarmModel.phase !== 'cancelled') { + this.swarmModel = applySwarmEvent(this.swarmModel, { + t: 'done', + succeeded: this.swarmModel.doneCount, + failed: this.swarmModel.failedCount, + }); + } + } + + /** Pop the body children past the fixed header index and rebuild them. */ + private rebuildBody(): void { + while (this.children.length > SWARM_BODY_START_INDEX) { + this.children.pop(); + } + this.buildSwarmBody(); + } + + // ── Swarm dashboard rendering ──────────────────────────────────── + // + // The swarm card mirrors `AgentGroupComponent`'s gutter/indent/color + // vocabulary. No animated, per-render content is used so the rendered lines + // stay identical across consecutive renders — the property that lets + // pi-tui's differential renderer keep one stable card. + + /** + * Single-line header for the Swarm card (carried by `headerText`). Mirrors + * `AgentGroupComponent.buildHeader`: a status bullet (roleAssistant while + * active, success when terminal), the bold `Swarm` label, a dim `· title` + * segment (omitted when empty so no dangling `·`), and a dim phase/summary + * tail. The displayed task is sourced live from the tool-call args rather + * than the stale model so it reflects the fully-streamed task string. + */ + private buildSwarmHeader(): string { + const c = this.colors; + const m = this.swarmModel; + const rawTask = str(this.toolCall.args['task']).replaceAll(/\s+/g, ' ').trim(); + const title = rawTask.length > 56 ? `${rawTask.slice(0, 56)}…` : rawTask; + const label = chalk.hex(c.primary).bold('Swarm'); + const titlePart = title.length > 0 ? chalk.dim(` · ${title}`) : ''; + const terminal = m.phase === 'done' || m.phase === 'cancelled' || m.phase === 'failed'; + const bullet = + m.phase === 'failed' + ? chalk.hex(c.error)(STATUS_BULLET) + : terminal + ? chalk.hex(c.success)(STATUS_BULLET) + : chalk.hex(c.roleAssistant)(STATUS_BULLET); + let tail: string; + if (terminal) { + const tag = + m.phase === 'cancelled' ? ' · cancelled' : m.phase === 'failed' ? ' · failed' : ''; + // Surface drops alongside ✓/✗ so a recovered-with-gaps run is honest about + // the missing subtasks; omitted when zero to keep the common run compact. + const droppedPart = m.droppedCount > 0 ? ` ${String(m.droppedCount)}⊘` : ''; + tail = chalk.dim( + ` · ${String(m.workers.size)} workers · ${String(m.doneCount)}✓ ${String(m.failedCount)}✗${droppedPart}${tag}`, + ); + } else if (m.phase === 'planning') { + tail = chalk.dim(' · planning…'); + } else if (m.phase === 'synthesizing') { + tail = chalk.dim(' · synthesizing…'); + } else { + tail = chalk.dim(` · ${String(m.doneCount + m.failedCount)}/${String(m.total)} workers`); + } + return `${bullet}${label}${titlePart}${tail}`; + } + + /** + * Renders one or two gutter lines per worker into the body, mirroring + * `AgentGroupComponent.appendLines` (the `├─`/`└─`/`│` vocabulary, the + * 2-space lead, and the dim/primary/error coloring). While still planning + * with no workers yet, a single dim placeholder line keeps the card from + * rendering blank. + */ + private buildSwarmBody(): void { + const m = this.swarmModel; + const workers = [...m.workers.values()]; + if (m.phase === 'planning' && workers.length === 0) { + this.addChild(new Text(` ${chalk.dim('└─ planning subtasks…')}`, 0, 0)); + return; + } + workers.forEach((w, idx) => { + const isLast = idx === workers.length - 1; + for (const line of this.buildSwarmWorkerLine(w, isLast)) { + this.addChild(new Text(line, 0, 0)); + } + }); + // A whole-swarm failure (planner/synthesizer error) surfaces its reason as + // an error line so the card is honest about what went wrong instead of + // hiding the message behind a 'cancelled'-looking header. + if (m.phase === 'failed') { + const reason = m.failureMessage ?? 'swarm failed'; + this.addChild(new Text(` ${chalk.hex(this.colors.error)(`✗ ${reason}`)}`, 0, 0)); + } + } + + /** + * Builds the gutter lines for one worker. Line 1 carries the branch, the + * role, and a dim stats tail; line 2 (omitted once the worker is done) + * carries the latest activity or the failure reason. Matches + * `AgentGroupComponent`'s two-line gutter format. + */ + private buildSwarmWorkerLine(w: WorkerRow, isLast: boolean): string[] { + const c = this.colors; + const branch1 = isLast ? '└─' : '├─'; + const branch2 = isLast ? ' ' : '│ '; + const role = chalk.hex(c.primary)(w.role); + + // Live token counts are shown for every worker (running, retrying, done) so + // the dashboard stays consistent with `AgentGroupComponent`, which renders + // live tokens for all subagents from `agent.status.updated`. Running workers + // get their figure from `worker.tokens`; done workers from `worker.done`. + const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : ''; + let statsPart = ''; + if (w.status === 'done') { + statsPart = chalk.dim(` · ${String(w.toolCount)} call${w.toolCount === 1 ? '' : 's'}${tok}`); + } else if (w.status === 'retrying') { + statsPart = chalk.dim(` · retrying…${tok}`); + } else if (w.status === 'running' && w.toolCount > 0) { + statsPart = chalk.dim(` · ${String(w.toolCount)} call${w.toolCount === 1 ? '' : 's'}${tok}`); + } + const line1 = ` ${branch1} ${role}${statsPart}`; + + if (w.status === 'done') { + return [line1]; + } + // Retrying is a transient in-flight state shown as a single line so the + // role's row stays visible (and stable) while the coordinator re-runs it. + // Dim the role label to match the 'dropped' convention: non-running rows + // (retrying, dropped) use a dimmed label, running/done/failed keep primary. + if (w.status === 'retrying') { + return [` ${branch1} ${chalk.dim(w.role)}${statsPart}`]; + } + if (w.status === 'failed') { + const errLine = chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`); + return [line1, ` ${branch2} ${errLine}`]; + } + // Dropped: the coordinator gave up on this subtask. Dim the row and show the + // reason on the second gutter line so the gap is explicit, not silent. + if (w.status === 'dropped') { + const dropLine = chalk.dim(`dropped: ${w.error ?? 'no reason'}`); + return [` ${branch1} ${chalk.dim(w.role)}`, ` ${branch2} ${dropLine}`]; + } + const raw = w.latestActivity ?? 'starting…'; + const activity = + raw.length > SWARM_ACTIVITY_MAX_LENGTH ? `${raw.slice(0, SWARM_ACTIVITY_MAX_LENGTH)}…` : raw; + return [line1, ` ${branch2} ${chalk.dim(`now: ${activity}`)}`]; + } +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-call-shared.ts b/apps/kimi-code/src/tui/components/messages/tool-call-shared.ts new file mode 100644 index 00000000..ed379f90 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-call-shared.ts @@ -0,0 +1,21 @@ +/** + * Small helpers shared between `ToolCallComponent` and `SwarmCard`. + * + * These live in their own module rather than being duplicated so the two cards + * cannot drift (tests assert e.g. `1.8k tok` formatting in both). + */ + +/** Keeps a running swarm worker's activity to a single dashboard line. */ +export const SWARM_ACTIVITY_MAX_LENGTH = 48; + +/** Coerce an unknown value to a string, defaulting to '' for non-strings. */ +export function str(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +/** Compact token count formatting, e.g. `1.8k tok` / `2.0M tok`. */ +export function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`; + return `${String(n)} tok`; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 8ddd135d..fef299f8 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -23,21 +23,15 @@ import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; +import type { ManagedToolCard } from './managed-tool-card'; import { PlanBoxComponent } from './plan-box'; import { ShellExecutionComponent } from './shell-execution'; -import { - applySwarmEvent, - initialSwarmModel, - type SwarmEvent, - type SwarmModel, - type WorkerRow, -} from './swarm-dashboard-model'; +import type { SwarmEvent } from './swarm-dashboard-model'; +import { formatTokens, str } from './tool-call-shared'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { pickResultRenderer } from './tool-renderers/registry'; const MAX_ARG_LENGTH = 60; -/** Keeps a running swarm worker's activity to a single dashboard line. */ -const SWARM_ACTIVITY_MAX_LENGTH = 48; const MAX_SUB_TOOL_CALLS_SHOWN = 4; const MAX_SINGLE_SUBAGENT_TOOL_ROWS = 4; const APPROVED_PLAN_MARKER = '## Approved Plan:'; @@ -121,10 +115,6 @@ function backgroundFailureMessage( } } -function str(v: unknown): string { - return typeof v === 'string' ? v : ''; -} - function formatSubagentContextTokens(contextTokens: number | undefined): string | undefined { if (contextTokens === undefined || contextTokens <= 0) return undefined; const formatted = contextTokens >= 1000 ? `${(contextTokens / 1000).toFixed(1)}k` : String(contextTokens); @@ -457,7 +447,7 @@ class PrefixedWrappedLine implements Component { } } -export class ToolCallComponent extends Container { +export class ToolCallComponent extends Container implements ManagedToolCard { private expanded = false; private planExpanded = false; private toolCall: ToolCallBlockData; @@ -530,17 +520,6 @@ export class ToolCallComponent extends Container { private progressLines: string[] = []; private static readonly MAX_PROGRESS_LINES = 24; - // ── Swarm dashboard state ──────────────────────────────────────── - // - // Populated only when this tool call is the `Swarm` coordinator. The pure - // reducer in `swarm-dashboard-model` folds `applySwarm(event)` into this - // model; the body-building path renders the dashboard (one or two gutter - // lines per worker, mirroring `AgentGroupComponent`) instead of the normal - // progress/sub-tool/subagent blocks. No animated, per-render content is used - // so the rendered lines stay stable and pi-tui's differential renderer never - // re-emits the card into scrollback. - private swarmModel: SwarmModel | undefined; - /** * Registered by a group container (`AgentGroupComponent` or * `ReadGroupComponent`) when this component is borrowed as a hidden state @@ -565,9 +544,6 @@ export class ToolCallComponent extends Container { this.colors = colors; this.ui = ui; this.markdownTheme = markdownTheme; - if (toolCall.name === 'Swarm') { - this.swarmModel = initialSwarmModel(str(toolCall.args['task'])); - } this.applySubagentReplay(toolCall.subagent); this.addChild(new Spacer(1)); @@ -610,7 +586,6 @@ export class ToolCallComponent extends Container { // authoritative final state. Without this clear, a finished tool would // show both the streamed status lines and the final output stacked. this.progressLines = []; - this.finalizeSwarmModelIfNeeded(result); this.finalizeSubagentElapsedIfNeeded(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); @@ -653,49 +628,23 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } - /** True when this tool call drives the `Swarm` coordinator dashboard. */ - isSwarm(): boolean { - return this.toolCall.name === 'Swarm'; - } - /** - * Fold a swarm dashboard event into the model and re-render in place. - * No-ops for non-swarm tool calls so callers can route blindly. Mirrors - * {@link ToolCallComponent.appendProgress} so the swarm card stays a single, - * stable component managed by the normal tool-call lifecycle. + * Always false: the `Swarm` coordinator renders via the dedicated + * {@link SwarmCard}, which `streaming-ui` selects at tool-call-start time. + * Kept on `ToolCallComponent` so routing guards can call `isSwarm()` on the + * `ManagedToolCard` union without re-narrowing. */ - applySwarm(event: SwarmEvent): void { - if (this.swarmModel === undefined) return; - this.swarmModel = applySwarmEvent(this.swarmModel, event); - this.headerText.setText(this.buildHeader()); - this.rebuildBody(); - this.notifySnapshotChange(); - this.ui?.requestRender(); + isSwarm(): boolean { + return false; } /** - * Drives the swarm dashboard to its terminal state when the tool result - * lands. An ordinary failure (planner/synthesizer error) has already driven - * the model to 'failed' via a progress event carrying the reason, so leave it - * be; only a genuine abort/cancel reaches an error result still non-terminal, - * so finalize that as cancelled. A success result ensures the header shows - * the summary even if the `done` progress event was missed. + * Swarm dashboard events are handled by {@link SwarmCard}; on a non-swarm + * `ToolCallComponent` this is a safe no-op so callers can route blindly after + * an `isSwarm()` guard. */ - private finalizeSwarmModelIfNeeded(result: ToolResultBlockData): void { - if (this.swarmModel === undefined) return; - if (result.is_error === true) { - if (this.swarmModel.phase !== 'failed') { - this.swarmModel = applySwarmEvent(this.swarmModel, { t: 'cancelled' }); - } - return; - } - if (this.swarmModel.phase !== 'done' && this.swarmModel.phase !== 'cancelled') { - this.swarmModel = applySwarmEvent(this.swarmModel, { - t: 'done', - succeeded: this.swarmModel.doneCount, - failed: this.swarmModel.failedCount, - }); - } + applySwarm(_event: SwarmEvent): void { + void _event; } dispose(): void { @@ -1225,10 +1174,6 @@ export class ToolCallComponent extends Container { bullet = chalk.hex(colors.roleAssistant)(STATUS_BULLET); } - if (this.swarmModel !== undefined) { - return this.buildSwarmHeader(); - } - if (toolCall.name === 'ExitPlanMode') { const label = chalk.hex(colors.primary).bold('Current plan'); if (!isFinished || result === undefined || result.is_error === true) { @@ -1284,139 +1229,6 @@ export class ToolCallComponent extends Container { return tone(` · ${text}`); } - // ── Swarm dashboard rendering ──────────────────────────────────── - // - // The swarm card mirrors `AgentGroupComponent`'s gutter/indent/color - // vocabulary. No animated, per-render content is used so the rendered lines - // stay identical across consecutive renders — the property that lets - // pi-tui's differential renderer keep one stable card. - - /** - * Single-line header for the Swarm card (carried by `headerText`). Mirrors - * `AgentGroupComponent.buildHeader`: a status bullet (roleAssistant while - * active, success when terminal), the bold `Swarm` label, a dim `· title` - * segment (omitted when empty so no dangling `·`), and a dim phase/summary - * tail. The displayed task is sourced live from the tool-call args rather - * than the stale model so it reflects the fully-streamed task string. - */ - private buildSwarmHeader(): string { - const c = this.colors; - const m = this.swarmModel; - if (m === undefined) return ''; - const rawTask = str(this.toolCall.args['task']).replace(/\s+/g, ' ').trim(); - const title = rawTask.length > 56 ? `${rawTask.slice(0, 56)}…` : rawTask; - const label = chalk.hex(c.primary).bold('Swarm'); - const titlePart = title.length > 0 ? chalk.dim(` · ${title}`) : ''; - const terminal = m.phase === 'done' || m.phase === 'cancelled' || m.phase === 'failed'; - const bullet = - m.phase === 'failed' - ? chalk.hex(c.error)(STATUS_BULLET) - : terminal - ? chalk.hex(c.success)(STATUS_BULLET) - : chalk.hex(c.roleAssistant)(STATUS_BULLET); - let tail: string; - if (terminal) { - const tag = - m.phase === 'cancelled' ? ' · cancelled' : m.phase === 'failed' ? ' · failed' : ''; - // Surface drops alongside ✓/✗ so a recovered-with-gaps run is honest about - // the missing subtasks; omitted when zero to keep the common run compact. - const droppedPart = m.droppedCount > 0 ? ` ${String(m.droppedCount)}⊘` : ''; - tail = chalk.dim( - ` · ${String(m.workers.size)} workers · ${String(m.doneCount)}✓ ${String(m.failedCount)}✗${droppedPart}${tag}`, - ); - } else if (m.phase === 'planning') { - tail = chalk.dim(' · planning…'); - } else if (m.phase === 'synthesizing') { - tail = chalk.dim(' · synthesizing…'); - } else { - tail = chalk.dim(` · ${String(m.doneCount + m.failedCount)}/${String(m.total)} workers`); - } - return `${bullet}${label}${titlePart}${tail}`; - } - - /** - * Renders one or two gutter lines per worker into the body, mirroring - * `AgentGroupComponent.appendLines` (the `├─`/`└─`/`│` vocabulary, the - * 2-space lead, and the dim/primary/error coloring). While still planning - * with no workers yet, a single dim placeholder line keeps the card from - * rendering blank. - */ - private buildSwarmBody(): void { - const m = this.swarmModel; - if (m === undefined) return; - const workers = [...m.workers.values()]; - if (m.phase === 'planning' && workers.length === 0) { - this.addChild(new Text(` ${chalk.dim('└─ planning subtasks…')}`, 0, 0)); - return; - } - workers.forEach((w, idx) => { - const isLast = idx === workers.length - 1; - for (const line of this.buildSwarmWorkerLine(w, isLast)) { - this.addChild(new Text(line, 0, 0)); - } - }); - // A whole-swarm failure (planner/synthesizer error) surfaces its reason as - // an error line so the card is honest about what went wrong instead of - // hiding the message behind a 'cancelled'-looking header. - if (m.phase === 'failed') { - const reason = m.failureMessage ?? 'swarm failed'; - this.addChild(new Text(` ${chalk.hex(this.colors.error)(`✗ ${reason}`)}`, 0, 0)); - } - } - - /** - * Builds the gutter lines for one worker. Line 1 carries the branch, the - * role, and a dim stats tail; line 2 (omitted once the worker is done) - * carries the latest activity or the failure reason. Matches - * `AgentGroupComponent`'s two-line gutter format. - */ - private buildSwarmWorkerLine(w: WorkerRow, isLast: boolean): string[] { - const c = this.colors; - const branch1 = isLast ? '└─' : '├─'; - const branch2 = isLast ? ' ' : '│ '; - const role = chalk.hex(c.primary)(w.role); - - // Live token counts are shown for every worker (running, retrying, done) so - // the dashboard stays consistent with `AgentGroupComponent`, which renders - // live tokens for all subagents from `agent.status.updated`. Running workers - // get their figure from `worker.tokens`; done workers from `worker.done`. - const tok = w.tokens !== undefined && w.tokens > 0 ? ` · ${formatTokens(w.tokens)}` : ''; - let statsPart = ''; - if (w.status === 'done') { - statsPart = chalk.dim(` · ${String(w.toolCount)} call${w.toolCount === 1 ? '' : 's'}${tok}`); - } else if (w.status === 'retrying') { - statsPart = chalk.dim(` · retrying…${tok}`); - } else if (w.status === 'running' && w.toolCount > 0) { - statsPart = chalk.dim(` · ${String(w.toolCount)} call${w.toolCount === 1 ? '' : 's'}${tok}`); - } - const line1 = ` ${branch1} ${role}${statsPart}`; - - if (w.status === 'done') { - return [line1]; - } - // Retrying is a transient in-flight state shown as a single line so the - // role's row stays visible (and stable) while the coordinator re-runs it. - // Dim the role label to match the 'dropped' convention: non-running rows - // (retrying, dropped) use a dimmed label, running/done/failed keep primary. - if (w.status === 'retrying') { - return [` ${branch1} ${chalk.dim(w.role)}${statsPart}`]; - } - if (w.status === 'failed') { - const errLine = chalk.hex(c.error)(`failed: ${w.error ?? 'error'}`); - return [line1, ` ${branch2} ${errLine}`]; - } - // Dropped: the coordinator gave up on this subtask. Dim the row and show the - // reason on the second gutter line so the gap is explicit, not silent. - if (w.status === 'dropped') { - const dropLine = chalk.dim(`dropped: ${w.error ?? 'no reason'}`); - return [` ${branch1} ${chalk.dim(w.role)}`, ` ${branch2} ${dropLine}`]; - } - const raw = w.latestActivity ?? 'starting…'; - const activity = - raw.length > SWARM_ACTIVITY_MAX_LENGTH ? `${raw.slice(0, SWARM_ACTIVITY_MAX_LENGTH)}…` : raw; - return [line1, ` ${branch2} ${chalk.dim(`now: ${activity}`)}`]; - } - private rebuildContent(): void { while (this.children.length > this.callPreviewEndIndex) { this.children.pop(); @@ -1448,7 +1260,6 @@ export class ToolCallComponent extends Container { * styled individually so surrounding prose keeps its default dim tone. */ private buildProgressBlock(): void { - if (this.swarmModel !== undefined) return; if (this.progressLines.length === 0) return; if (this.result !== undefined) return; for (const raw of this.progressLines) { @@ -1469,7 +1280,6 @@ export class ToolCallComponent extends Container { } private buildSubagentBlock(): void { - if (this.swarmModel !== undefined) return; if ( this.subagentAgentId === undefined && this.ongoingSubCalls.size === 0 && @@ -1745,10 +1555,6 @@ export class ToolCallComponent extends Container { private buildCallPreview(): void { const name = this.toolCall.name; - if (this.swarmModel !== undefined) { - this.buildSwarmBody(); - return; - } if (name === 'ExitPlanMode') { this.buildPlanPreview(); return; @@ -1938,9 +1744,6 @@ export class ToolCallComponent extends Container { private buildContent(): void { const { result } = this; - // Swarm renders its dashboard via buildSwarmBody; the result output is the - // synthesized report which is surfaced elsewhere, not in this card. - if (this.swarmModel !== undefined) return; if (result === undefined || !result.output) return; if (this.isSingleSubagentView()) { @@ -2075,12 +1878,6 @@ function computeLatestActivity( return undefined; } -function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k tok`; - return `${String(n)} tok`; -} - function formatActivityLine( verb: string, toolName: string, diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 4fd3b011..5c18e12d 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -34,6 +34,7 @@ import type { import { MoonLoader } from '../components/chrome/moon-loader'; import { StatusMessageComponent } from '../components/messages/status-message'; import { workerActivityFromTool } from '../components/messages/swarm-dashboard-model'; +import { ToolCallComponent } from '../components/messages/tool-call'; import { MAIN_AGENT_ID, OAUTH_LOGIN_REQUIRED_CODE, @@ -282,6 +283,11 @@ export class SessionEventHandler { return true; } + // Past the swarm guard, the generic subagent block API is ToolCallComponent + // only. A non-ToolCallComponent card here would be a swarm card (already + // handled above), so this narrows the registry union for the calls below. + if (!(toolCall instanceof ToolCallComponent)) return true; + toolCall.setSubagentMeta(subagentId, sourceName); switch (event.type) { @@ -576,7 +582,9 @@ export class SessionEventHandler { const text = event.update.text; if (text === undefined || text.length === 0) return; const tc = this.host.streamingUI.getToolComponent(event.toolCallId); - if (tc === undefined) return; + // Status progress lines target ToolCallComponent only; the swarm card uses + // custom swarm progress (handled above) and never status progress. + if (!(tc instanceof ToolCallComponent)) return; tc.appendProgress(text); } @@ -820,7 +828,7 @@ export class SessionEventHandler { } } tc ??= this.createStandaloneSubagentToolCall(event); - if (tc === undefined) return; + if (!(tc instanceof ToolCallComponent)) return; tc.onSubagentSpawned({ agentId: event.subagentId, agentName: event.subagentName, @@ -856,6 +864,7 @@ export class SessionEventHandler { }); return; } + if (!(tc instanceof ToolCallComponent)) return; tc.onSubagentCompleted({ contextTokens: event.contextTokens, usage: event.usage, @@ -897,6 +906,7 @@ export class SessionEventHandler { tc.applySwarm({ t: 'worker.failed', id: event.subagentId, error: event.error }); return; } + if (!(tc instanceof ToolCallComponent)) return; tc.onSubagentFailed({ error: event.error }); streamingUI.removeToolComponentIfInactive(event.parentToolCallId); } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index c8a2d1b6..63c36b7c 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -3,7 +3,9 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; import { CompactionComponent } from '../components/dialogs/compaction'; +import type { ManagedToolCard } from '../components/messages/managed-tool-card'; import { ReadGroupComponent } from '../components/messages/read-group'; +import { SwarmCard } from '../components/messages/swarm-card'; import { ThinkingComponent } from '../components/messages/thinking'; import { ToolCallComponent } from '../components/messages/tool-call'; import { STREAMING_UI_FLUSH_MS } from '../constant/streaming'; @@ -59,7 +61,7 @@ export class StreamingUIController { string, { name?: string; argumentsText: string; startedAtMs: number } >(); - private _pendingToolComponents = new Map(); + private _pendingToolComponents = new Map(); private _pendingAgentGroup: { readonly turnId: string | undefined; readonly step: number; @@ -152,7 +154,7 @@ export class StreamingUIController { this._activeToolCalls.delete(id); } - getToolComponent(id: string): ToolCallComponent | undefined { + getToolComponent(id: string): ManagedToolCard | undefined { return this._pendingToolComponents.get(id); } @@ -239,7 +241,9 @@ export class StreamingUIController { }; for (const tc of this._pendingToolComponents.values()) { - visit(tc); + // Only ToolCallComponent carries the Agent background-task API; SwarmCard + // is never a background Agent, so skip it. + if (tc instanceof ToolCallComponent) visit(tc); if (agentIdMatch !== undefined) break; } if (agentIdMatch === undefined) { @@ -596,13 +600,26 @@ export class StreamingUIController { // A tool call of any other kind breaks an in-flight Agent/Read run, so the // pending groups are reset here to avoid a non-Agent/Read call (e.g. Swarm) - // between Agent/Read calls leaving a stale pending group. Swarm itself flows - // through the normal ToolCallComponent path below — it renders its dashboard - // via the managed tool-call lifecycle (one stable component per tool id). + // between Agent/Read calls leaving a stale pending group. Swarm renders its + // dashboard through the dedicated SwarmCard branch below — still a single + // managed component per tool id, never the Agent/Read group-attach paths. if (toolCall.name !== 'Agent') this._pendingAgentGroup = null; if (toolCall.name !== 'Read') this._pendingReadGroup = null; const { state } = this.host; + + // Swarm is hosted by the same managed lifecycle but rendered by a + // dedicated top-level SwarmCard, selected here at tool-call-start time. + // It is the single creation point, stored in the same registry and added + // to the transcript exactly once; later events mutate it in place. + if (toolCall.name === 'Swarm') { + const swarmCard = new SwarmCard(toolCall, undefined, state.theme.colors, state.ui); + this._pendingToolComponents.set(toolCall.id, swarmCard); + state.transcriptContainer.addChild(swarmCard); + state.ui.requestRender(); + return; + } + const tc = new ToolCallComponent( toolCall, undefined, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 3d519287..748d3057 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -67,11 +67,13 @@ import { TasksBrowserController } from './controllers/tasks-browser'; import { FileMentionProvider } from './components/editor/file-mention-provider'; import { AssistantMessageComponent } from './components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; +import type { ManagedToolCard } from './components/messages/managed-tool-card'; import { SkillActivationComponent } from './components/messages/skill-activation'; import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; +import { SwarmCard } from './components/messages/swarm-card'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; import { UserMessageComponent } from './components/messages/user-message'; @@ -1192,14 +1194,24 @@ export class KimiTUI { } case 'tool_call': if (entry.toolCallData) { - const tc = new ToolCallComponent( - entry.toolCallData, - entry.toolCallData.result, - this.state.theme.colors, - this.state.ui, - this.state.theme.markdownTheme, - this.state.appState.workDir, - ); + // Swarm replays into its dedicated SwarmCard, mirroring the live + // tool-call-start path; SwarmCard.setResult finalizes identically. + const tc: ManagedToolCard = + entry.toolCallData.name === 'Swarm' + ? new SwarmCard( + entry.toolCallData, + entry.toolCallData.result, + this.state.theme.colors, + this.state.ui, + ) + : new ToolCallComponent( + entry.toolCallData, + entry.toolCallData.result, + this.state.theme.colors, + this.state.ui, + this.state.theme.markdownTheme, + this.state.appState.workDir, + ); if (this.state.toolOutputExpanded) tc.setExpanded(true); if (this.state.planExpanded) tc.setPlanExpanded(true); return tc; diff --git a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts index 10d6fb43..b022ddb6 100644 --- a/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts +++ b/apps/kimi-code/test/tui/components/messages/swarm-routing.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it } from 'vitest'; import type { Event } from '@moonshot-ai/kimi-code-sdk'; import { SessionEventHandler, type SessionEventHost } from '#/tui/controllers/session-event-handler'; -import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { SwarmCard } from '#/tui/components/messages/swarm-card'; import { workerActivityFromTool } from '#/tui/components/messages/swarm-dashboard-model'; import { darkColors } from '#/tui/theme/colors'; const strip = (t: string): string => t.replaceAll(/\[[0-9;]*m/g, ''); -function makeSwarm(): ToolCallComponent { - return new ToolCallComponent( +function makeSwarm(): SwarmCard { + return new SwarmCard( { id: 'tc-swarm', name: 'Swarm', args: { task: 'task' } }, undefined, darkColors, @@ -40,7 +40,7 @@ describe('swarm dashboard wiring (translation)', () => { const mockHost = { streamingUI: { setTurnId: (): void => {}, - getToolComponent: (id: string): ToolCallComponent | undefined => + getToolComponent: (id: string): SwarmCard | undefined => id === parentToolCallId ? dash : undefined, }, } as unknown as SessionEventHost; @@ -108,7 +108,7 @@ describe('swarm dashboard wiring (translation)', () => { const mockHost = { streamingUI: { setTurnId: (): void => {}, - getToolComponent: (id: string): ToolCallComponent | undefined => + getToolComponent: (id: string): SwarmCard | undefined => id === parentToolCallId ? dash : undefined, }, } as unknown as SessionEventHost; @@ -177,7 +177,7 @@ describe('swarm dashboard wiring (translation)', () => { const mockHost = { streamingUI: { setTurnId: (): void => {}, - getToolComponent: (id: string): ToolCallComponent | undefined => + getToolComponent: (id: string): SwarmCard | undefined => id === parentToolCallId ? dash : undefined, }, } as unknown as SessionEventHost; @@ -268,7 +268,7 @@ describe('swarm dashboard wiring (translation)', () => { const mockHost = { streamingUI: { setTurnId: (): void => {}, - getToolComponent: (id: string): ToolCallComponent | undefined => + getToolComponent: (id: string): SwarmCard | undefined => id === parentToolCallId ? dash : undefined, }, } as unknown as SessionEventHost; @@ -334,7 +334,7 @@ describe('swarm dashboard wiring (translation)', () => { const mockHost = { streamingUI: { setTurnId: (): void => {}, - getToolComponent: (id: string): ToolCallComponent | undefined => + getToolComponent: (id: string): SwarmCard | undefined => id === parentToolCallId ? dash : undefined, }, } as unknown as SessionEventHost; @@ -409,7 +409,7 @@ describe('swarm dashboard wiring (translation)', () => { const mockHost = { streamingUI: { setTurnId: (): void => {}, - getToolComponent: (id: string): ToolCallComponent | undefined => + getToolComponent: (id: string): SwarmCard | undefined => id === parentToolCallId ? dash : undefined, }, } as unknown as SessionEventHost; diff --git a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts index feee992f..955a46cd 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { SwarmCard } from '#/tui/components/messages/swarm-card'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { darkColors } from '#/tui/theme/colors'; @@ -10,15 +11,15 @@ function strip(text: string): string { .replaceAll(new RegExp(`${ESC}\\][0-9];;[^\\u0007]*\\u0007`, 'g'), ''); } -function makeSwarm(task: string): ToolCallComponent { - return new ToolCallComponent( +function makeSwarm(task: string): SwarmCard { + return new SwarmCard( { id: 'tc-swarm', name: 'Swarm', args: { task } }, undefined, darkColors, ); } -describe('ToolCallComponent swarm mode', () => { +describe('SwarmCard swarm mode', () => { it('identifies swarm tool calls and no-ops applySwarm on non-swarm tools', () => { const swarm = makeSwarm('t'); expect(swarm.isSwarm()).toBe(true); @@ -218,7 +219,7 @@ describe('ToolCallComponent swarm mode', () => { // The coordinator's `tool.call.started` fires before the streamed args // finish, so the task is empty at construction time. The header must read // the live task from the tool call once `updateToolCall` syncs it. - const c = new ToolCallComponent( + const c = new SwarmCard( { id: 'tc-swarm', name: 'Swarm', args: {} }, undefined, darkColors, From 14611437437196da2d05eeb58ed830c1bc089625 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 30 May 2026 14:34:13 +0800 Subject: [PATCH 28/28] fix(kimi-code): show the swarm report when replaying a completed run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session resume reconstructs a swarm card from the tool call + final result, but the live tool.progress / subagent.* events that populate the dashboard are not replayed. A resumed completed swarm therefore finalized an empty model and rendered "0 workers · 0✓ 0✗" with the synthesized report — the actual deliverable — hidden entirely. When the card has no live worker data (nothing was replayed), render the result body instead of the empty dashboard and drop the misleading worker tail. A live whole-swarm failure still shows its reason via the existing '✗ ' line; live runs (which always have worker data) are unaffected. --- .../src/tui/components/messages/swarm-card.ts | 36 ++++++++++++++++++- .../messages/tool-call-swarm.test.ts | 21 +++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/components/messages/swarm-card.ts b/apps/kimi-code/src/tui/components/messages/swarm-card.ts index 3e49fbbd..67dd29b9 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-card.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-card.ts @@ -161,6 +161,16 @@ export class SwarmCard extends Container implements ManagedToolCard { } } + /** + * True once live progress arrived (a plan was announced or a worker spawned). + * False on a card reconstructed from session history: replay restores the + * tool call + final result but NOT the live tool.progress / subagent.* events + * that populate the dashboard, so a resumed completed swarm has no worker data. + */ + private hasLiveData(): boolean { + return this.swarmModel.total > 0 || this.swarmModel.workers.size > 0; + } + /** Pop the body children past the fixed header index and rebuild them. */ private rebuildBody(): void { while (this.children.length > SWARM_BODY_START_INDEX) { @@ -199,7 +209,17 @@ export class SwarmCard extends Container implements ManagedToolCard { ? chalk.hex(c.success)(STATUS_BULLET) : chalk.hex(c.roleAssistant)(STATUS_BULLET); let tail: string; - if (terminal) { + if (terminal && !this.hasLiveData()) { + // Resumed from history with no replayed worker data: the worker stats + // would all be zero and misleading, so show just the phase tag (if any) + // and let the result body carry the synthesized report. + tail = + m.phase === 'cancelled' + ? chalk.dim(' · cancelled') + : m.phase === 'failed' + ? chalk.dim(' · failed') + : ''; + } else if (terminal) { const tag = m.phase === 'cancelled' ? ' · cancelled' : m.phase === 'failed' ? ' · failed' : ''; // Surface drops alongside ✓/✗ so a recovered-with-gaps run is honest about @@ -227,6 +247,20 @@ export class SwarmCard extends Container implements ManagedToolCard { */ private buildSwarmBody(): void { const m = this.swarmModel; + // Resumed-from-history fallback: when no live worker data was replayed, the + // dashboard would render an empty "0 workers" body and hide the synthesized + // report. Render the result body (the actual deliverable) instead. A live + // whole-swarm failure (phase 'failed') is excluded — it already surfaces its + // reason via the '✗ ' line below. + if (!this.hasLiveData() && m.phase !== 'failed' && this.result !== undefined) { + const output = this.result.output.trimEnd(); + if (output.length > 0) { + for (const line of output.split('\n')) { + this.addChild(new Text(line, 0, 0)); + } + return; + } + } const workers = [...m.workers.values()]; if (m.phase === 'planning' && workers.length === 0) { this.addChild(new Text(` ${chalk.dim('└─ planning subtasks…')}`, 0, 0)); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts index 955a46cd..e5a30584 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call-swarm.test.ts @@ -229,4 +229,25 @@ describe('SwarmCard swarm mode', () => { const out = strip(c.render(80).join('\n')); expect(out).toContain('explore the repo'); }); + + it('falls back to the synthesized result body when replayed with no live worker data', () => { + // On session resume the card is reconstructed with the final result but the + // live tool.progress/subagent.* events that populate the dashboard are not + // replayed. Without a fallback it would finalize to an empty + // "0 workers · 0✓ 0✗" dashboard and the synthesized report would be lost. + const c = new SwarmCard( + { id: 'tc-swarm', name: 'Swarm', args: { task: 'analyze the repo' } }, + { + tool_call_id: 'tc-swarm', + output: 'SYNTHESIZED REPORT: the architecture is layered and well-tested.', + is_error: false, + }, + darkColors, + ); + const out = strip(c.render(80).join('\n')); + // The real deliverable is surfaced ... + expect(out).toContain('SYNTHESIZED REPORT: the architecture is layered'); + // ... and the misleading empty-dashboard tail is suppressed. + expect(out).not.toContain('0 workers'); + }); });