diff --git a/.changeset/template-agent-swarm.md b/.changeset/template-agent-swarm.md new file mode 100644 index 000000000..6896ebdcb --- /dev/null +++ b/.changeset/template-agent-swarm.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kosong": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add swarm agent runs with SDK/TUI controls, live progress, and rate-limit-aware retries. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 7d41e6246..190781dbc 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -474,6 +474,8 @@ function runPromptTurn( case 'subagent.completed': case 'subagent.failed': case 'subagent.spawned': + case 'subagent.started': + case 'subagent.suspended': case 'tool.list.updated': case 'turn.started': case 'turn.step.completed': diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index e3c1616d2..b3e4593ac 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -42,6 +42,7 @@ import { handleProviderCommand } from './provider'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; import { handlePluginsCommand } from './plugins'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; +import { handleSwarmCommand } from './swarm'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -73,6 +74,7 @@ export { showPermissionPicker, showSettingsSelector, } from './config'; +export { handleSwarmCommand } from './swarm'; export { handleFeedbackCommand, showMcpServers, @@ -300,6 +302,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/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 8dd5dde95..769571a62 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -23,6 +23,7 @@ export { showPermissionPicker, showSettingsSelector, } from './config'; +export { handleSwarmCommand } from './swarm'; export { handleFeedbackCommand, showMcpServers, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 78c2bfa1f..6a75c42d6 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -17,6 +17,11 @@ const GOAL_NEXT_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ { value: 'manage', description: 'Manage upcoming goals' }, ]; +const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'on', description: 'Turn swarm mode on' }, + { value: 'off', description: 'Turn swarm mode off' }, +]; + /** Argument autocompletion for the `/goal` command (subcommands). */ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); @@ -31,6 +36,11 @@ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteIte return completeLeadingArg(GOAL_ARG_COMPLETIONS, argumentPrefix); } +/** Argument autocompletion for the `/swarm` command (subcommands). */ +export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); +} + export const BUILTIN_SLASH_COMMANDS = [ { name: 'yolo', @@ -67,6 +77,15 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'), }, + { + name: 'swarm', + aliases: [], + description: 'Toggle swarm mode or run one task in swarm mode', + priority: 100, + experimentalFlag: 'agent_swarm', + completeArgs: swarmArgumentCompletions, + 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 000000000..65baa2fcc --- /dev/null +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -0,0 +1,155 @@ +import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +import { + SwarmStartPermissionPromptComponent, + type SwarmStartPermissionChoice, +} from '../components/dialogs/swarm-start-permission-prompt'; +import { + SwarmModeMarkerComponent, + type SwarmModeMarkerState, +} from '../components/messages/swarm-markers'; +import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleSwarmCommand(host: SlashCommandHost, args: string): Promise { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const prompt = args.trim(); + const mode = swarmModeSubcommand(prompt); + if (mode !== undefined) { + await applySwarmMode(host, mode, `/swarm ${prompt}`); + return; + } + + if (prompt.length === 0) { + await applySwarmMode(host, !host.state.appState.swarmMode, '/swarm'); + return; + } + + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + if (host.state.appState.permissionMode === 'manual') { + showSwarmStartPermissionPrompt(host, `/swarm ${prompt}`, 'Swarm task not started.', (choice) => + startSwarmWithPermission(host, prompt, choice), + ); + return; + } + + await startSwarmTask(host, prompt); +} + +function showSwarmStartPermissionPrompt( + host: SlashCommandHost, + commandText: string, + cancelStatus: string, + onSelect: (choice: SwarmStartPermissionChoice) => Promise, +): void { + const cancelStart = (): void => { + host.restoreInputText(commandText); + host.showStatus(cancelStatus); + }; + host.mountEditorReplacement( + new SwarmStartPermissionPromptComponent({ + colors: host.state.theme.colors, + onSelect: (choice) => { + host.restoreEditor(); + void onSelect(choice); + }, + onCancel: cancelStart, + }), + ); +} + +async function startSwarmWithPermission( + host: SlashCommandHost, + prompt: string, + choice: SwarmStartPermissionChoice, +): Promise { + if (choice === 'auto') { + if (!(await setPermissionForSwarm(host, choice))) return; + } + await startSwarmTask(host, prompt); +} + +async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMode): Promise { + try { + await host.requireSession().setPermission(mode); + } catch (error) { + host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); + return false; + } + host.setAppState({ permissionMode: mode }); + return true; +} + +async function startSwarmTask(host: SlashCommandHost, prompt: string): Promise { + if (!host.state.appState.swarmMode && !(await setSwarmMode(host, true, 'task'))) { + return; + } + renderSwarmModeMarker(host, 'active'); + host.sendNormalUserInput(prompt); +} + +async function applySwarmMode( + host: SlashCommandHost, + enabled: boolean, + commandText: string, +): Promise { + if (enabled && host.state.appState.swarmMode) { + host.showStatus('Swarm mode is already on.'); + return; + } + if (!enabled && !host.state.appState.swarmMode) { + host.showStatus('Swarm mode is already off.'); + return; + } + if (enabled && host.state.appState.permissionMode === 'manual') { + showSwarmStartPermissionPrompt(host, commandText, 'Swarm mode not enabled.', async (choice) => { + if (choice === 'auto' && !(await setPermissionForSwarm(host, choice))) return; + if (!(await setSwarmMode(host, true, 'manual'))) return; + renderSwarmModeMarker(host, 'active'); + }); + return; + } + if (!(await setSwarmMode(host, enabled, 'manual'))) return; + renderSwarmModeMarker(host, enabled ? 'active' : 'inactive'); +} + +async function setSwarmMode( + host: SlashCommandHost, + enabled: boolean, + trigger: 'manual' | 'task', +): Promise { + try { + await host.requireSession().setSwarmMode(enabled, trigger); + } catch (error) { + host.showError( + `Failed to ${enabled ? 'enable' : 'disable'} swarm mode: ${formatErrorMessage(error)}`, + ); + return false; + } + host.setAppState({ swarmMode: enabled }); + host.state.swarmModeEntry = enabled ? trigger : undefined; + return true; +} + +function swarmModeSubcommand(input: string): boolean | undefined { + const command = input.toLowerCase(); + if (command === 'on') return true; + if (command === 'off') return false; + return undefined; +} + +function renderSwarmModeMarker(host: SlashCommandHost, state: SwarmModeMarkerState): void { + host.state.transcriptContainer.addChild( + new SwarmModeMarkerComponent(state, host.state.theme.colors), + ); + host.state.ui.requestRender(); +} diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 752fd27aa..03c3b025e 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -2,6 +2,7 @@ import type { Component } from '@earendil-works/pi-tui'; import { WelcomeComponent } from '../components/chrome/welcome'; import { AgentGroupComponent } from '../components/messages/agent-group'; +import { AgentSwarmProgressComponent } from '../components/messages/agent-swarm-progress'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from '../components/messages/background-agent-status'; import { CronMessageComponent } from '../components/messages/cron-message'; @@ -167,6 +168,7 @@ function isUndoContextComponent(child: Component): boolean { child instanceof ThinkingComponent || child instanceof ToolCallComponent || child instanceof AgentGroupComponent || + child instanceof AgentSwarmProgressComponent || child instanceof ReadGroupComponent || child instanceof SkillActivationComponent || child instanceof BackgroundAgentStatusComponent || diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 350da47ce..b6e84d703 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -287,9 +287,12 @@ export class FooterComponent implements Component { // ── Line 1: mode badges + model + [N task(s) running] + [N agent(s) running] + cwd + git + hints ── const left: string[] = []; - if (state.permissionMode === 'auto') left.push(chalk.hex(colors.warning).bold('auto')); - if (state.permissionMode === 'yolo') left.push(chalk.hex(colors.warning).bold('yolo')); - if (state.planMode) left.push(chalk.hex(colors.primary).bold('plan')); + const modes: string[] = []; + if (state.permissionMode === 'auto') modes.push(chalk.hex(colors.warning).bold('auto')); + if (state.permissionMode === 'yolo') modes.push(chalk.hex(colors.warning).bold('yolo')); + if (state.planMode) modes.push(chalk.hex(colors.primary).bold('plan')); + if (state.swarmMode) modes.push(chalk.hex(colors.accent).bold('swarm')); + if (modes.length > 0) left.push(modes.join(' ')); const goalBadge = formatGoalBadge(state.goal, colors, this.goalWallClockMs(state.goal)); if (goalBadge !== null) left.push(goalBadge); diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index 0e88e6af4..0f9c971ea 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -18,6 +18,7 @@ export class MoonLoader extends Text { private interval: number; private colorFn?: (s: string) => string; private label: string; + private displayText = ''; constructor( ui: TUI, @@ -59,10 +60,15 @@ export class MoonLoader extends Text { this.updateDisplay(); } + renderInline(): string { + return this.displayText; + } + private updateDisplay(): void { const frame = this.frames[this.currentFrame]!; const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; - this.setText(this.label ? `${coloredFrame} ${this.label}` : coloredFrame); + this.displayText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + this.setText(this.displayText); this.ui.requestRender(); } } diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index 636a6baa4..ade2ba69e 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -1,23 +1,11 @@ -import { - Key, - matchesKey, - truncateToWidth, - visibleWidth, - type Component, - type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import { SELECT_POINTER } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; -export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; +import { + StartPermissionPromptComponent, + type StartPermissionOption, +} from './start-permission-prompt'; -interface GoalStartOption { - readonly value: GoalStartPermissionChoice; - readonly label: string; - readonly description: string; -} +export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; export interface GoalStartPermissionPromptOptions { readonly colors: ColorPalette; @@ -26,7 +14,7 @@ export interface GoalStartPermissionPromptOptions { readonly onCancel: () => void; } -const MANUAL_OPTIONS: readonly GoalStartOption[] = [ +const MANUAL_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -52,7 +40,7 @@ const MANUAL_OPTIONS: readonly GoalStartOption[] = [ }, ]; -const YOLO_OPTIONS: readonly GoalStartOption[] = [ +const YOLO_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -84,113 +72,18 @@ const YOLO_NOTICE_LINES = [ 'Switch to Auto if you want questions skipped during goal work.', ] as const; -export class GoalStartPermissionPromptComponent implements Component, Focusable { - focused = false; - private selectedIndex = 0; - - constructor(private readonly opts: GoalStartPermissionPromptOptions) {} - - invalidate(): void {} - - handleInput(data: string): void { - if (matchesKey(data, Key.escape)) { - this.opts.onCancel(); - return; - } - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); - return; - } - if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { - this.opts.onSelect(this.options[this.selectedIndex]!.value); - } - } - - render(width: number): string[] { - const { colors } = this.opts; - const rule = chalk.hex(colors.primary)('─'.repeat(width)); - const lines = [ - rule, - chalk.hex(colors.primary).bold(` ${this.title}`), - chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter select · Esc cancel'), - '', - ]; - - const textWidth = Math.max(20, width - 2); - for (const paragraph of this.noticeLines) { - for (const line of wrapPlain(paragraph, textWidth)) { - lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); - } - lines.push(''); - } - - for (let i = 0; i < this.options.length; i += 1) { - const option = this.options[i]!; - const selected = i === this.selectedIndex; - const pointer = selected ? SELECT_POINTER : ' '; - lines.push( - chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `) + - styleLabel(option.label, selected, colors), - ); - for (const line of wrapPlain(option.description, Math.max(20, width - 4))) { - lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); - } - lines.push(''); - } - - lines.push(rule); - return lines.map((line) => truncateToWidth(line, width)); - } - - private get options(): readonly GoalStartOption[] { - return this.opts.mode === 'yolo' ? YOLO_OPTIONS : MANUAL_OPTIONS; - } - - private get noticeLines(): readonly string[] { - return this.opts.mode === 'yolo' ? YOLO_NOTICE_LINES : MANUAL_NOTICE_LINES; - } - - private get title(): string { - return this.opts.mode === 'yolo' - ? 'Start a goal in YOLO mode?' - : 'Start a goal with approvals on?'; - } -} - -function styleLabel(label: string, selected: boolean, colors: ColorPalette): string { - if (selected) return chalk.hex(colors.primary).bold(label); - return styleModeNames(label, colors, colors.text); -} - -function styleModeNames(text: string, colors: ColorPalette, baseHex: string): string { - const base = chalk.hex(baseHex); - const strong = chalk.hex(colors.textStrong).bold; - return text - .split(/(\b(?:Manual|Auto|YOLO)\b)/g) - .map((part) => { - if (part === 'Manual' || part === 'Auto' || part === 'YOLO') return strong(part); - return base(part); - }) - .join(''); -} - -function wrapPlain(text: string, width: number): string[] { - const words = text.split(/\s+/).filter((word) => word.length > 0); - const lines: string[] = []; - let current = ''; - for (const word of words) { - const candidate = current.length === 0 ? word : `${current} ${word}`; - if (visibleWidth(candidate) <= width) { - current = candidate; - continue; - } - if (current.length > 0) lines.push(current); - current = visibleWidth(word) <= width ? word : truncateToWidth(word, width, '…'); +export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { + constructor(opts: GoalStartPermissionPromptOptions) { + super({ + colors: opts.colors, + title: + opts.mode === 'yolo' + ? 'Start a goal in YOLO mode?' + : 'Start a goal with approvals on?', + noticeLines: opts.mode === 'yolo' ? YOLO_NOTICE_LINES : MANUAL_NOTICE_LINES, + options: opts.mode === 'yolo' ? YOLO_OPTIONS : MANUAL_OPTIONS, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); } - if (current.length > 0) lines.push(current); - return lines.length > 0 ? lines : ['']; } diff --git a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts new file mode 100644 index 000000000..875905a61 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -0,0 +1,130 @@ +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Component, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; + +export type StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; + +export interface StartPermissionOption { + readonly value: TChoice; + readonly label: string; + readonly description: string; +} + +export interface StartPermissionPromptOptions< + TChoice extends StartPermissionChoice = StartPermissionChoice, +> { + readonly colors: ColorPalette; + readonly title: string; + readonly noticeLines: readonly string[]; + readonly options: readonly StartPermissionOption[]; + readonly onSelect: (choice: TChoice) => void; + readonly onCancel: () => void; +} + +export class StartPermissionPromptComponent + implements Component, Focusable +{ + focused = false; + private selectedIndex = 0; + + constructor(private readonly opts: StartPermissionPromptOptions) {} + + invalidate(): void {} + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(this.opts.options.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + this.opts.onSelect(this.opts.options[this.selectedIndex]!.value); + } + } + + render(width: number): string[] { + const { colors } = this.opts; + const rule = chalk.hex(colors.primary)('─'.repeat(width)); + const lines = [ + rule, + chalk.hex(colors.primary).bold(` ${this.opts.title}`), + chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter select · Esc cancel'), + '', + ]; + + const textWidth = Math.max(20, width - 2); + for (const paragraph of this.opts.noticeLines) { + for (const line of wrapPlain(paragraph, textWidth)) { + lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); + } + lines.push(''); + } + + for (let i = 0; i < this.opts.options.length; i += 1) { + const option = this.opts.options[i]!; + const selected = i === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + lines.push( + chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `) + + styleLabel(option.label, selected, colors), + ); + for (const line of wrapPlain(option.description, Math.max(20, width - 4))) { + lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); + } + lines.push(''); + } + + lines.push(rule); + return lines.map((line) => truncateToWidth(line, width)); + } +} + +function styleLabel(label: string, selected: boolean, colors: ColorPalette): string { + if (selected) return chalk.hex(colors.primary).bold(label); + return styleModeNames(label, colors, colors.text); +} + +function styleModeNames(text: string, colors: ColorPalette, baseHex: string): string { + const base = chalk.hex(baseHex); + const strong = chalk.hex(colors.textStrong).bold; + return text + .split(/(\b(?:Manual|Auto|YOLO)\b)/g) + .map((part) => { + if (part === 'Manual' || part === 'Auto' || part === 'YOLO') return strong(part); + return base(part); + }) + .join(''); +} + +function wrapPlain(text: string, width: number): string[] { + const words = text.split(/\s+/).filter((word) => word.length > 0); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const candidate = current.length === 0 ? word : `${current} ${word}`; + if (visibleWidth(candidate) <= width) { + current = candidate; + continue; + } + if (current.length > 0) lines.push(current); + current = visibleWidth(word) <= width ? word : truncateToWidth(word, width, '…'); + } + if (current.length > 0) lines.push(current); + return lines.length > 0 ? lines : ['']; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts new file mode 100644 index 000000000..cbb910b04 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts @@ -0,0 +1,48 @@ +import type { ColorPalette } from '#/tui/theme/colors'; + +import { + StartPermissionPromptComponent, + type StartPermissionOption, +} from './start-permission-prompt'; + +export type SwarmStartPermissionChoice = 'auto' | 'manual'; + +export interface SwarmStartPermissionPromptOptions { + readonly colors: ColorPalette; + readonly onSelect: (choice: SwarmStartPermissionChoice) => void; + readonly onCancel: () => void; +} + +const OPTIONS: readonly StartPermissionOption[] = [ + { + value: 'auto', + label: 'Switch to Auto and start', + description: + 'Best for swarm tasks. Tools are approved automatically, and questions are skipped.', + }, + { + value: 'manual', + label: 'Start in Manual', + description: + 'Keep approvals on. Kimi Code may stop and wait for you during the swarm task.', + }, +]; + +const NOTICE_LINES = [ + 'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', + 'Manual mode can block swarm work while agents are running.', + 'You can go back without losing your command.', +] as const; + +export class SwarmStartPermissionPromptComponent extends StartPermissionPromptComponent { + constructor(opts: SwarmStartPermissionPromptOptions) { + super({ + colors: opts.colors, + title: 'Start a swarm task with approvals on?', + noticeLines: NOTICE_LINES, + options: OPTIONS, + onSelect: opts.onSelect, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/index.ts b/apps/kimi-code/src/tui/components/index.ts index c8035dfb8..52f6c052e 100644 --- a/apps/kimi-code/src/tui/components/index.ts +++ b/apps/kimi-code/src/tui/components/index.ts @@ -28,6 +28,7 @@ export * from './messages/read-group'; export * from './messages/shell-execution'; export * from './messages/skill-activation'; export * from './messages/status-message'; +export * from './messages/swarm-markers'; export * from './messages/thinking'; export * from './messages/tool-call'; export * from './messages/usage-panel'; diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index d936d01b8..501d1c936 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -200,7 +200,8 @@ export class AgentGroupComponent extends Container { return; } // Running or not-yet-started agents show latest activity, with a fallback. - const activity = snap.latestActivity ?? 'Initializing…'; + const fallback = snap.phase === 'queued' ? 'Queued…' : 'Initializing…'; + const activity = snap.latestActivity ?? fallback; this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); } diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts new file mode 100644 index 000000000..64343f9d0 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts @@ -0,0 +1,436 @@ +const DEFAULT_RATE_WINDOW_MS = 45_000; +const DEFAULT_CATCHUP_TIME_MS = 1_500; +const DEFAULT_WORKLOAD_SPREAD_FACTOR = 1.5; +const DEFAULT_UNFINISHED_PROGRESS_CAP = 0.85; +const DEFAULT_MAX_BOOST_GAIN = 0.75; +const RATE_TOOL_CONFIDENCE_SCALE = 4; +const BOOST_TOOL_CONFIDENCE_SCALE = 3; +const MIN_RATE_FACTOR = 0.25; +const HALF_TICK = 0.5; + +export type AgentSwarmProgressEstimatorPhase = + | 'pending' + | 'queued' + | 'suspended' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface AgentSwarmProgressEstimatorOptions { + readonly rateWindowMs?: number; + readonly catchupTimeMs?: number; + readonly maxCatchupTicksPerSecond?: number; + readonly workloadSpreadFactor?: number; + readonly unfinishedProgressCap?: number; + readonly maxBoostGain?: number; +} + +export interface AgentSwarmProgressEstimateInput { + readonly memberKey: string; + readonly phase: AgentSwarmProgressEstimatorPhase; + readonly capacityTicks: number; + readonly nowMs: number; +} + +export interface AgentSwarmProgressEstimate { + readonly rawTicks: number; + readonly displayTicks: number; + readonly estimatedTotalToolCalls?: number; + readonly estimatedProgress?: number; + readonly targetProgress?: number; + readonly targetTicks?: number; + readonly boosted: boolean; + readonly confidence?: number; +} + +interface MemberProgressState { + startedAtMs?: number; + pausedAtMs?: number; + pausedDurationMs: number; + terminalAtMs?: number; + terminalKind?: 'completed' | 'failed' | 'cancelled'; + rawTicks: number; + readonly seenToolCallIds: Set; + toolCallActiveTimesMs: number[]; + displayTicks: number; + lastEstimateAtMs?: number; + lastTargetTicks?: number; +} + +interface CompletedSample { + readonly totalMs: number; + readonly rawTicks: number; +} + +interface EstimatePrior { + readonly completedCount: number; + readonly typicalTotalMs: number; + readonly typicalToolCalls: number; + readonly typicalRatePerMs: number; +} + +export class AgentSwarmProgressEstimator { + private readonly members = new Map(); + private readonly rateWindowMs: number; + private readonly catchupTimeMs: number; + private readonly maxCatchupTicksPerSecond: number | undefined; + private readonly workloadSpreadFactor: number; + private readonly unfinishedProgressCap: number; + private readonly maxBoostGain: number; + + constructor(options: AgentSwarmProgressEstimatorOptions = {}) { + this.rateWindowMs = positiveOrDefault(options.rateWindowMs, DEFAULT_RATE_WINDOW_MS); + this.catchupTimeMs = positiveOrDefault(options.catchupTimeMs, DEFAULT_CATCHUP_TIME_MS); + this.maxCatchupTicksPerSecond = positiveOrUndefined(options.maxCatchupTicksPerSecond); + this.workloadSpreadFactor = spreadFactorOrDefault( + options.workloadSpreadFactor, + DEFAULT_WORKLOAD_SPREAD_FACTOR, + ); + this.unfinishedProgressCap = clampPositiveRatio( + options.unfinishedProgressCap, + DEFAULT_UNFINISHED_PROGRESS_CAP, + ); + this.maxBoostGain = clampPositiveRatio(options.maxBoostGain, DEFAULT_MAX_BOOST_GAIN); + } + + ensureMember(memberKey: string, nowMs: number): void { + void nowMs; + this.getOrCreateMember(memberKey); + } + + removeMissingMembers(memberKeys: readonly string[]): void { + const live = new Set(memberKeys); + for (const memberKey of this.members.keys()) { + if (!live.has(memberKey)) this.members.delete(memberKey); + } + } + + markStarted(memberKey: string, nowMs: number): void { + const state = this.getOrCreateMember(memberKey); + this.startWork(state, nowMs); + if (state.rawTicks === 0) { + state.rawTicks = 1; + state.displayTicks = Math.max(state.displayTicks, 1); + } + delete state.terminalAtMs; + delete state.terminalKind; + } + + markQueued(memberKey: string, nowMs: number): void { + const state = this.getOrCreateMember(memberKey); + if (state.startedAtMs === undefined || state.terminalKind !== undefined) return; + state.pausedAtMs ??= nowMs; + state.lastEstimateAtMs = nowMs; + delete state.lastTargetTicks; + } + + recordToolCall(input: { + readonly memberKey: string; + readonly toolCallId: string; + readonly nowMs: number; + }): { readonly accepted: boolean; readonly rawTicks: number } { + const state = this.getOrCreateMember(input.memberKey); + this.startWork(state, input.nowMs); + if (state.seenToolCallIds.has(input.toolCallId)) { + return { accepted: false, rawTicks: state.rawTicks }; + } + state.seenToolCallIds.add(input.toolCallId); + state.toolCallActiveTimesMs.push(this.activeElapsedMs(state, input.nowMs)); + state.rawTicks += 1; + state.displayTicks = Math.max(state.displayTicks + 1, state.rawTicks); + delete state.terminalAtMs; + delete state.terminalKind; + return { accepted: true, rawTicks: state.rawTicks }; + } + + markCompleted(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'completed'); + } + + markFailed(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'failed'); + } + + markCancelled(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'cancelled'); + } + + estimate(input: AgentSwarmProgressEstimateInput): AgentSwarmProgressEstimate { + const state = this.getOrCreateMember(input.memberKey); + const capacityTicks = Math.max(1, input.capacityTicks); + const rawTicks = state.rawTicks; + const previousDisplayTicks = Math.max(state.displayTicks, rawTicks); + const prior = this.buildPrior(); + const baseEstimate = { + rawTicks, + displayTicks: previousDisplayTicks, + boosted: false, + }; + + if (input.phase !== 'running' || rawTicks <= 0 || prior === undefined) { + state.displayTicks = previousDisplayTicks; + state.lastEstimateAtMs = input.nowMs; + delete state.lastTargetTicks; + return baseEstimate; + } + + const completedConfidence = this.completedSampleConfidence(prior.completedCount); + const estimatedTotalToolCalls = this.estimateTotalToolCalls( + state, + prior, + input.nowMs, + completedConfidence, + ); + const estimatedProgress = Math.min( + this.unfinishedProgressCap, + rawTicks / estimatedTotalToolCalls, + ); + const rawProgress = Math.min(1, rawTicks / capacityTicks); + if (estimatedProgress <= rawProgress) { + state.displayTicks = previousDisplayTicks; + state.lastEstimateAtMs = input.nowMs; + delete state.lastTargetTicks; + return { + ...baseEstimate, + estimatedTotalToolCalls, + estimatedProgress, + boosted: false, + }; + } + + const toolConfidence = confidence(rawTicks, BOOST_TOOL_CONFIDENCE_SCALE); + const boostConfidence = completedConfidence * toolConfidence; + const boostGain = this.maxBoostGain * boostConfidence; + const targetProgress = rawProgress + boostGain * (estimatedProgress - rawProgress); + const targetTicks = Math.max(rawTicks, targetProgress * capacityTicks); + const displayTicks = this.catchUpDisplayTicks( + state, + previousDisplayTicks, + targetTicks, + capacityTicks, + input.nowMs, + ); + + state.displayTicks = displayTicks; + state.lastEstimateAtMs = input.nowMs; + state.lastTargetTicks = targetTicks; + return { + rawTicks, + displayTicks, + estimatedTotalToolCalls, + estimatedProgress, + targetProgress, + targetTicks, + boosted: displayTicks > rawTicks, + confidence: boostConfidence, + }; + } + + estimateAll( + inputs: readonly AgentSwarmProgressEstimateInput[], + ): Map { + const estimates = new Map(); + for (const input of inputs) { + estimates.set(input.memberKey, this.estimate(input)); + } + return estimates; + } + + hasPendingCatchup(): boolean { + return Array.from(this.members.values()).some( + (state) => state.lastTargetTicks !== undefined && state.lastTargetTicks > state.displayTicks + 0.1, + ); + } + + private markTerminal( + memberKey: string, + nowMs: number, + terminalKind: 'completed' | 'failed' | 'cancelled', + ): void { + const state = this.getOrCreateMember(memberKey); + this.finishPausedInterval(state, nowMs); + state.terminalAtMs = nowMs; + state.terminalKind = terminalKind; + state.displayTicks = Math.max(state.displayTicks, state.rawTicks); + delete state.lastTargetTicks; + } + + private startWork(state: MemberProgressState, nowMs: number): void { + const wasQueued = state.startedAtMs === undefined || state.pausedAtMs !== undefined; + state.startedAtMs ??= nowMs; + this.finishPausedInterval(state, nowMs); + if (!wasQueued) return; + delete state.lastEstimateAtMs; + delete state.lastTargetTicks; + } + + private finishPausedInterval(state: MemberProgressState, nowMs: number): void { + if (state.pausedAtMs === undefined) return; + state.pausedDurationMs += Math.max(0, nowMs - state.pausedAtMs); + delete state.pausedAtMs; + } + + private activeElapsedMs(state: MemberProgressState, nowMs: number): number { + if (state.startedAtMs === undefined) return 0; + const currentPausedMs = + state.pausedAtMs === undefined ? 0 : Math.max(0, nowMs - state.pausedAtMs); + return Math.max(0, nowMs - state.startedAtMs - state.pausedDurationMs - currentPausedMs); + } + + private getOrCreateMember(memberKey: string): MemberProgressState { + const state = this.members.get(memberKey) ?? { + pausedDurationMs: 0, + rawTicks: 0, + seenToolCallIds: new Set(), + toolCallActiveTimesMs: [], + displayTicks: 0, + }; + this.members.set(memberKey, state); + return state; + } + + private buildPrior(): EstimatePrior | undefined { + const samples = this.completedSamples(); + if (samples.length === 0) return undefined; + return { + completedCount: samples.length, + typicalTotalMs: logMedian(samples.map((sample) => sample.totalMs)), + typicalToolCalls: logMedian(samples.map((sample) => sample.rawTicks)), + typicalRatePerMs: logMedian( + samples.map((sample) => (sample.rawTicks + HALF_TICK) / sample.totalMs), + ), + }; + } + + private completedSamples(): CompletedSample[] { + const samples: CompletedSample[] = []; + for (const state of this.members.values()) { + if (state.terminalKind !== 'completed') continue; + if (state.startedAtMs === undefined || state.terminalAtMs === undefined) continue; + if (state.rawTicks <= 0) continue; + const totalMs = this.activeElapsedMs(state, state.terminalAtMs); + if (totalMs <= 0) continue; + samples.push({ totalMs, rawTicks: state.rawTicks }); + } + return samples; + } + + private estimateTotalToolCalls( + state: MemberProgressState, + prior: EstimatePrior, + nowMs: number, + completedConfidence: number, + ): number { + const elapsedMs = this.activeElapsedMs(state, nowMs); + const localRatePerMs = this.estimateLocalRatePerMs(state, elapsedMs); + const rateWeight = confidence(state.rawTicks, RATE_TOOL_CONFIDENCE_SCALE); + const clampedLocalRatePerMs = Math.max( + localRatePerMs, + prior.typicalRatePerMs * MIN_RATE_FACTOR, + ); + const ratePerMs = geometricInterpolate( + prior.typicalRatePerMs, + clampedLocalRatePerMs, + rateWeight, + ); + const totalMs = Math.max(prior.typicalTotalMs, elapsedMs / this.unfinishedProgressCap); + const estimatedTotalToolCalls = ratePerMs * totalMs; + const boundedTotalToolCalls = this.softBoundTotalToolCalls( + estimatedTotalToolCalls, + prior, + completedConfidence, + ); + return Math.max( + boundedTotalToolCalls, + state.rawTicks / this.unfinishedProgressCap, + 1, + ); + } + + private softBoundTotalToolCalls( + totalToolCalls: number, + prior: EstimatePrior, + completedConfidence: number, + ): number { + const lowerBound = prior.typicalToolCalls / this.workloadSpreadFactor; + const upperBound = prior.typicalToolCalls * this.workloadSpreadFactor; + const bounded = Math.max(lowerBound, Math.min(upperBound, totalToolCalls)); + if (bounded === totalToolCalls) return totalToolCalls; + return geometricInterpolate(totalToolCalls, bounded, completedConfidence); + } + + private estimateLocalRatePerMs( + state: MemberProgressState, + elapsedMs: number, + ): number { + if (elapsedMs <= 0 || state.toolCallActiveTimesMs.length === 0) return 0; + let decayedToolCalls = 0; + for (const timeMs of state.toolCallActiveTimesMs) { + decayedToolCalls += Math.exp(-Math.max(0, elapsedMs - timeMs) / this.rateWindowMs); + } + const decayedElapsedMs = this.rateWindowMs * (1 - Math.exp(-elapsedMs / this.rateWindowMs)); + if (decayedElapsedMs <= 0) return 0; + return decayedToolCalls / decayedElapsedMs; + } + + private catchUpDisplayTicks( + state: MemberProgressState, + previousDisplayTicks: number, + targetTicks: number, + capacityTicks: number, + nowMs: number, + ): number { + if (targetTicks <= previousDisplayTicks) return previousDisplayTicks; + const lastEstimateAtMs = state.lastEstimateAtMs ?? nowMs; + const elapsedMs = Math.max(0, nowMs - lastEstimateAtMs); + if (elapsedMs <= 0) return previousDisplayTicks; + const alpha = 1 - Math.exp(-elapsedMs / this.catchupTimeMs); + const desiredDelta = (targetTicks - previousDisplayTicks) * alpha; + const maxCatchupTicksPerSecond = this.maxCatchupTicksPerSecond ?? capacityTicks / 2; + const maxDelta = Math.max(0, maxCatchupTicksPerSecond * (elapsedMs / 1_000)); + return previousDisplayTicks + Math.min(desiredDelta, maxDelta); + } + + private completedSampleConfidence(completedCount: number): number { + return confidence(completedCount, 1 + this.workloadSpreadFactor); + } +} + +function positiveOrDefault(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback; +} + +function positiveOrUndefined(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function spreadFactorOrDefault(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 1 ? value : fallback; +} + +function clampPositiveRatio(value: number | undefined, fallback: number): number { + const ratio = positiveOrDefault(value, fallback); + return Math.max(0.01, Math.min(0.99, ratio)); +} + +function confidence(count: number, scale: number): number { + return 1 - Math.exp(-Math.max(0, count) / scale); +} + +function geometricInterpolate(low: number, high: number, weight: number): number { + const safeLow = Math.max(Number.EPSILON, low); + const safeHigh = Math.max(Number.EPSILON, high); + return Math.exp((1 - weight) * Math.log(safeLow) + weight * Math.log(safeHigh)); +} + +function logMedian(values: readonly number[]): number { + const logs = values + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.log(value)) + .toSorted((left, right) => left - right); + if (logs.length === 0) return 1; + const middle = Math.floor(logs.length / 2); + if (logs.length % 2 === 1) return Math.exp(logs[middle]!); + return Math.exp((logs[middle - 1]! + logs[middle]!) / 2); +} diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts new file mode 100644 index 000000000..c7b8cae9c --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -0,0 +1,1724 @@ +import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { + AgentSwarmProgressEstimator, + type AgentSwarmProgressEstimatorPhase, +} from '#/tui/components/messages/agent-swarm-progress-estimator'; +import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { gradientText } from '#/tui/theme/gradient-text'; + +const TEXT_CELL_PREFERRED_WIDTH = 30; +const CELL_GAP = ' '; +const FRAME_INTERVAL_MS = 80; +const TEXT_BRAILLE_BAR_MIN_WIDTH = 6; +const BRAILLE_BAR_MAX_WIDTH = 8; +const BRAILLE_EMPTY = '⣀'; +const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; +const BRAILLE_LEVELS = ['⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'] as const; +const PHASE_LABEL_WIDTH = 'Completed'.length; +const MIN_LABEL_WIDTH = PHASE_LABEL_WIDTH; +const MAX_LATEST_MODEL_CHARS = 2_000; +const COMPLETE_FILL_MS = 360; +const FAILED_PLACEHOLDER_RED_FACTOR = 0.75; +const FAILED_PLACEHOLDER_NON_RED_FACTOR = 0.25; +const STATUS_BAR_CHAR = '━'; +const CANCELLED_MARK = '⊘ '; +const TOTAL_STATUS_BAR_GAP = 2; +const PROMPTING_TEXT_TRAILING_GAP = 1; +const ACTIVITY_SPINNER_PLACEHOLDER = ' '; +const AGENT_SWARM_LEFT_INDENT = ' '; +const AGENT_SWARM_RIGHT_GAP = 1; +const AGENT_SWARM_NON_GRID_LINES = 6; +const COMPACT_TERMINAL_MARK_WIDTH = 1; +const ORCHESTRATING_LABEL = 'Orchestrating...'; +const PROMPTING_LABEL = 'Prompting...'; +const WORKING_LABEL = 'Working...'; +const COMPLETED_LABEL = 'Completed.'; +const FAILED_LABEL = 'Failed.'; +const ABORTED_LABEL = 'Aborted.'; +const CANCELLED_LABEL = 'Cancelled.'; +const QUEUED_LABEL = 'Queued...'; +const SUSPENDED_LABEL = 'Rate limited...'; +const RESUMED_ITEM_LABEL = '(resumed)'; +const CANCELLED_LABEL_DARKEN_FACTOR = 0.72; +const AGENT_SWARM_TITLE_ACCENT_BIAS = 1.3; + +const STATUS_BAR_ORDER = [ + 'completed', + 'working', + 'suspended', + 'queued', + 'cancelled', + 'failed', +] as const; + +type AgentSwarmPhase = AgentSwarmProgressEstimatorPhase; +type StatusBarPhase = typeof STATUS_BAR_ORDER[number]; +type TotalStatus = 'working' | 'completed' | 'suspended' | 'failed' | 'aborted'; +type ClearableMemberKey = + | 'completedAtMs' + | 'completedText' + | 'failedAtMs' + | 'failureText' + | 'cancelledLabelText' + | 'cancelledLabelColor' + | 'cancelledMarkColor' + | 'cancelledBarColor' + | 'suspendedReason'; + +const COMPLETED_CLEAR_KEYS = [ + 'failedAtMs', + 'failureText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const FAILED_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const TERMINAL_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const CANCELLED_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; + +interface AgentSwarmMember { + readonly id: string; + agentId?: string; + phase: AgentSwarmPhase; + ticks: number; + itemText: string; + latestModelText: string; + completedText?: string; + failureText?: string; + cancelledLabelText?: string; + cancelledLabelColor?: string; + cancelledMarkColor?: string; + cancelledBarColor?: string; + suspendedReason?: string; + completedAtMs?: number; + failedAtMs?: number; +} + +interface AgentSwarmSnapshot { + readonly phase: AgentSwarmPhase; + readonly ticks: number; + readonly latestModelText: string; + readonly phaseElapsedMs: number; +} + +interface AgentSwarmResultStatus { + readonly index: number; + readonly status: 'completed' | 'failed' | 'cancelled'; + readonly completedText?: string; + readonly failureText?: string; +} + +export interface AgentSwarmResultSummary { + readonly completed: number; + readonly failed: number; + readonly aborted: number; + readonly parsed: boolean; +} + +interface AgentSwarmSummary { + readonly active: number; + readonly completed: number; + readonly failed: number; + readonly cancelled: number; +} + +export interface AgentSwarmGridLayoutInput { + readonly width: number; + readonly height: number; + readonly count: number; +} + +export interface AgentSwarmGridLayout { + readonly renderText: boolean; + readonly barCells: number; + readonly columns: number; + readonly rows: number; + readonly cellWidth: number; + readonly columnGap: number; + readonly leftPadding: number; +} + +export interface AgentSwarmProgressOptions { + readonly description: string; + readonly colors: ColorPalette; + readonly requestRender?: () => void; + readonly availableGridHeight?: () => number | undefined; +} + +const PHASE_LABELS: Record = { + pending: QUEUED_LABEL, + queued: QUEUED_LABEL, + suspended: SUSPENDED_LABEL, + running: 'Running', + completed: 'Completed', + failed: 'Failed', + cancelled: ABORTED_LABEL, +}; + +export class AgentSwarmProgressComponent implements Component { + private members: AgentSwarmMember[]; + private readonly progressEstimator = new AgentSwarmProgressEstimator(); + private description: string; + private readonly colors: ColorPalette; + private readonly requestRender: (() => void) | undefined; + private readonly availableGridHeight: (() => number | undefined) | undefined; + private inputComplete = false; + private failed = false; + private aborted = false; + private itemsStarted = false; + private toolCallActive = true; + private promptTemplateText = ''; + private activitySpinnerText: (() => string) | undefined; + private timer: ReturnType | undefined; + + constructor(options: AgentSwarmProgressOptions) { + this.description = options.description; + this.colors = options.colors; + this.requestRender = options.requestRender; + this.availableGridHeight = options.availableGridHeight; + this.members = []; + } + + dispose(): void { + if (this.timer === undefined) return; + clearInterval(this.timer); + this.timer = undefined; + } + + invalidate(): void {} + + setActivitySpinnerText(provider: (() => string) | undefined): void { + if (!this.toolCallActive) return; + this.activitySpinnerText = provider; + } + + markToolCallEnded(): void { + this.toolCallActive = false; + this.activitySpinnerText = undefined; + } + + isToolCallActive(): boolean { + return this.toolCallActive; + } + + isRequestStreaming(): boolean { + return !this.inputComplete; + } + + updateArgs( + args: Record, + options: { readonly streamingArguments?: string | undefined } = {}, + ): void { + const streamingArguments = options.streamingArguments; + const description = agentSwarmDescriptionFromArgs(args); + if (description.length > 0 || this.description.length === 0) { + this.description = description; + } + const fullRows = [...agentSwarmResumeItemsFromArgs(args), ...agentSwarmItemsFromArgs(args)]; + const partialRows = streamingArguments === undefined + ? [] + : [ + ...agentSwarmPartialResumeItemsFromArguments(streamingArguments), + ...agentSwarmPartialItemsFromArguments(streamingArguments), + ]; + if ( + fullRows.length > 0 || + partialRows.length > 0 || + (streamingArguments !== undefined && agentSwarmWorkItemsStartedFromArguments(streamingArguments)) + ) { + this.itemsStarted = true; + } + const fullPromptTemplate = agentSwarmPromptTemplateFromArgs(args); + const partialPromptTemplate = + streamingArguments === undefined + ? '' + : agentSwarmPartialPromptTemplateFromArguments(streamingArguments); + const promptTemplate = + fullPromptTemplate.length > 0 ? fullPromptTemplate : partialPromptTemplate; + if (promptTemplate.length > 0 || this.promptTemplateText.length === 0) { + this.promptTemplateText = promptTemplate; + } + + const itemCount = Math.max(fullRows.length, partialRows.length); + if (itemCount > 0) this.ensureMemberCount(itemCount); + this.updateItemTexts(fullRows, partialRows); + } + + markInputComplete(): void { + if (!this.inputComplete) { + this.inputComplete = true; + for (const member of this.members) { + if (member.phase === 'pending') member.phase = 'queued'; + } + } + this.startAnimationIfNeeded(); + } + + registerSubagent(input: { + readonly agentId: string; + readonly swarmIndex?: number; + readonly description?: string | undefined; + }): void { + const member = this.findMemberForSubagent(input.agentId, input.swarmIndex); + if (member === undefined) return; + member.agentId = input.agentId; + if (member.phase === 'pending') member.phase = 'queued'; + this.startAnimationIfNeeded(); + } + + markStarted(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + const nowMs = Date.now(); + this.progressEstimator.markStarted(member.id, nowMs); + member.ticks = Math.max(member.ticks, 1); + this.promoteToRunning(member, nowMs); + this.startAnimationIfNeeded(); + } + + recordToolCall(input: { + readonly agentId: string; + readonly toolCallId: string; + }): void { + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined) return; + const result = this.progressEstimator.recordToolCall({ + memberKey: member.id, + toolCallId: input.toolCallId, + nowMs: Date.now(), + }); + if (!result.accepted) return; + member.ticks = result.rawTicks; + this.promoteToRunning(member); + this.startAnimationIfNeeded(); + } + + appendModelDelta(input: { + readonly agentId: string; + readonly delta: string; + }): void { + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined || input.delta.length === 0) return; + member.latestModelText = `${member.latestModelText}${input.delta}`.slice( + -MAX_LATEST_MODEL_CHARS, + ); + this.promoteToRunning(member, Date.now(), true); + } + + markCompleted(agentId: string, completedText?: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined || member.phase === 'failed' || member.phase === 'cancelled') return; + const nowMs = Date.now(); + this.completeMember(member, nowMs, completedText); + this.startAnimationIfNeeded(); + } + + markSuspended(input: { + readonly agentId: string; + readonly reason: string; + readonly swarmIndex?: number; + readonly description?: string | undefined; + }): void { + const member = this.findMemberByAgentId(input.agentId) ?? + this.findMemberForSubagent(input.agentId, input.swarmIndex); + if (member === undefined || member.phase === 'completed' || member.phase === 'cancelled') return; + member.agentId = input.agentId; + this.progressEstimator.markQueued(member.id, Date.now()); + member.phase = 'suspended'; + clearMemberState(member, ...TERMINAL_CLEAR_KEYS); + this.startAnimationIfNeeded(); + } + + markFailed(agentId: string, failureText?: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + const nowMs = Date.now(); + this.failMember(member, nowMs, failureText); + this.startAnimationIfNeeded(); + } + + markSwarmFailed(failureText?: string): void { + this.failed = true; + this.aborted = false; + const nowMs = Date.now(); + for (const member of this.members) { + if (isTerminalPhase(member.phase)) continue; + this.failMember(member, nowMs, failureText); + } + this.startAnimationIfNeeded(); + } + + markCancelled(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + this.cancelMember(member, Date.now()); + } + + markActiveCancelled(): void { + this.aborted = true; + const nowMs = Date.now(); + for (const member of this.members) { + if (isTerminalPhase(member.phase)) continue; + this.cancelMember(member, nowMs); + } + this.startAnimationIfNeeded(); + } + + applyResult(output: string): boolean { + const statuses = parseAgentSwarmResultStatuses(output); + if (statuses.length === 0) return false; + this.aborted = false; + const nowMs = Date.now(); + for (const entry of statuses) { + this.ensureMemberCount(entry.index); + const member = this.members[entry.index - 1]; + if (member === undefined) continue; + if (entry.status === 'completed') { + this.completeMember(member, nowMs, entry.completedText); + } else if (entry.status === 'failed') { + this.failMember(member, nowMs, entry.failureText); + } else { + this.cancelMember(member, nowMs); + } + } + this.startAnimationIfNeeded(); + return true; + } + + render(width: number): string[] { + const outerWidth = Math.max(1, width); + const innerWidth = Math.max( + 1, + outerWidth - visibleWidth(AGENT_SWARM_LEFT_INDENT) - AGENT_SWARM_RIGHT_GAP, + ); + if (this.members.length === 0) { + const lines = [ + '', + this.renderHeader(innerWidth, undefined), + '', + this.renderStatusLine(innerWidth), + '', + ]; + return this.indentLines(lines, outerWidth); + } + + const nowMs = Date.now(); + const snapshots = this.members.map((member): AgentSwarmSnapshot => ({ + phase: member.phase, + ticks: member.ticks, + latestModelText: member.latestModelText, + phaseElapsedMs: terminalPhaseElapsedMs(member, nowMs), + })); + const summary = summarizeSnapshots(snapshots); + const lines = [ + '', + this.renderHeader(innerWidth, summary), + '', + ...this.renderGrid( + innerWidth, + this.availableGridHeight?.(), + snapshots, + nowMs, + ), + '', + this.renderStatusLine(innerWidth), + '', + ]; + this.startAnimationIfNeeded(); + return this.indentLines(lines, outerWidth); + } + + private indentLines(lines: readonly string[], width: number): string[] { + const contentWidth = Math.max( + 0, + width - visibleWidth(AGENT_SWARM_LEFT_INDENT) - AGENT_SWARM_RIGHT_GAP, + ); + return lines.map((line) => + truncateToWidth( + AGENT_SWARM_LEFT_INDENT + truncateToWidth(line, contentWidth), + width, + ) + ); + } + + private renderHeader(width: number, _summary: AgentSwarmSummary | undefined): string { + if (width <= 3) return chalk.hex(this.colors.primary)('─'.repeat(width)); + + const title = gradientText('Agent Swarm', this.colors.primary, this.colors.accent, AGENT_SWARM_TITLE_ACCENT_BIAS); + const description = + this.description.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) + : ''; + const prefixText = '─ '; + const labelWidth = Math.max(1, width - visibleWidth(prefixText) - 1); + const label = truncateToWidth(title + description, labelWidth); + const suffixWidth = Math.max(0, width - visibleWidth(prefixText) - visibleWidth(label)); + const suffix = suffixWidth === 0 ? '' : ` ${'─'.repeat(Math.max(0, suffixWidth - 1))}`; + return chalk.hex(this.colors.primary)(prefixText) + label + chalk.hex(this.colors.primary)(suffix); + } + + private renderStatusLine(width: number): string { + const status = totalStatus(this.members, { + failed: this.failed, + aborted: this.aborted, + }); + const prefix = this.renderActivityPrefix(status); + if (prefix.length > 0) { + const contentWidth = Math.max(0, width - visibleWidth(prefix)); + if (contentWidth <= 0) return truncateToWidth(prefix, width); + return truncateToWidth(`${prefix}${this.renderStatusLineContent(contentWidth, status)}`, width); + } + return this.renderStatusLineContent(width, status); + } + + private renderActivityPrefix(status: TotalStatus): string { + if (this.toolCallActive) return this.activitySpinnerText?.() ?? ''; + return activityPrefixForTotalStatus(status, this.colors); + } + + private renderStatusLineContent(width: number, status: TotalStatus): string { + if (status !== 'working') return this.renderProgressStatusLine(width, status); + + if (!this.inputComplete) { + return this.renderOrchestratingStatusLine(width); + } + + return this.renderProgressStatusLine(width, status); + } + + private renderProgressStatusLine(width: number, status: TotalStatus): string { + const label = renderStatusLabel( + totalStatusLabel(status), + totalStatusLabelColor(status, this.members, this.colors), + ); + if (this.members.length === 0) return truncateToWidth(label, width); + const barWidth = Math.max(0, width - visibleWidth(label) - TOTAL_STATUS_BAR_GAP); + if (barWidth <= 0) return truncateToWidth(label, width); + return truncateToWidth( + `${label}${' '.repeat(TOTAL_STATUS_BAR_GAP)}${renderStatusPipBar(this.members, barWidth, this.colors)}`, + width, + ); + } + + private renderOrchestratingStatusLine(width: number): string { + if (this.itemsStarted) { + return truncateToWidth( + renderStatusLabel(ORCHESTRATING_LABEL, this.colors.primary), + width, + ); + } + + const promptTemplate = collapseWhitespace(this.promptTemplateText); + const label = renderStatusLabel( + promptTemplate.length > 0 ? PROMPTING_LABEL : ORCHESTRATING_LABEL, + this.colors.primary, + ); + if (promptTemplate.length === 0) return truncateToWidth(label, width); + + const availablePromptWidth = Math.max( + 0, + width - visibleWidth(label) - PROMPTING_TEXT_TRAILING_GAP, + ); + const separator = visibleWidth(promptTemplate) <= availablePromptWidth - 1 ? ' ' : ' '; + const promptWidth = Math.max(0, availablePromptWidth - visibleWidth(separator)); + if (promptWidth <= 0) return truncateToWidth(label, width); + const prompt = chalk.hex(this.colors.textDim)(truncateStartToWidth(promptTemplate, promptWidth)); + return truncateToWidth(`${label}${separator}${prompt}`, width); + } + + private renderGrid( + width: number, + height: number | undefined, + snapshots: readonly AgentSwarmSnapshot[], + nowMs: number, + ): string[] { + const layout = calculateAgentSwarmGridLayout({ + width, + height: height ?? Number.POSITIVE_INFINITY, + count: this.members.length, + }); + const columns = Math.max(1, layout.columns); + const rows = layout.rows; + const cellGap = ' '.repeat(layout.columnGap); + const leftPadding = ' '.repeat(layout.leftPadding); + const lines: string[] = []; + + for (let row = 0; row < rows; row += 1) { + const cells: string[] = []; + for (let col = 0; col < columns; col += 1) { + const index = row * columns + col; + const member = this.members[index]; + const snapshot = snapshots[index]; + if (member === undefined || snapshot === undefined) continue; + cells.push(padAnsi(this.renderCell(member, snapshot, layout, nowMs), layout.cellWidth)); + } + lines.push(leftPadding + cells.join(cellGap)); + } + return lines; + } + + private renderCell( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + layout: AgentSwarmGridLayout, + nowMs: number, + ): string { + const width = layout.cellWidth; + if (snapshot.phase === 'pending') { + return renderPendingCell(member, width, this.colors); + } + if (snapshot.phase === 'cancelled' && snapshot.ticks <= 0) { + return renderCancelledUnstartedCell(member, width, this.colors); + } + if (!layout.renderText) { + return this.renderCompactCell(member, snapshot, layout.barCells, nowMs); + } + if (snapshot.phase === 'queued' && snapshot.ticks <= 0) { + return renderQueuedCell(member, width, this.colors); + } + + const estimate = this.progressEstimator.estimate({ + memberKey: member.id, + phase: snapshot.phase, + capacityTicks: layout.barCells * BRAILLE_LEVELS.length, + nowMs, + }); + const id = chalk.hex(this.colors.primary)(member.id); + const bar = brailleBar( + estimate.displayTicks, + snapshot.phase, + layout.barCells, + this.colors, + snapshot.phaseElapsedMs, + cancelledProgressColor(member, snapshot.phase, this.colors), + ); + const prefix = `${id} ${bar} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + const label = renderCellLabel(member, snapshot, labelWidth, this.colors); + return prefix + label; + } + + private renderCompactCell( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + barCells: number, + nowMs: number, + ): string { + const estimatePhase = snapshot.phase === 'pending' ? 'queued' : snapshot.phase; + const estimate = this.progressEstimator.estimate({ + memberKey: member.id, + phase: estimatePhase, + capacityTicks: barCells * BRAILLE_LEVELS.length, + nowMs, + }); + const id = chalk.hex(this.colors.primary)(member.id); + const bar = brailleBar( + estimate.displayTicks, + estimatePhase, + barCells, + this.colors, + snapshot.phaseElapsedMs, + cancelledProgressColor(member, snapshot.phase, this.colors), + ); + return `${id} ${bar}${compactTerminalMark(member, snapshot.phase, this.colors)}`; + } + + private findMemberForSubagent( + agentId: string, + swarmIndex: number | undefined, + ): AgentSwarmMember | undefined { + const existing = this.findMemberByAgentId(agentId); + if (existing !== undefined) return existing; + + if (swarmIndex !== undefined && Number.isInteger(swarmIndex) && swarmIndex > 0) { + this.ensureMemberCount(swarmIndex); + const byIndex = this.members[swarmIndex - 1]; + if (byIndex !== undefined) return byIndex; + } + + const unassigned = this.members.find((member) => member.agentId === undefined); + if (unassigned !== undefined) return unassigned; + + this.ensureMemberCount(this.members.length + 1); + return this.members.at(-1); + } + + private findMemberByAgentId(agentId: string): AgentSwarmMember | undefined { + return this.members.find((member) => member.agentId === agentId); + } + + private ensureMemberCount(count: number): void { + if (count <= this.members.length) return; + const previousLength = this.members.length; + this.members = [ + ...this.members, + ...createMembers(count, this.inputComplete ? 'queued' : 'pending').slice(this.members.length), + ]; + const nowMs = Date.now(); + for (let index = previousLength; index < this.members.length; index += 1) { + const member = this.members[index]; + if (member !== undefined) this.progressEstimator.ensureMember(member.id, nowMs); + } + } + + private updateItemTexts(fullItems: readonly string[], partialItems: readonly string[]): void { + const count = Math.max(fullItems.length, partialItems.length, this.members.length); + for (let index = 0; index < count; index += 1) { + const member = this.members[index]; + if (member === undefined) continue; + const itemText = fullItems[index] ?? partialItems[index]; + if (itemText !== undefined) member.itemText = itemText; + } + } + + private startAnimationIfNeeded(): void { + if (this.requestRender === undefined || this.timer !== undefined) return; + if (!this.hasAnimatedMembers()) return; + const requestRender = this.requestRender; + this.timer = setInterval(() => { + requestRender(); + if (!this.hasAnimatedMembers()) this.dispose(); + }, FRAME_INTERVAL_MS); + if (typeof this.timer === 'object' && 'unref' in this.timer) { + this.timer.unref(); + } + } + + private hasAnimatedMembers(): boolean { + const now = Date.now(); + return ( + this.progressEstimator.hasPendingCatchup() || + this.members.some((member) => + ( + member.phase === 'completed' && + member.completedAtMs !== undefined && + now - member.completedAtMs < COMPLETE_FILL_MS + ) || + ( + member.phase === 'failed' && + member.failedAtMs !== undefined && + now - member.failedAtMs < COMPLETE_FILL_MS + ), + ) + ); + } + + private promoteToRunning(member: AgentSwarmMember, nowMs?: number, setTicks = false): void { + if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { + member.phase = 'running'; + if (nowMs !== undefined) this.progressEstimator.markStarted(member.id, nowMs); + if (setTicks) member.ticks = Math.max(member.ticks, 1); + } + delete member.suspendedReason; + } + + private completeMember(member: AgentSwarmMember, nowMs: number, completedText?: string): void { + if (member.phase !== 'completed') { + this.progressEstimator.markCompleted(member.id, nowMs); + member.completedAtMs = nowMs; + } + const normalizedCompletedText = normalizeFinalOutputText(completedText); + if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; + member.phase = 'completed'; + clearMemberState(member, ...COMPLETED_CLEAR_KEYS); + } + + private failMember(member: AgentSwarmMember, nowMs: number, failureText?: string): void { + if (member.phase !== 'failed') { + this.progressEstimator.markFailed(member.id, nowMs); + member.failedAtMs = nowMs; + } + const normalizedFailureText = normalizeFailureText(failureText); + if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; + member.phase = 'failed'; + clearMemberState(member, ...FAILED_CLEAR_KEYS); + } + + private cancelMember(member: AgentSwarmMember, nowMs: number): void { + const previousPhase = member.phase; + this.progressEstimator.markCancelled(member.id, nowMs); + member.phase = 'cancelled'; + clearMemberState(member, ...CANCELLED_CLEAR_KEYS); + if (previousPhase === 'pending' || previousPhase === 'queued' || previousPhase === 'suspended') { + member.cancelledLabelText = CANCELLED_LABEL; + member.cancelledLabelColor = cancelledLabelColor(this.colors); + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } else if (previousPhase === 'running') { + member.cancelledLabelText = runningCellLabelText(member); + member.cancelledLabelColor = cancelledLabelColor(this.colors); + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } else { + member.cancelledLabelText = ABORTED_LABEL; + member.cancelledLabelColor = this.colors.warning; + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } + } +} + +function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[] { + return Array.from({ length: count }, (_item, index) => ({ + id: String(index + 1).padStart(3, '0'), + phase, + ticks: 0, + itemText: '', + latestModelText: '', + })); +} + +function clearMemberState(member: AgentSwarmMember, ...keys: ClearableMemberKey[]): void { + for (const key of keys) delete member[key]; +} + +function isTerminalPhase(phase: AgentSwarmPhase): boolean { + return phase === 'completed' || phase === 'failed' || phase === 'cancelled'; +} + +function terminalPhaseElapsedMs(member: AgentSwarmMember, nowMs: number): number { + const startedAtMs = member.phase === 'completed' + ? member.completedAtMs + : member.phase === 'failed' + ? member.failedAtMs + : undefined; + return startedAtMs === undefined ? 0 : Math.max(0, nowMs - startedAtMs); +} + +export function agentSwarmItemsFromArgs(args: Record): string[] { + const items = args['items']; + if (!Array.isArray(items)) return []; + return items.map(String); +} + +function agentSwarmResumeItemsFromArgs(args: Record): string[] { + const resumeAgentIds = args['resume_agent_ids']; + if ( + typeof resumeAgentIds !== 'object' || + resumeAgentIds === null || + Array.isArray(resumeAgentIds) + ) { + return []; + } + return Object.keys(resumeAgentIds).map(() => RESUMED_ITEM_LABEL); +} + +export function agentSwarmPartialItemsCountFromArguments(argumentsText: string): number { + return agentSwarmPartialItemsFromArguments(argumentsText).length; +} + +function agentSwarmWorkItemsStartedFromArguments(argumentsText: string): boolean { + return /"items"\s*:/.test(argumentsText) || /"resume_agent_ids"\s*:/.test(argumentsText); +} + +export function agentSwarmPartialItemsFromArguments(argumentsText: string): string[] { + const match = /"items"\s*:\s*\[/.exec(argumentsText); + if (match === null) return []; + const items: string[] = []; + for (let i = match.index + match[0].length; i < argumentsText.length; i += 1) { + const ch = argumentsText[i]; + if (ch === ']') return items; + if (ch !== '"') continue; + + const parsed = parsePartialJsonString(argumentsText, i + 1); + items.push(parsed.value); + if (parsed.closed) { + i = parsed.nextIndex; + continue; + } + return items; + } + return items; +} + +function agentSwarmPartialResumeItemsFromArguments(argumentsText: string): string[] { + const match = /"resume_agent_ids"\s*:\s*\{/.exec(argumentsText); + if (match === null) return []; + return Array.from( + { length: countPartialJsonObjectEntries(argumentsText, match.index + match[0].length) }, + () => RESUMED_ITEM_LABEL, + ); +} + +export function agentSwarmDescriptionFromArgs(args: Record): string { + const description = args['description']; + return typeof description === 'string' ? description : ''; +} + +function agentSwarmPromptTemplateFromArgs(args: Record): string { + const promptTemplate = args['prompt_template']; + return typeof promptTemplate === 'string' ? promptTemplate : ''; +} + +function agentSwarmPartialPromptTemplateFromArguments(argumentsText: string): string { + const match = /"prompt_template"\s*:\s*"/.exec(argumentsText); + if (match === null) return ''; + return parsePartialJsonString(argumentsText, match.index + match[0].length).value; +} + +export function agentSwarmResultSummaryFromOutput(output: string): AgentSwarmResultSummary { + const statuses = parseAgentSwarmResultStatuses(output); + let completed = 0; + let failed = 0; + let aborted = 0; + for (const status of statuses) { + if (status.status === 'completed') completed += 1; + if (status.status === 'failed') failed += 1; + if (status.status === 'cancelled') aborted += 1; + } + return { + completed, + failed, + aborted, + parsed: statuses.length > 0, + }; +} + +function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] { + const xmlStatuses = parseAgentSwarmXmlResultStatuses(output); + if (xmlStatuses.length > 0) return xmlStatuses; + return parseAgentSwarmLegacyResultStatuses(output); +} + +function forEachSubagentTag( + output: string, + callback: (attrs: string, body: string, index: number) => T | undefined, +): T[] { + const result: T[] = []; + const tagPattern = /]*)>/g; + let match: RegExpExecArray | null; + let index = 0; + while ((match = tagPattern.exec(output)) !== null) { + const attrs = match[1] ?? ''; + const closeIndex = output.indexOf('', tagPattern.lastIndex); + if (closeIndex < 0) break; + const body = output.slice(tagPattern.lastIndex, closeIndex); + index += 1; + const value = callback(attrs, body, index); + if (value !== undefined) result.push(value); + tagPattern.lastIndex = closeIndex + ''.length; + } + return result; +} + +function parseAgentSwarmXmlResultStatuses(output: string): AgentSwarmResultStatus[] { + return forEachSubagentTag(output, (attrs, body, tagIndex) => { + const explicitIndex = Number(xmlAttribute(attrs, 'index')); + const index = + Number.isInteger(explicitIndex) && explicitIndex > 0 ? explicitIndex : tagIndex; + const outcome = xmlAttribute(attrs, 'outcome'); + if ( + outcome !== 'completed' && + outcome !== 'failed' && + outcome !== 'aborted' && + outcome !== 'cancelled' + ) { + return undefined; + } + return { + index, + status: outcome === 'aborted' || outcome === 'cancelled' ? 'cancelled' : outcome, + completedText: outcome === 'completed' ? body : undefined, + failureText: outcome === 'failed' ? body : undefined, + }; + }); +} + +function xmlAttribute(attrs: string, name: string): string | undefined { + const match = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs); + return match?.[1]; +} + +function forEachAgentBlock( + output: string, + callback: (block: string, index: number) => T | undefined, +): T[] { + const result: T[] = []; + for (const block of output.split(/\n(?=\[agent \d+\]\n)/)) { + const indexMatch = /^\[agent (\d+)\]$/m.exec(block); + if (indexMatch === null) continue; + const value = callback(block, Number(indexMatch[1])); + if (value !== undefined) result.push(value); + } + return result; +} + +function parseAgentSwarmLegacyResultStatuses(output: string): AgentSwarmResultStatus[] { + return forEachAgentBlock(output, (block, index) => { + const statusMatch = /^status: (completed|failed|aborted|cancelled)$/m.exec(block); + if (statusMatch === null) return undefined; + const status = statusMatch[1] as 'completed' | 'failed' | 'aborted' | 'cancelled'; + return { + index, + status: status === 'aborted' || status === 'cancelled' ? 'cancelled' : status, + completedText: status === 'completed' ? parseAgentSwarmCompletedText(block) : undefined, + failureText: status === 'failed' ? parseAgentSwarmFailureText(block) : undefined, + }; + }); +} + +function parseAgentSwarmCompletedText(block: string): string | undefined { + const marker = '\n[summary]\n'; + const markerIndex = block.indexOf(marker); + if (markerIndex < 0) return undefined; + return normalizeFinalOutputText(block.slice(markerIndex + marker.length)); +} + +function parseAgentSwarmFailureText(block: string): string | undefined { + const match = /^subagent error:\s*([\s\S]*)$/m.exec(block); + if (match === null) return undefined; + return normalizeFailureText(match[1]); +} + +function textGridLayout( + columns: number, + rows: number, + cellWidth: number, + gapWidth: number, + idWidth: number, +): AgentSwarmGridLayout { + return { + renderText: true, + barCells: barCellsForTextCellWidth(cellWidth, idWidth), + columns, + rows, + cellWidth, + columnGap: gapWidth, + leftPadding: 0, + }; +} + +export function calculateAgentSwarmGridLayout( + input: AgentSwarmGridLayoutInput, +): AgentSwarmGridLayout { + const count = Math.max(0, Math.floor(input.count)); + const width = Math.max(0, Math.floor(input.width)); + const height = Math.max(0, Math.floor(input.height)); + const idWidth = agentSwarmGridIdWidth(count); + + if (count === 0) { + return { + renderText: true, + barCells: 1, + columns: 0, + rows: 0, + cellWidth: 0, + columnGap: 0, + leftPadding: 0, + }; + } + + const textGapWidth = visibleWidth(CELL_GAP); + const compactGapWidth = textGapWidth; + const textColumns = columnsForCellWidth(width, count, TEXT_CELL_PREFERRED_WIDTH, textGapWidth); + const textRows = rowsForColumns(count, textColumns); + const textCellWidth = gridCellWidth(width, textColumns, textGapWidth); + if (textRows <= height && textCellWidth >= minTextCellWidth(idWidth)) { + return textGridLayout(textColumns, textRows, textCellWidth, textGapWidth, idWidth); + } + const targetTextColumns = height <= 0 ? count : Math.min(count, Math.ceil(count / height)); + const targetTextCellWidth = gridCellWidth(width, targetTextColumns, textGapWidth); + const targetTextRows = rowsForColumns(count, targetTextColumns); + if (height > 0 && targetTextRows <= height && targetTextCellWidth >= minTextCellWidth(idWidth)) { + return textGridLayout(targetTextColumns, targetTextRows, targetTextCellWidth, textGapWidth, idWidth); + } + + const compactColumns = compactColumnsForLayout(width, count, height, idWidth, compactGapWidth); + const compactCellWidthBudget = gridCellWidth(width, compactColumns, compactGapWidth); + const compactBarCells = compactBarCellsForCellWidth(compactCellWidthBudget, idWidth); + const compactActualCellWidth = compactCellWidth(idWidth, compactBarCells); + return { + renderText: false, + barCells: compactBarCells, + columns: compactColumns, + rows: rowsForColumns(count, compactColumns), + cellWidth: compactActualCellWidth, + columnGap: compactGapWidth, + leftPadding: 0, + }; +} + +export function agentSwarmGridHeightForTerminalRows( + rows: number | undefined, + followingRows = 0, +): number | undefined { + if (rows === undefined || !Number.isFinite(rows)) return undefined; + const rowsAfterSwarm = Number.isFinite(followingRows) + ? Math.max(0, Math.floor(followingRows)) + : 0; + return Math.max(0, Math.floor(rows) - rowsAfterSwarm - AGENT_SWARM_NON_GRID_LINES); +} + +function agentSwarmGridIdWidth(count: number): number { + return Math.max(3, String(Math.max(1, count)).length); +} + +function columnsForCellWidth( + width: number, + count: number, + cellWidth: number, + gapWidth: number, +): number { + if (count <= 1) return count <= 0 ? 0 : 1; + const columns = Math.floor((width + gapWidth) / (Math.max(1, cellWidth) + gapWidth)); + return Math.max(1, Math.min(count, columns)); +} + +function rowsForColumns(count: number, columns: number): number { + if (count <= 0) return 0; + return Math.ceil(count / Math.max(1, columns)); +} + +function gridCellWidth(width: number, columns: number, gapWidth: number): number { + if (columns <= 0) return 0; + return Math.max( + 1, + Math.floor((width - gapWidth * Math.max(0, columns - 1)) / columns), + ); +} + +function minTextCellWidth(idWidth: number): number { + return idWidth + TEXT_BRAILLE_BAR_MIN_WIDTH + 4 + MIN_LABEL_WIDTH; +} + +function barCellsForTextCellWidth(cellWidth: number, idWidth: number): number { + const fixedWidth = idWidth + 1 + 2 + 1 + MIN_LABEL_WIDTH; + const availableForBar = cellWidth - fixedWidth; + return availableForBar >= TEXT_BRAILLE_BAR_MIN_WIDTH + ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) + : TEXT_BRAILLE_BAR_MIN_WIDTH; +} + +function compactColumnsForLayout( + width: number, + count: number, + height: number, + idWidth: number, + gapWidth: number, +): number { + const maxColumns = columnsForCellWidth(width, count, compactCellWidth(idWidth, 1), gapWidth); + if (height <= 0) return maxColumns; + const targetColumns = Math.min(count, Math.ceil(count / height)); + return Math.max(1, Math.min(targetColumns, maxColumns)); +} + +function compactBarCellsForCellWidth(cellWidth: number, idWidth: number): number { + return Math.max( + 1, + cellWidth - compactFixedWidth(idWidth) - COMPACT_TERMINAL_MARK_WIDTH, + ); +} + +function compactCellWidth(idWidth: number, barCells: number): number { + return compactFixedWidth(idWidth) + Math.max(1, barCells) + COMPACT_TERMINAL_MARK_WIDTH; +} + +function compactFixedWidth(idWidth: number): number { + return idWidth + 1 + 2; +} + +function summarizeSnapshots(snapshots: readonly AgentSwarmSnapshot[]): AgentSwarmSummary { + let completed = 0; + let failed = 0; + let cancelled = 0; + for (const snapshot of snapshots) { + if (snapshot.phase === 'completed') completed += 1; + if (snapshot.phase === 'failed') failed += 1; + if (snapshot.phase === 'cancelled') cancelled += 1; + } + return { + active: snapshots.length - completed - failed - cancelled, + completed, + failed, + cancelled, + }; +} + +function brailleBar( + ticks: number, + phase: AgentSwarmPhase, + width: number, + colors: ColorPalette, + phaseElapsedMs: number, + phaseColorOverride?: string, +): string { + const innerWidth = Math.max(1, width); + if (phase === 'pending') return ''; + if (phase === 'failed') return bracketBar(failedBrailleBar(ticks, innerWidth, phaseElapsedMs, colors), colors); + const displayTicks = phase === 'completed' ? completedDisplayTicks(ticks, innerWidth, phaseElapsedMs) : ticks; + if (phase === 'cancelled') { + const cancelledColor = phaseColorOverride ?? colors.warning; + return bracketBar( + accumulatedBrailleBar(displayTicks, innerWidth, cancelledColor, colors, () => cancelledColor), + colors, + ); + } + const colorMap: Record, string> = { + queued: colors.textDim, + suspended: colors.textDim, + running: colors.success, + completed: colors.success, + }; + return bracketBar(accumulatedBrailleBar(displayTicks, innerWidth, colorMap[phase], colors), colors); +} + +function cancelledProgressColor( + member: AgentSwarmMember, + phase: AgentSwarmPhase, + colors: ColorPalette, +): string | undefined { + if (phase !== 'cancelled') return undefined; + return member.cancelledBarColor ?? colors.warning; +} + +function bracketBar(content: string, colors: ColorPalette): string { + const bracket = chalk.hex(colors.textMuted); + return bracket('[') + content + bracket(']'); +} + +function phaseColor(phase: AgentSwarmPhase, colors: ColorPalette): string { + const map: Record = { + pending: colors.textDim, + queued: colors.textDim, + suspended: colors.textDim, + running: colors.textDim, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; +} + +interface StatusBarCount { + readonly phase: StatusBarPhase; + readonly count: number; +} + +function renderStatusPipBar( + members: readonly AgentSwarmMember[], + width: number, + colors: ColorPalette, +): string { + const safeWidth = Math.max(1, width); + const counts = statusBarCounts(members); + if (counts.length === 0) { + return chalk.hex(colors.textMuted)(STATUS_BAR_CHAR.repeat(safeWidth)); + } + + const segmentWidths = allocateSegmentWidths(counts.map((entry) => entry.count), safeWidth); + return counts.map((entry, index) => { + const segmentWidth = segmentWidths[index] ?? 0; + if (segmentWidth <= 0) return ''; + return chalk.hex(statusBarColor(entry.phase, colors))(STATUS_BAR_CHAR.repeat(segmentWidth)); + }).join(''); +} + +function renderStatusLabel(label: string, color: string): string { + return ` ${chalk.hex(color)(label)}`; +} + +function activityPrefixForTotalStatus(status: TotalStatus, colors: ColorPalette): string { + const marks: Record = { + completed: SUCCESS_MARK.trimEnd(), + failed: FAILURE_MARK.trimEnd(), + aborted: CANCELLED_MARK.trimEnd(), + working: '', + suspended: '', + }; + const mark = marks[status]; + return mark.length > 0 + ? ` ${chalk.hex(totalStatusColor(status, colors))(mark)}` + : ACTIVITY_SPINNER_PLACEHOLDER; +} + +function statusBarCounts(members: readonly AgentSwarmMember[]): StatusBarCount[] { + const counts = new Map(); + for (const member of members) { + const phase = statusBarPhase(member.phase); + counts.set(phase, (counts.get(phase) ?? 0) + 1); + } + return STATUS_BAR_ORDER.flatMap((phase) => { + const count = counts.get(phase) ?? 0; + return count > 0 ? [{ phase, count }] : []; + }); +} + +function statusBarPhase(phase: AgentSwarmPhase): StatusBarPhase { + const map: Record = { + pending: 'queued', + queued: 'queued', + suspended: 'suspended', + running: 'working', + completed: 'completed', + failed: 'failed', + cancelled: 'cancelled', + }; + return map[phase]; +} + +function statusBarColor(phase: StatusBarPhase, colors: ColorPalette): string { + const map: Record = { + queued: colors.textMuted, + working: colors.primary, + suspended: colors.textMuted, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; +} + +function totalStatus( + members: readonly AgentSwarmMember[], + force: { readonly failed: boolean; readonly aborted: boolean }, +): TotalStatus { + if (force.aborted) return 'aborted'; + const phases = new Set(members.map((m) => m.phase)); + const hasActive = phases.has('pending') || phases.has('queued') || phases.has('suspended') || phases.has('running'); + if (!hasActive && members.length > 0) { + if (phases.has('cancelled')) return 'aborted'; + if (phases.has('completed')) return 'completed'; + return 'failed'; + } + if (force.failed) return 'failed'; + if (phases.has('suspended') && !phases.has('running')) return 'suspended'; + return 'working'; +} + +function totalStatusLabel(status: TotalStatus): string { + const map: Record = { + working: WORKING_LABEL, + completed: COMPLETED_LABEL, + suspended: SUSPENDED_LABEL, + failed: FAILED_LABEL, + aborted: ABORTED_LABEL, + }; + return map[status]; +} + +function totalStatusColor(status: TotalStatus, colors: ColorPalette): string { + const map: Record = { + working: colors.success, + completed: colors.success, + suspended: colors.textDim, + failed: colors.error, + aborted: colors.warning, + }; + return map[status]; +} + +function totalStatusLabelColor( + status: TotalStatus, + members: readonly AgentSwarmMember[], + colors: ColorPalette, +): string { + if (status === 'working' && !members.some((member) => member.phase === 'completed')) { + return colors.primary; + } + return totalStatusColor(status, colors); +} + +function allocateSegmentWidths(counts: readonly number[], width: number): number[] { + const total = counts.reduce((sum, count) => sum + count, 0); + if (total <= 0 || width <= 0) return counts.map(() => 0); + + const exact = counts.map((count) => count * width / total); + const widths = exact.map(Math.floor); + let remaining = width - widths.reduce((sum, value) => sum + value, 0); + const order = exact + .map((value, index) => ({ index, fraction: value - Math.floor(value) })) + .toSorted((a, b) => b.fraction - a.fraction || a.index - b.index); + + for (const entry of order) { + if (remaining <= 0) break; + widths[entry.index] = (widths[entry.index] ?? 0) + 1; + remaining -= 1; + } + return widths; +} + +function renderCellLabel( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + width: number, + colors: ColorPalette, +): string { + const latestLine = latestNonEmptyLine(snapshot.latestModelText); + if (snapshot.phase === 'running') { + return truncateWithColor(runningCellLabelText(member), width, colors.textDim); + } + if (snapshot.phase === 'failed' && member.failureText !== undefined) { + return truncateWithColor(`${FAILURE_MARK}${member.failureText}`, width, colors.error); + } + if (snapshot.phase === 'completed') { + return renderCompletedCellLabel(member.completedText ?? latestLine, width, colors); + } + if (snapshot.phase === 'cancelled') { + return renderCancelledCellLabel(member, width, colors); + } + return truncateWithColor(PHASE_LABELS[snapshot.phase], width, phaseColor(snapshot.phase, colors)); +} + +function runningCellLabelText(member: AgentSwarmMember): string { + const latestLine = latestNonEmptyLine(member.latestModelText); + const itemText = collapseWhitespace(member.itemText); + const text = latestLine.length > 0 ? latestLine : itemText; + return text.length > 0 ? text : PHASE_LABELS.running; +} + +function renderCancelledCellLabel( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const labelText = member.cancelledLabelText ?? ABORTED_LABEL; + const labelColor = member.cancelledLabelColor ?? colors.warning; + const markColor = member.cancelledMarkColor ?? colors.warning; + const labelStyle = chalk.hex(labelColor); + return truncateToWidth( + chalk.hex(markColor)(CANCELLED_MARK) + labelStyle(labelText), + width, + labelStyle('…'), + ); +} + +function renderCompletedCellLabel( + text: string, + width: number, + colors: ColorPalette, +): string { + const finalText = normalizeFinalOutputText(text); + const label = finalText === undefined ? SUCCESS_MARK.trimEnd() : `${SUCCESS_MARK}${finalText}`; + return truncateWithColor(label, width, colors.success); +} + +function compactTerminalMark( + member: AgentSwarmMember, + phase: AgentSwarmPhase, + colors: ColorPalette, +): string { + if (phase === 'completed') return chalk.hex(colors.success)(SUCCESS_MARK.trimEnd()); + if (phase === 'failed') return chalk.hex(colors.error)(FAILURE_MARK.trimEnd()); + if (phase === 'cancelled') { + return chalk.hex(member.cancelledMarkColor ?? colors.warning)(CANCELLED_MARK.trimEnd()); + } + return ''; +} + +function renderPendingCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const itemText = collapseWhitespace(member.itemText); + const label = itemText.length > 0 ? itemText : QUEUED_LABEL; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + truncateWithColor(label, labelWidth, colors.textDim); +} + +function renderQueuedCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + truncateWithColor(QUEUED_LABEL, labelWidth, colors.textDim); +} + +function renderCancelledUnstartedCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + renderCancelledCellLabel(member, labelWidth, colors); +} + +function truncateWithColor(text: string, width: number, color: string): string { + const colorize = chalk.hex(color); + return truncateToWidth(colorize(text), width, colorize('…')); +} + +function truncateStartToWidth(text: string, width: number): string { + if (visibleWidth(text) <= width) return text; + const ellipsis = '…'; + const ellipsisWidth = visibleWidth(ellipsis); + if (width <= ellipsisWidth) return truncateToWidth(ellipsis, width); + + const targetWidth = width - ellipsisWidth; + const segments = Array.from(text); + let tail = ''; + let tailWidth = 0; + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index] ?? ''; + const segmentWidth = visibleWidth(segment); + if (tailWidth + segmentWidth > targetWidth) break; + tail = segment + tail; + tailWidth += segmentWidth; + } + return ellipsis + tail; +} + +function collapseWhitespace(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function normalizeFailureText(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + const nestedFailureText = nestedAgentSwarmFailureText(text); + const normalized = stripAgentSwarmPrefix(collapseWhitespace(nestedFailureText ?? text)); + return normalized.length > 0 ? normalized : undefined; +} + +function nestedAgentSwarmFailureText(text: string): string | undefined { + const xmlFailureText = nestedAgentSwarmXmlFailureText(text); + if (xmlFailureText !== undefined) return nestedAgentSwarmFailureText(xmlFailureText) ?? xmlFailureText; + + if (!/^\s*agent_swarm:\s*failed\b/m.test(text)) return undefined; + const match = /^\s*subagent error:\s*([\s\S]*?)(?=\n\[agent \d+\]\n|$)/m.exec(text); + if (match === null) return undefined; + const failureText = match[1]; + if (failureText === undefined) return undefined; + return nestedAgentSwarmFailureText(failureText) ?? failureText; +} + +function nestedAgentSwarmXmlFailureText(text: string): string | undefined { + if (!/ { + return entry.status === 'failed' && entry.failureText !== undefined; + }); + return failed?.failureText; +} + +function stripAgentSwarmPrefix(text: string): string { + return text.replace(/^agent_swarm:\s*(?:failed|completed)?\s*/i, '').trim(); +} + +function normalizeFinalOutputText(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + const normalized = collapseWhitespace(text); + return normalized.length > 0 ? normalized : undefined; +} + +function latestNonEmptyLine(text: string): string { + const lines = text.split(/\r?\n/); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = collapseWhitespace(lines[index] ?? ''); + if (line.length > 0) return line; + } + return ''; +} + +function countPartialJsonObjectEntries(text: string, startIndex: number): number { + let count = 0; + let expectKey = true; + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '}') return count; + if (ch === ',') { + expectKey = true; + continue; + } + if (ch !== '"') continue; + + const parsed = parsePartialJsonString(text, i + 1); + if (expectKey) { + if (parsed.closed || parsed.value.length > 0) count += 1; + expectKey = false; + } + if (!parsed.closed) return count; + i = parsed.nextIndex; + } + return count; +} + +function parsePartialJsonString( + text: string, + startIndex: number, +): { value: string; closed: boolean; nextIndex: number } { + let value = ''; + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '"') return { value, closed: true, nextIndex: i }; + if (ch !== '\\') { + value += ch; + continue; + } + + const escaped = text[i + 1]; + if (escaped === undefined) return { value, closed: false, nextIndex: i }; + switch (escaped) { + case 'n': value += '\n'; break; + case 't': value += '\t'; break; + case 'r': value += '\r'; break; + case 'b': value += '\b'; break; + case 'f': value += '\f'; break; + case '"': + case '\\': + case '/': + value += escaped; + break; + case 'u': { + const hex = text.slice(i + 2, i + 6); + if (hex.length < 4) return { value, closed: false, nextIndex: i }; + const code = Number.parseInt(hex, 16); + if (Number.isNaN(code)) return { value, closed: false, nextIndex: i }; + value += String.fromCodePoint(code); + i += 4; + break; + } + default: + value += escaped; + } + i += 1; + } + return { value, closed: false, nextIndex: text.length }; +} + +function padAnsi(text: string, width: number): string { + const truncated = truncateToWidth(text, width); + return truncated + ' '.repeat(Math.max(0, width - visibleWidth(truncated))); +} + +function completedDisplayTicks(ticks: number, width: number, phaseElapsedMs: number): number { + const fullBarTicks = width * BRAILLE_LEVELS.length; + if (ticks >= fullBarTicks) return fullBarTicks; + const fillProgress = Math.max(0, Math.min(1, phaseElapsedMs / COMPLETE_FILL_MS)); + return Math.min(fullBarTicks, Math.ceil(ticks + (fullBarTicks - ticks) * fillProgress)); +} + +function failedBrailleBar( + ticks: number, + width: number, + phaseElapsedMs: number, + colors: ColorPalette, +): string { + const redCellCount = Math.ceil( + completedDisplayTicks(ticks, width, phaseElapsedMs) / BRAILLE_LEVELS.length, + ); + const placeholderColor = darkenRedHexColor(colors.error); + return accumulatedBrailleBar( + ticks, + width, + colors.error, + colors, + (cellIndex) => cellIndex < redCellCount ? placeholderColor : colors.textDim, + ); +} + +function darkenRedHexColor(hex: string): string { + return darkenHexColor( + hex, + FAILED_PLACEHOLDER_RED_FACTOR, + FAILED_PLACEHOLDER_NON_RED_FACTOR, + FAILED_PLACEHOLDER_NON_RED_FACTOR, + ); +} + +function cancelledLabelColor(colors: ColorPalette): string { + return darkenHexColor(colors.warning, CANCELLED_LABEL_DARKEN_FACTOR); +} + +function darkenHexColor( + hex: string, + redFactor: number, + greenFactor = redFactor, + blueFactor = redFactor, +): string { + const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); + if (match === null) return hex; + const darken = (channel: string, factor: number): string => + Math.max(0, Math.min(255, Math.round(Number.parseInt(channel, 16) * factor))) + .toString(16) + .padStart(2, '0'); + return `#${darken(match[1]!, redFactor)}${darken(match[2]!, greenFactor)}${darken( + match[3]!, + blueFactor, + )}`; +} + +function accumulatedBrailleBar( + ticks: number, + width: number, + filledColor: string, + colors: ColorPalette, + emptyColorForCell?: (cellIndex: number) => string, +): string { + const dotsPerCell = BRAILLE_LEVELS.length; + const cycleSize = width * dotsPerCell; + const safeTicks = Math.max(0, Math.ceil(ticks)); + const completedCycles = Math.floor(safeTicks / cycleSize); + const cycleTicks = safeTicks % cycleSize; + const activeCells = cycleTicks === 0 ? 0 : Math.ceil(cycleTicks / dotsPerCell); + const separatorIndex = completedCycles > 0 && activeCells > 0 && activeCells < width + ? activeCells + : -1; + + let out = ''; + let pending = ''; + let pendingColor: string | undefined; + const flush = (): void => { + if (pending.length === 0 || pendingColor === undefined) return; + out += chalk.hex(pendingColor)(pending); + pending = ''; + }; + const append = (char: string, color: string): void => { + if (pendingColor !== color) { + flush(); + pendingColor = color; + } + pending += char; + }; + + for (let i = 0; i < width; i += 1) { + if (i === separatorIndex) { + append(BRAILLE_RIGHT_COLUMN_FULL, filledColor); + continue; + } + + const cellStart = i * dotsPerCell; + const countThisCycle = Math.max(0, Math.min(dotsPerCell, cycleTicks - cellStart)); + const count = countThisCycle > 0 ? countThisCycle : completedCycles > 0 ? dotsPerCell : 0; + append( + count === 0 ? BRAILLE_EMPTY : BRAILLE_LEVELS[count - 1]!, + count === 0 ? emptyColorForCell?.(i) ?? colors.textDim : filledColor, + ); + } + flush(); + return out; +} diff --git a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts new file mode 100644 index 000000000..f24cac6b6 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -0,0 +1,34 @@ +import type { Component } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; + +export type SwarmModeMarkerState = 'active' | 'inactive' | 'ended'; + +export class SwarmModeMarkerComponent implements Component { + constructor( + private readonly state: SwarmModeMarkerState, + private readonly colors: ColorPalette, + ) {} + + invalidate(): void {} + + render(_width: number): string[] { + const color = this.state === 'inactive' ? this.colors.textDim : this.colors.success; + const marker = chalk.hex(color).bold(STATUS_BULLET); + const label = chalk.hex(color).bold(swarmMarkerLabel(this.state)); + return ['', marker + label]; + } +} + +function swarmMarkerLabel(state: SwarmModeMarkerState): string { + switch (state) { + case 'active': + return 'Swarm activated'; + case 'inactive': + return 'Swarm deactivated'; + case 'ended': + return 'Swarm ended'; + } +} 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 72f3b7dd6..649c4ffd1 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -16,13 +16,14 @@ 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, SUCCESS_MARK } 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'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; +import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; import { PlanBoxComponent } from './plan-box'; import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; @@ -35,9 +36,10 @@ const APPROVED_PLAN_MARKER = '## Approved Plan:'; const STREAMING_PROGRESS_INTERVAL_MS = 1000; const SUBAGENT_ELAPSED_INTERVAL_MS = 1000; const PROGRESS_URL_RE = /https?:\/\/\S+/g; +const ABORTED_MARK = '⊘'; type SubagentTextKind = 'thinking' | 'text'; - +type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded'; interface FinishedSubCall { readonly name: string; @@ -75,7 +77,7 @@ export interface ToolCallSubagentSnapshot { readonly toolName: string; readonly toolCallDescription: string; readonly agentName: string | undefined; - readonly phase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined; + readonly phase: SubagentPhase | undefined; readonly toolCount: number; readonly tokens: number; readonly isError: boolean; @@ -508,8 +510,8 @@ export class ToolCallComponent extends Container { */ private subagentText = ''; private subagentThinkingText = ''; - // ── Subagent lifecycle state from subagent.spawned/completed/failed ── - private subagentPhase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined; + // ── Subagent lifecycle state from subagent.spawned/started/completed/failed ── + private subagentPhase: SubagentPhase | undefined; /** * Authoritative terminal phase for a backgrounded subagent. Set from * `BackgroundTaskInfo.status` via `setBackgroundTaskTerminalStatus` once @@ -874,7 +876,7 @@ export class ToolCallComponent extends Container { const shouldTick = this.isSingleSubagentView() && this.subagentStartedAtMs !== undefined && - (phase === 'spawning' || phase === 'running'); + (phase === 'queued' || phase === 'spawning' || phase === 'running'); if (!shouldTick) { this.stopSubagentElapsedTimer(); return; @@ -882,7 +884,7 @@ export class ToolCallComponent extends Container { if (this.ui === undefined || this.subagentElapsedTimer !== undefined) return; this.subagentElapsedTimer = setInterval(() => { const latestPhase = this.getDerivedSubagentPhase(); - if (latestPhase !== 'spawning' && latestPhase !== 'running') { + if (latestPhase !== 'queued' && latestPhase !== 'spawning' && latestPhase !== 'running') { this.stopSubagentElapsedTimer(); return; } @@ -909,10 +911,10 @@ export class ToolCallComponent extends Container { } /** - * Handles SDK `subagent.spawned`. The child agent is registered, but internal - * activity events (`assistant.delta` or `tool.call.started`) may not have - * arrived yet, so the UI moves to the 'spawning' placeholder state unless the - * agent is running in the background. + * Handles SDK `subagent.spawned`. The child agent is registered with the + * parent call, but its prompt may still be queued behind other subagents. + * `subagent.started` moves it to 'running' when the child turn actually + * begins. */ onSubagentSpawned(meta: { agentId: string; @@ -921,7 +923,7 @@ export class ToolCallComponent extends Container { }): void { this.subagentAgentId = meta.agentId; this.subagentAgentName = meta.agentName; - this.subagentPhase = meta.runInBackground ? 'backgrounded' : 'spawning'; + this.subagentPhase = meta.runInBackground ? 'backgrounded' : 'queued'; this.subagentStartedAtMs = Date.now(); this.subagentEndedAtMs = undefined; this.syncSubagentElapsedTimer(); @@ -931,6 +933,27 @@ export class ToolCallComponent extends Container { this.ui?.requestRender(); } + /** Handles SDK `subagent.started` once a queued child turn begins. */ + onSubagentStarted(meta: { + agentId: string; + agentName?: string | undefined; + runInBackground: boolean; + }): void { + this.subagentAgentId = meta.agentId; + this.subagentAgentName = meta.agentName; + if ( + !meta.runInBackground && + (this.subagentPhase === undefined || this.subagentPhase === 'queued') + ) { + this.subagentPhase = 'running'; + } + this.syncSubagentElapsedTimer(); + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + /** * Handles SDK `subagent.completed`. Moves the phase to 'done' and records * token usage plus the result summary for the header chip and tail summary. @@ -1078,7 +1101,11 @@ export class ToolCallComponent extends Container { this.subagentText += text; } // Child-agent activity means it is running unless already terminal/backgrounded. - if (this.subagentPhase === undefined || this.subagentPhase === 'spawning') { + if ( + this.subagentPhase === undefined || + this.subagentPhase === 'queued' || + this.subagentPhase === 'spawning' + ) { this.subagentPhase = 'running'; } this.headerText.setText(this.buildHeader()); @@ -1097,7 +1124,11 @@ export class ToolCallComponent extends Container { : {}), }); this.upsertSubToolActivity(call.id, call.name, call.args, 'ongoing'); - if (this.subagentPhase === undefined || this.subagentPhase === 'spawning') { + if ( + this.subagentPhase === undefined || + this.subagentPhase === 'queued' || + this.subagentPhase === 'spawning' + ) { this.subagentPhase = 'running'; } this.headerText.setText(this.buildHeader()); @@ -1123,6 +1154,13 @@ export class ToolCallComponent extends Container { streamingArguments: nextArgsText, }); this.upsertSubToolActivity(delta.id, delta.name ?? existing?.name ?? 'Tool', parsed, 'ongoing'); + if ( + this.subagentPhase === undefined || + this.subagentPhase === 'queued' || + this.subagentPhase === 'spawning' + ) { + this.subagentPhase = 'running'; + } this.headerText.setText(this.buildHeader()); this.rebuildContent(); this.notifySnapshotChange(); @@ -1366,6 +1404,7 @@ export class ToolCallComponent extends Container { /** * Header phase/token chip. No chip is shown when phase is undefined. + * queued -> queued * spawning -> starting * running -> running * done -> N tools, 8.4k tok @@ -1377,6 +1416,9 @@ export class ToolCallComponent extends Container { const dim = chalk.dim; const parts: string[] = []; switch (this.subagentPhase) { + case 'queued': + parts.push('○ queued'); + break; case 'spawning': parts.push('↻ starting…'); break; @@ -1425,13 +1467,7 @@ export class ToolCallComponent extends Container { return this.toolCall.name === 'Agent' && this.hasSubagentState(); } - private getDerivedSubagentPhase(): - | 'spawning' - | 'running' - | 'done' - | 'failed' - | 'backgrounded' - | undefined { + private getDerivedSubagentPhase(): SubagentPhase | undefined { if (this.backgroundTaskTerminalPhase !== undefined) { return this.backgroundTaskTerminalPhase; } @@ -1463,9 +1499,7 @@ export class ToolCallComponent extends Container { return `${bullet}${label} ${status}${descriptionText}${stats}`; } - private formatSingleSubagentStatus( - phase: 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded' | undefined, - ): string { + private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string { switch (phase) { case 'done': return chalk.hex(this.colors.success)('Completed'); @@ -1475,6 +1509,8 @@ export class ToolCallComponent extends Container { return chalk.hex(this.colors.primary)('Running'); case 'backgrounded': return 'Backgrounded'; + case 'queued': + return chalk.hex(this.colors.primary)('Queued'); case 'spawning': case undefined: return chalk.hex(this.colors.primary)('Starting'); @@ -1751,7 +1787,14 @@ export class ToolCallComponent extends Container { private buildContent(): void { const { result } = this; - if (result === undefined || !result.output) return; + if (result === undefined) return; + + if (this.toolCall.name === 'AgentSwarm') { + this.buildAgentSwarmResultSummary(result); + return; + } + + if (!result.output) return; if (this.isSingleSubagentView()) { return; @@ -1812,6 +1855,46 @@ export class ToolCallComponent extends Container { } } + private buildAgentSwarmResultSummary(result: ToolResultBlockData): void { + const summary = agentSwarmResultSummaryFromOutput(result.output); + const dim = chalk.hex(this.colors.textDim); + const segments: string[] = []; + + if (summary.completed > 0) { + segments.push(chalk.hex(this.colors.success)( + `${SUCCESS_MARK.trimEnd()} ${String(summary.completed)} completed`, + )); + } + if (summary.failed > 0) { + segments.push(chalk.hex(this.colors.error)( + `${FAILURE_MARK.trimEnd()} ${String(summary.failed)} failed`, + )); + } + if (summary.aborted > 0) { + segments.push(chalk.hex(this.colors.warning)( + `${ABORTED_MARK} ${String(summary.aborted)} aborted`, + )); + } + + if (segments.length > 0) { + this.addChild(new Text(`${dim('Agent swarm: ')}${segments.join(dim(' · '))}`, 2, 0)); + return; + } + + const isAborted = result.is_error === true && /\b(?:aborted|cancelled)\b/i.test(result.output); + const color = isAborted + ? this.colors.warning + : result.is_error === true + ? this.colors.error + : this.colors.success; + const label = isAborted + ? `${ABORTED_MARK} Aborted.` + : result.is_error === true + ? `${FAILURE_MARK.trimEnd()} Failed.` + : `${SUCCESS_MARK.trimEnd()} Completed.`; + this.addChild(new Text(`${dim('Agent swarm: ')}${chalk.hex(color)(label)}`, 2, 0)); + } + /** * Render AskUserQuestion's JSON payload as a friendly Q/A list. * Returns true on success (caller skips the default JSON dump); diff --git a/apps/kimi-code/src/tui/constant/symbols.ts b/apps/kimi-code/src/tui/constant/symbols.ts index bf237484d..bba41d60a 100644 --- a/apps/kimi-code/src/tui/constant/symbols.ts +++ b/apps/kimi-code/src/tui/constant/symbols.ts @@ -4,6 +4,7 @@ export const STATUS_BULLET = '● '; // Shared transcript markers. Keep widths stable because message wrapping // assumes the marker occupies the leading cells. export const USER_MESSAGE_BULLET = '✨ '; +export const SUCCESS_MARK = '✓ '; export const FAILURE_MARK = '✗ '; // Shared selector markers — keep every list picker visually consistent. diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index a1fc1e140..7ebe79118 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -124,33 +124,6 @@ export class BtwPanelController { } this.host.state.ui.requestRender(); return true; - case 'agent.status.updated': - case 'background.task.started': - case 'background.task.terminated': - case 'compaction.blocked': - case 'compaction.cancelled': - case 'compaction.completed': - case 'compaction.started': - case 'cron.fired': - case 'error': - case 'mcp.server.status': - case 'session.meta.updated': - case 'skill.activated': - case 'subagent.completed': - case 'subagent.failed': - case 'subagent.spawned': - case 'tool.call.delta': - case 'tool.call.started': - case 'tool.list.updated': - case 'tool.progress': - case 'tool.result': - case 'turn.started': - case 'turn.step.completed': - case 'turn.step.interrupted': - case 'turn.step.retrying': - case 'turn.step.started': - case 'warning': - return true; default: return true; } 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 ee7f8977e..24f866ae1 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -17,9 +17,6 @@ import type { Session, SessionMetaUpdatedEvent, SkillActivatedEvent, - SubagentCompletedEvent, - SubagentFailedEvent, - SubagentSpawnedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -38,7 +35,10 @@ import { MoonLoader } from '../components/chrome/moon-loader'; import { buildGoalMarker } from '../components/messages/goal-markers'; import { StatusMessageComponent } from '../components/messages/status-message'; import { - MAIN_AGENT_ID, + SwarmModeMarkerComponent, + type SwarmModeMarkerState, +} from '../components/messages/swarm-markers'; +import { OAUTH_LOGIN_REQUIRED_CODE, OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, } from '../constant/kimi-tui'; @@ -56,9 +56,8 @@ import { restoreGoalQueueItem, type UpcomingGoal, } from '../goal-queue-store'; -import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; -import { formatHookResultMarkdown, formatHookResultPlain } from '../utils/hook-result-format'; +import { formatHookResultMarkdown } from '../utils/hook-result-format'; import { McpOAuthAuthorizationUrlOpener } from '../utils/mcp-oauth'; import { formatMcpStartupStatusSummary, @@ -73,9 +72,9 @@ import { nextTranscriptId } from '../utils/transcript-id'; import type { BtwPanelController } from './btw-panel'; import type { StreamingUIController } from './streaming-ui'; import type { TasksBrowserController } from './tasks-browser'; +import { SubAgentEventHandler } from './subagent-event-handler'; import type { AppState, - BackgroundAgentMetadata, LivePaneState, QueuedMessage, ToolCallBlockData, @@ -99,6 +98,7 @@ export interface SessionEventHost { showError(msg: string): void; showStatus(msg: string, color?: string): void; showNotice(title: string, detail?: string): void; + updateActivityPane(): void; track(event: string, props?: Record): void; mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; @@ -113,13 +113,22 @@ export interface SessionEventHost { } export class SessionEventHandler { - constructor(private readonly host: SessionEventHost) {} + readonly subAgentEventHandler: SubAgentEventHandler; + + constructor(private readonly host: SessionEventHost) { + this.subAgentEventHandler = new SubAgentEventHandler(host, { + backgroundTasks: this.backgroundTasks, + backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal, + syncBackgroundAgentBadge: () => { + this.syncBackgroundTaskBadge(); + }, + }); + } // Runtime state – owned by this handler, reset between sessions. - backgroundAgentMetadata: Map = new Map(); backgroundTasks: Map = new Map(); backgroundTaskTranscriptedTerminal: Set = new Set(); - subagentInfo: Map = new Map(); + renderedSkillActivationIds: Set = new Set(); renderedMcpServerStatusKeys: Map = new Map(); mcpServerStatusSpinners: Map = new Map(); @@ -131,10 +140,9 @@ export class SessionEventHandler { private queuedGoalPromotionTimer: ReturnType | undefined; resetRuntimeState(): void { - this.backgroundAgentMetadata.clear(); this.backgroundTasks.clear(); this.backgroundTaskTranscriptedTerminal.clear(); - this.subagentInfo.clear(); + this.subAgentEventHandler.resetRuntimeState(); this.renderedSkillActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); this.mcpServers.clear(); @@ -146,6 +154,18 @@ export class SessionEventHandler { this.stopAllMcpServerStatusSpinners(); } + clearAgentSwarmProgress(): void { + this.subAgentEventHandler.clearAgentSwarmProgress(); + } + + hasActiveAgentSwarmToolCall(): boolean { + return this.subAgentEventHandler.hasActiveAgentSwarmToolCall(); + } + + syncAgentSwarmActivitySpinner(spinner: MoonLoader | undefined): void { + this.subAgentEventHandler.syncAgentSwarmActivitySpinner(spinner); + } + startSubscription(): void { const { host } = this; const session = host.requireSession(); @@ -202,7 +222,7 @@ export class SessionEventHandler { } handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { - if (this.routeSubagentEvent(event)) return; + if (this.subAgentEventHandler.routeChildAgentEvent(event)) return; if ('turnId' in event && event.turnId !== undefined) { this.host.streamingUI.setTurnId(String(event.turnId)); @@ -232,9 +252,12 @@ export class SessionEventHandler { case 'compaction.completed': this.handleCompactionEnd(event, sendQueued); break; case 'compaction.blocked': break; case 'compaction.cancelled': this.handleCompactionCancel(event, sendQueued); break; - case 'subagent.spawned': this.handleSubagentSpawned(event); break; - case 'subagent.completed': this.handleSubagentCompleted(event); break; - case 'subagent.failed': this.handleSubagentFailed(event); break; + case 'subagent.spawned': + case 'subagent.started': + case 'subagent.suspended': + case 'subagent.completed': + case 'subagent.failed': + this.subAgentEventHandler.handleLifecycleEvent(event); break; case 'background.task.started': case 'background.task.terminated': this.handleBackgroundTaskEvent(event); break; @@ -256,94 +279,9 @@ export class SessionEventHandler { // Private handlers // --------------------------------------------------------------------------- - private routeSubagentEvent(event: Event): boolean { - const subagentId = event.agentId; - if (subagentId === MAIN_AGENT_ID) return false; - - const { streamingUI } = this.host; - if (this.host.btwPanelController.routeEvent(event)) return true; - - const info = this.subagentInfo.get(subagentId); - if (info === undefined) return true; - if (info.parentToolCallId.length === 0) return true; - const { parentToolCallId } = info; - const sourceName = info.name; - const toolCall = streamingUI.getToolComponent(parentToolCallId); - if (toolCall === undefined) return true; - toolCall.setSubagentMeta(subagentId, sourceName); - - switch (event.type) { - case 'hook.result': - toolCall.appendSubagentText(formatHookResultPlain(event), 'text'); - return true; - case 'assistant.delta': - toolCall.appendSubagentText(event.delta, 'text'); - return true; - case 'thinking.delta': - toolCall.appendSubagentText(event.delta, 'thinking'); - return true; - case 'tool.call.started': - toolCall.appendSubToolCall({ - id: `${subagentId}:${event.toolCallId}`, - name: event.name, - args: argsRecord(event.args), - }); - return true; - case 'tool.call.delta': - toolCall.appendSubToolCallDelta({ - id: `${subagentId}:${event.toolCallId}`, - name: event.name, - argumentsPart: event.argumentsPart ?? null, - }); - return true; - case 'tool.result': - toolCall.finishSubToolCall({ - tool_call_id: `${subagentId}:${event.toolCallId}`, - output: serializeToolResultOutput(event.output), - is_error: event.isError, - }); - return true; - case 'agent.status.updated': { - const usageObj = event.usage; - const totalUsage = usageObj?.total ?? usageObj?.currentTurn; - toolCall.updateSubagentMetrics({ - contextTokens: event.contextTokens, - usage: totalUsage, - }); - return true; - } - case 'background.task.started': - case 'background.task.terminated': - case 'compaction.blocked': - case 'compaction.cancelled': - case 'compaction.completed': - case 'compaction.started': - case 'cron.fired': - case 'error': - case 'warning': - case 'goal.updated': - case 'session.meta.updated': - case 'skill.activated': - case 'subagent.completed': - case 'subagent.failed': - case 'subagent.spawned': - case 'tool.progress': - case 'tool.list.updated': - case 'mcp.server.status': - case 'turn.ended': - case 'turn.started': - case 'turn.step.completed': - case 'turn.step.interrupted': - case 'turn.step.retrying': - case 'turn.step.started': - return true; - default: - return true; - } - } - private handleTurnBegin(_event: TurnStartedEvent): void { void _event; + this.clearAgentSwarmProgress(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); this.host.patchLivePane({ @@ -375,9 +313,11 @@ export class SessionEventHandler { }); } - private handleTurnEnd(_event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { - void _event; + private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { this.host.streamingUI.flushNow(); + if (event.reason === 'cancelled') { + this.markActiveAgentSwarmsCancelled(); + } const todos = this.host.state.todoPanel.getTodos(); if (todos.length > 0 && todos.every((t) => t.status === 'done')) { this.host.streamingUI.setTodoList([]); @@ -430,6 +370,10 @@ export class SessionEventHandler { if (text !== undefined) this.host.showStatus(text); } + private markActiveAgentSwarmsCancelled(): void { + this.subAgentEventHandler.markActiveAgentSwarmsCancelled(); + } + private isAnthropicSessionActive(): boolean { const { state } = this.host; const providerKey = state.appState.availableModels[state.appState.model]?.provider; @@ -444,6 +388,7 @@ export class SessionEventHandler { const reason = event.reason; if (reason === 'error') return; if (reason === 'aborted' || reason === undefined || reason === '') { + this.markActiveAgentSwarmsCancelled(); this.host.showStatus('Interrupted by user', this.host.state.theme.colors.error); return; } @@ -517,6 +462,9 @@ export class SessionEventHandler { turnId, }; streamingUI.registerToolCall(toolCall); + if (event.name === 'AgentSwarm') { + this.subAgentEventHandler.handleAgentSwarmToolCallStarted(event.toolCallId, toolCall.args); + } this.host.patchLivePane({ mode: 'tool', pendingApproval: null, @@ -528,6 +476,15 @@ export class SessionEventHandler { if (event.toolCallId.length === 0) return; const { state, streamingUI } = this.host; streamingUI.accumulateToolCallDelta(event.toolCallId, event.name, event.argumentsPart); + const preview = streamingUI.getStreamingToolCallPreview(event.toolCallId); + if ( + preview !== undefined && + (preview.name === 'AgentSwarm' || this.subAgentEventHandler.hasAgentSwarmProgress(event.toolCallId)) + ) { + this.subAgentEventHandler.handleAgentSwarmToolCallDelta(event.toolCallId, preview.args, { + streamingArguments: preview.argumentsText, + }); + } this.host.patchLivePane({ mode: 'tool', @@ -559,6 +516,11 @@ export class SessionEventHandler { synthetic: event.synthetic, }; const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); + this.subAgentEventHandler.handleAgentSwarmToolResult( + event.toolCallId, + resultData, + event.isError === true, + ); if (matchedCall !== undefined && matchedCall.name === 'TodoList' && !event.isError) { const rawTodos = (matchedCall.args as { todos?: unknown }).todos; if (Array.isArray(rawTodos)) { @@ -574,16 +536,34 @@ export class SessionEventHandler { } private handleStatusUpdate(event: AgentStatusUpdatedEvent): void { + const shouldRenderSwarmEnded = + event.swarmMode === false && + this.host.state.appState.swarmMode && + this.host.state.swarmModeEntry === 'task'; const patch: Partial = {}; if (event.contextUsage !== undefined) patch.contextUsage = event.contextUsage; if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; if (event.planMode !== undefined) patch.planMode = event.planMode; + if (event.swarmMode !== undefined) patch.swarmMode = event.swarmMode; if (event.permission !== undefined) { patch.permissionMode = event.permission; } if (event.model !== undefined) patch.model = event.model; if (Object.keys(patch).length > 0) this.host.setAppState(patch); + if (event.swarmMode === false) { + this.host.state.swarmModeEntry = undefined; + if (shouldRenderSwarmEnded) { + this.renderSwarmModeMarker('ended'); + } + } + } + + private renderSwarmModeMarker(state: SwarmModeMarkerState): void { + this.host.state.transcriptContainer.addChild( + new SwarmModeMarkerComponent(state, this.host.state.theme.colors), + ); + this.host.state.ui.requestRender(); } private handleGoalUpdated(event: GoalUpdatedEvent): void { @@ -935,177 +915,6 @@ export class SessionEventHandler { } } - private handleSubagentSpawned(event: SubagentSpawnedEvent): void { - const { streamingUI } = this.host; - this.subagentInfo.set(event.subagentId, { - parentToolCallId: event.parentToolCallId, - name: event.subagentName, - }); - - if (event.runInBackground) { - const meta = this.buildBackgroundAgentMetadata(event); - this.backgroundAgentMetadata.set(event.subagentId, meta); - this.appendBackgroundAgentEntry('started', meta); - this.syncBackgroundAgentBadge(); - return; - } - - let tc = streamingUI.getToolComponent(event.parentToolCallId); - if (tc === undefined) { - const toolCall = streamingUI.getActiveToolCall(event.parentToolCallId); - if (toolCall !== undefined) { - streamingUI.onToolCallStart(toolCall); - tc = streamingUI.getToolComponent(event.parentToolCallId); - } - } - tc ??= this.createStandaloneSubagentToolCall(event); - if (tc === undefined) return; - tc.onSubagentSpawned({ - agentId: event.subagentId, - agentName: event.subagentName, - runInBackground: event.runInBackground, - }); - } - - private handleSubagentCompleted(event: SubagentCompletedEvent): void { - const { streamingUI } = this.host; - const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); - if (backgroundMeta !== undefined) { - const taskId = this.findAgentTaskId(event.subagentId, backgroundMeta); - this.backgroundAgentMetadata.delete(event.subagentId); - this.syncBackgroundAgentBadge(); - if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { - return; - } - if (taskId !== undefined) { - this.backgroundTaskTranscriptedTerminal.add(taskId); - } - const extras = - event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; - this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); - return; - } - const tc = streamingUI.getToolComponent(event.parentToolCallId); - if (tc === undefined) return; - tc.onSubagentCompleted({ - contextTokens: event.contextTokens, - usage: event.usage, - resultSummary: event.resultSummary, - }); - streamingUI.removeToolComponentIfInactive(event.parentToolCallId); - } - - private handleSubagentFailed(event: SubagentFailedEvent): void { - const { streamingUI } = this.host; - const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); - if (backgroundMeta !== undefined) { - const taskId = this.findAgentTaskId(event.subagentId, backgroundMeta); - const task = taskId === undefined ? undefined : this.backgroundTasks.get(taskId); - this.backgroundAgentMetadata.delete(event.subagentId); - this.syncBackgroundAgentBadge(); - if (task?.kind === 'agent' && task.status === 'timed_out') { - // The deadline path already stamped the Agent card as timed out; the - // abort-triggered child failure should not downgrade it to failed. - return; - } - // Push the real subagent error onto the parent Agent card too — - // `background.task.terminated` arrives separately (possibly later) - // with no error string and would only stamp the generic - // `Background agent failed`. The card and the separate transcript - // entry now share the same actual reason. - streamingUI.applyBackgroundTaskTerminalStatus({ - agentId: event.subagentId, - description: backgroundMeta.description ?? '', - status: 'failed', - errorText: event.error, - }); - if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { - return; - } - if (taskId !== undefined) { - this.backgroundTaskTranscriptedTerminal.add(taskId); - } - this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); - return; - } - const tc = streamingUI.getToolComponent(event.parentToolCallId); - if (tc === undefined) return; - tc.onSubagentFailed({ error: event.error }); - streamingUI.removeToolComponentIfInactive(event.parentToolCallId); - } - - private createStandaloneSubagentToolCall(event: SubagentSpawnedEvent) { - const { streamingUI } = this.host; - const description = event.description ?? `Run ${event.subagentName} agent`; - const { turnId, step } = streamingUI.getTurnContext(); - const toolCall: ToolCallBlockData = { - id: event.parentToolCallId, - name: 'Agent', - args: { - description, - subagent_type: event.subagentName, - }, - description, - step, - turnId, - }; - streamingUI.onToolCallStart(toolCall); - return streamingUI.getToolComponent(event.parentToolCallId); - } - - private findAgentTaskId( - subagentId: string, - meta: BackgroundAgentMetadata, - ): string | undefined { - for (const info of this.backgroundTasks.values()) { - if (info.kind !== 'agent') continue; - if (info.agentId === subagentId) return info.taskId; - } - const description = meta.description ?? meta.agentName; - if (description === undefined) return undefined; - let match: string | undefined; - for (const info of this.backgroundTasks.values()) { - if (info.kind !== 'agent') continue; - if (info.description !== description) continue; - if (match !== undefined) return undefined; - match = info.taskId; - } - return match; - } - - private buildBackgroundAgentMetadata(event: SubagentSpawnedEvent): BackgroundAgentMetadata { - const parent = this.host.streamingUI.getActiveToolCall(event.parentToolCallId); - const description = parent?.args['description'] ?? event.description; - return { - agentId: event.subagentId, - parentToolCallId: event.parentToolCallId, - agentName: event.subagentName, - description: typeof description === 'string' ? description : undefined, - }; - } - - private appendBackgroundAgentEntry( - phase: 'started' | 'completed' | 'failed', - meta: BackgroundAgentMetadata, - extras: { resultSummary?: string; error?: string } | undefined = undefined, - ): void { - const status = formatBackgroundAgentTranscript(phase, meta, extras); - const entry: TranscriptEntry = { - id: nextTranscriptId(), - kind: 'status', - turnId: this.host.streamingUI.getTurnContext().turnId, - renderMode: 'plain', - content: status.headline, - detail: status.detail, - backgroundAgentStatus: status, - }; - this.host.appendTranscriptEntry(entry); - } - - private syncBackgroundAgentBadge(): void { - this.syncBackgroundTaskBadge(); - } - // --------------------------------------------------------------------------- // Background task lifecycle // --------------------------------------------------------------------------- diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index a268eb87d..5e87fdd11 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -1,6 +1,5 @@ import type { AgentReplayRecord, - BackgroundTaskInfo, ContextMessage, PermissionMode, PromptOrigin, @@ -139,10 +138,13 @@ export class SessionReplayRenderer { private hydrateBackgroundState(agent: ResumedAgentState): void { const { state, sessionEventHandler } = this.host; const projection = replayBackgroundProjection(agent.background); - sessionEventHandler.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata); - sessionEventHandler.backgroundTasks = new Map( - agent.background.map((info) => [info.taskId, info]), + sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata = new Map( + projection.backgroundAgentMetadata, ); + sessionEventHandler.backgroundTasks.clear(); + for (const info of agent.background) { + sessionEventHandler.backgroundTasks.set(info.taskId, info); + } sessionEventHandler.backgroundTaskTranscriptedTerminal.clear(); for (const info of agent.background) { if (isTerminalBackgroundTask(info)) { @@ -547,7 +549,7 @@ export class SessionReplayRenderer { detail: status.detail, backgroundAgentStatus: status, }); - sessionEventHandler.backgroundAgentMetadata.delete(meta.agentId); + sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.delete(meta.agentId); } } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 095fe2b68..35d0f6a9b 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -278,7 +278,7 @@ export class StreamingUIController { existingComponent.updateToolCall(toolCall); } else if (existing === undefined) { this.finalizeLiveTextBuffers('tool'); - if (toolCall.name !== 'Agent') { + if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentSwarm') { this.onToolCallStart(toolCall); } } @@ -299,6 +299,19 @@ export class StreamingUIController { this.pendingToolCallFlushIds.add(id); } + getStreamingToolCallPreview( + id: string, + ): { name: string; args: Record; argumentsText: string; startedAtMs: number } | undefined { + const streaming = this._streamingToolCallArguments.get(id); + if (streaming === undefined) return undefined; + return { + name: streaming.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool', + args: parseStreamingArgs(streaming.argumentsText), + argumentsText: streaming.argumentsText, + startedAtMs: streaming.startedAtMs, + }; + } + /** Completes a tool call: delivers the result and removes tracking state. * Returns the matched ToolCallBlockData, or undefined if no call was tracked. */ completeToolResult(toolCallId: string, result: ToolResultBlockData): ToolCallBlockData | undefined { @@ -720,7 +733,7 @@ export class StreamingUIController { const existingComponent = this._pendingToolComponents.get(id); if (existingComponent !== undefined) { existingComponent.updateToolCall(toolCall); - } else if (toolCall.name !== 'Agent') { + } else if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentSwarm') { this.onToolCallStart(toolCall); } } diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts new file mode 100644 index 000000000..5f774991d --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -0,0 +1,638 @@ +import type { + BackgroundTaskInfo, + Event, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Component } from '@earendil-works/pi-tui'; + +import { + AgentSwarmProgressComponent, + agentSwarmDescriptionFromArgs, + agentSwarmGridHeightForTerminalRows, +} from '../components/messages/agent-swarm-progress'; +import { MAIN_AGENT_ID } from '../constant/kimi-tui'; +import type { + BackgroundAgentMetadata, + ToolCallBlockData, + ToolResultBlockData, + TranscriptEntry, +} from '../types'; +import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; +import { argsRecord, serializeToolResultOutput } from '../utils/event-payload'; +import { formatHookResultPlain } from '../utils/hook-result-format'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { SessionEventHost } from './session-event-handler'; + +export interface SubagentInfo { + readonly parentToolCallId: string; + readonly name: string; + readonly runInBackground: boolean; + readonly swarmIndex?: number; +} + +export type SubagentLifecycleEvent = Event & { type: `subagent.${string}` }; +type SubagentLifecycleEventOf = + SubagentLifecycleEvent & { type: Type }; + +export interface SubAgentEventHandlerDependencies { + readonly backgroundTasks: ReadonlyMap; + readonly backgroundTaskTranscriptedTerminal: Set; + readonly syncBackgroundAgentBadge: () => void; +} + +function renderedRowsAfterChild( + children: readonly Component[], + child: Component, + width: number, +): number { + const childIndex = children.indexOf(child); + if (childIndex < 0) return 0; + return children + .slice(childIndex + 1) + .reduce((sum, component) => sum + component.render(width).length, 0); +} + +export class SubAgentEventHandler { + readonly subagentInfo: Map = new Map(); + private readonly agentSwarmProgress: Map = new Map(); + backgroundAgentMetadata: Map = new Map(); + + constructor( + private readonly host: SessionEventHost, + private readonly deps: SubAgentEventHandlerDependencies, + ) {} + + resetRuntimeState(): void { + this.subagentInfo.clear(); + this.backgroundAgentMetadata.clear(); + this.clearAgentSwarmProgress(); + } + + routeChildAgentEvent(event: Event): boolean { + if (isSubagentLifecycleEvent(event)) return false; + + const childAgentId = event.agentId; + if (childAgentId === MAIN_AGENT_ID) return false; + if (this.host.btwPanelController.routeEvent(event)) return true; + + const info = this.subagentInfo.get(childAgentId); + if (info === undefined || info.parentToolCallId.length === 0) return true; + + const { parentToolCallId } = info; + const swarmProgress = this.agentSwarmProgress.get(parentToolCallId); + if (swarmProgress !== undefined) { + this.applySubagentEventToSwarmProgress(swarmProgress, event, childAgentId); + this.requestRender(); + return true; + } + + const toolCall = this.host.streamingUI.getToolComponent(parentToolCallId); + if (toolCall === undefined) return true; + toolCall.setSubagentMeta(childAgentId, info.name); + + if (event.type === 'hook.result') { + toolCall.appendSubagentText(formatHookResultPlain(event), 'text'); + } else if (event.type === 'assistant.delta') { + toolCall.appendSubagentText(event.delta, 'text'); + } else if (event.type === 'thinking.delta') { + toolCall.appendSubagentText(event.delta, 'thinking'); + } else if (event.type === 'tool.call.started') { + toolCall.appendSubToolCall({ + id: `${childAgentId}:${event.toolCallId}`, + name: event.name, + args: argsRecord(event.args), + }); + } else if (event.type === 'tool.call.delta') { + toolCall.appendSubToolCallDelta({ + id: `${childAgentId}:${event.toolCallId}`, + name: event.name, + argumentsPart: event.argumentsPart ?? null, + }); + } else if (event.type === 'tool.result') { + toolCall.finishSubToolCall({ + tool_call_id: `${childAgentId}:${event.toolCallId}`, + output: serializeToolResultOutput(event.output), + is_error: event.isError, + }); + } else if (event.type === 'agent.status.updated') { + const usageObj = event.usage; + const totalUsage = usageObj?.total ?? usageObj?.currentTurn; + toolCall.updateSubagentMetrics({ + contextTokens: event.contextTokens, + usage: totalUsage, + }); + } + return true; + } + + handleLifecycleEvent(event: SubagentLifecycleEvent): void { + switch (event.type) { + case 'subagent.spawned': + this.handleSubagentSpawned(event); + return; + case 'subagent.started': + this.handleSubagentStarted(event); + return; + case 'subagent.suspended': + this.handleSubagentSuspended(event); + return; + case 'subagent.completed': + this.handleSubagentCompleted(event); + return; + case 'subagent.failed': + this.handleSubagentFailed(event); + return; + } + } + + clearAgentSwarmProgress(): void { + for (const progress of this.agentSwarmProgress.values()) { + progress.dispose(); + } + this.agentSwarmProgress.clear(); + this.host.updateActivityPane(); + } + + hasAgentSwarmProgress(toolCallId: string): boolean { + return this.agentSwarmProgress.has(toolCallId); + } + + hasActiveAgentSwarmToolCall(): boolean { + return Array.from(this.agentSwarmProgress.values()).some((progress) => + progress.isToolCallActive() + ); + } + + syncAgentSwarmActivitySpinner( + spinner: { renderInline(): string } | undefined, + ): void { + for (const progress of this.agentSwarmProgress.values()) { + progress.setActivitySpinnerText( + spinner === undefined ? undefined : () => spinner.renderInline(), + ); + } + } + + handleAgentSwarmToolCallStarted( + toolCallId: string, + args: Record, + ): void { + const progress = this.ensureAgentSwarmProgress(toolCallId, args); + progress.markInputComplete(); + this.requestRender(); + } + + handleAgentSwarmToolCallDelta( + toolCallId: string, + args: Record, + options: { readonly streamingArguments?: string | undefined }, + ): void { + this.ensureAgentSwarmProgress(toolCallId, args, options); + this.requestRender(); + } + + handleAgentSwarmToolResult( + toolCallId: string, + resultData: ToolResultBlockData, + isError: boolean, + ): void { + const progress = this.agentSwarmProgress.get(toolCallId); + if (progress === undefined) return; + + if (isError && isUserCancelledSubagentError(resultData.output)) { + if (progress.isRequestStreaming()) { + this.removeAgentSwarmProgress(toolCallId, progress); + } else { + progress.markToolCallEnded(); + progress.markActiveCancelled(); + } + } else if (isError) { + progress.markToolCallEnded(); + if (!progress.applyResult(resultData.output)) { + progress.markSwarmFailed(resultData.output); + } + } else { + progress.markToolCallEnded(); + progress.applyResult(resultData.output); + } + this.host.updateActivityPane(); + this.requestRender(); + } + + markActiveAgentSwarmsCancelled(): void { + let updated = false; + for (const [toolCallId, progress] of this.agentSwarmProgress) { + if (progress.isRequestStreaming()) { + this.removeAgentSwarmProgress(toolCallId, progress); + updated = true; + continue; + } + progress.markActiveCancelled(); + updated = true; + } + if (updated) this.requestRender(); + } + + private handleSubagentSpawned( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + this.rememberSubagent(event); + + if (event.runInBackground) { + const meta = this.buildBackgroundAgentMetadata(event); + this.backgroundAgentMetadata.set(event.subagentId, meta); + this.appendBackgroundAgentEntry('started', meta); + this.deps.syncBackgroundAgentBadge(); + return; + } + + this.handleForegroundSubagentSpawned(event); + } + + private handleSubagentStarted( + event: SubagentLifecycleEventOf<'subagent.started'>, + ): void { + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined) return; + if (!info.runInBackground) this.handleForegroundSubagentStarted(event, info); + } + + private handleSubagentSuspended( + event: SubagentLifecycleEventOf<'subagent.suspended'>, + ): void { + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined) return; + if (!info.runInBackground) this.handleForegroundSubagentSuspended(event, info); + } + + private handleSubagentCompleted( + event: SubagentLifecycleEventOf<'subagent.completed'>, + ): void { + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + const taskId = this.findAgentTaskId( + event.subagentId, + backgroundMeta, + this.deps.backgroundTasks, + ); + this.backgroundAgentMetadata.delete(event.subagentId); + this.deps.syncBackgroundAgentBadge(); + if (taskId !== undefined && this.deps.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.deps.backgroundTaskTranscriptedTerminal.add(taskId); + } + const extras = + event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; + this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); + return; + } + + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined || info.runInBackground) return; + this.handleForegroundSubagentCompleted(event, info); + } + + private handleSubagentFailed( + event: SubagentLifecycleEventOf<'subagent.failed'>, + ): void { + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + const taskId = this.findAgentTaskId( + event.subagentId, + backgroundMeta, + this.deps.backgroundTasks, + ); + const task = taskId === undefined ? undefined : this.deps.backgroundTasks.get(taskId); + this.backgroundAgentMetadata.delete(event.subagentId); + this.deps.syncBackgroundAgentBadge(); + if (task?.kind === 'agent' && task.status === 'timed_out') { + return; + } + this.host.streamingUI.applyBackgroundTaskTerminalStatus({ + agentId: event.subagentId, + description: backgroundMeta.description ?? '', + status: 'failed', + errorText: event.error, + }); + if (taskId !== undefined && this.deps.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.deps.backgroundTaskTranscriptedTerminal.add(taskId); + } + this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); + return; + } + + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined || info.runInBackground) return; + this.handleForegroundSubagentFailed(event, info); + } + + private findAgentTaskId( + subagentId: string, + meta: BackgroundAgentMetadata, + backgroundTasks: ReadonlyMap, + ): string | undefined { + for (const info of backgroundTasks.values()) { + if (info.kind !== 'agent') continue; + if (info.agentId === subagentId) return info.taskId; + } + const description = meta.description ?? meta.agentName; + if (description === undefined) return undefined; + let match: string | undefined; + for (const info of backgroundTasks.values()) { + if (info.kind !== 'agent') continue; + if (info.description !== description) continue; + if (match !== undefined) return undefined; + match = info.taskId; + } + return match; + } + + private buildBackgroundAgentMetadata( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): BackgroundAgentMetadata { + const parent = this.host.streamingUI.getActiveToolCall(event.parentToolCallId); + const description = parent?.args['description'] ?? event.description; + return { + agentId: event.subagentId, + parentToolCallId: event.parentToolCallId, + agentName: event.subagentName, + description: typeof description === 'string' ? description : undefined, + }; + } + + private appendBackgroundAgentEntry( + phase: 'started' | 'completed' | 'failed', + meta: BackgroundAgentMetadata, + extras: { resultSummary?: string; error?: string } | undefined = undefined, + ): void { + const status = formatBackgroundAgentTranscript(phase, meta, extras); + const entry: TranscriptEntry = { + id: nextTranscriptId(), + kind: 'status', + turnId: this.host.streamingUI.getTurnContext().turnId, + renderMode: 'plain', + content: status.headline, + detail: status.detail, + backgroundAgentStatus: status, + }; + this.host.appendTranscriptEntry(entry); + } + + private rememberSubagent( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + this.subagentInfo.set(event.subagentId, { + parentToolCallId: event.parentToolCallId, + name: event.subagentName, + runInBackground: event.runInBackground, + swarmIndex: event.swarmIndex, + }); + } + + private handleForegroundSubagentSpawned( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + if (this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => { + progress.registerSubagent({ + agentId: event.subagentId, + swarmIndex: event.swarmIndex, + }); + })) { + return; + } + + let tc = this.getOrActivateToolComponent(event.parentToolCallId); + tc ??= this.createStandaloneSubagentToolCall(event); + if (tc === undefined) return; + tc.onSubagentSpawned({ + agentId: event.subagentId, + agentName: event.subagentName, + runInBackground: event.runInBackground, + }); + } + + private handleForegroundSubagentStarted( + event: SubagentLifecycleEventOf<'subagent.started'>, + info: SubagentInfo, + ): void { + if (this.updateAgentSwarmProgress(info.parentToolCallId, (progress) => { + progress.markStarted(event.subagentId); + })) { + return; + } + + const tc = this.getOrActivateToolComponent(info.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentStarted({ + agentId: event.subagentId, + agentName: info.name, + runInBackground: info.runInBackground, + }); + } + + private handleForegroundSubagentSuspended( + event: SubagentLifecycleEventOf<'subagent.suspended'>, + info: SubagentInfo, + ): void { + this.updateAgentSwarmProgress(info.parentToolCallId, (progress) => { + progress.markSuspended({ + agentId: event.subagentId, + reason: event.reason, + swarmIndex: info.swarmIndex, + }); + }); + } + + private handleForegroundSubagentCompleted( + event: SubagentLifecycleEventOf<'subagent.completed'>, + info: SubagentInfo, + ): void { + const { parentToolCallId } = info; + if (this.updateAgentSwarmProgress(parentToolCallId, (progress) => { + progress.markCompleted(event.subagentId, event.resultSummary); + })) { + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(parentToolCallId); + if (tc === undefined) return; + tc.onSubagentCompleted({ + contextTokens: event.contextTokens, + usage: event.usage, + resultSummary: event.resultSummary, + }); + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + } + + private handleForegroundSubagentFailed( + event: SubagentLifecycleEventOf<'subagent.failed'>, + info: SubagentInfo, + ): void { + const { parentToolCallId } = info; + if (this.updateAgentSwarmProgress(parentToolCallId, (progress) => { + this.markAgentSwarmFailedOrCancelled(progress, event.subagentId, event.error); + })) { + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(parentToolCallId); + if (tc === undefined) return; + tc.onSubagentFailed({ error: event.error }); + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + } + + private applySubagentEventToSwarmProgress( + progress: AgentSwarmProgressComponent, + event: Event, + subagentId: string, + ): void { + if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { + progress.appendModelDelta({ agentId: subagentId, delta: event.delta }); + } else if (event.type === 'tool.call.started') { + progress.recordToolCall({ agentId: subagentId, toolCallId: event.toolCallId }); + } + } + + private updateAgentSwarmProgress( + parentToolCallId: string, + update: (progress: AgentSwarmProgressComponent) => void, + ): boolean { + const progress = this.agentSwarmProgress.get(parentToolCallId); + if (progress === undefined) return false; + update(progress); + this.requestRender(); + return true; + } + + private ensureAgentSwarmProgress( + toolCallId: string, + args: Record, + options: { readonly streamingArguments?: string | undefined } = {}, + ): AgentSwarmProgressComponent { + const existing = this.agentSwarmProgress.get(toolCallId); + if (existing !== undefined) { + existing.updateArgs(args, options); + return existing; + } + + const progress = new AgentSwarmProgressComponent({ + description: agentSwarmDescriptionFromArgs(args), + colors: this.host.state.theme.colors, + availableGridHeight: () => this.agentSwarmGridHeight(), + requestRender: () => { + this.requestRender(); + }, + }); + progress.updateArgs(args, options); + this.agentSwarmProgress.set(toolCallId, progress); + this.host.streamingUI.finalizeLiveTextBuffers('tool'); + this.host.state.transcriptContainer.addChild(progress); + this.host.updateActivityPane(); + this.requestRender(); + return progress; + } + + private removeAgentSwarmProgress( + toolCallId: string, + progress: AgentSwarmProgressComponent, + ): void { + this.agentSwarmProgress.delete(toolCallId); + progress.dispose(); + const children = this.host.state.transcriptContainer.children; + const index = children.indexOf(progress); + if (index >= 0) { + children.splice(index, 1); + this.host.state.transcriptContainer.invalidate(); + } + this.host.updateActivityPane(); + } + + private agentSwarmGridHeight(): number | undefined { + const { state } = this.host; + const terminalRows = state.ui.terminal.rows; + const terminalColumns = state.ui.terminal.columns; + if (!Number.isFinite(terminalColumns) || terminalColumns <= 0) { + return agentSwarmGridHeightForTerminalRows(terminalRows); + } + + const width = Math.floor(terminalColumns); + const rowsAfterSwarm = renderedRowsAfterChild( + state.ui.children, + state.transcriptContainer, + width, + ); + return agentSwarmGridHeightForTerminalRows(terminalRows, rowsAfterSwarm); + } + + private markAgentSwarmFailedOrCancelled( + progress: AgentSwarmProgressComponent, + subagentId: string, + error: string, + ): void { + if (isUserCancelledSubagentError(error)) { + progress.markCancelled(subagentId); + } else { + progress.markFailed(subagentId, error); + } + } + + private getOrActivateToolComponent(parentToolCallId: string) { + let component = this.host.streamingUI.getToolComponent(parentToolCallId); + if (component !== undefined) return component; + const toolCall = this.host.streamingUI.getActiveToolCall(parentToolCallId); + if (toolCall === undefined) return undefined; + this.host.streamingUI.onToolCallStart(toolCall); + return this.host.streamingUI.getToolComponent(parentToolCallId); + } + + private createStandaloneSubagentToolCall( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ) { + const description = event.description ?? `Run ${event.subagentName} agent`; + const { turnId, step } = this.host.streamingUI.getTurnContext(); + const toolCall: ToolCallBlockData = { + id: event.parentToolCallId, + name: 'Agent', + args: { + description, + subagent_type: event.subagentName, + }, + description, + step, + turnId, + }; + this.host.streamingUI.onToolCallStart(toolCall); + return this.host.streamingUI.getToolComponent(event.parentToolCallId); + } + + private requestRender(): void { + this.host.state.ui.requestRender(); + } +} + +function isSubagentLifecycleEvent(event: Event): event is SubagentLifecycleEvent { + return ( + event.type === 'subagent.spawned' || + event.type === 'subagent.started' || + event.type === 'subagent.suspended' || + event.type === 'subagent.completed' || + event.type === 'subagent.failed' + ); +} + +function isUserCancelledSubagentError(error: string): boolean { + // Structured AgentSwarm results use outcome="aborted" and are parsed separately. + switch (error.trim()) { + case 'Aborted by the user': + case 'The user manually interrupted this subagent batch.': + return true; + default: + return false; + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b1b5f7643..991b09a49 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -162,6 +162,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, + swarmMode: false, thinking: false, contextUsage: 0, contextTokens: 0, @@ -1036,6 +1037,7 @@ export class KimiTUI { thinking: status.thinkingLevel !== 'off', permissionMode: status.permission, planMode: status.planMode, + swarmMode: status.swarmMode ?? false, contextTokens: status.contextTokens, maxContextTokens: status.maxContextTokens, contextUsage: status.contextUsage, @@ -1066,6 +1068,7 @@ export class KimiTUI { this.approvalController.cancelAll(reason); this.questionController.cancelAll(reason); this.session = undefined; + this.state.swarmModeEntry = undefined; this.harness.setTelemetryContext({ sessionId: null }); this.setAppState({ goal: null }); return previous; @@ -1112,6 +1115,7 @@ export class KimiTUI { this.aborted = false; this.streamingUI.discardPending(); this.state.queuedMessages = []; + this.state.swarmModeEntry = undefined; this.harness.interactiveAgentId = MAIN_AGENT_ID; this.streamingUI.resetToolCallState(); this.streamingUI.resetToolUi(); @@ -1468,24 +1472,32 @@ export class KimiTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); + const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); + const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}`; if ( - effectiveMode === this.lastActivityMode && + activityModeKey === this.lastActivityMode && (effectiveMode === 'waiting' || effectiveMode === 'thinking' || effectiveMode === 'tool') ) { + if (placeSpinnerInAgentSwarm) { + this.syncAgentSwarmActivitySpinner(this.state.activitySpinner?.instance); + } return; } - this.lastActivityMode = effectiveMode; + this.lastActivityMode = activityModeKey; this.state.activityContainer.clear(); switch (effectiveMode) { case 'hidden': this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); this.state.ui.requestRender(); return; case 'waiting': { const spinner = this.ensureActivitySpinner('moon'); + this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); + if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'waiting', @@ -1496,12 +1508,14 @@ export class KimiTUI { } case 'thinking': { this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); break; } case 'composing': { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => chalk.hex(this.state.theme.colors.primary)(s), ); + this.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'composing', @@ -1512,6 +1526,8 @@ export class KimiTUI { } case 'tool': { const spinner = this.ensureActivitySpinner('moon'); + this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); + if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'tool', @@ -1523,6 +1539,7 @@ export class KimiTUI { case 'idle': case 'session': { this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); break; } } @@ -1636,6 +1653,17 @@ export class KimiTUI { ); } + private shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode: EffectiveActivityPaneMode): boolean { + return ( + this.sessionEventHandler.hasActiveAgentSwarmToolCall() && + (effectiveMode === 'waiting' || effectiveMode === 'tool') + ); + } + + private syncAgentSwarmActivitySpinner(spinner: MoonLoader | undefined): void { + this.sessionEventHandler.syncAgentSwarmActivitySpinner(spinner); + } + private syncTerminalProgress(active: boolean): void { if (this.state.terminalState.progressActive === active) return; this.state.terminal.setProgress(active); diff --git a/apps/kimi-code/src/tui/theme/gradient-text.ts b/apps/kimi-code/src/tui/theme/gradient-text.ts new file mode 100644 index 000000000..f0d12d9d9 --- /dev/null +++ b/apps/kimi-code/src/tui/theme/gradient-text.ts @@ -0,0 +1,39 @@ +import chalk from 'chalk'; + +interface RgbColor { + readonly red: number; + readonly green: number; + readonly blue: number; +} + +export function gradientText(text: string, fromHex: string, toHex: string, accentBias = 1): string { + const chars = Array.from(text); + const from = parseHexColor(fromHex); + const to = parseHexColor(toHex); + if (chars.length <= 1 || from === undefined || to === undefined) { + return chalk.hex(fromHex).bold(text); + } + const safeAccentBias = Number.isFinite(accentBias) ? Math.max(0, accentBias) : 1; + return chars.map((char, index) => { + const ratio = Math.min(1, (index / (chars.length - 1)) * safeAccentBias); + return chalk.hex(interpolateHexColor(from, to, ratio)).bold(char); + }).join(''); +} + +function parseHexColor(hex: string): RgbColor | undefined { + const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); + if (match === null) return undefined; + return { + red: Number.parseInt(match[1]!, 16), + green: Number.parseInt(match[2]!, 16), + blue: Number.parseInt(match[3]!, 16), + }; +} + +function interpolateHexColor(from: RgbColor, to: RgbColor, ratio: number): string { + const mix = (start: number, end: number): string => + Math.round(start + (end - start) * ratio) + .toString(16) + .padStart(2, '0'); + return `#${mix(from.red, to.red)}${mix(from.green, to.green)}${mix(from.blue, to.blue)}`; +} diff --git a/apps/kimi-code/src/tui/theme/index.ts b/apps/kimi-code/src/tui/theme/index.ts index a9a143633..5cd97384f 100644 --- a/apps/kimi-code/src/tui/theme/index.ts +++ b/apps/kimi-code/src/tui/theme/index.ts @@ -9,6 +9,7 @@ export { darkColors, lightColors, getColorPalette } from './colors'; export type { ColorPalette, ResolvedTheme } from './colors'; export { createThemeStyles } from './styles'; export type { ThemeStyles } from './styles'; +export { gradientText } from './gradient-text'; export { createMarkdownTheme, createEditorTheme } from './pi-tui-theme'; export { detectTerminalTheme } from './detect'; diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 41c714639..bc1c3214b 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -51,6 +51,7 @@ export interface TUIState { tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; queuedMessages: QueuedMessage[]; + swarmModeEntry: 'manual' | 'task' | undefined; } export function createTUIState(options: KimiTUIOptions): TUIState { @@ -99,5 +100,6 @@ export function createTUIState(options: KimiTUIOptions): TUIState { tasksBrowser: undefined, externalEditorRunning: false, queuedMessages: [], + swarmModeEntry: undefined, }; } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index c88216538..350b62d52 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -18,6 +18,7 @@ export interface AppState { sessionId: string; permissionMode: PermissionMode; planMode: boolean; + swarmMode: boolean; thinking: boolean; contextUsage: number; contextTokens: number; diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 5d2657957..b1478697c 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -56,6 +56,7 @@ export function appStateFromResumeAgent(agent: ResumedAgentState): Partial[0]); + handler.handleLifecycleEvent({ + type: 'subagent.started', + subagentId: 'agent-1', + } as Parameters[0]); + + const progress = state.transcriptContainer.children.find( + (child): child is AgentSwarmProgressComponent => child instanceof AgentSwarmProgressComponent, + ); + if (progress === undefined) throw new Error('expected AgentSwarm progress'); + return progress; +} + describe('updateActivityPane terminal progress', () => { it('toggles terminal progress when the activity pane enters and leaves work mode', () => { vi.useFakeTimers(); @@ -111,4 +144,57 @@ describe('updateActivityPane terminal progress', () => { vi.useRealTimers(); } }); + + it('moves the moon spinner into the AgentSwarm progress row while active', () => { + vi.useFakeTimers(); + try { + const { driver, state, setProgress } = makeDriverWithTerminalProgress(); + const progress = startSwarmProgress(driver, state); + state.livePane = { ...state.livePane, mode: 'tool' }; + + driver.updateActivityPane(); + + expect(setProgress).toHaveBeenCalledTimes(1); + expect(setProgress).toHaveBeenLastCalledWith(true); + expect(state.activitySpinner).not.toBeNull(); + expect(state.activityContainer.children).toHaveLength(0); + expect(strip(progress.render(80).join('\n'))).toContain('🌑 Working...'); + + state.activitySpinner?.instance.stop(); + driver.sessionEventHandler.clearAgentSwarmProgress(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps ended AgentSwarm progress on a placeholder instead of the moon spinner', () => { + vi.useFakeTimers(); + try { + const { driver, state } = makeDriverWithTerminalProgress(); + const progress = startSwarmProgress(driver, state); + driver.sessionEventHandler.subAgentEventHandler.handleAgentSwarmToolResult( + 'call_swarm', + { + tool_call_id: 'call_swarm', + output: 'Done', + is_error: false, + }, + false, + ); + state.livePane = { ...state.livePane, mode: 'tool' }; + + driver.updateActivityPane(); + + expect(state.activitySpinner).not.toBeNull(); + expect(state.activityContainer.children).toHaveLength(1); + const output = strip(progress.render(80).join('\n')); + expect(output).toContain(' Working...'); + expect(output).not.toContain('🌑 Working...'); + + state.activitySpinner?.instance.stop(); + driver.sessionEventHandler.clearAgentSwarmProgress(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 3b8f4946e..510206edf 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -4,6 +4,7 @@ import { parseSlashInput, resolveSlashCommandAvailability, sortSlashCommands, + swarmArgumentCompletions, type KimiSlashCommand, } from '#/tui/commands/index'; import { describe, expect, it } from 'vitest'; @@ -47,6 +48,31 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(plan!, 'clear')).toBe('idle-only'); }); + it('keeps swarm mode changes and swarm tasks idle-only', () => { + const swarm = findBuiltInSlashCommand('swarm'); + expect(swarm).toBeDefined(); + expect((swarm as KimiSlashCommand).experimentalFlag).toBe('agent_swarm'); + expect(resolveSlashCommandAvailability(swarm!, 'on')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(swarm!, 'off')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(swarm!, 'Ship feature X')).toBe('idle-only'); + }); + + it('offers swarm subcommand argument completions', () => { + const values = (prefix: string): string[] | null => { + const items = swarmArgumentCompletions(prefix); + return items === null ? null : items.map((item) => item.value); + }; + + expect(values('')).toEqual(['on', 'off']); + expect(values('O')).toEqual(['on', 'off']); + expect(swarmArgumentCompletions('of')).toEqual([ + { value: 'off', label: 'off', description: 'Turn swarm mode off' }, + ]); + expect(values('on')).toBeNull(); + expect(values('off')).toBeNull(); + expect(values('Ship feature X')).toBeNull(); + }); + it('defaults commands without explicit availability to idle-only', () => { const command: KimiSlashCommand = { name: 'example', diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index b98619773..b1a05bfe9 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -21,6 +21,10 @@ function resolve( } describe('resolveSlashCommandInput', () => { + afterEach(() => { + setExperimentalFeatures([]); + }); + it('returns not-command for normal text', () => { expect(resolve('hello')).toEqual({ kind: 'not-command' }); }); @@ -54,6 +58,7 @@ describe('resolveSlashCommandInput', () => { }); it('blocks idle-only built-ins while streaming', () => { + setExperimentalFeatures([{ id: 'agent_swarm', enabled: true }]); expect(resolve('/new', { isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'new', @@ -89,9 +94,20 @@ describe('resolveSlashCommandInput', () => { commandName: 'experiments', reason: 'streaming', }); + expect(resolve('/swarm on', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'swarm', + reason: 'streaming', + }); + expect(resolve('/swarm off', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'swarm', + reason: 'streaming', + }); }); it('blocks model and session pickers while compacting', () => { + setExperimentalFeatures([{ id: 'agent_swarm', enabled: true }]); expect(resolve('/sessions', { isCompacting: true })).toEqual({ kind: 'blocked', commandName: 'sessions', @@ -112,6 +128,16 @@ describe('resolveSlashCommandInput', () => { commandName: 'experiments', reason: 'compacting', }); + expect(resolve('/swarm on', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'swarm', + reason: 'compacting', + }); + expect(resolve('/swarm off', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'swarm', + reason: 'compacting', + }); }); it('allows always-available built-ins while streaming', () => { @@ -194,6 +220,22 @@ describe('resolveSlashCommandInput', () => { }); }); + it('treats /swarm as a normal message when agent_swarm is disabled', () => { + expect(resolve('/swarm on')).toEqual({ + kind: 'message', + input: '/swarm on', + }); + }); + + it('resolves /swarm when agent_swarm is enabled', () => { + setExperimentalFeatures([{ id: 'agent_swarm', enabled: true }]); + expect(resolve('/swarm Ship feature X')).toMatchObject({ + kind: 'builtin', + name: 'swarm', + args: 'Ship feature X', + }); + }); + }); describe('goal command resolution', () => { 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 000000000..3c9418989 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleSwarmCommand } from '#/tui/commands/index'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { getColorPalette } from '#/tui/theme/colors'; + +const ENTER = '\r'; +const ESCAPE = '\u001B'; +const DOWN = '\u001B[B'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +interface TestComponent { + render(width: number): string[]; +} + +function makeHost( + overrides: { + model?: string; + hasSession?: boolean; + permissionMode?: 'manual' | 'auto' | 'yolo'; + swarmMode?: boolean; + } = {}, +) { + const session = { + setPermission: vi.fn(async () => {}), + setSwarmMode: vi.fn(async () => {}), + }; + const hasSession = overrides.hasSession ?? true; + const host = { + state: { + appState: { + model: overrides.model ?? 'kimi-model', + permissionMode: overrides.permissionMode ?? 'auto', + swarmMode: overrides.swarmMode ?? false, + }, + theme: { colors: getColorPalette('dark') }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: hasSession ? session : undefined, + requireSession: () => session, + setAppState: vi.fn((patch: Record) => Object.assign(host.state.appState, patch)), + showError: vi.fn(), + showStatus: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + sendNormalUserInput: vi.fn(), + } as unknown as SlashCommandHost; + return { host, session }; +} + +interface TestPicker { + handleInput(data: string): void; + render(width: number): string[]; +} + +function mountedPicker(host: SlashCommandHost): TestPicker { + const mock = host.mountEditorReplacement as ReturnType; + return mock.mock.calls[0]?.[0] as TestPicker; +} + +function markerAddChild(host: SlashCommandHost): ReturnType { + return host.state.transcriptContainer.addChild as ReturnType; +} + +function expectSwarmMarker(host: SlashCommandHost, text: string): void { + const components = markerAddChild(host).mock.calls.map(([component]) => component as TestComponent); + const rendered = stripAnsi(components.at(-1)?.render(80).join('\n') ?? ''); + expect(rendered).toContain(text); +} + +describe('handleSwarmCommand', () => { + it('sends the swarm prompt as a normal prompt after enabling swarm mode', async () => { + const { host, session } = makeHost({ permissionMode: 'auto' }); + + await handleSwarmCommand(host, 'Ship feature X'); + + expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(host.state.swarmModeEntry).toBe('task'); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('sends the swarm prompt without re-entering swarm mode when already on', async () => { + const { host, session } = makeHost({ permissionMode: 'auto', swarmMode: true }); + + await handleSwarmCommand(host, 'Ship feature X'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(host.state.swarmModeEntry).toBeUndefined(); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('turns swarm mode on without sending a prompt', async () => { + const { host, session } = makeHost({ model: '' }); + + await handleSwarmCommand(host, 'on'); + + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('manual'); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('asks before turning swarm mode on in Manual mode', async () => { + const { host, session } = makeHost({ model: '', permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'on'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(host).render(80).join('\n')); + expect(text).toContain('Manual mode can block swarm work'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('manual'); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns swarm mode on when called without args while swarm mode is off', async () => { + const { host, session } = makeHost({ model: '', swarmMode: false }); + + await handleSwarmCommand(host, ''); + + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('manual'); + expectSwarmMarker(host, 'Swarm activated'); + expect(host.showError).not.toHaveBeenCalled(); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not call the session when swarm mode is already on', async () => { + const { host, session } = makeHost({ model: '', swarmMode: true }); + + await handleSwarmCommand(host, 'on'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalledWith({ swarmMode: true }); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Swarm mode is already on.'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns swarm mode off without sending a prompt', async () => { + const { host, session } = makeHost({ model: '', swarmMode: true }); + + await handleSwarmCommand(host, 'off'); + + expect(session.setSwarmMode).toHaveBeenCalledWith(false, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); + expect(host.state.swarmModeEntry).toBeUndefined(); + expectSwarmMarker(host, 'Swarm deactivated'); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns swarm mode off when called without args while swarm mode is on', async () => { + const { host, session } = makeHost({ model: '', swarmMode: true }); + + await handleSwarmCommand(host, ''); + + expect(session.setSwarmMode).toHaveBeenCalledWith(false, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); + expect(host.state.swarmModeEntry).toBeUndefined(); + expectSwarmMarker(host, 'Swarm deactivated'); + expect(host.showError).not.toHaveBeenCalled(); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not call the session when swarm mode is already off', async () => { + const { host, session } = makeHost({ model: '', swarmMode: false }); + + await handleSwarmCommand(host, 'off'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalledWith({ swarmMode: false }); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Swarm mode is already off.'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('asks before starting a swarm task in Manual mode', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(host).render(80).join('\n')); + expect(text).toContain('Manual mode can block swarm work'); + expect(text).not.toContain('Switch to YOLO and start'); + expect(text).not.toContain('Do not start'); + }); + + it('defaults to Auto when confirming a Manual-mode swarm start', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.state.swarmModeEntry).toBe('task'); + expectSwarmMarker(host, 'Swarm activated'); + }); + + it('can start a Manual-mode swarm task without changing permission', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + const picker = mountedPicker(host); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); + expect(host.state.swarmModeEntry).toBe('task'); + expectSwarmMarker(host, 'Swarm activated'); + }); + + it('returns the command to the input box when a Manual-mode swarm start is cancelled', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ESCAPE); + + expect(host.restoreInputText).toHaveBeenCalledWith('/swarm Ship feature X'); + expect(host.showStatus).toHaveBeenCalledWith('Swarm task not started.'); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not start when permission update fails', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + session.setPermission.mockRejectedValueOnce(new Error('denied')); + + await handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to set permission mode'), + ); + }); + expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not send from Manual mode when enabling swarm mode fails after confirmation', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + session.setSwarmMode.mockRejectedValueOnce(new Error('denied')); + + await handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to enable swarm mode'), + ); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not send a prompt when enabling swarm mode fails', async () => { + const { host, session } = makeHost({ permissionMode: 'auto' }); + session.setSwarmMode.mockRejectedValueOnce(new Error('denied')); + + await handleSwarmCommand(host, 'Ship feature X'); + + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to enable swarm mode'), + ); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index a9f6eadde..e5d56b0ed 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -47,6 +47,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + swarmMode: false, theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 163b3b53b..530f9281e 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -24,6 +24,7 @@ const appState: AppState = { streamingPhase: 'idle', streamingStartTime: 0, planMode: false, + swarmMode: false, theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts new file mode 100644 index 000000000..96ffed025 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -0,0 +1,1310 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { + AgentSwarmProgressComponent, + agentSwarmDescriptionFromArgs, + agentSwarmGridHeightForTerminalRows, + agentSwarmItemsFromArgs, + agentSwarmPartialItemsCountFromArguments, + agentSwarmPartialItemsFromArguments, + calculateAgentSwarmGridLayout, +} from '#/tui/components/messages/agent-swarm-progress'; +import { AgentSwarmProgressEstimator } from '#/tui/components/messages/agent-swarm-progress-estimator'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function withAnsiColor(run: () => T): T { + const previousChalkLevel = chalk.level; + chalk.level = 3; + try { + return run(); + } finally { + chalk.level = previousChalkLevel; + } +} + +function darkenHexColor(hex: string, factor: number): string { + const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); + if (match === null) return hex; + const darken = (channel: string): string => + Math.max(0, Math.min(255, Math.round(Number.parseInt(channel, 16) * factor))) + .toString(16) + .padStart(2, '0'); + return `#${darken(match[1]!)}${darken(match[2]!)}${darken(match[3]!)}`; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('calculateAgentSwarmGridLayout', () => { + it('keeps text when the text grid fits the available height', () => { + expect(calculateAgentSwarmGridLayout({ + width: 100, + height: 3, + count: 9, + })).toEqual({ + renderText: true, + barCells: 8, + columns: 3, + rows: 3, + cellWidth: 32, + columnGap: 2, + leftPadding: 0, + }); + }); + + it('drops text and recomputes columns when compact bars fit', () => { + expect(calculateAgentSwarmGridLayout({ + width: 100, + height: 5, + count: 30, + })).toEqual({ + renderText: false, + barCells: 8, + columns: 6, + rows: 5, + cellWidth: 15, + columnGap: 2, + leftPadding: 0, + }); + }); + + it('keeps text by adding columns when the minimum text cell width still fits', () => { + expect(calculateAgentSwarmGridLayout({ + width: 120, + height: 4, + count: 20, + })).toEqual({ + renderText: true, + barCells: 6, + columns: 5, + rows: 4, + cellWidth: 22, + columnGap: 2, + leftPadding: 0, + }); + }); + + it('drops text when the target text columns would make bars narrower than six cells', () => { + expect(calculateAgentSwarmGridLayout({ + width: 117, + height: 4, + count: 20, + })).toEqual({ + renderText: false, + barCells: 14, + columns: 5, + rows: 4, + cellWidth: 21, + columnGap: 2, + leftPadding: 0, + }); + }); + + it('compresses compact bar cells only as much as needed to keep the target row count', () => { + expect(calculateAgentSwarmGridLayout({ + width: 100, + height: 4, + count: 40, + })).toEqual({ + renderText: false, + barCells: 1, + columns: 10, + rows: 4, + cellWidth: 8, + columnGap: 2, + leftPadding: 0, + }); + }); + + it('keeps at least one bar cell when no rows are available', () => { + expect(calculateAgentSwarmGridLayout({ + width: 20, + height: 0, + count: 4, + })).toEqual({ + renderText: false, + barCells: 2, + columns: 2, + rows: 2, + cellWidth: 9, + columnGap: 2, + leftPadding: 0, + }); + }); + + it('keeps compact gaps fixed and uses remaining width for equal bars', () => { + const layout = calculateAgentSwarmGridLayout({ + width: 107, + height: 5, + count: 30, + }); + const usedWidth = + layout.leftPadding + + layout.columns * layout.cellWidth + + Math.max(0, layout.columns - 1) * layout.columnGap; + const rightPadding = 107 - usedWidth; + + expect(layout.renderText).toBe(false); + expect(layout.barCells).toBe(9); + expect(layout.cellWidth).toBe(16); + expect(layout.columnGap).toBe(2); + expect(layout.leftPadding).toBe(0); + expect(rightPadding).toBe(1); + }); + + it('derives the grid height left inside the AgentSwarm block', () => { + expect(agentSwarmGridHeightForTerminalRows(undefined)).toBeUndefined(); + expect(agentSwarmGridHeightForTerminalRows(10)).toBe(4); + expect(agentSwarmGridHeightForTerminalRows(20, 5)).toBe(9); + expect(agentSwarmGridHeightForTerminalRows(4)).toBe(0); + }); +}); + +describe('AgentSwarmProgressComponent', () => { + it('renders an orchestrating panel before subagents spawn', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('Agent Swarm'); + expect(output).toContain('Review changed files'); + expect(output).toContain('Orchestrating...'); + expect(output).not.toContain('01'); + }); + + it('renders a trailing blank line without a bottom divider', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + const lines = strip(component.render(100).join('\n')).split('\n'); + + expect(lines.at(-1)).toBe(' '); + expect(lines.at(-2)).toContain('Orchestrating...'); + expect(lines.at(-2)).not.toMatch(/^─+$/); + }); + + it('reserves one blank column on the right edge', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markInputComplete(); + component.markStarted('agent-1'); + + const rendered = component.render(80).map(strip); + const statusLine = rendered.find((line) => line.includes('Working...')); + const gridLine = rendered.find((line) => line.includes('001 [')); + + expect(rendered.every((line) => visibleWidth(line) <= 79)).toBe(true); + expect(rendered.some((line) => line.includes('Agent Swarm'))).toBe(true); + expect(statusLine).toBeDefined(); + expect(statusLine?.match(/ *$/)?.[0].length).toBe(0); + expect(gridLine).toBeDefined(); + expect(visibleWidth(gridLine ?? '')).toBeLessThanOrEqual(79); + }); + + it('renders orchestrating and prompting labels in primary blue', () => { + withAnsiColor(() => { + const orchestrating = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + const orchestratingLine = orchestrating.render(100).join('\n') + .split('\n') + .find((line) => line.includes('Orchestrating...')); + expect(orchestratingLine).toContain(chalk.hex(darkColors.primary)('Orchestrating...')); + + const prompting = new AgentSwarmProgressComponent({ + description: '', + colors: darkColors, + }); + prompting.updateArgs({}, { + streamingArguments: '{"prompt_template":"Review every changed TypeScript file', + }); + + const promptingLine = prompting.render(100).join('\n') + .split('\n') + .find((line) => line.includes('Prompting...')); + expect(promptingLine).toContain(chalk.hex(darkColors.primary)('Prompting...')); + }); + }); + + it('renders spawned subagents as queued rows without empty progress bars', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.registerSubagent({ agentId: 'agent-2', description: 'Review changed files #2 (coder)' }); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('001 Queued...'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002 ['); + expect(output).not.toContain('agents=2'); + }); + + it('renders agent ids in primary blue', () => { + withAnsiColor(() => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + const queuedLine = component.render(80).join('\n') + .split('\n') + .find((line) => strip(line).startsWith(' 001 Queued...')); + expect(queuedLine).toContain(chalk.hex(darkColors.primary)('001')); + + component.markInputComplete(); + component.markStarted('agent-1'); + const activeLine = component.render(80).join('\n') + .split('\n') + .find((line) => strip(line).startsWith(' 001 [')); + expect(activeLine).toContain(chalk.hex(darkColors.primary)('001')); + }); + }); + + it('renders a blank line above the AgentSwarm header', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + + const lines = strip(component.render(100).join('\n')).split('\n'); + + expect(lines[0]).toBe(' '); + expect(lines[1]).toContain('Agent Swarm'); + }); + + it('fits three queued columns with the narrower gap and minimum cell width', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.registerSubagent({ agentId: 'agent-2', description: 'Review changed files #2 (coder)' }); + component.registerSubagent({ agentId: 'agent-3', description: 'Review changed files #3 (coder)' }); + + const lines = strip(component.render(97).join('\n')).split('\n'); + const queuedLine = lines.find((line) => line.includes('001 Queued...')); + + expect(queuedLine).toBeDefined(); + expect(queuedLine).toContain('002 Queued...'); + expect(queuedLine).toContain('003 Queued...'); + }); + + it('omits subagent text when the compact grid is needed to fit available height', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + availableGridHeight: () => 5, + }); + + for (let index = 1; index <= 30; index += 1) { + component.registerSubagent({ + agentId: `agent-${String(index)}`, + description: `Review changed files #${String(index)} (coder)`, + }); + } + component.markInputComplete(); + for (let index = 1; index <= 30; index += 1) { + component.markStarted(`agent-${String(index)}`); + } + + const lines = strip(component.render(102).join('\n')).split('\n'); + const gridLines = lines.filter((line) => /\b\d{3} \[/.test(line)); + + expect(gridLines).toHaveLength(5); + expect(gridLines[0]).toContain('001 ['); + expect(gridLines[0]).toContain('006 ['); + expect(gridLines.join('\n')).not.toContain('Running'); + }); + + it('keeps streamed pending items as text even when compact layout is selected', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + availableGridHeight: () => 5, + }); + + component.updateArgs({ + items: Array.from({ length: 30 }, (_item, index) => `f${String(index + 1)}.ts`), + }); + + const output = strip(component.render(102).join('\n')); + + expect(output).toContain('001 f1.ts'); + expect(output).toContain('006 f6.ts'); + expect(output).not.toContain('001 ['); + }); + + it('prefixes a cancelled running subagent label with the aborted mark without changing the text', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markInputComplete(); + component.markStarted('agent-1'); + component.appendModelDelta({ agentId: 'agent-1', delta: 'Inspecting src/a.ts' }); + component.markCancelled('agent-1'); + + const output = strip(component.render(100).join('\n')); + const cellLine = output.split('\n').find((line) => line.includes('001 [')); + + expect(cellLine).toBeDefined(); + expect(cellLine).toContain('⊘ Inspecting src/a.ts'); + expect(cellLine).not.toContain('⊘ Aborted.'); + }); + + it('shows a dark yellow cancelled label without a progress bar for queued subagents', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markInputComplete(); + component.markCancelled('agent-1'); + + const output = strip(component.render(100).join('\n')); + const cellLine = output.split('\n').find((line) => line.includes('001 ')); + + expect(cellLine).toBeDefined(); + expect(cellLine).toContain('⊘ Cancelled.'); + expect(cellLine).not.toContain('['); + expect(cellLine).not.toContain('⊘ Aborted.'); + }); + + it('renders terminal marks against compact bars when subagent text is hidden', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + availableGridHeight: () => 5, + }); + + for (let index = 1; index <= 30; index += 1) { + component.registerSubagent({ + agentId: `agent-${String(index)}`, + description: `Review changed files #${String(index)} (coder)`, + }); + } + component.markInputComplete(); + for (let index = 1; index <= 30; index += 1) { + component.markStarted(`agent-${String(index)}`); + } + component.markCompleted('agent-1'); + component.markFailed('agent-2', 'Agent timed out'); + component.markCancelled('agent-3'); + + const lines = strip(component.render(102).join('\n')).split('\n'); + const gridLine = lines.find((line) => line.includes('001 [')); + + expect(gridLine).toBeDefined(); + expect(gridLine).toMatch(/001 \[[^\]]+\]✓ +002 \[[^\]]+\]✗ +003 \[[^\]]+\]⊘/); + expect(gridLine).not.toContain('Completed'); + expect(gridLine).not.toContain('Failed'); + expect(gridLine).not.toContain('Aborted'); + }); + + it('advances from queued when a subagent tool call starts and marks terminal states', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.registerSubagent({ agentId: 'agent-2', description: 'Review changed files #2 (coder)' }); + component.recordToolCall({ agentId: 'agent-1', toolCallId: 'call-read' }); + + let output = strip(component.render(100).join('\n')); + expect(output).toContain('001 ['); + expect(output).toContain('Running'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('002 ['); + + component.markCompleted('agent-1'); + component.markFailed('agent-2'); + + output = strip(component.render(100).join('\n')); + expect(output).toContain('001 ['); + expect(output).toContain('✓'); + expect(output).toContain('Completed.'); + expect(output).toContain('002 ['); + expect(output).toContain('Failed'); + }); + + it('renders completed subagent output with a success mark', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markCompleted('agent-1', 'Reviewed imports and found no regressions'); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('✓ Reviewed imports and found no regressions'); + expect(output).toContain('Completed.'); + }); + + it('renders failure details from live subagent failures', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markFailed('agent-1', 'Provider request failed\nRetry budget exhausted'); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('✗ Provider request failed Retry budget exhausted'); + expect(output).not.toContain('Failed:'); + }); + + it('renders suspended subagents as rate limited and clears the state when they start again', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markStarted('agent-1'); + component.markSuspended({ + agentId: 'agent-1', + reason: 'Provider rate limit; subagent requeued for retry.', + }); + + let output = strip(component.render(100).join('\n')); + expect(output).toContain('Rate limited...'); + expect(output).not.toContain('Queued...'); + expect(output).not.toContain('Provider rate limit'); + expect(output).not.toContain('Failed'); + withAnsiColor(() => { + const rawLine = component.render(100).join('\n') + .split('\n') + .find((line) => strip(line).includes('001 [')); + expect(rawLine).toContain(chalk.hex(darkColors.textDim)('Rate limited...')); + }); + + component.markStarted('agent-1'); + + output = strip(component.render(100).join('\n')); + expect(output).toContain('Running'); + expect(output).not.toContain('Rate limited...'); + }); + + it('renders rate-limited subagents as dark yellow cancelled when cancelled', () => { + withAnsiColor(() => { + const cancelledTextColor = darkenHexColor(darkColors.warning, 0.72); + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markStarted('agent-1'); + component.markSuspended({ + agentId: 'agent-1', + reason: 'Provider rate limit; subagent requeued for retry.', + }); + component.markCancelled('agent-1'); + + const rawLine = component.render(100).join('\n') + .split('\n') + .find((line) => strip(line).includes('001 [')); + expect(rawLine).toBeDefined(); + expect(strip(rawLine ?? '')).toContain('⊘ Cancelled.'); + expect(strip(rawLine ?? '')).not.toContain('Rate limited...'); + expect(rawLine).toContain(chalk.hex(darkColors.warning)('⣀⣀⣀⣀⣀⣀⣀⣀')); + expect(rawLine).toContain(chalk.hex(darkColors.warning)('⊘ ')); + expect(rawLine).toContain(chalk.hex(cancelledTextColor)('Cancelled.')); + }); + }); + + it('renders failure details from AgentSwarm result output', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts'], + }); + component.applyResult([ + '', + 'failed: 1', + 'Agent timed out after 30s.', + '', + ].join('\n')); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('✗ Agent timed out after 30s.'); + expect(output).not.toContain('Failed:'); + }); + + it('applies no-index AgentSwarm result statuses by tag order', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }); + const applied = component.applyResult([ + '', + 'failed: 1, aborted: 1', + '' + + 'Agent timed out after 30s.', + '' + + 'User interrupted.', + '', + ].join('\n')); + + const output = strip(component.render(120).join('\n')); + + expect(applied).toBe(true); + expect(output).toContain('✗ Agent timed out after 30s.'); + expect(output).toContain('⊘ Cancelled.'); + expect(output).not.toContain('002 ['); + expect(output).not.toContain('Completed.'); + }); + + it('strips nested AgentSwarm prefixes from failure details', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts'], + }); + component.applyResult([ + '', + 'failed: 1', + 'agent_swarm: failed', + 'description: Nested review', + 'items: 1', + 'completed: 0', + 'failed: 1', + '', + '[agent 1]', + 'status: failed', + '', + 'subagent error: [provider.rate_limit] 429 request reached user+model max RPM.', + '', + ].join('\n')); + + const output = strip(component.render(120).join('\n')); + + expect(output).toContain('✗ [provider.rate_limit] 429 request reached user+model max RPM.'); + expect(output).not.toContain('agent_swarm:'); + expect(output).not.toContain('Failed:'); + }); + + it('renders completed summaries from AgentSwarm result output', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts'], + }); + component.applyResult([ + '', + 'completed: 1', + 'Reviewed src/a.ts and confirmed imports are stable.', + '', + ].join('\n')); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('✓ Reviewed src/a.ts and confirmed imports are stable.'); + expect(output).toContain('Completed.'); + }); + + it('shows completed total status when only some subagents fail', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }); + component.applyResult([ + '', + 'completed: 1, failed: 1', + 'Reviewed src/a.ts and confirmed imports are stable.', + 'Agent timed out after 30s.', + '', + ].join('\n')); + + const output = strip(component.render(120).join('\n')); + const totalStatusLine = output.split('\n').find((line) => line.includes('Completed.')); + + expect(totalStatusLine).toBeDefined(); + expect(totalStatusLine).not.toContain('Failed.'); + expect(output).toContain('✓ Reviewed src/a.ts'); + expect(output).toContain('✗ Agent timed out after 30s.'); + }); + + it('uses the latest assistant line as completed output when no summary is available', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.appendModelDelta({ + agentId: 'agent-1', + delta: 'Reviewing src/a.ts\nImports look stable', + }); + component.markCompleted('agent-1'); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('✓ Imports look stable'); + expect(output).toContain('Completed.'); + }); + + it('shows latest assistant text after the progress bar with ellipsis truncation', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markInputComplete(); + component.recordToolCall({ agentId: 'agent-1', toolCallId: 'call-read' }); + component.appendModelDelta({ + agentId: 'agent-1', + delta: 'Reviewing src/a.ts and checking imports for regressions in detail', + }); + + const output = strip(component.render(44).join('\n')); + expect(output).toContain('001 ['); + expect(output).toContain('Reviewing'); + expect(output).toContain('…'); + }); + + it('uses natural status label width for prompting text', () => { + const prompting = new AgentSwarmProgressComponent({ + description: '', + colors: darkColors, + }); + prompting.updateArgs({}, { + streamingArguments: '{"prompt_template":"Review the changed TypeScript files carefully', + }); + + const promptLine = strip(prompting.render(80).join('\n')) + .split('\n') + .find((line) => line.includes('Prompting...')); + expect(promptLine).toBeDefined(); + + const working = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + working.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + working.markInputComplete(); + working.markStarted('agent-1'); + + const workingLine = strip(working.render(80).join('\n')) + .split('\n') + .find((line) => line.includes('Working...')); + expect(workingLine).toBeDefined(); + + const promptTextIndex = promptLine?.indexOf('Review the changed') ?? -1; + const progressBarIndex = workingLine?.indexOf('━') ?? -1; + expect(promptTextIndex).toBeGreaterThan(0); + expect(progressBarIndex).toBeGreaterThan(0); + expect(promptTextIndex).toBe(visibleWidth(' Prompting... ')); + expect(progressBarIndex).toBe(visibleWidth(' Working... ')); + }); + + it('renders the activity spinner before the total status line', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markInputComplete(); + component.markStarted('agent-1'); + component.setActivitySpinnerText(() => '🌗'); + + const statusLine = strip(component.render(80).join('\n')) + .split('\n') + .find((line) => line.includes('Working...')); + + expect(statusLine).toBeDefined(); + expect(statusLine?.startsWith(' 🌗 Working...')).toBe(true); + }); + + it('renders working label blue until a subagent completes, then green', () => { + withAnsiColor(() => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.registerSubagent({ agentId: 'agent-2', description: 'Review changed files #2 (coder)' }); + component.markInputComplete(); + component.markStarted('agent-1'); + component.markStarted('agent-2'); + + const initialRawLine = component.render(80).join('\n') + .split('\n') + .find((line) => strip(line).includes('Working...')); + expect(initialRawLine).toContain(chalk.hex(darkColors.primary)('Working...')); + + component.markCompleted('agent-1'); + + const partialRawLine = component.render(80).join('\n') + .split('\n') + .find((line) => strip(line).includes('Working...')); + expect(partialRawLine).toContain(chalk.hex(darkColors.success)('Working...')); + }); + }); + + it('keeps a two-cell placeholder after the AgentSwarm tool call ends', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markInputComplete(); + component.markStarted('agent-1'); + component.setActivitySpinnerText(() => '🌗'); + component.markToolCallEnded(); + component.setActivitySpinnerText(() => '🌘'); + + const statusLine = strip(component.render(80).join('\n')) + .split('\n') + .find((line) => line.includes('Working...')); + + expect(statusLine).toBeDefined(); + expect(statusLine?.startsWith(' Working...')).toBe(true); + expect(statusLine).not.toContain('🌗'); + expect(statusLine).not.toContain('🌘'); + }); + + it('renders terminal status symbols in the same color as their labels', () => { + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + const completed = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + completed.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + completed.markInputComplete(); + completed.markCompleted('agent-1', 'Imports are stable'); + completed.setActivitySpinnerText(() => '🌗'); + completed.markToolCallEnded(); + completed.setActivitySpinnerText(() => '🌘'); + + const completedRawLine = completed.render(80).join('\n') + .split('\n') + .find((line) => strip(line).startsWith(' ✓ Completed.')); + const completedLine = completedRawLine === undefined ? undefined : strip(completedRawLine); + expect(completedLine).toBeDefined(); + expect(completedLine?.startsWith(' ✓ Completed.')).toBe(true); + expect(completedRawLine).toContain(chalk.hex(darkColors.success)('✓')); + expect(completedLine).not.toContain('🌗'); + expect(completedLine).not.toContain('🌘'); + + const failed = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + failed.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + failed.markInputComplete(); + failed.markFailed('agent-1', 'Agent timed out'); + failed.markToolCallEnded(); + + const failedRawLine = failed.render(80).join('\n') + .split('\n') + .find((line) => strip(line).startsWith(' ✗ Failed.')); + const failedLine = failedRawLine === undefined ? undefined : strip(failedRawLine); + expect(failedLine).toBeDefined(); + expect(failedLine?.startsWith(' ✗ Failed.')).toBe(true); + expect(failedRawLine).toContain(chalk.hex(darkColors.error)('✗')); + + const cancelled = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + cancelled.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + cancelled.markInputComplete(); + cancelled.markStarted('agent-1'); + cancelled.markCancelled('agent-1'); + cancelled.markToolCallEnded(); + + const cancelledOutput = cancelled.render(80).join('\n'); + expect(strip(cancelledOutput)).not.toContain('Cancelled.'); + + const cancelledRawLine = cancelledOutput + .split('\n') + .find((line) => strip(line).startsWith(' ⊘ Aborted.')); + const cancelledLine = cancelledRawLine === undefined ? undefined : strip(cancelledRawLine); + expect(cancelledLine).toBeDefined(); + expect(cancelledLine?.startsWith(' ⊘ Aborted.')).toBe(true); + expect(cancelledLine).not.toContain('Cancelled.'); + expect(cancelledRawLine).toContain(chalk.hex(darkColors.warning)('⊘')); + + const aborted = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + aborted.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + aborted.markInputComplete(); + aborted.markStarted('agent-1'); + aborted.markActiveCancelled(); + aborted.markToolCallEnded(); + + const abortedRawLine = aborted.render(80).join('\n') + .split('\n') + .find((line) => strip(line).startsWith(' ⊘ Aborted.')); + const abortedLine = abortedRawLine === undefined ? undefined : strip(abortedRawLine); + expect(abortedLine).toBeDefined(); + expect(abortedLine?.startsWith(' ⊘ Aborted.')).toBe(true); + expect(abortedRawLine).toContain(chalk.hex(darkColors.warning)('⊘')); + } finally { + chalk.level = previousChalkLevel; + } + }); + + it('colors cancelled cell labels from the subagent state at cancellation time', () => { + withAnsiColor(() => { + const cancelledTextColor = darkenHexColor(darkColors.warning, 0.72); + const running = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + running.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + running.markInputComplete(); + running.markStarted('agent-1'); + running.appendModelDelta({ agentId: 'agent-1', delta: 'Inspecting src/a.ts' }); + running.markCancelled('agent-1'); + + const runningRawLine = running.render(100).join('\n') + .split('\n') + .find((line) => strip(line).includes('001 [')); + expect(runningRawLine).toBeDefined(); + expect(runningRawLine).toContain(chalk.hex(darkColors.warning)('⣀⣀⣀⣀⣀⣀⣀⣀')); + expect(runningRawLine).toContain(chalk.hex(darkColors.warning)('⊘ ')); + expect(runningRawLine).toContain(chalk.hex(cancelledTextColor)('Inspecting src/a.ts')); + + const queued = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + queued.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + queued.markInputComplete(); + queued.markCancelled('agent-1'); + + const queuedRawLine = queued.render(100).join('\n') + .split('\n') + .find((line) => strip(line).includes('001 ')); + expect(queuedRawLine).toBeDefined(); + expect(strip(queuedRawLine ?? '')).not.toContain('['); + expect(queuedRawLine).toContain(chalk.hex(darkColors.warning)('⊘ ')); + expect(queuedRawLine).toContain(chalk.hex(cancelledTextColor)('Cancelled.')); + }); + }); + + it('reserves one trailing cell for prompting streaming text', () => { + const prompting = new AgentSwarmProgressComponent({ + description: '', + colors: darkColors, + }); + prompting.updateArgs({}, { + streamingArguments: '{"prompt_template":"Review every changed TypeScript file and summarize regressions carefully before reporting', + }); + + const promptLine = strip(prompting.render(50).join('\n')) + .split('\n') + .find((line) => line.includes('Prompting...')); + + expect(promptLine).toBeDefined(); + expect(visibleWidth(promptLine ?? '')).toBe(48); + }); + + it('renders boosted fractional progress ticks without leaking undefined cells', () => { + vi.useFakeTimers(); + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + vi.setSystemTime(0); + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + component.markStarted('agent-1'); + for (let index = 0; index < 10; index += 1) { + vi.setSystemTime(1_000 + index * 1_000); + component.recordToolCall({ agentId: 'agent-1', toolCallId: `done-${index}` }); + } + vi.setSystemTime(40_000); + component.markCompleted('agent-1'); + + component.registerSubagent({ agentId: 'agent-2', description: 'Review changed files #2 (coder)' }); + component.markStarted('agent-2'); + for (let index = 0; index < 3; index += 1) { + vi.setSystemTime(45_000 + index * 5_000); + component.recordToolCall({ agentId: 'agent-2', toolCallId: `running-${index}` }); + } + + vi.setSystemTime(60_000); + component.render(100); + vi.setSystemTime(61_000); + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('002 ['); + expect(output).not.toContain('undefined'); + }); + + it('keeps spawned rows queued when AgentSwarm input completes', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ + agentId: 'agent-1', + description: 'Review changed files #1 (coder)', + }); + let output = strip(component.render(100).join('\n')); + expect(output).toContain('001 Queued...'); + expect(output).not.toContain('001 ['); + + component.markInputComplete(); + output = strip(component.render(100).join('\n')); + expect(output).toContain('001 Queued...'); + expect(output).not.toContain('001 ['); + }); + + it('creates pending rows from streamed args items', () => { + const component = new AgentSwarmProgressComponent({ + description: '', + colors: darkColors, + }); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }); + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('Agent Swarm'); + expect(output).toContain('Review changed files'); + expect(output).toContain('001 src/a.ts'); + expect(output).toContain('002 src/b.ts'); + }); + + it('creates pending rows from resume_agent_ids before streamed args items', () => { + const component = new AgentSwarmProgressComponent({ + description: '', + colors: darkColors, + }); + + component.updateArgs({ + description: 'Review changed files', + resume_agent_ids: { + 'agent-old-1': 'continue', + 'agent-old-2': 'continue', + }, + items: ['src/a.ts'], + }); + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('001 (resumed)'); + expect(output).toContain('002 (resumed)'); + expect(output).toContain('003 src/a.ts'); + expect(output).not.toContain('001 ['); + }); + + it('counts partial items before each string is complete', () => { + expect( + agentSwarmPartialItemsCountFromArguments('{"items":["src/a.ts","src/b'), + ).toBe(2); + expect( + agentSwarmPartialItemsCountFromArguments('{"items":["src/a.ts","src/\\"b.ts","src/c'), + ).toBe(3); + expect( + agentSwarmPartialItemsFromArguments('{"items":["src/a.ts","src/\\"b.ts","src/c'), + ).toEqual(['src/a.ts', 'src/"b.ts', 'src/c']); + }); + + it('creates pending rows from partial streaming arguments', () => { + const component = new AgentSwarmProgressComponent({ + description: '', + colors: darkColors, + }); + + component.updateArgs({}, { + streamingArguments: '{"description":"Review changed files","items":["src/a.ts","src/b', + }); + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('001 src/a.ts'); + expect(output).toContain('002 src/b'); + }); + + it('creates pending rows from partial streaming resume_agent_ids', () => { + const component = new AgentSwarmProgressComponent({ + description: '', + colors: darkColors, + }); + + component.updateArgs({}, { + streamingArguments: + '{"description":"Resume reviews","resume_agent_ids":{"agent-old-1":"continue","agent-old-2":"cont', + }); + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('001 (resumed)'); + expect(output).toContain('002 (resumed)'); + expect(output).not.toContain('003'); + }); + + it('adds subagent rows incrementally as spawn events arrive', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + }); + + component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + let output = strip(component.render(100).join('\n')); + expect(output).toContain('001 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002'); + + component.registerSubagent({ agentId: 'agent-2', description: 'Review changed files #2 (coder)' }); + output = strip(component.render(100).join('\n')); + expect(output).toContain('001 Queued...'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002 ['); + }); + + it('maps subagents by structured swarm indexes when descriptions include issue references', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Fix #123', + colors: darkColors, + }); + + component.updateArgs({ + description: 'Fix #123', + items: ['src/a.ts', 'src/b.ts'], + }); + component.registerSubagent({ + agentId: 'agent-2', + description: 'Fix #123 #2 (coder)', + swarmIndex: 2, + }); + component.markStarted('agent-2'); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('001 src/a.ts'); + expect(output).toContain('002 ['); + expect(output).not.toContain('123 ['); + }); + + it('extracts description and item list from AgentSwarm args', () => { + const args = { + description: 'Review changed files', + items: ['src/a.ts', 123], + }; + + expect(agentSwarmDescriptionFromArgs(args)).toBe('Review changed files'); + expect(agentSwarmItemsFromArgs(args)).toEqual(['src/a.ts', '123']); + }); +}); + +describe('AgentSwarmProgressEstimator', () => { + it('counts a started subagent as one progress tick before tool calls arrive', () => { + const estimator = new AgentSwarmProgressEstimator(); + + estimator.markStarted('001', 0); + const estimate = estimator.estimate({ + memberKey: '001', + phase: 'running', + capacityTicks: 56, + nowMs: 1_000, + }); + + expect(estimate.rawTicks).toBe(1); + expect(estimate.displayTicks).toBe(1); + }); + + it('keeps raw tool-call ticks without completed samples and deduplicates calls', () => { + const estimator = new AgentSwarmProgressEstimator(); + + estimator.markStarted('001', 0); + expect( + estimator.recordToolCall({ memberKey: '001', toolCallId: 'read', nowMs: 1_000 }), + ).toEqual({ accepted: true, rawTicks: 2 }); + expect( + estimator.recordToolCall({ memberKey: '001', toolCallId: 'read', nowMs: 2_000 }), + ).toEqual({ accepted: false, rawTicks: 2 }); + + const estimate = estimator.estimate({ + memberKey: '001', + phase: 'running', + capacityTicks: 56, + nowMs: 3_000, + }); + + expect(estimate.rawTicks).toBe(2); + expect(estimate.displayTicks).toBe(2); + expect(estimate.estimatedTotalToolCalls).toBeUndefined(); + expect(estimate.boosted).toBe(false); + }); + + it('excludes queued wait time from completed work samples', () => { + const estimator = new AgentSwarmProgressEstimator(); + + estimator.ensureMember('001', 0); + estimator.markStarted('001', 60_000); + estimator.recordToolCall({ memberKey: '001', toolCallId: 'read', nowMs: 61_000 }); + estimator.markQueued('001', 62_000); + estimator.markStarted('001', 122_000); + estimator.recordToolCall({ memberKey: '001', toolCallId: 'write', nowMs: 123_000 }); + estimator.markCompleted('001', 124_000); + + const samples = ( + estimator as unknown as { + completedSamples(): Array<{ totalMs: number; rawTicks: number }>; + } + ).completedSamples(); + expect(samples).toEqual([{ totalMs: 4_000, rawTicks: 3 }]); + }); + + it('does not catch up progress using queued wait before start', () => { + const estimator = new AgentSwarmProgressEstimator({ + catchupTimeMs: 1_000, + maxCatchupTicksPerSecond: 100, + }); + + estimator.markStarted('001', 0); + for (let index = 0; index < 10; index += 1) { + estimator.recordToolCall({ + memberKey: '001', + toolCallId: `done-${index}`, + nowMs: 1_000 + index * 1_000, + }); + } + estimator.markCompleted('001', 40_000); + + estimator.ensureMember('002', 0); + estimator.estimate({ + memberKey: '002', + phase: 'queued', + capacityTicks: 56, + nowMs: 0, + }); + estimator.markStarted('002', 60_000); + + const estimate = estimator.estimate({ + memberKey: '002', + phase: 'running', + capacityTicks: 56, + nowMs: 60_000, + }); + + expect(estimate.rawTicks).toBe(1); + expect(estimate.displayTicks).toBe(1); + expect(estimate.targetTicks).toBeGreaterThan(1); + expect(estimate.boosted).toBe(false); + }); + + it('smoothly catches up toward completed-agent estimates without jumping to them', () => { + const estimator = new AgentSwarmProgressEstimator({ + catchupTimeMs: 1_000, + maxCatchupTicksPerSecond: 100, + }); + + estimator.markStarted('001', 0); + for (let index = 0; index < 10; index += 1) { + estimator.recordToolCall({ + memberKey: '001', + toolCallId: `done-${index}`, + nowMs: 1_000 + index * 1_000, + }); + } + estimator.markCompleted('001', 40_000); + + estimator.markStarted('002', 0); + for (let index = 0; index < 3; index += 1) { + estimator.recordToolCall({ + memberKey: '002', + toolCallId: `running-${index}`, + nowMs: 5_000 + index * 5_000, + }); + } + + const first = estimator.estimate({ + memberKey: '002', + phase: 'running', + capacityTicks: 56, + nowMs: 20_000, + }); + + expect(first.rawTicks).toBe(4); + expect(first.displayTicks).toBe(4); + expect(first.estimatedTotalToolCalls).toBeGreaterThan(4); + expect(first.targetTicks).toBeGreaterThan(4); + expect(estimator.hasPendingCatchup()).toBe(true); + + const second = estimator.estimate({ + memberKey: '002', + phase: 'running', + capacityTicks: 56, + nowMs: 21_000, + }); + + expect(second.displayTicks).toBeGreaterThan(4); + expect(second.displayTicks).toBeLessThan(second.targetTicks ?? 0); + expect(second.boosted).toBe(true); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index b48f2eff5..88cc967e2 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -128,6 +128,61 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('do not show'); }); + it('renders AgentSwarm results as a one-line summary without raw XML', () => { + const output = [ + '', + 'completed: 1, failed: 1, aborted: 1', + 'Reviewed src/a.ts.', + 'Agent timed out.', + 'User aborted.', + '', + ].join('\n'); + const component = new ToolCallComponent( + { + id: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + }, + }, + { + tool_call_id: 'call_swarm', + output, + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(120).join('\n')); + + expect(out).toContain('Agent swarm: ✓ 1 completed · ✗ 1 failed · ⊘ 1 aborted'); + expect(out).not.toContain(''); + expect(out).not.toContain('Reviewed src/a.ts.'); + expect(out).not.toContain('Agent timed out.'); + }); + + it('renders an AgentSwarm fallback summary when the result is not structured', () => { + const component = new ToolCallComponent( + { + id: 'call_swarm_failed', + name: 'AgentSwarm', + args: { description: 'Review changed files' }, + }, + { + tool_call_id: 'call_swarm_failed', + output: 'provider request failed', + is_error: true, + }, + darkColors, + ); + + const out = strip(component.render(120).join('\n')); + + expect(out).toContain('Agent swarm: ✗ Failed.'); + expect(out).not.toContain('provider request failed'); + }); + it('still renders tool output when the body merely contains { const component = new ToolCallComponent( { @@ -575,7 +630,7 @@ describe('ToolCallComponent', () => { }); let out = strip(component.render(120).join('\n')); - expect(out).toContain('Explore Agent Starting (explore project xxx) · 0 tools · 0s'); + expect(out).toContain('Explore Agent Queued (explore project xxx) · 0 tools · 0s'); expect(out).not.toContain('Using Agent'); expect(out).not.toContain('Used Agent'); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 25a552631..04bdfacd2 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -11,6 +11,7 @@ function fakeInitialAppState(): AppState { sessionId: 'sess-1', permissionMode: 'manual', planMode: false, + swarmMode: false, thinking: false, contextUsage: 0, contextTokens: 0, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index f9d20f7ed..c7266fae2 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -12,6 +12,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { + AgentSwarmProgressComponent, + agentSwarmGridHeightForTerminalRows, +} from '#/tui/components/messages/agent-swarm-progress'; import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; @@ -134,6 +138,7 @@ function makeSession(overrides: Record = {}) { setThinking: vi.fn(async () => {}), setPermission: vi.fn(async () => {}), setPlanMode: vi.fn(async () => {}), + setSwarmMode: vi.fn(async () => {}), onEvent: vi.fn(() => vi.fn()), listMcpServers: vi.fn(async () => []), listSkills: vi.fn(async () => []), @@ -246,6 +251,10 @@ function renderTranscript(driver: MessageDriver): string { return driver.state.transcriptContainer.render(120).join('\n'); } +function renderActivity(driver: MessageDriver): string { + return driver.state.activityContainer.render(120).join('\n'); +} + function renderBtwPanel(driver: MessageDriver): string { return driver.state.btwPanelContainer.render(120).join('\n'); } @@ -277,6 +286,13 @@ function setTerminalRows(driver: MessageDriver, rows: number): void { }); } +function setTerminalColumns(driver: MessageDriver, columns: number): void { + Object.defineProperty(driver.state.terminal, 'columns', { + configurable: true, + get: () => columns, + }); +} + function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } @@ -899,6 +915,46 @@ command = "vim" ).toHaveLength(1); }); + it('removes AgentSwarm progress from undone turns', async () => { + const { driver, session } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.handleUserInput('launch swarm'); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('launch swarm'); + expect(transcript).toContain('Agent Swarm'); + expect(transcript).toContain('Review changed files'); + + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/undo'); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('launch swarm'); + expect(transcript).not.toContain('Agent Swarm'); + expect(transcript).not.toContain('Review changed files'); + }); + it('removes approval notices from undone turns', async () => { const { driver, session } = await makeDriver(); const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as @@ -2024,6 +2080,80 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); }); + it('renders swarm mode markers from /swarm commands, not tool-triggered status updates', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + swarmMode: true, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.swarmMode).toBe(true); + expect(stripSgr(renderTranscript(driver))).not.toContain('Swarm activated'); + + let transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm activated')).toBe(0); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + swarmMode: false, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.swarmMode).toBe(false); + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('Swarm deactivated'); + expect(transcript).not.toContain('Swarm ended'); + + expect(countOccurrences(transcript, 'Swarm activated')).toBe(0); + expect(countOccurrences(transcript, 'Swarm deactivated')).toBe(0); + expect(countOccurrences(transcript, 'Swarm ended')).toBe(0); + }); + + it('renders an ended marker when a one-shot /swarm task exits', async () => { + const { driver, session } = await makeDriver(undefined, { + getExperimentalFeatures: vi.fn(async () => [{ id: 'agent_swarm', enabled: true }]), + }); + driver.state.appState.permissionMode = 'auto'; + + driver.handleUserInput('/swarm Ship feature X'); + + await vi.waitFor(() => { + expect(session.setSwarmMode).toHaveBeenCalledWith(true, 'task'); + }); + await vi.waitFor(() => { + expect(countOccurrences(stripSgr(renderTranscript(driver)), 'Swarm activated')).toBe(1); + }); + let transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm activated')).toBe(1); + expect(transcript).not.toContain('Swarm ended'); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + swarmMode: false, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.swarmMode).toBe(false); + transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm activated')).toBe(1); + expect(countOccurrences(transcript, 'Swarm ended')).toBe(1); + expect(transcript).not.toContain('Swarm deactivated'); + }); + it('queues Ctrl-S input instead of steering while /init is running', async () => { let resolveInit: (() => void) | undefined; const session = makeSession({ @@ -2247,6 +2377,426 @@ command = "vim" }); }); + it('renders AgentSwarm progress in the transcript instead of the tool-card body', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + swarmIndex: 1, + runInBackground: false, + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId: 'agent-2', + subagentName: 'coder', + description: 'Review changed files #2 (coder)', + swarmIndex: 2, + runInBackground: false, + } as Event, + sendQueued, + ); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + toolCallId: 'call_read', + name: 'Read', + args: { path: 'src/a.ts' }, + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + delta: 'Reviewing src/a.ts and checking imports for regressions in detail', + } as Event, + sendQueued, + ); + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('01 ['); + expect(transcript).toContain('Reviewing src/a.ts'); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.suspended', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + reason: 'Provider rate limit; subagent requeued for retry.', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 ['); + expect(transcript).toContain('Queued...'); + expect(transcript).not.toContain('Provider rate limit'); + expect(transcript).not.toContain('Failed'); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.started', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('01 ['); + expect(transcript).not.toContain('Suspended'); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + reason: 'completed', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent Swarm'); + expect(transcript).toContain('Review changed files'); + expect(transcript).toContain('001 ['); + expect(transcript).toContain('Reviewing src/a.ts'); + expect(transcript).not.toContain('Completed'); + expect(transcript).toContain('002 Queued...'); + expect(transcript).not.toContain('002 ['); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.completed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + resultSummary: 'Imports are stable', + } as Event, + sendQueued, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('✓ Imports are stable'); + expect(transcript).not.toContain('Completed'); + }); + + it('marks only core user-cancellation subagent failures as cancelled', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + + for (const [index, subagentId] of ['agent-1', 'agent-2'].entries()) { + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId, + subagentName: 'coder', + description: `Review changed files #${String(index + 1)} (coder)`, + swarmIndex: index + 1, + runInBackground: false, + } as Event, + sendQueued, + ); + } + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + error: 'Aborted by the user', + } as Event, + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-2', + error: 'The user manually interrupted this subagent x.', + } as Event, + sendQueued, + ); + + const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); + expect(transcript).toContain('⊘ Cancelled.'); + expect(transcript).toContain('✗ The user manually interrupted this subagent x.'); + }); + + it('does not let later transcript entries reduce the AgentSwarm grid height', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + const terminalColumns = 80; + setTerminalColumns(driver, terminalColumns); + const outerChildren = driver.state.ui.children; + const transcriptIndex = outerChildren.indexOf(driver.state.transcriptContainer); + const rowsAfterTranscript = outerChildren + .slice(transcriptIndex + 1) + .reduce((sum, child) => sum + child.render(terminalColumns).length, 0); + const nonGridRows = 20 - (agentSwarmGridHeightForTerminalRows(20) ?? 0); + setTerminalRows(driver, rowsAfterTranscript + nonGridRows + 2); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts'], + }, + } as Event, + sendQueued, + ); + + const swarmProgress = driver.state.transcriptContainer.children.find( + (child): child is AgentSwarmProgressComponent => child instanceof AgentSwarmProgressComponent, + ); + if (swarmProgress === undefined) throw new Error('expected AgentSwarm progress'); + + const transcriptWidth = Math.max(1, terminalColumns - 2); + const renderSwarm = (): string => + stripSgr(swarmProgress.render(transcriptWidth).join('\n')); + + expect(renderSwarm()).toContain('001 Queued...'); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_read', + name: 'Read', + args: { path: 'src/after.ts' }, + } as Event, + sendQueued, + ); + + const transcriptChildren = driver.state.transcriptContainer.children; + const swarmIndex = transcriptChildren.indexOf( + swarmProgress as (typeof transcriptChildren)[number], + ); + expect(swarmIndex).toBeGreaterThanOrEqual(0); + + const rowsAfterSwarmInTranscript = transcriptChildren + .slice(swarmIndex + 1) + .reduce((sum, child) => sum + child.render(transcriptWidth).length, 0); + expect(rowsAfterSwarmInTranscript).toBeGreaterThan(0); + + expect(renderSwarm()).toContain('001 Queued...'); + const transcript = stripSgr( + driver.state.transcriptContainer.render(terminalColumns).join('\n'), + ); + expect(transcript).toContain('Using Read (src/after.ts)'); + }); + + it('shows AgentSwarm as completed when only some subagents fail', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + output: [ + '', + 'completed: 1, failed: 1', + 'Imports are stable.', + 'Agent timed out after 30s.', + '', + ].join('\n'), + isError: undefined, + } as Event, + sendQueued, + ); + + const transcript = stripSgr(renderTranscript(driver)); + const totalStatusLine = transcript.split('\n').find((line) => line.includes('Completed.')); + expect(totalStatusLine).toBeDefined(); + expect(totalStatusLine).not.toContain('Failed.'); + expect(transcript).toContain('✓ Imports are stable.'); + expect(transcript).toContain('✗ Agent timed out after 30s.'); + }); + + it('renders AgentSwarm progress while tool args are still streaming', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + argumentsPart: '{"description":"Review changed files', + } as Event, + sendQueued, + ); + + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent Swarm'); + expect(transcript).toContain('Orchestrating...'); + expect(transcript).not.toContain('01'); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + argumentsPart: '","items":["src/a.ts","src/b', + } as Event, + sendQueued, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent Swarm'); + expect(transcript).toContain('Review changed files'); + expect(transcript).toContain('001 src/a.ts'); + expect(transcript).toContain('002 src/b'); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + swarmIndex: 1, + runInBackground: false, + } as Event, + sendQueued, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 Queued...'); + expect(transcript).not.toContain('001 ['); + expect(transcript).toContain('002 src/b'); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 Queued...'); + expect(transcript).toContain('002 Queued...'); + expect(transcript).not.toContain('001 ['); + expect(transcript).not.toContain('002 ['); + }); + it('shows plan review reject on the plan card without an approval notice', async () => { const planContent = '# Reject Plan\n\n- keep this plan visible after reject'; const session = makeSession({ diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index ee8f57849..9d21b927e 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -18,6 +18,10 @@ import { ReadGroupComponent } from '#/tui/components/messages/read-group'; vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + interface ReplayDriver { readonly state: TUIState; readonly streamingUI: StreamingUIController; @@ -108,6 +112,7 @@ function baseAgentState( replay, permission: { mode: 'manual', rules: [] }, plan: null, + swarmMode: false, usage: {}, tools: [], toolStore: {}, @@ -320,6 +325,81 @@ describe('KimiTUI resume message replay', () => { expect(driver.streamingUI.getToolComponent('call_read_2')).toBeUndefined(); }); + it('renders replayed AgentSwarm calls as compact result summaries', async () => { + const replay: AgentReplayRecord[] = [ + message('user', [{ type: 'text', text: 'review files with a swarm' }]), + message('assistant', [], { + toolCalls: [ + toolCall('call_swarm', 'AgentSwarm', { + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }), + ], + }), + message( + 'tool', + [{ + type: 'text', + text: [ + '', + 'completed: 1, failed: 1', + 'Reviewed src/a.ts.', + 'Agent timed out.', + '', + ].join('\n'), + }], + { toolCallId: 'call_swarm' }, + ), + ]; + + const driver = await replayIntoDriver(replay); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + + expect(transcript).toContain('Agent swarm: ✓ 1 completed · ✗ 1 failed'); + expect(transcript).not.toContain(''); + expect(transcript).not.toContain('Reviewed src/a.ts.'); + expect(transcript).not.toContain('Agent timed out.'); + }); + + it('does not show no-index replayed AgentSwarm failures as completed', async () => { + const replay: AgentReplayRecord[] = [ + message('user', [{ type: 'text', text: 'review files with a swarm' }]), + message('assistant', [], { + toolCalls: [ + toolCall('call_swarm', 'AgentSwarm', { + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }), + ], + }), + message( + 'tool', + [{ + type: 'text', + text: [ + '', + 'failed: 1, aborted: 1', + 'Call AgentSwarm with resume_agent_ids using the agent_id values ' + + 'in this result to continue unfinished work.', + '' + + 'Agent timed out.', + '' + + 'User interrupted.', + '', + ].join('\n'), + }], + { toolCallId: 'call_swarm' }, + ), + ]; + + const driver = await replayIntoDriver(replay); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + + expect(transcript).toContain('Agent swarm: ✗ 1 failed · ⊘ 1 aborted'); + expect(transcript).not.toContain('Agent swarm: ✓ Completed.'); + expect(transcript).not.toContain(''); + }); + it('hydrates todo and background snapshot state from resumed main agent', async () => { const driver = await replayIntoDriver([], { toolStore: { @@ -360,8 +440,12 @@ describe('KimiTUI resume message replay', () => { ], }); - expect(driver.sessionEventHandler.backgroundAgentMetadata.has('agent-bg1')).toBe(true); - expect(driver.sessionEventHandler.backgroundAgentMetadata.has('task-bg1')).toBe(false); + expect( + driver.sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.has('agent-bg1'), + ).toBe(true); + expect( + driver.sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.has('task-bg1'), + ).toBe(false); driver.sessionEventHandler.handleEvent( { @@ -369,7 +453,6 @@ describe('KimiTUI resume message replay', () => { agentId: 'main', sessionId: 'ses-replay', subagentId: 'agent-bg1', - parentToolCallId: 'task-bg1', resultSummary: 'Reviewed the long-running work.', }, () => {}, @@ -379,7 +462,9 @@ describe('KimiTUI resume message replay', () => { (entry) => entry.backgroundAgentStatus?.phase === 'completed', ); - expect(driver.sessionEventHandler.backgroundAgentMetadata.has('agent-bg1')).toBe(false); + expect( + driver.sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.has('agent-bg1'), + ).toBe(false); expect(status?.backgroundAgentStatus?.headline).toBe('agent completed in background'); expect(status?.backgroundAgentStatus?.detail).toContain('Review long-running work'); }); @@ -416,14 +501,17 @@ describe('KimiTUI resume message replay', () => { agentId: 'main', sessionId: 'ses-replay', subagentId: 'agent-bg-timeout', - parentToolCallId: 'task-bg-timeout', error: 'The subagent was aborted.', }, () => {}, ); expect(applyTerminalStatus.mock.calls.map(([args]) => args.status)).toEqual(['timed_out']); - expect(driver.sessionEventHandler.backgroundAgentMetadata.has('agent-bg-timeout')).toBe(false); + expect( + driver.sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.has( + 'agent-bg-timeout', + ), + ).toBe(false); expect(driver.sessionEventHandler.backgroundTaskTranscriptedTerminal.has('task-bg-timeout')) .toBe(true); expect( diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index bf38aa8ac..1a414f8cb 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -219,7 +219,7 @@ api_key = "sk-xxx" | `pattern` | `string` | Yes | Match pattern in the form `ToolName` or `ToolName(arg-pattern)`, e.g. `Read` or `Bash(rm -rf*)` | | `reason` | `string` | No | Rule description for debugging and auditing | -Built-in tool names are listed in [Built-in tools](../reference/tools.md); MCP tools and custom tools can only be matched by tool name — argument patterns are not supported for them. +Built-in tool names are listed in [Built-in tools](../reference/tools.md). Most built-in tools that accept rule arguments define their own matching subject, such as `Bash(command-pattern)` or `Read(path-pattern)`. `AgentSwarm`, MCP tools, and custom tools can only be matched by tool name — argument patterns are not supported for them. ```toml [[permission.rules]] diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index fb008341d..d6bd373b0 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -14,7 +14,7 @@ Kimi Code CLI includes three built-in sub-agents, ready to use out of the box, e ## How to Invoke -Sub-agents are scheduled automatically by the main Agent — based on task complexity, context consumption, and sub-task independence, they are dispatched at the right moment without the user having to specify one. +Sub-agents are scheduled automatically by the main Agent — based on task complexity, context consumption, and sub-task independence, they are dispatched at the right moment without the user having to specify one. Each dispatch is presented in the terminal as an approval request (unless it matches an allow rule or YOLO mode is active), giving you a chance to review the task description. You can also instruct the main Agent directly in conversation to use a specific sub-agent, for example: "Use explore to map out the relevant files before making any changes." diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index d9a1cc8f9..47538b040 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -46,6 +46,8 @@ Some commands are only available in the idle state. Executing these commands whi | `/auto [on\|off]` | — | Toggle auto permission mode. When enabled, tool approvals are handled automatically and the Agent will not ask the user questions | Yes | | `/plan [on\|off]` | — | Toggle Plan mode. Without arguments, flips the current state; explicitly passing `on`/`off` forces the setting. Simply toggling does not create an empty plan file | Yes | | `/plan clear` | — | Clear the current plan | No | +| `/swarm on\|off` | — | Turn swarm mode on or off without sending a prompt. | Yes | +| `/swarm ` | — | Turn swarm mode on, then send `` as a normal prompt. If the turn completes normally, swarm mode turns off automatically. In `manual` permission mode, Kimi Code asks whether to switch to `auto` before starting. | No | | `/goal [...]` | — | Start or manage an autonomous goal (experimental feature; enable it from `/experiments`, `[experimental].goal_command`, or `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1`) | See below | ::: warning diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 6131dcdfe..c354bc384 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -85,11 +85,14 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | Tool | Default Approval | Description | | --- | --- | --- | | `Agent` | Auto-allow | Spawn a sub-Agent to execute a subtask | +| `AgentSwarm` | Auto-allow in swarm mode; otherwise requires approval | Launch item-based subagents or resume existing subagents | | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | **`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. See [Agent & Sub-Agents](../customization/agents.md) for details. +**`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. + **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. **`Skill`** allows the Agent to actively invoke a registered inline-type Skill. Accepts `skill` (the Skill name) and optional `args` (additional argument text). Only `type = "inline"` Skills can be called via this tool; Skills with `disableModelInvocation: true` are rejected. Maximum nesting depth is 3 levels. See [Agent Skills](../customization/skills.md) for details. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 8fd50d9a1..15b71e4ca 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -219,7 +219,7 @@ api_key = "sk-xxx" | `pattern` | `string` | 是 | 匹配模式,格式为 `工具名` 或 `工具名(参数模式)`,如 `Read`、`Bash(rm -rf*)` | | `reason` | `string` | 否 | 规则说明,仅用于调试和审计 | -内置工具名见[内置工具](../reference/tools.md);MCP 工具和自定义工具只能按工具名匹配,不支持参数模式。 +内置工具名见[内置工具](../reference/tools.md)。大多数支持规则参数的内置工具会定义自己的匹配对象,例如 `Bash(command-pattern)` 或 `Read(path-pattern)`。`AgentSwarm`、MCP 工具和自定义工具只能按工具名匹配,不支持参数模式。 ```toml [[permission.rules]] diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index eebb55ac1..959df39b1 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -44,6 +44,8 @@ | `/auto [on\|off]` | — | 切换 auto 权限模式。开启后工具审批自动处理,Agent 不会向用户提问 | 是 | | `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时翻转;显式传 `on`/`off` 时强制设置。单纯切换不会创建空计划文件 | 是 | | `/plan clear` | — | 清除当前 plan 方案 | 否 | +| `/swarm on\|off` | — | 开启或关闭 swarm mode,但不发送提示词。 | 是 | +| `/swarm ` | — | 先开启 swarm mode,再把 `` 作为普通提示词发送。如果该轮次正常完成,swarm mode 会自动关闭。若当前是 `manual` 权限模式,启动前会提示是否切换到 `auto`。 | 否 | | `/goal [...]` | — | 开始或管理目标模式(实验功能;可通过 `/experiments`、`[experimental].goal_command` 或 `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1` 启用) | 见下文 | ::: warning 注意 diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 61920019b..0747a5e1a 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -85,11 +85,14 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | 工具 | 默认审批 | 说明 | | --- | --- | --- | | `Agent` | 自动放行 | 派生子 Agent 执行子任务 | +| `AgentSwarm` | swarm mode 中自动放行,否则需审批 | 启动基于 item 的子 Agent,或恢复已有子 Agent | | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | **`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务使用固定 30 分钟超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 +**`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。 + **`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 **`Skill`** 允许 Agent 主动调用已注册的 inline 类型 Skill。接受 `skill`(Skill 名称)和可选的 `args`(附加参数文本)。只有 `type = "inline"` 的 Skill 能通过此工具调用;`disableModelInvocation: true` 的 Skill 会被拒绝。嵌套调用深度上限 3 层。Skill 体系细节见 [Agent Skills](../customization/skills.md)。 diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 1beebd659..6beb0a6ea 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -40,6 +40,7 @@ export class ContextMemory { content: readonly ContentPart[], origin: PromptOrigin = USER_PROMPT_ORIGIN, ): void { + if (content.length === 0) return; this.appendMessage({ role: 'user', content: [...content], @@ -49,7 +50,7 @@ export class ContextMemory { } appendSystemReminder(content: string, origin: PromptOrigin): void { - const text = `\n${content}\n`; + const text = `\n${content.trim()}\n`; this.appendMessage({ role: 'user', content: [{ type: 'text', text }], @@ -58,6 +59,19 @@ export class ContextMemory { }); } + popMatchedMessage(matcher: (origin: PromptOrigin | undefined) => boolean): boolean { + const lastDeferred = this.deferredMessages.at(-1); + const last = lastDeferred ?? this._history.at(-1); + if (last === undefined) return false; + if (!matcher(last.origin)) return false; + if (lastDeferred !== undefined) { + this.deferredMessages.pop(); + } else { + this._history.pop(); + } + return true; + } + clear(): void { this.agent.records.logRecord({ type: 'context.clear' }); this._history = []; diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts index 5bfbf5231..d0a78b975 100644 --- a/packages/agent-core/src/agent/context/types.ts +++ b/packages/agent-core/src/agent/context/types.ts @@ -64,6 +64,11 @@ export interface HookResultOrigin { readonly blocked?: boolean; } +export interface RetryOrigin { + readonly kind: 'retry'; + readonly trigger?: string; +} + export type PromptOrigin = | UserPromptOrigin | SkillActivationOrigin @@ -73,7 +78,8 @@ export type PromptOrigin = | BackgroundTaskOrigin | CronJobOrigin | CronMissedOrigin - | HookResultOrigin; + | HookResultOrigin + | RetryOrigin; export type ContextMessage = Message & { readonly origin?: PromptOrigin | undefined; diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index c686fcbe6..c81fcb454 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -46,6 +46,7 @@ import { } from './records'; import { ReplayBuilder } from './replay'; import { SkillManager } from './skill'; +import { SwarmMode } from './swarm'; import { ToolManager } from './tool/index'; import { TurnFlow } from './turn'; import { @@ -59,6 +60,7 @@ import type { Kaos } from '@moonshot-ai/kaos'; import type { ToolServices } from '../tools/support/services'; export type { AgentRecord, AgentRecordPersistence } from './records'; +export type { SwarmModeTrigger } from './swarm'; export type { BuiltinTool, ToolInfo, ToolSource, UserToolRegistration } from './tool'; export { buildGoalCompletionMessage } from './goal/completion'; @@ -118,6 +120,7 @@ export class Agent { readonly injection: InjectionManager; readonly permission: PermissionManager; readonly planMode: PlanMode; + readonly swarmMode: SwarmMode; readonly usage: UsageRecorder; readonly skills: SkillManager | null; readonly tools: ToolManager; @@ -169,6 +172,7 @@ export class Agent { this.injection = new InjectionManager(this); this.permission = new PermissionManager(this, options.permission); this.planMode = new PlanMode(this); + this.swarmMode = new SwarmMode(this); this.usage = new UsageRecorder(this); this.skills = options.skills ? new SkillManager(this, options.skills) : null; this.tools = new ToolManager(this); @@ -354,6 +358,15 @@ export class Agent { this.planMode.cancel(payload.id); }, clearPlan: () => this.planMode.clear(), + enterSwarm: (payload) => { + this.swarmMode.enter(payload.trigger); + }, + exitSwarm: () => { + this.swarmMode.exit(); + }, + getSwarmMode: () => { + return this.swarmMode.isActive; + }, beginCompaction: (payload) => { this.fullCompaction.begin({ source: 'manual', instruction: payload.instruction }); }, @@ -421,6 +434,7 @@ export class Agent { maxContextTokens, contextUsage, planMode: this.planMode.isActive, + swarmMode: this.swarmMode.isActive, permission: this.permission.mode, usage, }); diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts index 5cc0386c2..7f2caa0f1 100644 --- a/packages/agent-core/src/agent/permission/policies/index.ts +++ b/packages/agent-core/src/agent/permission/policies/index.ts @@ -15,6 +15,7 @@ import { PlanModeGuardDenyPermissionPolicy } from './plan-mode-guard-deny'; import { PlanModeToolApprovePermissionPolicy } from './plan-mode-tool-approve'; import { PreToolCallHookPermissionPolicy } from './pre-tool-call-hook'; import { SessionApprovalHistoryPermissionPolicy } from './session-approval-history'; +import { SwarmModeAgentSwarmApprovePermissionPolicy } from './swarm-mode-agent-swarm-approve'; import { UserConfiguredAllowPermissionPolicy, UserConfiguredAskPermissionPolicy, @@ -53,6 +54,8 @@ export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy new CwdOutsideFileWriteAskPermissionPolicy(agent), // yolo mode → approve. new YoloModeApprovePermissionPolicy(agent), + // Swarm mode keeps AgentSwarm available without making it a globally default-approved tool. + new SwarmModeAgentSwarmApprovePermissionPolicy(agent), // Tool is in the default-approve list (read-only / UI helpers) → approve. new DefaultToolApprovePermissionPolicy(), // Write/Edit on POSIX paths inside cwd inside a git work tree → approve. diff --git a/packages/agent-core/src/agent/permission/policies/swarm-mode-agent-swarm-approve.ts b/packages/agent-core/src/agent/permission/policies/swarm-mode-agent-swarm-approve.ts new file mode 100644 index 000000000..ae9df0184 --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/swarm-mode-agent-swarm-approve.ts @@ -0,0 +1,16 @@ +import type { Agent } from '../..'; +import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types'; + +export class SwarmModeAgentSwarmApprovePermissionPolicy implements PermissionPolicy { + readonly name = 'swarm-mode-agent-swarm-approve'; + + constructor(private readonly agent: Agent) {} + + evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined { + if (context.toolCall.name !== 'AgentSwarm') return; + if (!this.agent.swarmMode.isActive) return; + return { + kind: 'approve', + }; + } +} diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 65a5ee6bb..aa9f1a28a 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -75,6 +75,12 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { case 'plan_mode.exit': agent.planMode.exit(input.id); return; + case 'swarm_mode.enter': + agent.swarmMode.restoreEnter(input.trigger); + return; + case 'swarm_mode.exit': + agent.swarmMode.exit(); + return; case 'context.append_message': agent.context.appendMessage(input.message); return; diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index f2b9a783c..318ebe321 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -9,6 +9,7 @@ import type { ContextMessage, PromptOrigin } from '../context'; import type { PermissionApprovalResultRecord, PermissionMode } from '../permission'; import type { UserToolRegistration } from '../tool'; import type { UsageRecordScope } from '../usage'; +import type { SwarmModeTrigger } from '../swarm'; // Agent records are the ordered event log used to rebuild agent state on resume. // Use records, not state.json, when correctness depends on the order in which @@ -51,6 +52,11 @@ export interface AgentRecordEvents { id?: string; }; + 'swarm_mode.enter': { + trigger: SwarmModeTrigger; + }; + 'swarm_mode.exit': {}; + 'tools.register_user_tool': UserToolRegistration; 'tools.unregister_user_tool': { name: string; diff --git a/packages/agent-core/src/agent/swarm/enter-reminder.md b/packages/agent-core/src/agent/swarm/enter-reminder.md new file mode 100644 index 000000000..033750637 --- /dev/null +++ b/packages/agent-core/src/agent/swarm/enter-reminder.md @@ -0,0 +1,21 @@ +## Swarm Mode + +You are now in "agent swarm" mode. The user may send tasks that require a large number of parallel subagents. + +## Workflow + +You do not need to use TodoList to record this workflow. + +1. First, you may need to do a small amount of exploratory work before deciding how to divide the task across subagents. You may not need subagents during this exploratory phase. + +2. After exploring, if you are convinced no subagent is needed to complete the task, tell the user why and wait for further instructions; otherwise, continue with the appropriate delegation. + +3. Once you have enough context, do not handle the main work yourself. Use AgentSwarm with a `prompt_template` containing the `{{item}}` placeholder and an `items` array for the requested or appropriate number of subagents, partitioning the problem so each item gives one subagent a distinct part of the work. Pass `subagent_type` when the whole swarm should use a non-default subagent profile. + +## Coordination + +- Give each subagent a distinct scope of work. +- Avoid duplicating work across subagents. +- Avoid assigning conflicting changes or responsibilities to different subagents. +- Remember that subagents have your full capabilities. Do not overload their prompts with excessive detail; only describe the necessary background and each subagent's specific task. +- Unless the user explicitly specifies a lower limit, do not try to conserve the number of agents. AgentSwarm supports up to 128 subagents and queues launches automatically, so decompose work as finely as possible while keeping subagent responsibilities non-conflicting; combine tasks only when they are genuinely inseparable. If the subagents only need to read, inspect, or report back without making changes, their scopes may overlap slightly. diff --git a/packages/agent-core/src/agent/swarm/exit-reminder.md b/packages/agent-core/src/agent/swarm/exit-reminder.md new file mode 100644 index 000000000..4bd6825a3 --- /dev/null +++ b/packages/agent-core/src/agent/swarm/exit-reminder.md @@ -0,0 +1,5 @@ +## Swarm Mode Ended + +Swarm Mode has ended. You are no longer required to follow the Swarm Mode workflow. + +The user's next request is likely to be a regular request that does not need AgentSwarm. If the request still benefits from parallel subagents, you may call the AgentSwarm tool, but decide from the new request itself rather than the ended Swarm Mode workflow. diff --git a/packages/agent-core/src/agent/swarm/index.ts b/packages/agent-core/src/agent/swarm/index.ts new file mode 100644 index 000000000..037149cba --- /dev/null +++ b/packages/agent-core/src/agent/swarm/index.ts @@ -0,0 +1,60 @@ +import type { Agent } from '..'; + +import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md'; +import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md'; + +/** + * manual = persistent toggle (/swarm on); + * task = one-shot /swarm prompt; + * tool = AgentSwarm entry. + */ +export type SwarmModeTrigger = 'manual' | 'task' | 'tool'; + +export class SwarmMode { + protected active: SwarmModeTrigger | null = null; + + constructor(protected readonly agent: Agent) {} + + enter(trigger: SwarmModeTrigger): void { + if (this.active !== null) return; + this.agent.records.logRecord({ type: 'swarm_mode.enter', trigger }); + this.active = trigger; + if (trigger !== 'tool') { + this.agent.context.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { + kind: 'injection', + variant: 'swarm_mode', + }); + } + this.agent.emitStatusUpdated(); + } + + restoreEnter(trigger: SwarmModeTrigger): void { + this.active = trigger; + } + + exit(): void { + if (this.active === null) return; + this.agent.records.logRecord({ type: 'swarm_mode.exit' }); + const trigger = this.active; + this.active = null; + this.agent.emitStatusUpdated(); + if (trigger === 'tool') return; + if (this.agent.context.popMatchedMessage((origin) => origin?.kind === 'injection' && origin.variant === 'swarm_mode')) { + return; + } + if (!this.agent.records.restoring) { + this.agent.context.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { + kind: 'injection', + variant: 'swarm_mode_exit', + }); + } + } + + get isActive(): boolean { + return this.active !== null; + } + + get shouldAutoExit(): boolean { + return this.active === 'task' || this.active === 'tool'; + } +} diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 3bdc72c14..fb16cca5e 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -377,6 +377,7 @@ export class ToolManager { this.enabledTools.has('TaskStop'); const goalCommandEnabled = this.agent.experimentalFlags.enabled('goal_command') && this.agent.type === 'main'; + const agentSwarmEnabled = this.agent.experimentalFlags.enabled('agent_swarm'); this.builtinTools = new Map( [ new b.ReadTool(kaos, workspace), @@ -415,6 +416,9 @@ export class ToolManager { log: this.agent.log, }, ), + this.agent.subagentHost && + agentSwarmEnabled && + new b.AgentSwarmTool(this.agent.subagentHost, this.agent.swarmMode), toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher), ] diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 0fac303b9..8901ca3fd 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; +import { createControlledPromise, type ControlledPromise } from '@antfu/utils'; import { APIConnectionError, APIContextOverflowError, @@ -41,8 +42,10 @@ import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; import { ToolCallDeduplicator } from './tool-dedup'; interface ActiveTurn { - controller: AbortController; - promise: Promise; + readonly turnId: number; + readonly controller: AbortController; + readonly promise: Promise; + readonly firstRequest: ControlledPromise; } interface BufferedSteer { @@ -137,6 +140,10 @@ export class TurnFlow { return this.launch(input, origin); } + retry(trigger?: string): number | null { + return this.prompt([], { kind: 'retry', trigger }); + } + private launch(input: readonly ContentPart[], origin: PromptOrigin): number | null { if (this.activeTurn) { this.agent.emitEvent({ @@ -156,7 +163,17 @@ export class TurnFlow { const turnId = this.allocateTurnId(); const controller = new AbortController(); const promise = this.turnWorker(turnId, input, origin, controller.signal); - this.activeTurn = { controller, promise }; + const firstRequest = createControlledPromise(); + this.activeTurn = { + turnId, + controller, + promise, + firstRequest, + }; + + void firstRequest.catch(() => undefined); + void promise.then(firstRequest.reject, firstRequest.reject); + return turnId; } @@ -205,11 +222,15 @@ export class TurnFlow { return this.activeTurn !== null && this.activeTurn !== 'resuming'; } - waitForCurrentTurn(signal?: AbortSignal | undefined): Promise { - const active = this.activeTurn; - if (active === null || active === 'resuming') { - return Promise.reject(new Error('No active turn')); + private ensureActiveTurn(): ActiveTurn { + if (this.activeTurn === null || this.activeTurn === 'resuming') { + throw new Error('No active turn'); } + return this.activeTurn; + } + + waitForCurrentTurn(signal?: AbortSignal | undefined): Promise { + const active = this.ensureActiveTurn(); signal?.throwIfAborted(); if (signal === undefined) return active.promise; @@ -224,6 +245,10 @@ export class TurnFlow { }); } + waitForTurnFirstRequest(): Promise { + return this.ensureActiveTurn().firstRequest; + } + private abortTurn(reason: unknown) { if (this.activeTurn !== 'resuming') { // The reason (a user cancellation by default, or the originating signal's @@ -475,6 +500,9 @@ export class TurnFlow { if (standalone && this.currentId === turnId) { this.activeTurn = null; } + if (this.agent.swarmMode.shouldAutoExit) { + this.agent.swarmMode.exit(); + } if (errorEvent !== undefined) { this.agent.emitEvent(errorEvent); } @@ -693,6 +721,7 @@ export class TurnFlow { this.agent.context.appendLoopEvent(event); }, emitLiveEvent: (event: LoopEvent) => { + this.noteFirstRequestEvent(event); this.trackLoopTelemetry(event, turnId); const mapped = mapLoopEvent(event, turnId); if (mapped !== undefined) this.agent.emitEvent(mapped); @@ -700,6 +729,24 @@ export class TurnFlow { }); } + private noteFirstRequestEvent(event: LoopEvent): void { + switch (event.type) { + case 'step.end': + case 'content.part': + case 'tool.call': + case 'text.delta': + case 'thinking.delta': + case 'tool.call.delta': { + const active = this.activeTurn; + if (active === null || active === 'resuming') return; + active.firstRequest.resolve(); + return; + } + default: + return; + } + } + private trackLoopTelemetry(event: LoopEvent, turnId: number): void { if (event.type === 'step.begin') { this.beginTrackedStep(turnId, event.step); diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index e202cc6dd..d4a200488 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -36,6 +36,14 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, + { + id: 'agent_swarm', + title: 'Agent swarm', + description: 'Enable the AgentSwarm tool and /swarm command.', + env: 'KIMI_CODE_EXPERIMENTAL_AGENT_SWARM', + default: false, + surface: 'both', + }, { id: 'sub_skill', title: 'Sub-skill', diff --git a/packages/agent-core/src/profile/default/agent.yaml b/packages/agent-core/src/profile/default/agent.yaml index 9907703d8..794698b97 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 + - AgentSwarm - FetchURL - AskUserQuestion - EnterPlanMode diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index da186c721..a811290bc 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -3,6 +3,7 @@ import type { AgentContextData } from '#/agent/context'; import type { BackgroundTaskInfo } from '#/agent/background'; import type { PermissionData, PermissionMode } from '#/agent/permission'; import type { PlanData } from '#/agent/plan'; +import type { SwarmModeTrigger } from '#/agent/swarm'; import type { ToolInfo } from '#/agent/tool'; import type { KimiConfig, KimiConfigPatch, McpServerConfig } from '#/config'; import type { ExperimentalFeatureState } from '#/flags'; @@ -167,6 +168,9 @@ export interface SetModelResult { export interface CancelPlanPayload { readonly id?: string; } +export interface EnterSwarmPayload { + readonly trigger: SwarmModeTrigger; +} export interface BeginCompactionPayload { readonly instruction?: string; } @@ -315,6 +319,9 @@ export interface AgentAPI { enterPlan: (payload: EmptyPayload) => void; cancelPlan: (payload: CancelPlanPayload) => void; clearPlan: (payload: EmptyPayload) => void; + enterSwarm: (payload: EnterSwarmPayload) => void; + exitSwarm: (payload: EmptyPayload) => void; + getSwarmMode: (payload: EmptyPayload) => boolean; beginCompaction: (payload: BeginCompactionPayload) => void; cancelCompaction: (payload: EmptyPayload) => void; registerTool: (payload: RegisterToolPayload) => void; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index f78a9b8ef..56207ebfd 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -50,6 +50,7 @@ import type { CreateGoalPayload, CreateSessionPayload, EmptyPayload, + EnterSwarmPayload, GoalControlPayload, GoalSnapshot, GoalToolResult, @@ -523,6 +524,18 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).clearPlan(payload); } + enterSwarm({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).enterSwarm(payload); + } + + exitSwarm({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).exitSwarm(payload); + } + + getSwarmMode({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).getSwarmMode(payload); + } + beginCompaction({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).beginCompaction(payload); } @@ -943,6 +956,7 @@ async function resumeSessionResult( const context = await api.getContext({ agentId }); const permission = await api.getPermission({ agentId }); const plan = await api.getPlan({ agentId }); + const swarmMode = await api.getSwarmMode({ agentId }); const usage = await api.getUsage({ agentId }); agents[agentId] = { type: agent.type, @@ -951,6 +965,7 @@ async function resumeSessionResult( replay: agent.replayBuilder.buildResult(), permission, plan, + swarmMode, usage, tools: await api.getTools({ agentId }), toolStore: agent.tools.storeData(), diff --git a/packages/agent-core/src/rpc/events.ts b/packages/agent-core/src/rpc/events.ts index f39163a2f..73c4c2a32 100644 --- a/packages/agent-core/src/rpc/events.ts +++ b/packages/agent-core/src/rpc/events.ts @@ -48,6 +48,7 @@ export interface AgentStatusUpdatedEvent { readonly maxContextTokens?: number | undefined; readonly contextUsage?: number | undefined; readonly planMode?: boolean | undefined; + readonly swarmMode?: boolean | undefined; readonly permission?: PermissionMode | undefined; readonly usage?: UsageStatus | undefined; } @@ -208,13 +209,24 @@ export interface SubagentSpawnedEvent { readonly parentToolCallUuid?: string | undefined; readonly parentAgentId?: string | undefined; readonly description?: string | undefined; + readonly swarmIndex?: number; readonly runInBackground: boolean; } +export interface SubagentStartedEvent { + readonly type: 'subagent.started'; + readonly subagentId: string; +} + +export interface SubagentSuspendedEvent { + readonly type: 'subagent.suspended'; + readonly subagentId: string; + readonly reason: string; +} + export interface SubagentCompletedEvent { readonly type: 'subagent.completed'; readonly subagentId: string; - readonly parentToolCallId: string; readonly resultSummary: string; readonly usage?: TokenUsage | undefined; readonly contextTokens?: number | undefined; @@ -223,7 +235,6 @@ export interface SubagentCompletedEvent { export interface SubagentFailedEvent { readonly type: 'subagent.failed'; readonly subagentId: string; - readonly parentToolCallId: string; readonly error: string; } @@ -307,6 +318,8 @@ export type AgentEvent = | ToolListUpdatedEvent | McpServerStatusEvent | SubagentSpawnedEvent + | SubagentStartedEvent + | SubagentSuspendedEvent | SubagentCompletedEvent | SubagentFailedEvent | CompactionStartedEvent diff --git a/packages/agent-core/src/rpc/resumed.ts b/packages/agent-core/src/rpc/resumed.ts index 1259627f3..897471154 100644 --- a/packages/agent-core/src/rpc/resumed.ts +++ b/packages/agent-core/src/rpc/resumed.ts @@ -27,6 +27,7 @@ export interface ResumedAgentState { readonly replay: readonly AgentReplayRecord[]; readonly permission: PermissionData; readonly plan: PlanData; + readonly swarmMode?: boolean | undefined; readonly usage: UsageStatus; readonly tools: readonly ToolInfo[]; readonly toolStore?: Readonly>; diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 2d98ee827..5a0ee234a 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -76,6 +76,7 @@ export interface AgentMeta { readonly homedir: string; readonly type: AgentType; readonly parentAgentId: string | null; + readonly swarmItem?: string; } interface ResumedAgent { @@ -88,6 +89,7 @@ type AgentEntry = Agent | Promise; export interface CreateAgentOptions { readonly profile?: ResolvedAgentProfile; readonly parentAgentId?: string; + readonly swarmItem?: string; readonly persistMetadata?: boolean; } @@ -295,6 +297,7 @@ export class Session { homedir, type, parentAgentId, + swarmItem: options.swarmItem, }; void this.writeMetadata(); } @@ -329,12 +332,12 @@ export class Session { const mainAgent = this.requireMainAgent(); try { - const handle = await mainAgent.subagentHost!.spawn('coder', { + const handle = await mainAgent.subagentHost!.spawn({ + profileName: 'coder', parentToolCallId: 'generate-agents-md', prompt: DEFAULT_INIT_PROMPT, description: 'Initialize AGENTS.md', runInBackground: false, - origin: { kind: 'system_trigger', name: 'init' }, signal: new AbortController().signal, }); await handle.completion; diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index d500216bc..8cbee4ae5 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -7,6 +7,7 @@ import type { CancelPlanPayload, CreateGoalPayload, EmptyPayload, + EnterSwarmPayload, GoalControlPayload, GetBackgroundOutputPayload, GetBackgroundPayload, @@ -198,6 +199,18 @@ export class SessionAPIImpl implements PromisableMethods { return (await this.getAgent(agentId)).clearPlan(payload); } + async enterSwarm({ agentId, ...payload }: AgentScopedPayload) { + return (await this.getAgent(agentId)).enterSwarm(payload); + } + + async exitSwarm({ agentId, ...payload }: AgentScopedPayload) { + return (await this.getAgent(agentId)).exitSwarm(payload); + } + + async getSwarmMode({ agentId, ...payload }: AgentScopedPayload) { + return (await this.getAgent(agentId)).getSwarmMode(payload); + } + async beginCompaction({ agentId, ...payload }: AgentScopedPayload) { return (await this.getAgent(agentId)).beginCompaction(payload); } diff --git a/packages/agent-core/src/session/subagent-batch.ts b/packages/agent-core/src/session/subagent-batch.ts new file mode 100644 index 000000000..9146fa41b --- /dev/null +++ b/packages/agent-core/src/session/subagent-batch.ts @@ -0,0 +1,638 @@ +import { isProviderRateLimitError, type TokenUsage } from '@moonshot-ai/kosong'; +import * as retry from 'retry'; + +import type { + RunSubagentOptions, + SpawnSubagentOptions, + SubagentHandle, +} from './subagent-host'; +import { isUserCancellation } from '../utils/abort'; + +/* +Subagent batch scheduling contract: +Normal phase: +- Return results in input order; empty input returns an empty list. +- Start up to 5 tasks immediately, then 1 more every 700 ms while queued work remains; active tasks do not cap this ramp. +- Launch priority: previous agent id saved after a rate limit, explicit resume, then new spawn. +- Readiness can be reported while the attempt is active. Ready normal launches seed the first rate-limit capacity. +- The first provider rate limit stops the ramp and enters rate-limit phase. + +Rate-limit phase: +- A provider rate limit requeues while there is other unfinished work. Save the agent id for same-agent retry, emit suspended, and requeue the task at the front; its own eligibility delays are 3000 ms, 6000 ms, 12000 ms, then doubling. +- If the rate-limited attempt is the only unfinished task, fail that task instead of suspending the whole batch forever. +- Enter with capacity equal to ready normal launches, minimum 1; set the next global launch no earlier than 3000 ms later; then shrink capacity by 1, minimum 1. Later rate limits shrink by 1, minimum 1, at most once per 2000 ms. +- Each pass starts at most 1 task: active attempts must be below capacity, global launch time reached, and task eligibility reached. Choose the first eligible queued task, then set next global launch to now plus the current interval. If blocked by time or queued work remains after a launch, wake at the earlier of next launch/eligibility and next capacity recovery. +- Core recovery rule: in rate-limit phase, if work is queued and no provider rate limit happened for 3 minutes, capacity increases by 1, which can launch one more task immediately. This can happen once per quiet window; a new rate limit restarts the window. If active attempts still fill capacity, wake at the next recovery time. + +Results and cancellation: +- Completed, failed, aborted, and timed-out attempts occupy their input slots; when all slots have results, return the ordered list. A task timeout fails only that task and does not enter rate-limit phase or stop others. +- The first task signal is the batch signal. User cancellation preserves existing results, marks ready or agent-known unfinished tasks aborted/started, and marks never-started tasks aborted/not_started. Non-user cancellation rejects. +*/ + +const INITIAL_LAUNCH_LIMIT = 5; +const INITIAL_LAUNCH_INTERVAL_MS = 700; +const RATE_LIMIT_RETRY_BASE_MS = 3000; +const RATE_LIMIT_RETRY_FACTOR = 2; +const RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS = 2000; +const RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS = 3 * 60 * 1000; +const RATE_LIMIT_SUSPENDED_REASON = 'Provider rate limit; subagent requeued for retry.'; + +type BaseQueuedSubagentTask = { + readonly data: T; + readonly profileName: string; + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string; + readonly prompt: string; + readonly description: string; + readonly swarmIndex?: number; + readonly swarmItem?: string; + readonly runInBackground: boolean; + readonly timeout?: number; + readonly signal?: AbortSignal; +}; + +export type SpawnQueuedSubagentTask = BaseQueuedSubagentTask & { + readonly kind: 'spawn'; + readonly resumeAgentId?: undefined; +}; + +export type ResumeQueuedSubagentTask = BaseQueuedSubagentTask & { + readonly kind: 'resume'; + readonly resumeAgentId: string; +}; + +export type QueuedSubagentTask = + | SpawnQueuedSubagentTask + | ResumeQueuedSubagentTask; + +export type SubagentResult = { + readonly task: QueuedSubagentTask; + readonly agentId?: string; + readonly status: 'completed' | 'failed' | 'aborted'; + readonly state?: 'started' | 'not_started'; + readonly result?: string; + readonly usage?: TokenUsage; + readonly error?: string; +}; + +export type SubagentSuspendedEvent = { + readonly task: QueuedSubagentTask; + readonly agentId: string; + readonly reason: string; +}; + +export type SubagentBatchLauncher = { + spawn(options: SpawnSubagentOptions): Promise; + resume(agentId: string, options: RunSubagentOptions): Promise; + retry(agentId: string, options: RunSubagentOptions): Promise; + suspended?(event: SubagentSuspendedEvent): void; +}; + +type RateLimitedOutcome = { + readonly type: 'rate_limited'; + readonly agentId: string; + readonly error: string; +}; + +type AttemptOutcome = SubagentResult | RateLimitedOutcome; + +type TaskState = { + readonly index: number; + readonly task: QueuedSubagentTask; + agentId?: string; + retryAgentId?: string; + retryCount: number; + retryReadyAt: number; + started: boolean; +}; + +type ActiveAttempt = { + readonly state: TaskState; + readonly controller: AbortController; + cleanup: () => void; + ready: boolean; + timedOut: boolean; +}; + +export class SubagentBatch { + private readonly states: Array>; + private readonly pending: Array>; + private readonly results: Array | undefined>; + private readonly active = new Set>(); + private readonly controller = new AbortController(); + private readonly batchSignal: AbortSignal | undefined; + private readonly batchAbortListener: () => void; + private normalLaunchCount = 0; + private normalLaunchTimer: ReturnType | undefined; + private rateLimitLaunchTimer: ReturnType | undefined; + private resolve: ((results: Array>) => void) | undefined; + private reject: ((error: unknown) => void) | undefined; + private finished = false; + private started = false; + private rateLimitMode = false; + private startedSuccessCount = 0; + private rateLimitCapacity = 1; + private lastRateLimitAt: number | undefined; + private lastCapacityShrinkAt: number | undefined; + private lastCapacityRecoveryAt: number | undefined; + private globalRetryIntervalMs = RATE_LIMIT_RETRY_BASE_MS; + private nextRateLimitLaunchAt = 0; + + constructor( + private readonly launcher: SubagentBatchLauncher, + tasks: readonly QueuedSubagentTask[], + ) { + this.states = tasks.map((task, index) => ({ + index, + task, + retryCount: 0, + retryReadyAt: 0, + started: false, + })); + this.pending = [...this.states]; + this.results = Array.from({ length: tasks.length }); + this.batchSignal = tasks.find((task) => task.signal !== undefined)?.signal; + this.batchAbortListener = () => { + this.controller.abort(this.batchSignal?.reason); + if (isUserCancellation(this.batchSignal?.reason)) { + this.finishWithUserCancellation(); + } else { + this.fail(this.batchSignal?.reason ?? new Error('Aborted')); + } + }; + } + + run(): Promise>> { + if (this.started) { + throw new Error('SubagentBatch.run() can only be called once.'); + } + this.started = true; + + return new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + + if (this.states.length === 0) { + this.finish([]); + return; + } + + if (this.batchSignal?.aborted === true) { + this.batchAbortListener(); + return; + } + + this.batchSignal?.addEventListener('abort', this.batchAbortListener, { once: true }); + this.schedule(); + }); + } + + private schedule(): void { + if (this.finished) return; + if (this.finishIfComplete()) return; + if (this.controller.signal.aborted) return; + + if (this.rateLimitMode) { + this.scheduleRateLimitLaunch(); + } else { + this.scheduleNormalLaunch(); + } + } + + private scheduleNormalLaunch(): void { + while ( + this.normalLaunchCount < INITIAL_LAUNCH_LIMIT && + this.pending.length > 0 && + !this.rateLimitMode + ) { + this.startAttempt(this.pending.shift()!); + this.normalLaunchCount += 1; + } + + if ( + this.pending.length === 0 || + this.rateLimitMode || + this.normalLaunchTimer !== undefined + ) { + return; + } + + this.normalLaunchTimer = setTimeout(() => { + this.normalLaunchTimer = undefined; + if (this.finished || this.rateLimitMode || this.pending.length === 0) return; + this.startAttempt(this.pending.shift()!); + this.normalLaunchCount += 1; + this.schedule(); + }, INITIAL_LAUNCH_INTERVAL_MS); + } + + private scheduleRateLimitLaunch(): void { + this.clearRateLimitTimer(); + if (this.pending.length === 0) return; + + const now = Date.now(); + this.recoverRateLimitCapacity(now); + if (this.active.size >= this.rateLimitCapacity) { + this.scheduleRateLimitWakeup(this.nextRateLimitCapacityRecoveryAt(), now); + return; + } + + const nextAllowedAt = Math.max(this.nextRateLimitLaunchAt, this.nextPendingReadyAt()); + const nextWakeupAt = Math.min(nextAllowedAt, this.nextRateLimitCapacityRecoveryAt()); + if (nextWakeupAt > now) { + this.scheduleRateLimitWakeup(nextWakeupAt, now); + return; + } + + const pendingIndex = this.pending.findIndex((state) => state.retryReadyAt <= now); + if (pendingIndex === -1) return; + + const [state] = this.pending.splice(pendingIndex, 1); + this.startAttempt(state!); + this.nextRateLimitLaunchAt = now + this.globalRetryIntervalMs; + this.scheduleNextRateLimitWakeup(now); + } + + private startAttempt(state: TaskState): void { + if (this.finished || this.controller.signal.aborted) return; + + const attempt: ActiveAttempt = { + state, + controller: new AbortController(), + cleanup: () => {}, + ready: false, + timedOut: false, + }; + attempt.cleanup = this.linkAttemptSignals(attempt, state.task); + this.active.add(attempt); + + this.runAttempt(attempt).then( + (outcome) => { + this.handleAttemptOutcome(attempt, outcome); + }, + (error) => { + this.handleAttemptError(attempt, error); + }, + ); + } + + private async runAttempt(attempt: ActiveAttempt): Promise> { + const task = attempt.state.task; + const runOptions: RunSubagentOptions = { + parentToolCallId: task.parentToolCallId, + parentToolCallUuid: task.parentToolCallUuid, + prompt: task.prompt, + description: task.description, + swarmIndex: task.swarmIndex, + runInBackground: task.runInBackground, + signal: attempt.controller.signal, + onReady: () => { + this.markAttemptReady(attempt); + }, + suppressRateLimitFailureEvent: true, + }; + + let handle: SubagentHandle; + try { + attempt.controller.signal.throwIfAborted(); + if (attempt.state.retryAgentId !== undefined) { + handle = await this.launcher.retry(attempt.state.retryAgentId, runOptions); + } else if (task.kind === 'resume') { + handle = await this.launcher.resume(task.resumeAgentId, runOptions); + } else { + const spawnOptions: SpawnSubagentOptions = { + profileName: task.profileName, + swarmItem: task.swarmItem, + ...runOptions, + }; + handle = await this.launcher.spawn(spawnOptions); + } + } catch (error) { + return this.failedAttemptOutcome(attempt, error); + } + + attempt.state.agentId = handle.agentId; + try { + const completion = await handle.completion; + return { + task, + agentId: handle.agentId, + status: 'completed', + result: completion.result, + usage: completion.usage, + }; + } catch (error) { + if (isProviderRateLimitError(error)) { + return { + type: 'rate_limited', + agentId: handle.agentId, + error: this.attemptErrorMessage(attempt, error, 'failed'), + }; + } + + return this.failedAttemptOutcome(attempt, error); + } + } + + private failedAttemptOutcome(attempt: ActiveAttempt, error: unknown): SubagentResult { + const status = + attempt.controller.signal.aborted && isUserCancellation(attempt.controller.signal.reason) + ? 'aborted' + : 'failed'; + return { + task: attempt.state.task, + agentId: attempt.state.agentId, + status, + state: attempt.state.agentId === undefined ? 'not_started' : 'started', + error: this.attemptErrorMessage(attempt, error, status), + }; + } + + private markAttemptReady(attempt: ActiveAttempt): void { + if (this.finished || attempt.ready || !this.active.has(attempt)) return; + + attempt.ready = true; + attempt.state.started = true; + if (!this.rateLimitMode) { + this.startedSuccessCount += 1; + } + + if (this.rateLimitMode) { + this.globalRetryIntervalMs = RATE_LIMIT_RETRY_BASE_MS; + this.nextRateLimitLaunchAt = Date.now() + this.globalRetryIntervalMs; + this.schedule(); + } + } + + private handleAttemptOutcome(attempt: ActiveAttempt, outcome: AttemptOutcome): void { + if (!this.releaseAttempt(attempt)) return; + if (this.finished) return; + + if ('status' in outcome) { + this.results[attempt.state.index] = outcome; + } else if (this.isOnlyUnfinishedTask(attempt.state)) { + this.results[attempt.state.index] = { + task: attempt.state.task, + agentId: outcome.agentId, + status: 'failed', + state: 'started', + error: outcome.error, + }; + } else { + this.requeueRateLimited(attempt, outcome.agentId); + } + this.schedule(); + } + + private handleAttemptError(attempt: ActiveAttempt, error: unknown): void { + if (!this.releaseAttempt(attempt)) return; + if (this.finished) return; + this.results[attempt.state.index] = { + task: attempt.state.task, + agentId: attempt.state.agentId, + status: 'failed', + error: error instanceof Error ? error.message : String(error), + }; + this.schedule(); + } + + private releaseAttempt(attempt: ActiveAttempt): boolean { + if (!this.active.delete(attempt)) return false; + attempt.cleanup(); + return true; + } + + private requeueRateLimited(attempt: ActiveAttempt, agentId: string): void { + const state = attempt.state; + state.agentId = agentId; + state.retryAgentId = agentId; + this.launcher.suspended?.({ + task: state.task, + agentId, + reason: RATE_LIMIT_SUSPENDED_REASON, + }); + + const now = Date.now(); + this.lastRateLimitAt = now; + state.retryCount += 1; + const retryDelay = retry.createTimeout(Math.max(0, state.retryCount - 1), { + minTimeout: RATE_LIMIT_RETRY_BASE_MS, + maxTimeout: Number.POSITIVE_INFINITY, + factor: RATE_LIMIT_RETRY_FACTOR, + randomize: false, + }); + state.retryReadyAt = now + retryDelay; + this.pending.unshift(state); + this.enterRateLimitMode(now); + + if (!attempt.ready) { + this.globalRetryIntervalMs = Math.max(this.globalRetryIntervalMs * 2, retryDelay); + this.nextRateLimitLaunchAt = Math.max( + this.nextRateLimitLaunchAt, + now + this.globalRetryIntervalMs, + ); + } else { + this.nextRateLimitLaunchAt = Math.max( + this.nextRateLimitLaunchAt, + now + RATE_LIMIT_RETRY_BASE_MS, + ); + } + } + + private enterRateLimitMode(now: number): void { + if (!this.rateLimitMode) { + this.rateLimitMode = true; + this.clearNormalTimer(); + this.rateLimitCapacity = Math.max(1, this.startedSuccessCount); + this.nextRateLimitLaunchAt = Math.max( + this.nextRateLimitLaunchAt, + now + RATE_LIMIT_RETRY_BASE_MS, + ); + this.shrinkRateLimitCapacity(now, true); + return; + } + + this.shrinkRateLimitCapacity(now, false); + } + + private shrinkRateLimitCapacity(now: number, force: boolean): void { + if ( + !force && + this.lastCapacityShrinkAt !== undefined && + now - this.lastCapacityShrinkAt < RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS + ) { + return; + } + + this.rateLimitCapacity = Math.max(1, this.rateLimitCapacity - 1); + this.lastCapacityShrinkAt = now; + } + + private recoverRateLimitCapacity(now: number): void { + const nextRecoveryAt = this.nextRateLimitCapacityRecoveryAt(); + if (nextRecoveryAt > now) return; + + this.rateLimitCapacity += 1; + this.lastCapacityRecoveryAt = now; + this.nextRateLimitLaunchAt = Math.min(this.nextRateLimitLaunchAt, now); + } + + private nextRateLimitCapacityRecoveryAt(): number { + if (this.pending.length === 0 || this.lastRateLimitAt === undefined) { + return Number.POSITIVE_INFINITY; + } + + const latestCapacityChangeAt = Math.max( + this.lastRateLimitAt, + this.lastCapacityRecoveryAt ?? 0, + ); + return latestCapacityChangeAt + RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS; + } + + private scheduleRateLimitWakeup(wakeupAt: number, now: number): void { + if (!Number.isFinite(wakeupAt) || wakeupAt <= now) return; + this.rateLimitLaunchTimer = setTimeout(() => { + this.rateLimitLaunchTimer = undefined; + this.schedule(); + }, wakeupAt - now); + } + + private scheduleNextRateLimitWakeup(now: number): void { + if (this.pending.length === 0) return; + + const nextWakeupAt = + this.active.size >= this.rateLimitCapacity + ? this.nextRateLimitCapacityRecoveryAt() + : Math.min( + Math.max(this.nextRateLimitLaunchAt, this.nextPendingReadyAt()), + this.nextRateLimitCapacityRecoveryAt(), + ); + + this.scheduleRateLimitWakeup(nextWakeupAt, now); + } + + private nextPendingReadyAt(): number { + return this.pending.reduce((nextAt, state) => { + return Math.min(nextAt, state.retryReadyAt); + }, Number.POSITIVE_INFINITY); + } + + private finishIfComplete(): boolean { + if (this.results.every((result) => result !== undefined)) { + this.finish(this.results); + return true; + } + return false; + } + + private isOnlyUnfinishedTask(state: TaskState): boolean { + return this.results.every((result, index) => index === state.index || result !== undefined); + } + + private finishWithUserCancellation(): void { + if (this.finished) return; + + this.finish( + this.states.map((state) => { + const result = this.results[state.index]; + if (result !== undefined) return result; + + if (state.started || state.agentId !== undefined) { + return { + task: state.task, + agentId: state.agentId, + status: 'aborted', + state: 'started', + error: + 'The user manually interrupted this subagent batch before this subagent finished.', + }; + } + + return { + task: state.task, + status: 'aborted', + state: 'not_started', + error: + 'The user manually interrupted this subagent batch before this subagent was started.', + }; + }), + ); + } + + private finish(results: Array>): void { + if (this.finished) return; + this.finished = true; + this.cleanup(); + this.resolve?.(results); + } + + private fail(error: unknown): void { + if (this.finished) return; + this.finished = true; + this.cleanup(); + this.reject?.(error); + } + + private cleanup(): void { + this.batchSignal?.removeEventListener('abort', this.batchAbortListener); + this.clearNormalTimer(); + this.clearRateLimitTimer(); + for (const attempt of this.active.values()) { + attempt.cleanup(); + } + this.active.clear(); + } + + private clearNormalTimer(): void { + if (this.normalLaunchTimer !== undefined) clearTimeout(this.normalLaunchTimer); + this.normalLaunchTimer = undefined; + } + + private clearRateLimitTimer(): void { + if (this.rateLimitLaunchTimer !== undefined) clearTimeout(this.rateLimitLaunchTimer); + this.rateLimitLaunchTimer = undefined; + } + + private linkAttemptSignals(attempt: ActiveAttempt, task: QueuedSubagentTask): () => void { + const abortFromBatch = () => { + attempt.controller.abort(this.controller.signal.reason); + }; + const abortFromTask = () => { + attempt.controller.abort(task.signal?.reason); + }; + const timeout = + task.timeout === undefined + ? undefined + : setTimeout(() => { + attempt.timedOut = true; + attempt.controller.abort(new Error('Aborted')); + }, task.timeout); + + if (this.controller.signal.aborted) { + abortFromBatch(); + } else if (task.signal?.aborted === true) { + abortFromTask(); + } else { + this.controller.signal.addEventListener('abort', abortFromBatch, { once: true }); + task.signal?.addEventListener('abort', abortFromTask, { once: true }); + } + + return () => { + if (timeout !== undefined) clearTimeout(timeout); + this.controller.signal.removeEventListener('abort', abortFromBatch); + task.signal?.removeEventListener('abort', abortFromTask); + }; + } + + private attemptErrorMessage( + attempt: ActiveAttempt, + error: unknown, + status: SubagentResult['status'], + ): string { + if (attempt.timedOut && attempt.state.task.timeout !== undefined) { + return 'Subagent timed out.'; + } + if (status === 'aborted') return 'The user manually interrupted this subagent batch.'; + return error instanceof Error ? error.message : String(error); + } +} diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 17430d3dd..38e68b947 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -1,20 +1,44 @@ -import type { TokenUsage } from '@moonshot-ai/kosong'; +import { + APIProviderRateLimitError, + isProviderRateLimitError, + type TokenUsage, +} from '@moonshot-ai/kosong'; import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; +import { ErrorCodes, type KimiErrorPayload } from '../errors'; import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; import { InMemoryAgentRecordPersistence } from '../agent/records'; -import type { LoopTurnStopReason } from '../loop'; +import { isAbortError } from '../loop/errors'; import { DEFAULT_AGENT_PROFILES, prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; -import { linkAbortSignal, userCancellationReason } from '../utils/abort'; +import { + linkAbortSignal, + userCancellationReason, +} from '../utils/abort'; import { collectGitContext } from './git-context'; import type { Session } from './index'; +import { + SubagentBatch, + type SubagentResult, + type SubagentSuspendedEvent, + type QueuedSubagentTask, +} from './subagent-batch'; import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md'; +export const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000; +export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '30 minutes'; + +export type { + SubagentResult as QueuedSubagentRunResult, + QueuedSubagentTask, + ResumeQueuedSubagentTask, + SpawnQueuedSubagentTask, +} from './subagent-batch'; + /** * A subagent summary shorter than this many characters triggers one * follow-up turn that asks the subagent to expand it, so the parent @@ -27,6 +51,7 @@ const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; const TOOL_CALL_DISABLED_MESSAGE = 'Tool calls are disabled for side questions. Answer with text only.'; +const SUBAGENT_PROMPT_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'subagent' }; const SIDE_QUESTION_SYSTEM_REMINDER = ` This is a side-channel conversation with the user. You should answer user questions directly based on what you already know. @@ -42,26 +67,28 @@ IMPORTANT: - If you do not know the answer, say so directly. `; -type RunSubagentOptions = { +export interface RunSubagentOptions { readonly parentToolCallId: string; - readonly parentToolCallUuid?: string | undefined; + readonly parentToolCallUuid?: string; readonly prompt: string; readonly description: string; + readonly swarmIndex?: number; readonly runInBackground: boolean; - readonly origin?: PromptOrigin | undefined; readonly signal: AbortSignal; -}; + readonly onReady?: () => void; + readonly suppressRateLimitFailureEvent?: boolean; +} + +export interface SpawnSubagentOptions extends RunSubagentOptions { + readonly profileName: string; + readonly swarmItem?: string; +} type SubagentCompletion = { readonly result: string; readonly usage?: TokenUsage; }; -type ActiveChild = { - readonly controller: AbortController; - readonly runInBackground: boolean; -}; - export type SubagentHandle = { readonly agentId: string; readonly profileName: string; @@ -70,45 +97,38 @@ export type SubagentHandle = { }; export class SessionSubagentHost { - private readonly activeChildren = new Map(); + private readonly activeChildren = new Map< + string, + { + readonly controller: AbortController; + readonly runInBackground: boolean; + } + >(); constructor( private readonly session: Session, private readonly ownerAgentId: string, ) {} - async spawn(profileName: string, options: RunSubagentOptions): Promise { + async spawn(options: SpawnSubagentOptions): Promise { options.signal.throwIfAborted(); const parent = await this.session.ensureAgentResumed(this.ownerAgentId); - - const profile = this.resolveProfile(parent, profileName); + const profile = this.resolveProfile(parent, options.profileName); const { id, agent } = await this.session.createAgent( { type: 'sub', generate: parent.rawGenerate }, - { parentAgentId: this.ownerAgentId }, + { parentAgentId: this.ownerAgentId, swarmItem: options.swarmItem }, ); - const controller = new AbortController(); - const unlinkAbortSignal = linkAbortSignal(options.signal, controller); - this.activeChildren.set(id, { - controller, - runInBackground: options.runInBackground, - }); - - const completion = this.runChild( - parent, - id, - agent, - profile.name, - { - ...options, - signal: controller.signal, - }, - () => this.configureChild(parent, agent, profile), - ).finally(() => { - unlinkAbortSignal(); - this.activeChildren.delete(id); + const completion = this.runWithActiveChild(id, options, async (runOptions) => { + this.emitSubagentSpawned(parent, id, profile.name, runOptions); + try { + await this.configureChild(parent, agent, profile); + return await this.runPromptTurn(parent, id, agent, profile.name, runOptions); + } catch (error) { + this.emitSubagentFailed(parent, id, runOptions, error); + throw error; + } }); - return { agentId: id, profileName: profile.name, @@ -119,7 +139,45 @@ export class SessionSubagentHost { async resume(agentId: string, options: RunSubagentOptions): Promise { options.signal.throwIfAborted(); + const { parent, child, profileName } = await this.ensureIdleSubagent(agentId); + const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { + this.emitSubagentSpawned(parent, agentId, profileName, runOptions); + try { + child.config.update({ modelAlias: parent.config.modelAlias }); + return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); + } catch (error) { + this.emitSubagentFailed(parent, agentId, runOptions, error); + throw error; + } + }); + return { agentId, profileName, resumed: true, completion }; + } + async retry(agentId: string, options: RunSubagentOptions): Promise { + options.signal.throwIfAborted(); + const { parent, child, profileName } = await this.ensureIdleSubagent(agentId); + const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { + try { + runOptions.signal.throwIfAborted(); + child.config.update({ modelAlias: parent.config.modelAlias }); + this.emitSubagentStarted(parent, agentId); + const turnId = child.turn.retry('agent-host'); + if (turnId === null) { + throw new Error(`Agent instance "${agentId}" could not start a retry turn`); + } + this.observeFirstRequest(child, runOptions); + return await this.waitForChildCompletion(parent, agentId, child, profileName, runOptions); + } catch (error) { + this.emitSubagentFailed(parent, agentId, runOptions, error); + throw error; + } + }); + return { agentId, profileName, resumed: true, completion }; + } + + private async ensureIdleSubagent( + agentId: string, + ): Promise<{ readonly parent: Agent; readonly child: Agent; readonly profileName: string }> { const parent = await this.session.ensureAgentResumed(this.ownerAgentId); const metadata = this.session.metadata.agents[agentId]; if (metadata?.type !== 'sub') { @@ -130,47 +188,24 @@ export class SessionSubagentHost { } const child = await this.session.ensureAgentResumed(agentId); if (this.activeChildren.has(agentId) || child.turn.hasActiveTurn) { - throw new Error( - `Agent instance "${agentId}" is already running and cannot be resumed concurrently`, - ); + throw new Error(`Agent instance "${agentId}" is already running and cannot run concurrently`); } const profileName = child.config.profileName ?? 'subagent'; + return { parent, child, profileName }; + } - const controller = new AbortController(); - const unlinkAbortSignal = linkAbortSignal(options.signal, controller); - this.activeChildren.set(agentId, { - controller, - runInBackground: options.runInBackground, - }); + async runQueued(tasks: readonly QueuedSubagentTask[]): Promise>> { + return new SubagentBatch(this, tasks).run(); + } - const completion = this.runChild( - parent, - agentId, - child, - profileName, - { - ...options, - signal: controller.signal, - }, - // A resumed subagent is realigned to the parent agent's current model, - // so a parent setModel between the initial spawn and the resume is - // reflected — a subagent always uses the parent agent's model. - () => { - child.config.update({ modelAlias: parent.config.modelAlias }); - return Promise.resolve(); - }, - ).finally(() => { - unlinkAbortSignal(); - this.activeChildren.delete(agentId); + suspended(event: SubagentSuspendedEvent): void { + const parent = this.session.getReadyAgent?.(this.ownerAgentId); + parent?.emitEvent({ + type: 'subagent.suspended', + subagentId: event.agentId, + reason: event.reason, }); - - return { - agentId, - profileName, - resumed: true, - completion, - }; } async startBtw(): Promise { @@ -219,6 +254,14 @@ export class SessionSubagentHost { return (await this.session.ensureAgentResumed(agentId)).config.profileName; } + getSwarmItem(agentId: string): string | undefined { + const metadata = this.session.metadata.agents[agentId]; + if (metadata?.type !== 'sub' || metadata.parentAgentId !== this.ownerAgentId) { + return undefined; + } + return metadata.swarmItem; + } + private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile { const profile = DEFAULT_AGENT_PROFILES[parent.config.profileName ?? 'agent']?.subagents?.[profileName] ?? @@ -229,80 +272,82 @@ export class SessionSubagentHost { return profile; } - private async runChild( - parent: Agent, + private runWithActiveChild( childId: string, - child: Agent, - profileName: string, options: RunSubagentOptions, - prepareChild: () => Promise, + run: (options: RunSubagentOptions) => Promise, ): Promise { - parent.emitEvent({ - type: 'subagent.spawned', - subagentId: childId, - subagentName: profileName, - parentToolCallId: options.parentToolCallId, - parentToolCallUuid: options.parentToolCallUuid, - parentAgentId: this.ownerAgentId, - description: options.description, + const controller = new AbortController(); + const unlinkAbortSignal = linkAbortSignal(options.signal, controller); + this.activeChildren.set(childId, { + controller, runInBackground: options.runInBackground, }); - parent.telemetry.track('subagent_created', { - subagent_name: profileName, - run_in_background: options.runInBackground, + + return run({ ...options, signal: controller.signal }).finally(() => { + unlinkAbortSignal(); + this.activeChildren.delete(childId); }); + } - try { - await prepareChild(); - options.signal.throwIfAborted(); - await this.triggerSubagentStart(parent, profileName, options.prompt, options.signal); - options.signal.throwIfAborted(); + private async runPromptTurn( + parent: Agent, + childId: string, + child: Agent, + profileName: string, + options: RunSubagentOptions, + ): Promise { + options.signal.throwIfAborted(); + await this.triggerSubagentStart(parent, profileName, options.prompt, options.signal); + options.signal.throwIfAborted(); - // Explore subagents start cold; a git-context block helps them orient - // in the repository before searching. - let childPrompt = options.prompt; - if (profileName === 'explore') { - const gitContext = await collectGitContext(child.kaos, child.config.cwd); - if (gitContext) childPrompt = `${gitContext}\n\n${childPrompt}`; - } - const origin: PromptOrigin = options.origin ?? { kind: 'system_trigger', name: 'subagent' }; - child.turn.prompt([{ type: 'text', text: childPrompt }], origin); - await runChildTurnToCompletion(child, options.signal); + let childPrompt = options.prompt; + if (profileName === 'explore') { + const gitContext = await collectGitContext(child.kaos, child.config.cwd); + if (gitContext) childPrompt = `${gitContext}\n\n${childPrompt}`; + } - // 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 usage = child.usage.data().total; - parent.emitEvent({ - type: 'subagent.completed', - subagentId: childId, - parentToolCallId: options.parentToolCallId, - resultSummary: result, - usage, - contextTokens: child.context.tokenCount, - }); - this.triggerSubagentStop(parent, profileName, result); - return { result, usage }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - parent.emitEvent({ - type: 'subagent.failed', - subagentId: childId, - parentToolCallId: options.parentToolCallId, - error: message, - }); - throw error; + this.emitSubagentStarted(parent, childId); + const turnId = child.turn.prompt([{ type: 'text', text: childPrompt }], SUBAGENT_PROMPT_ORIGIN); + if (turnId === null) { + throw new Error(`Agent instance "${childId}" could not start a turn`); + } + this.observeFirstRequest(child, options); + return this.waitForChildCompletion(parent, childId, child, profileName, options); + } + + private async waitForChildCompletion( + parent: Agent, + childId: string, + child: Agent, + profileName: string, + options: RunSubagentOptions, + ): Promise { + 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 }], SUBAGENT_PROMPT_ORIGIN); + await runChildTurnToCompletion(child, options.signal); + result = lastAssistantText(child); } + const usage = child.usage.data().total; + parent.emitEvent({ + type: 'subagent.completed', + subagentId: childId, + resultSummary: result, + usage, + contextTokens: child.context.tokenCount, + }); + this.triggerSubagentStop(parent, profileName, result); + return { result, usage }; } private async configureChild( @@ -347,27 +392,92 @@ export class SessionSubagentHost { }, }); } + + private observeFirstRequest( + child: Agent, + options: RunSubagentOptions, + ): void { + if (options.onReady === undefined) return; + void child.turn + .waitForTurnFirstRequest() + .then(() => { + options.onReady?.(); + }) + .catch(() => {}); + } + + private emitSubagentSpawned( + parent: Agent, + childId: string, + profileName: string, + options: RunSubagentOptions, + ): void { + parent.emitEvent({ + type: 'subagent.spawned', + subagentId: childId, + subagentName: profileName, + parentToolCallId: options.parentToolCallId, + parentToolCallUuid: options.parentToolCallUuid, + parentAgentId: this.ownerAgentId, + description: options.description, + swarmIndex: options.swarmIndex, + runInBackground: options.runInBackground, + }); + parent.telemetry.track('subagent_created', { + subagent_name: profileName, + run_in_background: options.runInBackground, + }); + } + + private emitSubagentStarted( + parent: Agent, + childId: string, + ): void { + parent.emitEvent({ + type: 'subagent.started', + subagentId: childId, + }); + } + + private emitSubagentFailed( + parent: Agent, + childId: string, + options: RunSubagentOptions, + error: unknown, + ): void { + if (shouldSuppressQueuedAttemptFailureEvent(options, error)) return; + parent.emitEvent({ + type: 'subagent.failed', + subagentId: childId, + error: error instanceof Error ? error.message : String(error), + }); + } } async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Promise { const completion = await child.turn.waitForCurrentTurn(signal); const turnEnded = completion.event; if (turnEnded.reason !== 'completed') { + if (turnEnded.error?.code === ErrorCodes.PROVIDER_RATE_LIMIT) { + throw providerRateLimitErrorFromPayload(turnEnded.error); + } throw new Error( turnEnded.error === undefined ? `Subagent turn ${turnEnded.reason}` : `[${turnEnded.error.code}] ${turnEnded.error.message}`, ); } - throwIfSubagentStoppedAtMaxTokens(completion.stopReason); -} - -function throwIfSubagentStoppedAtMaxTokens(stopReason: LoopTurnStopReason | undefined): void { - if (stopReason === 'max_tokens') { + if (completion.stopReason === 'max_tokens') { throw new Error(`${SUBAGENT_MAX_TOKENS_ERROR}.`); } } +function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError { + const requestId = + typeof error.details?.['requestId'] === 'string' ? error.details['requestId'] : null; + return new APIProviderRateLimitError(error.message, requestId); +} + function lastAssistantText(agent: Agent): string { for (const message of [...agent.context.history].toReversed()) { if (message.role !== 'assistant') continue; @@ -379,3 +489,12 @@ function lastAssistantText(agent: Agent): string { } return ''; } + +function shouldSuppressQueuedAttemptFailureEvent( + options: RunSubagentOptions, + error: unknown, +): boolean { + if (options.suppressRateLimitFailureEvent !== true) return false; + if (isProviderRateLimitError(error)) return true; + return isAbortError(error) || options.signal.aborted; +} diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md new file mode 100644 index 000000000..38efbc69e --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md @@ -0,0 +1,7 @@ +Launch multiple subagents from one prompt template, existing agent resumes, or both. + +Use AgentSwarm when many subagents should run the same kind of task over different inputs. The placeholder is exactly `{{item}}`. For example, with `prompt_template` set to `Review {{item}} for likely regressions.` and `items` set to `["src/a.ts", "src/b.ts"]`, AgentSwarm launches two new subagents with those two concrete prompts. + +Use `resume_agent_ids` to continue subagents that already exist from earlier work, such as ones that failed or timed out: map each agent id to the prompt for that resumed subagent (usually `continue` if no extra information is needed). You may combine `resume_agent_ids` with `items` in the same call to resume existing subagents and launch new ones. Do not duplicate resumed work in `items`. + +Use enough subagents to keep the work focused and parallel. AgentSwarm supports up to 128 subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items. diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts new file mode 100644 index 000000000..c5d497dbe --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -0,0 +1,289 @@ +import { z } from 'zod'; + +import type { SwarmMode } from '../../../agent/swarm'; +import type { BuiltinTool } from '../../../agent/tool'; +import { + DEFAULT_SUBAGENT_TIMEOUT_MS, + type QueuedSubagentTask, + type SessionSubagentHost, +} from '../../../session/subagent-host'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { toInputJsonSchema } from '../../support/input-schema'; +import AGENT_SWARM_DESCRIPTION from './agent-swarm.md'; + +const DEFAULT_SUBAGENT_TYPE = 'coder'; +const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}'; +const MAX_AGENT_SWARM_SUBAGENTS = 128; + +export const AgentSwarmToolInputSchema = z + .object({ + description: z + .string() + .trim() + .min(1) + .describe('Short description for the whole swarm.'), + subagent_type: z + .string() + .trim() + .min(1) + .optional() + .describe( + 'Subagent type used for every spawned subagent. Defaults to coder when omitted.', + ), + prompt_template: z + .string() + .trim() + .min(1) + .optional() + .describe( + `Prompt template for each subagent. The ${PROMPT_TEMPLATE_PLACEHOLDER} placeholder is replaced with each item value.`, + ), + items: z + .array(z.string().trim().min(1)) + .max(MAX_AGENT_SWARM_SUBAGENTS) + .optional() + .describe( + `Values used to fill ${PROMPT_TEMPLATE_PLACEHOLDER}. Each item launches one new subagent.`, + ), + resume_agent_ids: z + .record(z.string().trim().min(1), z.string().trim().min(1)) + .optional() + .describe( + 'Map of existing subagent agent_id to the prompt used to resume that subagent. These resumed subagents are launched before new item-based subagents.', + ), + }) + .strict(); + +export type AgentSwarmToolInput = z.infer; + +interface AgentSwarmSpawnSpec { + readonly kind: 'spawn'; + readonly index: number; + readonly item: string; + readonly prompt: string; +} + +interface AgentSwarmResumeSpec { + readonly kind: 'resume'; + readonly index: number; + readonly agentId: string; + readonly item?: string; + readonly prompt: string; +} + +type AgentSwarmSpec = AgentSwarmSpawnSpec | AgentSwarmResumeSpec; + +interface SwarmRunResult { + readonly spec: AgentSwarmSpec; + readonly agentId?: string; + readonly status: 'completed' | 'failed' | 'aborted'; + readonly state?: 'started' | 'not_started'; + readonly result?: string; + readonly error?: string; +} + +export class AgentSwarmTool implements BuiltinTool { + readonly name = 'AgentSwarm' as const; + readonly description = AGENT_SWARM_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); + + constructor( + private readonly subagentHost: SessionSubagentHost, + private readonly swarmMode: SwarmMode, + ) {} + + resolveExecution(args: AgentSwarmToolInput): ToolExecution { + const agentCount = (args.items?.length ?? 0) + Object.keys(args.resume_agent_ids ?? {}).length; + return { + accesses: ToolAccesses.all(), + description: `Launching agent swarm: ${args.description}`, + display: { + kind: 'agent_call', + agent_name: `swarm (${agentCount} subagents)`, + prompt: args.description, + }, + approvalRule: this.name, + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: AgentSwarmToolInput, + context: ExecutableToolContext, + ): Promise { + try { + this.swarmMode.enter('tool'); + const result = await this.runSwarm(args, context.signal, context.toolCallId); + return { + output: result, + }; + } catch (error) { + return { + output: error instanceof Error ? error.message : String(error), + isError: true, + }; + } + } + + private async runSwarm( + args: AgentSwarmToolInput, + signal: AbortSignal, + toolCallId: string, + ): Promise { + const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; + const specs = createAgentSwarmSpecs(args, (agentId) => this.subagentHost.getSwarmItem(agentId)); + const tasks = specs.map((spec): QueuedSubagentTask => { + const descriptionName = spec.kind === 'resume' ? 'resume' : profileName; + const common = { + data: spec, + profileName: spec.kind === 'resume' ? 'subagent' : profileName, + parentToolCallId: toolCallId, + prompt: spec.prompt, + description: childDescription(args.description, spec.index, descriptionName), + swarmIndex: spec.index, + runInBackground: false, + swarmItem: spec.item, + signal, + timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, + }; + if (spec.kind === 'resume') { + return { + ...common, + kind: 'resume', + resumeAgentId: spec.agentId, + }; + } + return { + ...common, + kind: 'spawn', + }; + }); + const results = await this.subagentHost.runQueued(tasks); + return renderSwarmResults(results.map(({ task, ...result }) => ({ spec: task.data, ...result }))); + } +} + +function createAgentSwarmSpecs( + args: AgentSwarmToolInput, + getResumeItem: (agentId: string) => string | undefined, +): AgentSwarmSpec[] { + const resumeEntries = Object.entries(args.resume_agent_ids ?? {}).map(([agentId, prompt]) => ({ + agentId: agentId.trim(), + prompt: prompt.trim(), + })); + const items = (args.items ?? []).map((item) => item.trim()); + const itemCount = items.length; + const resumeCount = resumeEntries.length; + const totalCount = resumeCount + itemCount; + if (!hasMinimumAgentSwarmInputs(itemCount, resumeCount)) { + throw new Error('AgentSwarm requires at least 2 items unless resume_agent_ids is provided.'); + } + if (totalCount > MAX_AGENT_SWARM_SUBAGENTS) { + throw new Error(`AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`); + } + const promptTemplate = normalizeOptionalString(args.prompt_template); + if (items.length > 0 && promptTemplate === undefined) { + throw new Error('prompt_template is required when items are provided.'); + } + if (promptTemplate !== undefined && !promptTemplate.includes(PROMPT_TEMPLATE_PLACEHOLDER)) { + throw new Error( + `prompt_template must include the ${PROMPT_TEMPLATE_PLACEHOLDER} placeholder.`, + ); + } + + const seenPrompts = new Map(); + const specs: AgentSwarmSpec[] = []; + for (const entry of resumeEntries) { + specs.push({ + kind: 'resume', + index: specs.length + 1, + agentId: entry.agentId, + item: getResumeItem(entry.agentId), + prompt: entry.prompt, + }); + } + if (items.length > 0) { + const itemPromptTemplate = promptTemplate!; + items.forEach((item, index) => { + const prompt = itemPromptTemplate.split(PROMPT_TEMPLATE_PLACEHOLDER).join(item); + const previousIndex = seenPrompts.get(prompt); + if (previousIndex !== undefined) { + throw new Error( + `Duplicate subagent prompts from items ${String(previousIndex)} and ${String(index + 1)}. AgentSwarm requires distinct subagents.`, + ); + } + seenPrompts.set(prompt, index + 1); + specs.push({ + kind: 'spawn', + index: specs.length + 1, + item, + prompt, + }); + }); + } + return specs; +} + +function hasMinimumAgentSwarmInputs(itemCount: number, resumeCount: number): boolean { + return resumeCount > 0 || itemCount >= 2; +} + +function childDescription(swarmDescription: string, index: number, profileName: string): string { + return `${swarmDescription} #${String(index)} (${profileName})`; +} + +function renderSwarmResults(results: readonly SwarmRunResult[]): string { + const completed = results.filter((result) => result.status === 'completed').length; + const failed = results.filter((result) => result.status === 'failed').length; + const aborted = results.filter((result) => result.status === 'aborted').length; + const shouldRenderResumeHint = + results.some((result) => result.status !== 'completed') && + results.some((result) => result.agentId !== undefined); + const lines = [ + '', + `${renderSwarmSummary(completed, failed, aborted)}`, + ]; + + if (shouldRenderResumeHint) { + lines.push( + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + ); + } + + for (const result of results) { + const agentId = result.agentId === undefined ? '' : ` agent_id="${result.agentId}"`; + const mode = result.spec.kind === 'resume' ? ' mode="resume"' : ''; + const item = result.spec.item === undefined ? '' : ` item="${escapeXmlAttribute(result.spec.item)}"`; + const state = result.state === undefined ? '' : ` state="${result.state}"`; + const body = result.status === 'completed' ? (result.result ?? '') : (result.error ?? 'unknown error'); + lines.push( + `${body}`, + ); + } + + lines.push(''); + return lines.join('\n'); +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function renderSwarmSummary(completed: number, failed: number, aborted = 0): string { + const parts: string[] = []; + if (completed > 0) parts.push(`completed: ${String(completed)}`); + if (failed > 0) parts.push(`failed: ${String(failed)}`); + if (aborted > 0) parts.push(`aborted: ${String(aborted)}`); + return parts.join(', '); +} + +function escapeXmlAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 9e8af9be3..f5b4d9197 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -24,7 +24,12 @@ import { ToolAccesses } from '../../../loop/tool-access'; import { isAbortError } from '../../../loop/errors'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; import type { ResolvedAgentProfile } from '../../../profile'; -import type { SessionSubagentHost, SubagentHandle } from '../../../session/subagent-host'; +import { + DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION, + DEFAULT_SUBAGENT_TIMEOUT_MS, + type SessionSubagentHost, + type SubagentHandle, +} from '../../../session/subagent-host'; import { createDeadlineAbortSignal, isUserCancellation, @@ -99,9 +104,6 @@ export type AgentToolOutput = z.infer; const BACKGROUND_AGENT_UNAVAILABLE = 'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.'; -const AGENT_TIMEOUT_SECONDS = 30 * 60; -const AGENT_TIMEOUT_MS = AGENT_TIMEOUT_SECONDS * 1000; -const AGENT_TIMEOUT_DESCRIPTION = '30 minutes'; // ── AgentTool class ────────────────────────────────────────────────── @@ -186,7 +188,7 @@ export class AgentTool implements BuiltinTool { } const backgroundController = runInBackground ? new AbortController() : undefined; foregroundDeadline = - !runInBackground ? createDeadlineAbortSignal(signal, AGENT_TIMEOUT_MS) : undefined; + !runInBackground ? createDeadlineAbortSignal(signal, DEFAULT_SUBAGENT_TIMEOUT_MS) : undefined; const options = { parentToolCallId: toolCallId, @@ -203,7 +205,10 @@ export class AgentTool implements BuiltinTool { handle = await this.subagentHost.resume(resumeAgentId, options); } else { const profileName = requestedProfileName ?? 'coder'; - handle = await this.subagentHost.spawn(profileName, options); + handle = await this.subagentHost.spawn({ + profileName, + ...options, + }); } } catch (error) { this.log?.warn('subagent launch failed', { @@ -222,7 +227,7 @@ export class AgentTool implements BuiltinTool { try { taskId = this.backgroundManager!.registerTask( new AgentBackgroundTask(handle.completion, args.description, { - timeoutMs: AGENT_TIMEOUT_MS, + timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS, agentId: handle.agentId, subagentType: handle.profileName, abort: () => { @@ -274,7 +279,7 @@ export class AgentTool implements BuiltinTool { let message: string; const timedOut = foregroundDeadline?.timedOut() === true; if (timedOut) { - message = `Agent timed out after ${AGENT_TIMEOUT_DESCRIPTION}.`; + message = `Agent timed out after ${DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION}.`; } else if (isUserCancellation(signal.reason)) { message = 'The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user\'s next instruction.'; @@ -300,7 +305,7 @@ export class AgentTool implements BuiltinTool { } catch (error) { let message: string; if (foregroundDeadline?.timedOut() === true) { - message = `Agent timed out after ${AGENT_TIMEOUT_DESCRIPTION}.`; + message = `Agent timed out after ${DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION}.`; } else if (isUserCancellation(signal.reason)) { message = 'The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user\'s next instruction.'; diff --git a/packages/agent-core/src/tools/builtin/index.ts b/packages/agent-core/src/tools/builtin/index.ts index 020871d70..744f90c6f 100644 --- a/packages/agent-core/src/tools/builtin/index.ts +++ b/packages/agent-core/src/tools/builtin/index.ts @@ -5,6 +5,7 @@ export * from '../cron/cron-create'; export * from '../cron/cron-delete'; export * from '../cron/cron-list'; export * from './collaboration/agent'; +export * from './collaboration/agent-swarm'; export * from './collaboration/ask-user'; export * from './collaboration/skill-tool'; export * from './file/edit'; diff --git a/packages/agent-core/src/utils/abort.ts b/packages/agent-core/src/utils/abort.ts index ba5f8770f..6ef6d433b 100644 --- a/packages/agent-core/src/utils/abort.ts +++ b/packages/agent-core/src/utils/abort.ts @@ -33,10 +33,10 @@ export function isUserCancellation(value: unknown): value is UserCancellationErr } export function abortable(promise: Promise, signal: AbortSignal): Promise { - signal.throwIfAborted(); + if (signal.aborted) return Promise.reject(abortReason(signal)); return new Promise((resolve, reject) => { const onAbort = () => { - reject(abortError()); + reject(abortReason(signal)); }; signal.addEventListener('abort', onAbort, { once: true }); promise.then(resolve, reject).finally(() => { @@ -59,6 +59,17 @@ export function linkAbortSignal(source: AbortSignal, target: AbortController): ( }; } +function abortReason(signal: AbortSignal): Error { + if (signal.reason instanceof Error && !isDefaultAbortReason(signal.reason)) { + return signal.reason; + } + return abortError(); +} + +function isDefaultAbortReason(reason: Error): boolean { + return reason.name === 'AbortError' && reason.message === 'This operation was aborted'; +} + export interface DeadlineAbortSignal { readonly signal: AbortSignal; readonly timedOut: () => boolean; diff --git a/packages/agent-core/test/agent/basic.test.ts b/packages/agent-core/test/agent/basic.test.ts index 99575eec4..87cf617e3 100644 --- a/packages/agent-core/test/agent/basic.test.ts +++ b/packages/agent-core/test/agent/basic.test.ts @@ -32,7 +32,7 @@ it('runs a text-only agent turn from prompt to completion', async () => { [wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "", "turnId": "0", "step": 1, "usage": { "inputOther": 3, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }, "time": "