From 27dded508b6eddee491fb203476a713ca6d83ee0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 20:58:17 +0800 Subject: [PATCH 01/72] feat: add agent swarm tui support --- .changeset/agent-swarm-progress-ui.md | 5 + .changeset/template-agent-swarm.md | 6 + apps/kimi-code/src/cli/commands.ts | 9 + apps/kimi-code/src/main.ts | 14 + apps/kimi-code/src/tui/commands/dispatch.ts | 6 + apps/kimi-code/src/tui/commands/index.ts | 1 + apps/kimi-code/src/tui/commands/registry.ts | 6 + apps/kimi-code/src/tui/commands/swarm.ts | 77 +++ .../src/tui/components/chrome/footer.ts | 1 + .../dialogs/goal-start-permission-prompt.ts | 128 +--- .../dialogs/start-permission-prompt.ts | 125 ++++ .../dialogs/swarm-start-permission-prompt.ts | 60 ++ apps/kimi-code/src/tui/components/index.ts | 1 + .../messages/agent-swarm-progress.ts | 348 +++++++++++ .../tui/components/messages/swarm-markers.ts | 21 + .../src/tui/components/messages/tool-call.ts | 4 + .../tui/controllers/session-event-handler.ts | 81 +++ apps/kimi-code/src/tui/kimi-tui.ts | 43 ++ apps/kimi-code/src/tui/swarm-demo.ts | 548 ++++++++++++++++++ apps/kimi-code/src/tui/tui-state.ts | 3 + apps/kimi-code/src/tui/types.ts | 1 + apps/kimi-code/test/cli/options.test.ts | 27 +- .../kimi-code/test/tui/commands/swarm.test.ts | 150 +++++ .../test/tui/components/chrome/footer.test.ts | 1 + .../tui/components/chrome/welcome.test.ts | 1 + .../messages/agent-swarm-progress.test.ts | 61 ++ .../test/tui/create-tui-state.test.ts | 1 + .../test/tui/kimi-tui-message-flow.test.ts | 73 +++ docs/en/reference/kimi-command.md | 10 + docs/en/reference/slash-commands.md | 1 + docs/en/reference/tools.md | 3 + docs/zh/reference/kimi-command.md | 10 + docs/zh/reference/slash-commands.md | 1 + docs/zh/reference/tools.md | 3 + .../agent-core/src/agent/context/index.ts | 2 +- packages/agent-core/src/agent/index.ts | 7 + .../policies/default-tool-approve.ts | 1 + .../agent-core/src/agent/records/types.ts | 3 + packages/agent-core/src/agent/swarm/index.ts | 46 ++ .../src/agent/swarm/swarm-mode-reminder.md | 21 + packages/agent-core/src/agent/tool/index.ts | 1 + packages/agent-core/src/agent/turn/index.ts | 3 + .../agent-core/src/profile/default/agent.yaml | 1 + packages/agent-core/src/rpc/core-api.ts | 1 + packages/agent-core/src/rpc/core-impl.ts | 4 + packages/agent-core/src/rpc/events.ts | 1 + packages/agent-core/src/session/rpc.ts | 4 + .../builtin/collaboration/agent-swarm.md | 5 + .../builtin/collaboration/agent-swarm.old.md | 9 + .../collaboration/agent-swarm.old.ts.txt | 308 ++++++++++ .../builtin/collaboration/agent-swarm.ts | 281 +++++++++ .../agent-core/src/tools/builtin/index.ts | 1 + .../test/tools/builtin-current.test.ts | 50 ++ packages/node-sdk/src/rpc.ts | 9 + packages/node-sdk/src/session.ts | 8 + 55 files changed, 2483 insertions(+), 113 deletions(-) create mode 100644 .changeset/agent-swarm-progress-ui.md create mode 100644 .changeset/template-agent-swarm.md create mode 100644 apps/kimi-code/src/tui/commands/swarm.ts create mode 100644 apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts create mode 100644 apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts create mode 100644 apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts create mode 100644 apps/kimi-code/src/tui/components/messages/swarm-markers.ts create mode 100644 apps/kimi-code/src/tui/swarm-demo.ts create mode 100644 apps/kimi-code/test/tui/commands/swarm.test.ts create mode 100644 apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts create mode 100644 packages/agent-core/src/agent/swarm/index.ts create mode 100644 packages/agent-core/src/agent/swarm/swarm-mode-reminder.md create mode 100644 packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md create mode 100644 packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.md create mode 100644 packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.ts.txt create mode 100644 packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts diff --git a/.changeset/agent-swarm-progress-ui.md b/.changeset/agent-swarm-progress-ui.md new file mode 100644 index 000000000..a040e23ee --- /dev/null +++ b/.changeset/agent-swarm-progress-ui.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show live AgentSwarm progress in the TUI above the input box. diff --git a/.changeset/template-agent-swarm.md b/.changeset/template-agent-swarm.md new file mode 100644 index 000000000..d1fba16de --- /dev/null +++ b/.changeset/template-agent-swarm.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Add template-based AgentSwarm launches with shared subagent type selection. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 2c31b9fa3..15464f566 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -12,6 +12,7 @@ export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; export type UpgradeCommandHandler = () => void | Promise; +export type SwarmDemoCommandHandler = (count?: string) => void; export function createProgram( version: string, @@ -19,6 +20,7 @@ export function createProgram( onMigrate: MigrateCommandHandler, onPluginNodeRunner: PluginNodeRunnerHandler = () => {}, onUpgrade: UpgradeCommandHandler = () => {}, + onSwarmDemo: SwarmDemoCommandHandler = () => {}, ): Command { const program = new Command(CLI_COMMAND_NAME) .description('The Starting Point for Next-Gen Agents') @@ -79,6 +81,13 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerMigrateCommand(program, onMigrate); + program + .command('swarm-demo') + .description('Run an animated demo of the swarm progress UI.') + .argument('[count]', 'Number of swarms to render. Defaults to 32.') + .action((count: string | undefined) => { + onSwarmDemo(count); + }); program .command('upgrade') .description('Upgrade Kimi Code to the latest version.') diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 56d829447..ca0f19b26 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -36,6 +36,7 @@ import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; +import { runSwarmDemo } from './tui/swarm-demo'; import { initProcessName } from './utils/process/proctitle'; export async function handleMainCommand(opts: CLIOptions, version: string): Promise { @@ -102,6 +103,11 @@ export async function handleUpgradeCommand(version: string): Promise { process.exit(exitCode); } +export async function handleSwarmDemoCommand(count: string | undefined): Promise { + const exitCode = await runSwarmDemo({ count }); + process.exit(exitCode); +} + /** A neutral CLIOptions value — `kimi migrate` never opens a chat session. */ const MIGRATE_CLI_OPTIONS: CLIOptions = { session: undefined, @@ -170,6 +176,14 @@ export function main(): void { process.exit(1); }); }, + (count) => { + void handleSwarmDemoCommand(count).catch(async (error: unknown) => { + await logStartupFailure('run swarm demo', error); + process.stderr.write(formatStartupError(error, { operation: 'run swarm demo' })); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, ); program.parse(process.argv); diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 90250f87f..b0f47f08f 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -38,6 +38,7 @@ import { handleGoalCommand } from './goal'; import { handleProviderCommand } from './provider'; import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; import { handlePluginsCommand } from './plugins'; +import { handleSwarmCommand } from './swarm'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -67,6 +68,7 @@ export { showPermissionPicker, showSettingsSelector, } from './config'; +export { handleSwarmCommand } from './swarm'; export { handleFeedbackCommand, showMcpServers, @@ -127,6 +129,7 @@ export interface SlashCommandHost { createNewSession(): Promise; showSessionPicker(): Promise; sendNormalUserInput(text: string): void; + sendSwarmUserInput(text: string): void; sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; readonly skillCommandMap: Map; @@ -270,6 +273,9 @@ async function handleBuiltInSlashCommand( case 'plan': await handlePlanCommand(host, args); return; + case 'swarm': + 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 bdf794d8a..915dcb4ea 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -21,6 +21,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 c7a8d478a..b577a4757 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -53,6 +53,12 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'), }, + { + name: 'swarm', + aliases: [], + description: 'Run one task in swarm mode', + priority: 100, + }, { 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..5a336475a --- /dev/null +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -0,0 +1,77 @@ +import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +import { + SwarmStartPermissionPromptComponent, + type SwarmStartPermissionChoice, +} from '../components/dialogs/swarm-start-permission-prompt'; +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 function handleSwarmCommand(host: SlashCommandHost, args: string): void { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + const prompt = args.trim(); + if (prompt.length === 0) { + host.showError('Usage: /swarm '); + return; + } + + if (host.state.appState.permissionMode === 'manual') { + showSwarmStartPermissionPrompt(host, prompt); + return; + } + + host.sendSwarmUserInput(prompt); +} + +function showSwarmStartPermissionPrompt(host: SlashCommandHost, prompt: string): void { + const commandText = `/swarm ${prompt}`; + const cancelStart = (): void => { + host.restoreInputText(commandText); + host.showStatus('Swarm task not started.'); + }; + host.mountEditorReplacement( + new SwarmStartPermissionPromptComponent({ + colors: host.state.theme.colors, + onSelect: (choice) => { + if (choice === 'cancel') { + cancelStart(); + return; + } + host.restoreEditor(); + void startSwarmWithPermission(host, prompt, choice); + }, + onCancel: cancelStart, + }), + ); +} + +async function startSwarmWithPermission( + host: SlashCommandHost, + prompt: string, + choice: SwarmStartPermissionChoice, +): Promise { + if (choice === 'auto' || choice === 'yolo') { + if (!(await setPermissionForSwarm(host, choice))) return; + } + host.sendSwarmUserInput(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; +} diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 350da47ce..6a96bd9a3 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -290,6 +290,7 @@ export class FooterComponent implements Component { 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')); + if (state.swarmMode) left.push(chalk.hex(colors.primary).bold('swarm')); 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/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index df5beaf7c..b9ce7d385 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,22 +1,12 @@ -import { - Key, - matchesKey, - truncateToWidth, - visibleWidth, - type Component, - type Focusable, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - import type { ColorPalette } from '#/tui/theme/colors'; -export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; +import { + StartPermissionPromptComponent, + type StartPermissionChoice, + type StartPermissionOption, +} from './start-permission-prompt'; -interface GoalStartOption { - readonly value: GoalStartPermissionChoice; - readonly label: string; - readonly description: string; -} +export type GoalStartPermissionChoice = StartPermissionChoice; export interface GoalStartPermissionPromptOptions { readonly colors: ColorPalette; @@ -24,7 +14,7 @@ export interface GoalStartPermissionPromptOptions { readonly onCancel: () => void; } -const OPTIONS: readonly GoalStartOption[] = [ +const OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -56,99 +46,15 @@ const NOTICE_LINES = [ 'You can go back without losing your command.', ] 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(OPTIONS.length - 1, this.selectedIndex + 1); - return; - } - if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { - this.opts.onSelect(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(' Start a goal with approvals on?'), - chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter select · Esc return to input box'), - '', - ]; - - const textWidth = Math.max(20, width - 2); - for (const paragraph of NOTICE_LINES) { - for (const line of wrapPlain(paragraph, textWidth)) { - lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); - } - lines.push(''); - } - - for (let i = 0; i < OPTIONS.length; i += 1) { - const option = OPTIONS[i]!; - const selected = i === this.selectedIndex; - const pointer = selected ? '❯' : ' '; - 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, '…'); +export class GoalStartPermissionPromptComponent extends StartPermissionPromptComponent { + constructor(opts: GoalStartPermissionPromptOptions) { + super({ + colors: opts.colors, + title: 'Start a goal with approvals on?', + noticeLines: NOTICE_LINES, + options: 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..57e637d06 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -0,0 +1,125 @@ +import { + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Component, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export type StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; + +export interface StartPermissionOption { + readonly value: StartPermissionChoice; + readonly label: string; + readonly description: string; +} + +export interface StartPermissionPromptOptions { + readonly colors: ColorPalette; + readonly title: string; + readonly noticeLines: readonly string[]; + readonly options: readonly StartPermissionOption[]; + readonly onSelect: (choice: StartPermissionChoice) => 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 return to input box'), + '', + ]; + + 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 ? '❯' : ' '; + 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..80904d66e --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/swarm-start-permission-prompt.ts @@ -0,0 +1,60 @@ +import type { ColorPalette } from '#/tui/theme/colors'; + +import { + StartPermissionPromptComponent, + type StartPermissionChoice, + type StartPermissionOption, +} from './start-permission-prompt'; + +export type SwarmStartPermissionChoice = StartPermissionChoice; + +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: 'yolo', + label: 'Switch to YOLO and start', + description: + 'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.', + }, + { + value: 'manual', + label: 'Start in Manual', + description: + 'Keep approvals on. Kimi Code may stop and wait for you during the swarm task.', + }, + { + value: 'cancel', + label: 'Do not start', + description: 'Return to the input box with your swarm command.', + }, +]; + +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 bcff5b2ac..e48d04758 100644 --- a/apps/kimi-code/src/tui/components/index.ts +++ b/apps/kimi-code/src/tui/components/index.ts @@ -27,6 +27,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-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts new file mode 100644 index 000000000..6b5db757f --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -0,0 +1,348 @@ +import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +const MIN_CELL_WIDTH = 32; +const CELL_GAP = ' '; +const BRAILLE_BAR_MIN_WIDTH = 8; +const BRAILLE_BAR_MAX_WIDTH = 24; +const BRAILLE_EMPTY = '⣀'; +const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; +const BRAILLE_LEVELS = ['⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'] as const; +const PHASE_LABEL_WIDTH = 'Completed'.length; + +type AgentSwarmPhase = 'spawning' | 'working' | 'completed' | 'failed'; + +interface AgentSwarmMember { + readonly index: number; + readonly id: string; + agentId?: string; + phase: AgentSwarmPhase; + ticks: number; +} + +interface AgentSwarmSnapshot { + readonly phase: AgentSwarmPhase; + readonly ticks: number; +} + +interface AgentSwarmResultStatus { + readonly index: number; + readonly status: 'completed' | 'failed'; +} + +interface AgentSwarmSummary { + readonly active: number; + readonly completed: number; + readonly failed: number; +} + +export interface AgentSwarmProgressOptions { + readonly description: string; + readonly items: readonly string[]; + readonly colors: ColorPalette; +} + +const PHASE_LABELS: Record = { + spawning: 'Spawning', + working: 'Working', + completed: 'Completed', + failed: 'Failed', +}; + +export class AgentSwarmProgressComponent implements Component { + private readonly members: AgentSwarmMember[]; + private readonly seenToolCalls = new Set(); + private readonly description: string; + private readonly colors: ColorPalette; + + constructor(options: AgentSwarmProgressOptions) { + this.description = options.description; + this.colors = options.colors; + const safeItems = options.items.length > 0 ? options.items : ['agent']; + this.members = safeItems.map((_item, index) => ({ + index, + id: `swarm-${String(index + 1).padStart(3, '0')}`, + phase: 'spawning', + ticks: 0, + })); + } + + invalidate(): void {} + + registerSubagent(input: { + readonly agentId: string; + readonly description?: string | undefined; + }): void { + const member = this.findMemberForSubagent(input.agentId, input.description); + if (member === undefined) return; + member.agentId = input.agentId; + } + + recordToolCall(input: { + readonly agentId: string; + readonly toolCallId: string; + }): void { + const key = `${input.agentId}:${input.toolCallId}`; + if (this.seenToolCalls.has(key)) return; + this.seenToolCalls.add(key); + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined) return; + member.ticks += 1; + if (member.phase === 'spawning') member.phase = 'working'; + } + + markCompleted(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined || member.phase === 'failed') return; + member.phase = 'completed'; + } + + markFailed(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + member.phase = 'failed'; + } + + applyResult(output: string): void { + for (const entry of parseAgentSwarmResultStatuses(output)) { + const member = this.members[entry.index - 1]; + if (member === undefined) continue; + member.phase = entry.status; + } + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width); + const snapshots = this.members.map((member): AgentSwarmSnapshot => ({ + phase: member.phase, + ticks: member.ticks, + })); + const summary = summarizeSnapshots(snapshots); + const lines = [ + this.renderHeader(innerWidth, summary), + chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), + '', + ...this.renderGrid(innerWidth, snapshots), + '', + chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), + ]; + return lines.map((line) => truncateToWidth(line, innerWidth)); + } + + private renderHeader(width: number, summary: AgentSwarmSummary): string { + const title = chalk.hex(this.colors.primary).bold(' Agent swarm'); + const description = + this.description.length > 0 + ? chalk.hex(this.colors.text)(`: ${this.description}`) + : ''; + const count = chalk.hex(this.colors.textMuted)(` agents=${String(this.members.length)}`); + const activeLabel = chalk.hex(this.colors.accent)(` running=${String(summary.active)}`); + const doneLabel = chalk.hex(this.colors.success)(` complete=${String(summary.completed)}`); + const failedLabel = chalk.hex(this.colors.error)(` failed=${String(summary.failed)}`); + return truncateToWidth( + title + description + count + activeLabel + doneLabel + failedLabel, + width, + ); + } + + private renderGrid(width: number, snapshots: readonly AgentSwarmSnapshot[]): string[] { + const columns = columnsForWidth(width, this.members.length); + const gapWidth = visibleWidth(CELL_GAP); + const cellWidth = Math.max( + 1, + Math.floor((width - gapWidth * Math.max(0, columns - 1)) / columns), + ); + const rows = Math.ceil(this.members.length / columns); + 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, cellWidth), cellWidth)); + } + lines.push(cells.join(CELL_GAP)); + } + return lines; + } + + private renderCell(member: AgentSwarmMember, snapshot: AgentSwarmSnapshot, width: number): string { + const status = PHASE_LABELS[snapshot.phase]; + const fixedWidth = member.id.length + 2 + PHASE_LABEL_WIDTH + 1; + const availableForBar = width - fixedWidth - 2; + const barWidth = + availableForBar >= BRAILLE_BAR_MIN_WIDTH + ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) + : Math.max(1, availableForBar); + const id = chalk.hex(this.colors.textDim)(`${member.id}:`); + return [ + id, + stylePhase(status.padStart(PHASE_LABEL_WIDTH), snapshot.phase, this.colors), + brailleBar(snapshot.ticks, snapshot.phase, barWidth, this.colors), + ].join(' '); + } + + private findMemberForSubagent( + agentId: string, + description: string | undefined, + ): AgentSwarmMember | undefined { + const existing = this.findMemberByAgentId(agentId); + if (existing !== undefined) return existing; + + const index = parseAgentSwarmDescriptionIndex(description); + if (index !== undefined) { + const byDescription = this.members[index - 1]; + if (byDescription !== undefined) return byDescription; + } + + return this.members.find((member) => member.agentId === undefined); + } + + private findMemberByAgentId(agentId: string): AgentSwarmMember | undefined { + return this.members.find((member) => member.agentId === agentId); + } +} + +export function agentSwarmItemsFromArgs(args: Record): string[] { + const items = args['items']; + if (!Array.isArray(items)) return []; + return items.map(String); +} + +export function agentSwarmDescriptionFromArgs(args: Record): string { + const description = args['description']; + return typeof description === 'string' ? description : ''; +} + +function parseAgentSwarmDescriptionIndex(description: string | undefined): number | undefined { + if (description === undefined) return undefined; + const match = /#(\d+)(?:\s|$|\()/.exec(description); + if (match === null) return undefined; + const index = Number(match[1]); + return Number.isInteger(index) && index > 0 ? index : undefined; +} + +function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] { + const result: AgentSwarmResultStatus[] = []; + const blocks = output.split(/\n(?=\[agent \d+\]\n)/); + for (const block of blocks) { + const indexMatch = /^\[agent (\d+)\]$/m.exec(block); + const statusMatch = /^status: (completed|failed)$/m.exec(block); + if (indexMatch === null || statusMatch === null) continue; + result.push({ + index: Number(indexMatch[1]), + status: statusMatch[1] as 'completed' | 'failed', + }); + } + return result; +} + +function columnsForWidth(width: number, count: number): number { + if (count <= 1) return 1; + const gapWidth = visibleWidth(CELL_GAP); + const columns = Math.floor((width + gapWidth) / (MIN_CELL_WIDTH + gapWidth)); + return Math.max(1, Math.min(count, columns)); +} + +function summarizeSnapshots(snapshots: readonly AgentSwarmSnapshot[]): AgentSwarmSummary { + let completed = 0; + let failed = 0; + for (const snapshot of snapshots) { + if (snapshot.phase === 'completed') completed += 1; + if (snapshot.phase === 'failed') failed += 1; + } + return { + active: snapshots.length - completed - failed, + completed, + failed, + }; +} + +function brailleBar( + ticks: number, + phase: AgentSwarmPhase, + width: number, + colors: ColorPalette, +): string { + const innerWidth = Math.max(1, width); + const fillColor = phase === 'failed' ? colors.error : colors.success; + return bracketBar(accumulatedBrailleBar(ticks, innerWidth, fillColor, colors), colors); +} + +function bracketBar(content: string, colors: ColorPalette): string { + const bracket = chalk.hex(colors.textMuted); + return bracket('[') + content + bracket(']'); +} + +function stylePhase(label: string, phase: AgentSwarmPhase, colors: ColorPalette): string { + switch (phase) { + case 'spawning': + return chalk.hex(colors.textDim)(label); + case 'working': + return chalk.hex(colors.primary)(label); + case 'completed': + return chalk.hex(colors.success)(label); + case 'failed': + return chalk.hex(colors.error)(label); + } +} + +function padAnsi(text: string, width: number): string { + const truncated = truncateToWidth(text, width); + return truncated + ' '.repeat(Math.max(0, width - visibleWidth(truncated))); +} + +function accumulatedBrailleBar( + ticks: number, + width: number, + filledColor: string, + colors: ColorPalette, +): string { + const dotsPerCell = BRAILLE_LEVELS.length; + const cycleSize = width * dotsPerCell; + const safeTicks = Math.max(0, 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 ? 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..7e71f06a4 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -0,0 +1,21 @@ +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 class SwarmModeMarkerComponent implements Component { + constructor( + private readonly active: boolean, + private readonly colors: ColorPalette, + ) {} + + invalidate(): void {} + + render(_width: number): string[] { + const color = this.active ? this.colors.primary : this.colors.textDim; + const marker = chalk.hex(color).bold(STATUS_BULLET); + const label = chalk.hex(color).bold(this.active ? 'Swarm activated' : 'Swarm deactivated'); + return ['', marker + label]; + } +} 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..12beac0ed 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -1753,6 +1753,10 @@ export class ToolCallComponent extends Container { const { result } = this; if (result === undefined || !result.output) return; + if (this.toolCall.name === 'AgentSwarm') { + return; + } + if (this.isSingleSubagentView()) { return; } 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 183c6c01f..3ba1b18a9 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -34,7 +34,13 @@ import type { import { buildGoalCompletionMessage } from '@moonshot-ai/kimi-code-sdk'; import { MoonLoader } from '../components/chrome/moon-loader'; +import { + AgentSwarmProgressComponent, + agentSwarmDescriptionFromArgs, + agentSwarmItemsFromArgs, +} from '../components/messages/agent-swarm-progress'; import { buildGoalMarker } from '../components/messages/goal-markers'; +import { SwarmModeMarkerComponent } from '../components/messages/swarm-markers'; import { StatusMessageComponent } from '../components/messages/status-message'; import { MAIN_AGENT_ID, @@ -89,6 +95,7 @@ export interface SessionEventHost { showError(msg: string): void; showStatus(msg: string, color?: string): void; showNotice(title: string, detail?: string): void; + setAgentSwarmProgress(component: AgentSwarmProgressComponent | null): void; appendTranscriptEntry(entry: TranscriptEntry): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; shiftQueuedMessage(): QueuedMessage | undefined; @@ -107,6 +114,7 @@ export class SessionEventHandler { renderedMcpServerStatusKeys: Map = new Map(); mcpServerStatusSpinners: Map = new Map(); mcpServers: Map = new Map(); + agentSwarmProgress: Map = new Map(); resetRuntimeState(): void { this.backgroundAgentMetadata.clear(); @@ -116,6 +124,8 @@ export class SessionEventHandler { this.renderedSkillActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); this.mcpServers.clear(); + this.agentSwarmProgress.clear(); + this.host.setAgentSwarmProgress(null); this.stopAllMcpServerStatusSpinners(); } @@ -239,6 +249,21 @@ export class SessionEventHandler { const { parentToolCallId } = info; const sourceName = info.name; const toolCall = streamingUI.getToolComponent(parentToolCallId); + const swarmProgress = this.agentSwarmProgress.get(parentToolCallId); + if (swarmProgress !== undefined) { + if (event.type === 'tool.call.started') { + swarmProgress.recordToolCall({ + agentId: subagentId, + toolCallId: event.toolCallId, + }); + } else if (event.type === 'turn.ended') { + swarmProgress.markCompleted(subagentId); + } else if (event.type === 'subagent.failed') { + swarmProgress.markFailed(event.subagentId); + } + this.host.setAgentSwarmProgress(swarmProgress); + return true; + } if (toolCall === undefined) return true; toolCall.setSubagentMeta(subagentId, sourceName); @@ -293,6 +318,7 @@ export class SessionEventHandler { case 'warning': case 'session.meta.updated': case 'skill.activated': + case 'goal.updated': case 'subagent.completed': case 'subagent.failed': case 'subagent.spawned': @@ -313,6 +339,8 @@ export class SessionEventHandler { private handleTurnBegin(_event: TurnStartedEvent): void { void _event; + this.agentSwarmProgress.clear(); + this.host.setAgentSwarmProgress(null); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); this.host.patchLivePane({ @@ -484,6 +512,17 @@ export class SessionEventHandler { turnId, }; streamingUI.registerToolCall(toolCall); + if (event.name === 'AgentSwarm') { + const progress = new AgentSwarmProgressComponent({ + description: agentSwarmDescriptionFromArgs(toolCall.args), + items: agentSwarmItemsFromArgs(toolCall.args), + colors: this.host.state.theme.colors, + }); + this.agentSwarmProgress.set(event.toolCallId, progress); + this.host.setAgentSwarmProgress(progress); + } else if (this.agentSwarmProgress.size > 0) { + this.host.setAgentSwarmProgress(null); + } this.host.patchLivePane({ mode: 'tool', pendingApproval: null, @@ -526,6 +565,11 @@ export class SessionEventHandler { synthetic: event.synthetic, }; const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); + const progress = this.agentSwarmProgress.get(event.toolCallId); + if (progress !== undefined) { + progress.applyResult(resultData.output); + this.host.setAgentSwarmProgress(progress); + } if (matchedCall !== undefined && matchedCall.name === 'TodoList' && !event.isError) { const rawTodos = (matchedCall.args as { todos?: unknown }).todos; if (Array.isArray(rawTodos)) { @@ -542,15 +586,20 @@ export class SessionEventHandler { private handleStatusUpdate(event: AgentStatusUpdatedEvent): void { const patch: Partial = {}; + const previousSwarmMode = this.host.state.appState.swarmMode; 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 !== undefined && event.swarmMode !== previousSwarmMode) { + this.renderSwarmModeMarker(event.swarmMode); + } } private handleGoalUpdated(event: GoalUpdatedEvent): void { @@ -583,6 +632,14 @@ export class SessionEventHandler { } } + private renderSwarmModeMarker(active: boolean): void { + const { state } = this.host; + state.transcriptContainer.addChild( + new SwarmModeMarkerComponent(active, state.theme.colors), + ); + state.ui.requestRender(); + } + private handleSessionMetaChanged(event: SessionMetaUpdatedEvent): void { const title = event.title ?? stringValue(event.patch?.['title']); if (title !== undefined) { @@ -762,6 +819,16 @@ export class SessionEventHandler { return; } + const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); + if (swarmProgress !== undefined) { + swarmProgress.registerSubagent({ + agentId: event.subagentId, + description: event.description, + }); + this.host.setAgentSwarmProgress(swarmProgress); + return; + } + let tc = streamingUI.getToolComponent(event.parentToolCallId); if (tc === undefined) { const toolCall = streamingUI.getActiveToolCall(event.parentToolCallId); @@ -797,6 +864,13 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); return; } + const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); + if (swarmProgress !== undefined) { + swarmProgress.markCompleted(event.subagentId); + this.host.setAgentSwarmProgress(swarmProgress); + streamingUI.removeToolComponentIfInactive(event.parentToolCallId); + return; + } const tc = streamingUI.getToolComponent(event.parentToolCallId); if (tc === undefined) return; tc.onSubagentCompleted({ @@ -840,6 +914,13 @@ export class SessionEventHandler { this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); return; } + const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); + if (swarmProgress !== undefined) { + swarmProgress.markFailed(event.subagentId); + this.host.setAgentSwarmProgress(swarmProgress); + streamingUI.removeToolComponentIfInactive(event.parentToolCallId); + return; + } const tc = streamingUI.getToolComponent(event.parentToolCallId); if (tc === undefined) return; tc.onSubagentFailed({ error: event.error }); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index daa98c4a6..76848eaac 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -67,6 +67,7 @@ import { TasksBrowserController } from './controllers/tasks-browser'; import { installRainbowDance } from './easter-eggs/dance'; import { FileMentionProvider } from './components/editor/file-mention-provider'; import { AssistantMessageComponent } from './components/messages/assistant-message'; +import type { AgentSwarmProgressComponent } from './components/messages/agent-swarm-progress'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; import { CronMessageComponent } from './components/messages/cron-message'; import { GoalCompletionMessageComponent } from './components/messages/goal-panel'; @@ -160,6 +161,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, + swarmMode: false, thinking: false, contextUsage: 0, contextTokens: 0, @@ -707,6 +709,30 @@ export class KimiTUI { this.state.ui.requestRender(); } + sendSwarmUserInput(text: string): void { + if (this.state.appState.model.trim().length === 0) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + const session = this.session; + if (session === undefined) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: text, + }); + this.beginSessionRequest(); + void session.swarm(text).catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Failed to send swarm prompt: ${message}`); + }); + } + private validateMediaCapabilities( extraction: ReturnType, ): boolean { @@ -972,6 +998,13 @@ export class KimiTUI { this.state.ui.requestRender(); } + setAgentSwarmProgress(component: AgentSwarmProgressComponent | null): void { + if (this.state.agentSwarmProgress === component) return; + this.state.agentSwarmProgress = component; + this.updateActivityPane(); + this.state.ui.requestRender(); + } + // ========================================================================= // Session Runtime // ========================================================================= @@ -1417,6 +1450,16 @@ export class KimiTUI { // ========================================================================= updateActivityPane(): void { + if (this.state.agentSwarmProgress !== null) { + this.lastActivityMode = 'hidden'; + this.stopActivitySpinner(); + this.state.activityContainer.clear(); + this.state.activityContainer.addChild(this.state.agentSwarmProgress); + this.syncTerminalProgress(true); + this.state.ui.requestRender(); + return; + } + const effectiveMode = this.resolveActivityPaneMode(); this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); diff --git a/apps/kimi-code/src/tui/swarm-demo.ts b/apps/kimi-code/src/tui/swarm-demo.ts new file mode 100644 index 000000000..976d18cb8 --- /dev/null +++ b/apps/kimi-code/src/tui/swarm-demo.ts @@ -0,0 +1,548 @@ +import { + Container, + Key, + matchesKey, + ProcessTerminal, + truncateToWidth, + TUI, + visibleWidth, + type Focusable, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { CHROME_GUTTER } from './constant/rendering'; +import { GutterContainer } from './components/chrome/gutter-container'; +import { loadTuiConfig, TuiConfigParseError } from './config'; +import { createKimiTUIThemeBundle } from './theme/bundle'; +import type { ColorPalette } from './theme/colors'; +import { detectTerminalTheme } from './theme/detect'; +import { printableChar } from './utils/printable-key'; + +const DEFAULT_SWARM_COUNT = 32; +const MAX_SWARM_COUNT = 256; +const FRAME_INTERVAL_MS = 80; +const MIN_CELL_WIDTH = 32; +const CELL_GAP = ' '; +const BRAILLE_BAR_MIN_WIDTH = 8; +const BRAILLE_BAR_MAX_WIDTH = 24; +const BRAILLE_EMPTY = '⣀'; +const BRAILLE_SPAWNING_RIGHT = '⣷'; +const BRAILLE_SPAWNING_LEFT = '⣾'; +const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; +const BRAILLE_LEVELS = ['⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'] as const; +const NOMINAL_FULL_BAR_TICKS = BRAILLE_LEVELS.length * BRAILLE_BAR_MAX_WIDTH; +const PHASE_LABEL_WIDTH = 'Completed'.length; +const COMPLETE_FILL_MS = 360; +const LONG_SPAWNING_WAIT_MS = 30_000; +const FAILURE_COUNT = 2; + +export interface SwarmDemoRunOptions { + readonly count?: string; +} + +interface SwarmDemoComponentOptions { + readonly count: number; + readonly colors: ColorPalette; + readonly requestRender: () => void; + readonly onExit: () => void; +} + +type SwarmPhase = 'spawning' | 'working' | 'completed' | 'failed'; + +interface SwarmTask { + readonly index: number; + readonly id: string; + readonly waitMs: number; + readonly offsetMs: number; + readonly shouldFail: boolean; + readonly terminalTicks: number; + readonly tickTimesMs: readonly number[]; +} + +interface SwarmSnapshot { + readonly phase: SwarmPhase; + readonly ticks: number; + readonly phaseElapsedMs: number; +} + +interface SwarmSummary { + readonly active: number; + readonly completed: number; + readonly failed: number; +} + +const PHASE_LABELS: Record = { + spawning: 'Spawning', + working: 'Working', + completed: 'Completed', + failed: 'Failed', +}; + +export async function runSwarmDemo(options: SwarmDemoRunOptions = {}): Promise { + const count = resolveSwarmCount(options.count); + const colors = await loadSwarmDemoColors(); + const terminal = new ProcessTerminal(); + const ui = new TUI(terminal); + let stopped = false; + let resolveExit: (code: number) => void = () => {}; + const done = new Promise((resolve) => { + resolveExit = resolve; + }); + + const component = new SwarmDemoComponent({ + count, + colors, + requestRender: () => { + ui.requestRender(); + }, + onExit: () => { + void stop(0); + }, + }); + + const root = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + root.addChild(component); + ui.addChild(root); + ui.setFocus(component); + + const cleanupHandlers: Array<() => void> = []; + const addSignalHandler = (signal: NodeJS.Signals, code: number): void => { + const handler = (): void => { + void stop(code); + }; + process.prependListener(signal, handler); + cleanupHandlers.push(() => { + process.off(signal, handler); + }); + }; + addSignalHandler('SIGTERM', 143); + if (process.platform !== 'win32') addSignalHandler('SIGHUP', 129); + + async function stop(code: number): Promise { + if (stopped) return; + stopped = true; + for (const cleanup of cleanupHandlers) cleanup(); + cleanupHandlers.length = 0; + component.dispose(); + terminal.setProgress(false); + await terminal.drainInput().catch(() => {}); + ui.stop(); + resolveExit(code); + } + + try { + terminal.setTitle('Kimi swarm demo'); + terminal.setProgress(true); + ui.start(); + component.start(); + ui.requestRender(true); + } catch (error) { + component.dispose(); + for (const cleanup of cleanupHandlers) cleanup(); + cleanupHandlers.length = 0; + terminal.setProgress(false); + ui.stop(); + throw error; + } + + return done; +} + +export function resolveSwarmCount(raw: string | undefined): number { + if (raw === undefined || raw.trim().length === 0) return DEFAULT_SWARM_COUNT; + const count = Number(raw); + if (!Number.isInteger(count) || count < 1 || count > MAX_SWARM_COUNT) { + throw new Error( + `Invalid swarm count "${raw}". Use an integer from 1 to ${String(MAX_SWARM_COUNT)}.`, + ); + } + return count; +} + +async function loadSwarmDemoColors(): Promise { + try { + const config = await loadTuiConfig(); + const resolvedTheme = config.theme === 'auto' ? await detectTerminalTheme() : config.theme; + return createKimiTUIThemeBundle(config.theme, resolvedTheme).colors; + } catch (error) { + if (!(error instanceof TuiConfigParseError)) throw error; + const resolvedTheme = + error.fallback.theme === 'auto' ? await detectTerminalTheme() : error.fallback.theme; + return createKimiTUIThemeBundle(error.fallback.theme, resolvedTheme).colors; + } +} + +export class SwarmDemoComponent extends Container implements Focusable { + focused = false; + private readonly tasks: readonly SwarmTask[]; + private readonly colors: ColorPalette; + private readonly requestRender: () => void; + private readonly onExit: () => void; + private startedAt = Date.now(); + private frame = 0; + private timer: ReturnType | undefined; + + constructor(options: SwarmDemoComponentOptions) { + super(); + this.colors = options.colors; + this.requestRender = options.requestRender; + this.onExit = options.onExit; + this.tasks = createSwarmTasks(options.count); + } + + start(): void { + this.dispose(); + this.startedAt = Date.now(); + this.timer = setInterval(() => { + this.frame += 1; + this.requestRender(); + }, FRAME_INTERVAL_MS); + } + + dispose(): void { + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + handleInput(data: string): void { + const printable = printableChar(data); + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) || + printable === 'q' || + printable === 'Q' + ) { + this.onExit(); + } + } + + override render(width: number): string[] { + const innerWidth = Math.max(1, width); + const elapsedMs = Date.now() - this.startedAt; + const snapshots = this.tasks.map((task) => snapshotTask(task, elapsedMs)); + const summary = summarizeSnapshots(snapshots); + const lines: string[] = [ + this.renderHeader(innerWidth, summary), + chalk.hex(this.colors.textMuted)(' q / Esc / Ctrl-C exit'), + chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), + '', + ...this.renderGrid(innerWidth, snapshots), + '', + chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), + ]; + return lines.map((line) => truncateToWidth(line, innerWidth)); + } + + private renderHeader(width: number, summary: SwarmSummary): string { + const title = chalk.hex(this.colors.primary).bold(' Kimi swarm demo'); + const count = chalk.hex(this.colors.textMuted)(` swarms=${String(this.tasks.length)}`); + const activeLabel = chalk.hex(this.colors.accent)(` running=${String(summary.active)}`); + const doneLabel = chalk.hex(this.colors.success)(` complete=${String(summary.completed)}`); + const failedLabel = chalk.hex(this.colors.error)(` failed=${String(summary.failed)}`); + return truncateToWidth(title + count + activeLabel + doneLabel + failedLabel, width); + } + + private renderGrid(width: number, snapshots: readonly SwarmSnapshot[]): string[] { + const columns = columnsForWidth(width, this.tasks.length); + const gapWidth = visibleWidth(CELL_GAP); + const cellWidth = Math.max( + 1, + Math.floor((width - gapWidth * Math.max(0, columns - 1)) / columns), + ); + const rows = Math.ceil(this.tasks.length / columns); + 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 task = this.tasks[index]; + const snapshot = snapshots[index]; + if (task === undefined || snapshot === undefined) continue; + cells.push(padAnsi(this.renderCell(task, snapshot, cellWidth), cellWidth)); + } + lines.push(cells.join(CELL_GAP)); + } + return lines; + } + + private renderCell(task: SwarmTask, snapshot: SwarmSnapshot, width: number): string { + const status = PHASE_LABELS[snapshot.phase]; + const fixedWidth = task.id.length + 2 + PHASE_LABEL_WIDTH + 1; + const availableForBar = width - fixedWidth - 2; + const barWidth = + availableForBar >= BRAILLE_BAR_MIN_WIDTH + ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) + : Math.max(1, availableForBar); + const id = chalk.hex(this.colors.textDim)(`${task.id}:`); + return [ + id, + stylePhase(status.padStart(PHASE_LABEL_WIDTH), snapshot.phase, this.colors), + brailleBar( + snapshot.ticks, + snapshot.phase, + barWidth, + this.colors, + this.frame, + task.index, + snapshot.phaseElapsedMs, + ), + ].join(' '); + } +} + +function createSwarmTasks(count: number): readonly SwarmTask[] { + const failureIndexes = chooseFailureIndexes(count); + return Array.from({ length: count }, (_, index) => { + const shouldFail = failureIndexes.has(index); + let terminalTicks: number; + if (shouldFail) { + terminalTicks = 8 + Math.floor(Math.random() * 9); + } else if (Math.random() < 0.8) { + terminalTicks = Math.floor(NOMINAL_FULL_BAR_TICKS * (0.35 + Math.random() * 0.45)); + } else { + terminalTicks = + NOMINAL_FULL_BAR_TICKS + 10 + Math.floor(Math.random() * NOMINAL_FULL_BAR_TICKS); + } + + let waitMs = 250 + Math.floor(Math.random() * 1_100); + if (index === 0) waitMs = LONG_SPAWNING_WAIT_MS; + else if (shouldFail) waitMs = 120 + Math.floor(Math.random() * 360); + + return { + index, + id: `swarm-${String(index + 1).padStart(3, '0')}`, + waitMs, + offsetMs: index === 0 ? 0 : Math.floor(Math.random() * (shouldFail ? 250 : 900)), + shouldFail, + terminalTicks, + tickTimesMs: createTickTimes(terminalTicks, shouldFail), + }; + }); +} + +function snapshotTask(task: SwarmTask, elapsedMs: number): SwarmSnapshot { + const elapsed = elapsedMs + task.offsetMs; + if (elapsed < task.waitMs) { + return { phase: 'spawning', ticks: 0, phaseElapsedMs: elapsed }; + } + + const workingElapsed = elapsed - task.waitMs; + const ticks = ticksForElapsed(task.tickTimesMs, workingElapsed); + if (ticks >= task.terminalTicks) { + const terminalAtMs = task.tickTimesMs[task.terminalTicks - 1] ?? 0; + return { + phase: task.shouldFail ? 'failed' : 'completed', + ticks: task.terminalTicks, + phaseElapsedMs: Math.max(0, workingElapsed - terminalAtMs), + }; + } + return { phase: 'working', ticks, phaseElapsedMs: workingElapsed }; +} + +function summarizeSnapshots(snapshots: readonly SwarmSnapshot[]): SwarmSummary { + let completed = 0; + let failed = 0; + for (const snapshot of snapshots) { + if (snapshot.phase === 'completed') completed += 1; + if (snapshot.phase === 'failed') failed += 1; + } + return { + active: snapshots.length - completed - failed, + completed, + failed, + }; +} + +function columnsForWidth(width: number, count: number): number { + if (count <= 1) return 1; + const gapWidth = visibleWidth(CELL_GAP); + const columns = Math.floor((width + gapWidth) / (MIN_CELL_WIDTH + gapWidth)); + return Math.max(1, Math.min(count, columns)); +} + +function brailleBar( + ticks: number, + phase: SwarmPhase, + width: number, + colors: ColorPalette, + frame: number, + taskIndex: number, + phaseElapsedMs: number, +): string { + const innerWidth = Math.max(1, width); + switch (phase) { + case 'spawning': + return bracketBar(spawningBrailleBar(innerWidth, frame, taskIndex, colors), colors); + case 'working': + return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.success, colors), colors); + case 'completed': + return bracketBar( + accumulatedBrailleBar( + completedDisplayTicks(ticks, innerWidth, phaseElapsedMs), + innerWidth, + colors.success, + colors, + ), + colors, + ); + case 'failed': + return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.error, colors), colors); + } +} + +function bracketBar(content: string, colors: ColorPalette): string { + const bracket = chalk.hex(colors.textMuted); + return bracket('[') + content + bracket(']'); +} + +function stylePhase(label: string, phase: SwarmPhase, colors: ColorPalette): string { + switch (phase) { + case 'spawning': + return chalk.hex(colors.textDim)(label); + case 'working': + return chalk.hex(colors.primary)(label); + case 'completed': + return chalk.hex(colors.success)(label); + case 'failed': + return chalk.hex(colors.error)(label); + } +} + +function padAnsi(text: string, width: number): string { + const truncated = truncateToWidth(text, width); + return truncated + ' '.repeat(Math.max(0, width - visibleWidth(truncated))); +} + +function chooseFailureIndexes(count: number): ReadonlySet { + const target = Math.min(FAILURE_COUNT, count); + const candidates = + count > target + ? Array.from({ length: count - 1 }, (_, index) => index + 1) + : Array.from({ length: count }, (_, index) => index); + const indexes = new Set(); + while (indexes.size < target) { + indexes.add(candidates[Math.floor(Math.random() * candidates.length)]!); + } + return indexes; +} + +function createTickTimes(ticks: number, fastFailure: boolean): readonly number[] { + const times: number[] = []; + let elapsed = 0; + for (let i = 0; i < ticks; i += 1) { + elapsed += fastFailure ? randomFailureTickIntervalMs() : randomTickIntervalMs(); + times.push(elapsed); + } + return times; +} + +function randomFailureTickIntervalMs(): number { + return 50 + Math.floor(Math.random() * 120); +} + +function randomTickIntervalMs(): number { + const roll = Math.random(); + if (roll < 0.5) return 30 + Math.floor(Math.random() * 140); + if (roll < 0.8) return 170 + Math.floor(Math.random() * 480); + if (roll < 0.95) return 650 + Math.floor(Math.random() * 1_150); + return 1_800 + Math.floor(Math.random() * 3_200); +} + +function ticksForElapsed(tickTimesMs: readonly number[], elapsedMs: number): number { + let low = 0; + let high = tickTimesMs.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if ((tickTimesMs[mid] ?? 0) <= elapsedMs) { + low = mid + 1; + } else { + high = mid; + } + } + return low; +} + +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 spawningBrailleBar( + width: number, + frame: number, + taskIndex: number, + colors: ColorPalette, +): string { + if (width <= 1) { + return chalk.hex(colors.textMuted)(BRAILLE_SPAWNING_RIGHT); + } + let out = ''; + const maxPosition = width - 1; + const period = maxPosition * 2; + const position = (frame + taskIndex) % period; + const movingRight = position <= maxPosition; + const cursorCell = movingRight ? position : period - position; + const cursorChar = movingRight ? BRAILLE_SPAWNING_RIGHT : BRAILLE_SPAWNING_LEFT; + for (let i = 0; i < width; i += 1) { + out += chalk.hex(i === cursorCell ? colors.textMuted : colors.textDim)( + i === cursorCell ? cursorChar : BRAILLE_EMPTY, + ); + } + return out; +} + +function accumulatedBrailleBar( + ticks: number, + width: number, + filledColor: string, + colors: ColorPalette, +): string { + const dotsPerCell = BRAILLE_LEVELS.length; + const cycleSize = width * dotsPerCell; + const safeTicks = Math.max(0, 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 ? colors.textDim : filledColor, + ); + } + flush(); + return out; +} diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 1fb1f4445..99af1acc0 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -2,6 +2,7 @@ import { Container, ProcessTerminal, TUI, + type Component, } from '@earendil-works/pi-tui'; import { FooterComponent } from './components/chrome/footer'; @@ -39,6 +40,7 @@ export interface TUIState { appState: AppState; startupState: TUIStartupState; livePane: LivePaneState; + agentSwarmProgress: Component | null; transcriptEntries: TranscriptEntry[]; terminalState: TerminalState; activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null; @@ -85,6 +87,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { appState: { ...initialAppState }, startupState: 'pending', livePane: { ...INITIAL_LIVE_PANE }, + agentSwarmProgress: null, transcriptEntries: [], terminalState: createTerminalState(), activitySpinner: null, 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/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 185649f09..aa3739a41 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -280,12 +280,37 @@ describe('CLI options parsing', () => { expect(upgradeCalls).toBe(1); }); + it('routes swarm-demo with the optional count argument', () => { + const swarmDemoCounts: Array = []; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => {}, + (count) => { + swarmDemoCounts.push(count); + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', 'swarm-demo', '48']); + + expect(swarmDemoCounts).toEqual(['48']); + }); + it('registers the visible sub-commands', () => { const program = createProgram('0.0.0', () => {}, () => {}); const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) .map((command) => command.name()); - expect(commandNames).toEqual(['export', 'provider', 'migrate', 'upgrade']); + expect(commandNames).toEqual(['export', 'provider', 'migrate', 'swarm-demo', 'upgrade']); }); }); 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..86a09bc9e --- /dev/null +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -0,0 +1,150 @@ +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, ''); +} + +function makeHost( + overrides: { + model?: string; + hasSession?: boolean; + permissionMode?: 'manual' | 'auto' | 'yolo'; + } = {}, +) { + const session = { + setPermission: vi.fn(async () => {}), + }; + const hasSession = overrides.hasSession ?? true; + const host = { + state: { + appState: { + model: overrides.model ?? 'kimi-model', + permissionMode: overrides.permissionMode ?? 'auto', + }, + theme: { colors: getColorPalette('dark') }, + }, + 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(), + sendSwarmUserInput: 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; +} + +describe('handleSwarmCommand', () => { + it('sends the swarm prompt directly outside Manual mode', () => { + const { host, session } = makeHost({ permissionMode: 'auto' }); + + handleSwarmCommand(host, 'Ship feature X'); + + expect(session.setPermission).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.sendSwarmUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('asks before starting a swarm task in Manual mode', () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + handleSwarmCommand(host, 'Ship feature X'); + + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(host.sendSwarmUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(host).render(80).join('\n')); + expect(text).toContain('Manual mode can block swarm work'); + expect(text).toContain('Return to the input box with your swarm command'); + }); + + it('defaults to Auto when confirming a Manual-mode swarm start', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendSwarmUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + }); + + it('can start a Manual-mode swarm task without changing permission', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + handleSwarmCommand(host, 'Ship feature X'); + const picker = mountedPicker(host); + picker.handleInput(DOWN); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendSwarmUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).not.toHaveBeenCalled(); + }); + + it('can switch to YOLO when starting a Manual-mode swarm task', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + handleSwarmCommand(host, 'Ship feature X'); + const picker = mountedPicker(host); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendSwarmUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); + }); + + it('returns the command to the input box when a Manual-mode swarm start is cancelled', () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + 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(host.sendSwarmUserInput).not.toHaveBeenCalled(); + }); + + it('does not start when permission update fails', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + session.setPermission.mockRejectedValueOnce(new Error('denied')); + + handleSwarmCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to set permission mode'), + ); + }); + expect(host.sendSwarmUserInput).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..d832c64a4 --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSwarmProgressComponent, + agentSwarmDescriptionFromArgs, + agentSwarmItemsFromArgs, +} from '#/tui/components/messages/agent-swarm-progress'; +import { darkColors } from '#/tui/theme/colors'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('AgentSwarmProgressComponent', () => { + it('renders a full swarm panel with title, summary, and rows', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + colors: darkColors, + }); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('Agent swarm: Review changed files'); + expect(output).toContain('agents=2'); + expect(output).toContain('running=2'); + expect(output).toContain('swarm-001: Spawning'); + expect(output).toContain('swarm-002: Spawning'); + }); + + it('advances one step for each subagent tool call and marks terminal states', () => { + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + 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' }); + component.markCompleted('agent-1'); + component.markFailed('agent-2'); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('complete=1'); + expect(output).toContain('failed=1'); + expect(output).toContain('swarm-001: Completed'); + expect(output).toContain('swarm-002: Failed'); + }); + + 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']); + }); +}); 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 b5924ca18..67ff9578d 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 @@ -243,6 +243,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 countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } @@ -1499,6 +1503,75 @@ describe('KimiTUI message flow', () => { }); }); + it('renders AgentSwarm progress as the activity pane instead of 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)', + runInBackground: false, + } as Event, + sendQueued, + ); + + 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, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + reason: 'completed', + } as Event, + sendQueued, + ); + + const activity = stripSgr(renderActivity(driver)); + expect(activity).toContain('Agent swarm: Review changed files'); + expect(activity).toContain('swarm-001: Completed'); + expect(activity).toContain('swarm-002: Spawning'); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Using AgentSwarm'); + expect(transcript).not.toContain('swarm-001'); + }); + 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/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index d043775d6..719bcd26e 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -162,6 +162,16 @@ kimi migrate If you previously used an older version of kimi-cli, run this command to migrate historical sessions, configuration, and other data to kimi-code to avoid data loss. For the full migration flow, what gets migrated, and things to watch out for, see [Migrating from kimi-cli](../guides/migration.md). +### `kimi swarm-demo` + +Run an animated terminal UI demo that shows progress bars for multiple swarms. It does not start an LLM session or call a provider; it is only for previewing the swarm UI. + +```sh +kimi swarm-demo [count] +``` + +When `count` is omitted, the demo renders 32 swarms. Pass a positive integer to change the number shown. The demo keeps animating until you press `q`, `Esc`, or `Ctrl-C`. + ### `kimi upgrade` Check the latest Kimi Code CLI version immediately and show the update prompt. This command has no flags and exits after the selected action. diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 9b8c2e8d6..2e0a55ba9 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -43,6 +43,7 @@ Some commands are only available in the idle state. Running them while the sessi | `/auto [on\|off]` | — | Toggle auto permission mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. When enabled, tool approvals are handled automatically and the agent will not ask questions. | Yes | | `/plan [on\|off]` | — | Toggle Plan mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. Toggling alone does not create an empty plan file. | Yes | | `/plan clear` | — | Clear the current plan. | No | +| `/swarm ` | — | Run one task in swarm mode. In `manual` permission mode, Kimi Code asks whether to switch to `auto` before starting. | No | | `/goal [status\|pause\|resume\|cancel\|replace \|]` | — | Start or manage an autonomous goal. This command is experimental. Enable it with `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1`. | See below | ::: warning Note diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index f8544bc56..8a79b35fb 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -78,11 +78,14 @@ Collaboration tools handle inter-agent coordination, user interaction, and skill | Tool | Default approval | Description | | --- | --- | --- | | `Agent` | Auto-approved | Spawn a subagent to execute a subtask | +| `AgentSwarm` | Auto-approved | Launch multiple subagents from a prompt template | | `AskUserQuestion` | Auto-approved | Ask the user a question to obtain structured input | | `Skill` | Auto-approved | Invoke a registered inline skill | **`Agent`** delegates a subtask to a subagent. Required parameters are `prompt` (the full task description) and `description` (a short 3–5 word summary for UI display). Optional parameters include `subagent_type` (agent type, default `coder`), `resume` (the ID of an existing agent to resume), `run_in_background` (whether to run in the background, default false), and `timeout` (timeout in seconds, 30–3600). `subagent_type` and `resume` are mutually exclusive: when resuming an existing agent, addressing is done solely by ID. When the foreground `timeout` is omitted, the subagent runs to completion with no time limit; when the background `timeout` is omitted, it falls back to `[background] agent_task_timeout_s` in `config.toml`, and if that field is also unset, there is no time limit. In foreground mode the parent agent waits for the subagent to complete before continuing; in background mode it returns a task ID immediately, and upon completion a synthetic user message automatically routes control back to the main agent, with no polling required. For details on the subagent system, see [Subagents](../customization/agents.md). +**`AgentSwarm`** launches multiple subagents from a shared `prompt_template` and an `items` array. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one subagent. Pass `subagent_type` to choose the profile used by every subagent in the swarm, or omit it to use `coder`. The tool accepts 2 to 50 items, 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. + **`AskUserQuestion`** presents the user with a structured multiple-choice question, suitable for disambiguation or option-selection scenarios. The `questions` parameter accepts 1–4 questions; each question requires a `question` (question text ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and the optional `header` (a short category label of up to 12 characters, such as `Auth` or `Style`) and `multi_select` (whether multiple choices are allowed, default false). An "Other" option is automatically appended by the system, so there is no need to provide it manually in `options`. When `background` is true, the tool starts a background question task and returns a task ID immediately; the agent can continue working and will receive the answer automatically, or inspect it with `TaskOutput`. If the host does not implement interactive questioning, this tool returns a failure notice and the agent should instead ask the user directly in its text reply. **`Skill`** allows the agent to explicitly invoke a registered inline-type skill. It accepts `skill` (the skill name) and an optional `args` (additional argument text). Only skills with `type = "inline"` can be invoked through this tool; other types (such as `prompt` or `flow`) and skills that set `disableModelInvocation: true` in their frontmatter are rejected. To prevent recursive infinite loops, skill nesting depth is limited to 3 levels. For details on the skill system, see [Skills](../customization/skills.md). diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index b04e359f9..726054605 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -162,6 +162,16 @@ kimi migrate 如果你之前使用过旧版 kimi-cli,可以运行此命令将历史会话、配置等数据迁移到 kimi-code 中,避免数据丢失。完整的迁移流程、迁移内容与注意事项见 [从 kimi-cli 迁移](../guides/migration.md)。 +### `kimi swarm-demo` + +运行一个终端 UI 动画演示,展示多个 swarm 的进度条。该命令不会启动 LLM 会话,也不会调用供应商,只用于预览 swarm UI。 + +```sh +kimi swarm-demo [count] +``` + +省略 `count` 时默认展示 32 个 swarm。传入正整数可以调整数量。演示会持续动画,直到按下 `q`、`Esc` 或 `Ctrl-C` 退出。 + ### `kimi upgrade` 立即检查最新的 Kimi Code CLI 版本,并展示更新提示。该命令无任何 flag,所选操作结束后退出。 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 570feddfe..4c250be43 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -43,6 +43,7 @@ | `/auto [on\|off]` | — | 切换 auto 权限模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。开启后工具审批自动处理,Agent 不会向用户提问。 | 是 | | `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。单纯切换不会创建空计划文件。 | 是 | | `/plan clear` | — | 清除当前 plan 方案。 | 否 | +| `/swarm ` | — | 以 swarm 模式运行一个任务。若当前是 `manual` 权限模式,启动前会提示是否切换到 `auto`。 | 否 | | `/goal [status\|pause\|resume\|cancel\|replace \|]` | — | 开始或管理一个自主 goal。该命令仍是实验功能,通过 `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1` 启用。 | 见下文 | ::: warning 注意 diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 64c4590be..7e5032f98 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -78,11 +78,14 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 工 | 工具 | 默认审批 | 说明 | | --- | --- | --- | | `Agent` | 自动放行 | 派生子 Agent 执行子任务 | +| `AgentSwarm` | 自动放行 | 从提示词模板启动多个子 Agent | | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | **`Agent`** 用于将子任务委托给子 Agent 执行。必填参数为 `prompt`(完整任务描述)和 `description`(3–5 个词的简短说明,用于 UI 展示)。可选参数包括 `subagent_type`(Agent 类型,默认 `coder`)、`resume`(恢复已有 Agent 的 ID)、`run_in_background`(是否后台运行,默认 false)和 `timeout`(超时秒数,30–3600)。`subagent_type` 与 `resume` 互斥:恢复已有 Agent 时只通过 ID 寻址。前台 `timeout` 缺省表示不超时,子 Agent 运行至完成;后台 `timeout` 缺省时回落到 `config.toml` 的 `[background] agent_task_timeout_s`,该字段也未设置则无时间上限。前台模式下父 Agent 会等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 user 消息自动回到主 Agent,无需轮询。子 Agent 体系细节参见 [子 Agent](../customization/agents.md)。 +**`AgentSwarm`** 从共享的 `prompt_template` 和 `items` 数组启动多个子 Agent。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有子 Agent 使用的 profile;省略时默认使用 `coder`。本工具接受 2 到 50 个 item,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。 + **`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要用户消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(问题文本,以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符的短分类标签,如 `Auth`、`Style`)和 `multi_select`(是否多选,默认 false)。系统会自动附加「其他」选项,无需在 `options` 中手工提供。当 `background` 为 true 时,本工具会启动一个后台问题任务并立即返回任务 ID;Agent 可以继续工作,随后自动收到答案,也可以用 `TaskOutput` 检查答案。若宿主未实现交互式提问能力,本工具会返回失败提示,Agent 应改为直接在文本回复中向用户提问。 **`Skill`** 允许 Agent 主动调用已注册的 inline 类型 Skill。接受 `skill`(Skill 名称)和可选的 `args`(附加参数文本)。只有 `type = "inline"` 的 Skill 能通过此工具调用;其他类型(如 `prompt`、`flow`)以及在 frontmatter 中设置了 `disableModelInvocation: true` 的 Skill 会被拒绝。为防止递归死循环,Skill 嵌套调用深度上限为 3 层。Skill 体系细节参见 [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 386dccd98..3b65ef8d5 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -49,7 +49,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 }], diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 8ee06f260..b5c396fb5 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -50,6 +50,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 { @@ -120,6 +121,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; @@ -170,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); @@ -357,6 +360,9 @@ export class Agent { this.planMode.cancel(payload.id); }, clearPlan: () => this.planMode.clear(), + runSwarm: (payload) => { + this.swarmMode.run(payload.input); + }, beginCompaction: (payload) => { this.fullCompaction.begin({ source: 'manual', instruction: payload.instruction }); }, @@ -423,6 +429,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/default-tool-approve.ts b/packages/agent-core/src/agent/permission/policies/default-tool-approve.ts index 2f8355ce0..28b7eae0d 100644 --- a/packages/agent-core/src/agent/permission/policies/default-tool-approve.ts +++ b/packages/agent-core/src/agent/permission/policies/default-tool-approve.ts @@ -13,6 +13,7 @@ const DEFAULT_APPROVE_TOOLS = new Set([ 'WebSearch', 'FetchURL', 'Agent', + 'AgentSwarm', 'AskUserQuestion', 'Skill', // Goal control tools have no side effects on the world: GetGoal reads, and diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index 07d7f1e1e..e92c8079a 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -47,6 +47,9 @@ export interface AgentRecordEvents { id?: string; }; + 'swarm_mode.enter': {}; + 'swarm_mode.exit': {}; + 'tools.register_user_tool': UserToolRegistration; 'tools.unregister_user_tool': { name: string; 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..88ed0b5e6 --- /dev/null +++ b/packages/agent-core/src/agent/swarm/index.ts @@ -0,0 +1,46 @@ +import type { Agent } from '..'; +import type { ContentPart } from '@moonshot-ai/kosong'; + +import SWARM_MODE_REMINDER from './swarm-mode-reminder.md'; + +const SWARM_MODE_EXIT_REMINDER = 'Swarm mode has ended.'; + +export class SwarmMode { + protected active = false; + + constructor(protected readonly agent: Agent) {} + + run(input: readonly ContentPart[]): void { + this.agent.records.logRecord({ type: 'swarm_mode.enter' }); + this.active = true; + this.agent.context.appendSystemReminder(SWARM_MODE_REMINDER, { + kind: 'injection', + variant: 'swarm_mode', + }); + this.agent.emitStatusUpdated(); + if (this.agent.records.restoring) { + this.agent.turn.restorePrompt(); + } else { + const turnId = this.agent.turn.prompt(input); + if (turnId === null) this.exit(); + } + } + + exit(): void { + if (!this.active) return; + this.agent.records.logRecord({ type: 'swarm_mode.exit' }); + this.active = false; + if (this.agent.records.restoring) { + return; + } + this.agent.context.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { + kind: 'injection', + variant: 'swarm_mode_exit', + }); + this.agent.emitStatusUpdated(); + } + + get isActive(): boolean { + return this.active; + } +} diff --git a/packages/agent-core/src/agent/swarm/swarm-mode-reminder.md b/packages/agent-core/src/agent/swarm/swarm-mode-reminder.md new file mode 100644 index 000000000..64355265f --- /dev/null +++ b/packages/agent-core/src/agent/swarm/swarm-mode-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 limit, do not try to conserve the number of agents. 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 when that improves coverage or cross-checking. diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index ff8af5587..168511878 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -417,6 +417,7 @@ export class ToolManager { log: this.agent.log, }, ), + this.agent.subagentHost && new b.AgentSwarmTool(this.agent.subagentHost), 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 c68966778..cf4d905e5 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -473,6 +473,9 @@ export class TurnFlow { this.agent.usage.endTurn(); } this.agent.emitEvent(ended); + if (this.agent.swarmMode.isActive) { + this.agent.swarmMode.exit(); + } if (standalone && this.currentId === turnId) { this.activeTurn = null; } 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 814dd70d9..bbd3560de 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -309,6 +309,7 @@ export interface AgentAPI { enterPlan: (payload: EmptyPayload) => void; cancelPlan: (payload: CancelPlanPayload) => void; clearPlan: (payload: EmptyPayload) => void; + runSwarm: (payload: PromptPayload) => void; 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 750af181d..ca8fcd4ae 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -472,6 +472,10 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).clearPlan(payload); } + runSwarm({ sessionId, ...payload }: SessionAgentPayload) { + return this.sessionApi(sessionId).runSwarm(payload); + } + beginCompaction({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).beginCompaction(payload); } diff --git a/packages/agent-core/src/rpc/events.ts b/packages/agent-core/src/rpc/events.ts index f39163a2f..ea0b3f017 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; } diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index 2e6c0a5ed..a9db2a316 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -199,6 +199,10 @@ export class SessionAPIImpl implements PromisableMethods { return this.getAgent(agentId).clearPlan(payload); } + runSwarm({ agentId, ...payload }: AgentScopedPayload) { + return this.getAgent(agentId).runSwarm(payload); + } + beginCompaction({ agentId, ...payload }: AgentScopedPayload) { return this.getAgent(agentId).beginCompaction(payload); } 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..586223d40 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md @@ -0,0 +1,5 @@ +Launch multiple subagents from one prompt template and a list of item values. + +Use AgentSwarm when many subagents should run the same kind of task over different inputs. Do not create a JSONL file for this tool. + +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 subagents with those two concrete prompts. When a non-default subagent profile is needed, pass `subagent_type` once for the whole swarm. diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.md b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.md new file mode 100644 index 000000000..322b0b176 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.md @@ -0,0 +1,9 @@ +Launch multiple subagents from a JSONL agents file. This tool is for avoiding manually writing out many similar subagent prompts. + +Before calling AgentSwarm, generate the `agents_file` in a temporary directory with a code script. The JSONL file must be produced programmatically, and the script must use a loop to generate the agents instead of hard-coding every agent. DO NOT hand-write the JSONL file. + +The agents file: +- A file must define more than 3 subagents, no upper limit. +- Each line must be one JSON object with `prompt` and optional `subagent_type`. +- `prompt` is the task prompt sent as that subagent's first user message. +- `subagent_type` is one of the available subagent types, such as `coder`, `explore`, or `plan`. It defaults to `coder` when omitted. diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.ts.txt b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.ts.txt new file mode 100644 index 000000000..08b6dd7ad --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.ts.txt @@ -0,0 +1,308 @@ +import type { Kaos } from '@moonshot-ai/kaos'; +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import type { SessionSubagentHost, SubagentHandle } from '../../../session/subagent-host'; +import { + createDeadlineAbortSignal, + isUserCancellation, + type DeadlineAbortSignal, +} from '../../../utils/abort'; +import { isAbortError } from '../../../loop/errors'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { resolvePathAccessPath } from '../../policies/path-access'; +import { toInputJsonSchema } from '../../support/input-schema'; +import { literalRulePattern, matchesPathRuleSubject } from '../../support/rule-match'; +import type { WorkspaceConfig } from '../../support/workspace'; +import AGENT_SWARM_DESCRIPTION from './agent-swarm.md'; + +const MAX_SWARM_AGENTS = 50; +const DEFAULT_SUBAGENT_TYPE = 'coder'; + +export const AgentSwarmToolInputSchema = z.object({ + agents_file: z + .string() + .describe('Path to the JSONL agents file.'), + description: z + .string() + .trim() + .min(1) + .describe('Short description for the whole swarm.'), + timeout: z + .number() + .int() + .min(30) + .max(3600) + .optional() + .describe('Timeout in seconds for each subagent.'), +}); + +export type AgentSwarmToolInput = z.infer; + +const AgentSwarmSpecSchema = z + .object({ + prompt: z.string().trim().min(1), + subagent_type: z.string().trim().min(1).optional(), + }) + .strict(); + +type AgentSwarmSpec = z.infer; + +interface ParsedAgentSwarmSpec extends AgentSwarmSpec { + readonly lineNo: number; +} + +interface SwarmRunResult { + readonly spec: ParsedAgentSwarmSpec; + readonly agentId?: string; + readonly profileName: string; + readonly description: string; + readonly status: 'completed' | 'failed'; + 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 kaos: Kaos, + private readonly workspace: WorkspaceConfig, + private readonly subagentHost: SessionSubagentHost, + ) {} + + resolveExecution(args: AgentSwarmToolInput): ToolExecution { + const path = resolvePathAccessPath(args.agents_file, { + kaos: this.kaos, + workspace: this.workspace, + operation: 'read', + }); + return { + accesses: ToolAccesses.readFile(path), + description: `Launching agent swarm: ${args.description}`, + display: { + kind: 'agent_call', + agent_name: 'swarm', + prompt: args.agents_file, + }, + approvalRule: literalRulePattern(this.name, path), + matchesRule: (ruleArgs) => + matchesPathRuleSubject(ruleArgs, path, { + cwd: this.workspace.workspaceDir, + pathClass: this.kaos.pathClass(), + homeDir: this.kaos.gethome(), + }), + execute: (ctx) => this.execution(args, path, ctx), + }; + } + + private async execution( + args: AgentSwarmToolInput, + safePath: string, + context: ExecutableToolContext, + ): Promise { + try { + const text = await this.kaos.readText(safePath, { errors: 'strict' }); + const specs = parseAgentSwarmSpecs(text); + + const result = await this.runSwarm(args, specs, context.signal, context.toolCallId); + return { + output: result, + isError: swarmResultHasFailures(result) ? true : undefined, + }; + } catch (error) { + return { + output: error instanceof Error ? error.message : String(error), + isError: true, + }; + } + } + + private async runSwarm( + args: AgentSwarmToolInput, + specs: readonly ParsedAgentSwarmSpec[], + signal: AbortSignal, + toolCallId: string, + ): Promise { + let foregroundDeadline: DeadlineAbortSignal | undefined; + try { + foregroundDeadline = + args.timeout !== undefined + ? createDeadlineAbortSignal(signal, args.timeout * 1000) + : undefined; + const runSignal = foregroundDeadline?.signal ?? signal; + const results = await Promise.all( + specs.map((spec) => + this.runOne( + args, + spec, + runSignal, + toolCallId, + () => foregroundDeadline?.timedOut() === true, + ), + ), + ); + return renderSwarmResults(args, results); + } finally { + foregroundDeadline?.clear(); + } + } + + private async runOne( + args: AgentSwarmToolInput, + spec: ParsedAgentSwarmSpec, + signal: AbortSignal, + toolCallId: string, + timedOut: () => boolean, + ): Promise { + const profileName = spec.subagent_type ?? DEFAULT_SUBAGENT_TYPE; + const description = childDescription(args.description, spec.lineNo, profileName); + let handle: SubagentHandle | undefined; + try { + signal.throwIfAborted(); + handle = await this.subagentHost.spawn(profileName, { + parentToolCallId: toolCallId, + prompt: spec.prompt, + description, + runInBackground: false, + signal, + }); + const completion = await handle.completion; + return { + spec, + agentId: handle.agentId, + profileName: handle.profileName, + description, + status: 'completed', + result: completion.result, + }; + } catch (error) { + return { + spec, + agentId: handle?.agentId, + profileName: handle?.profileName ?? profileName, + description, + status: 'failed', + error: formatSubagentError(error, signal, timedOut, args.timeout), + }; + } + } +} + +function parseAgentSwarmSpecs(text: string): ParsedAgentSwarmSpec[] { + const specs: ParsedAgentSwarmSpec[] = []; + const seenAgents = new Map(); + const lines = text.split(/\r?\n/u); + for (const [index, rawLine] of lines.entries()) { + const lineNo = index + 1; + const line = rawLine.trim(); + if (line.length === 0) continue; + if (specs.length >= MAX_SWARM_AGENTS) { + throw new Error(`AgentSwarm supports at most ${String(MAX_SWARM_AGENTS)} agents per file.`); + } + let parsed: unknown; + try { + parsed = JSON.parse(line) as unknown; + } catch (error) { + throw new Error(`Invalid JSON on line ${String(lineNo)}: ${errorMessage(error)}`, { + cause: error, + }); + } + const result = AgentSwarmSpecSchema.safeParse(parsed); + if (!result.success) { + throw new Error(`Invalid subagent spec on line ${String(lineNo)}: ${result.error.message}`); + } + const duplicateKey = agentSpecDuplicateKey(result.data); + const duplicateLineNo = seenAgents.get(duplicateKey); + if (duplicateLineNo !== undefined) { + const subagentType = result.data.subagent_type ?? DEFAULT_SUBAGENT_TYPE; + throw new Error( + `Duplicate subagent spec on lines ${String(duplicateLineNo)} and ${String(lineNo)}: prompt and subagent_type "${subagentType}" are identical. AgentSwarm requires distinct subagents.`, + ); + } + seenAgents.set(duplicateKey, lineNo); + specs.push({ ...result.data, lineNo }); + } + if (specs.length < 2) { + throw new Error( + 'AgentSwarm requires at least 2 subagent specs. Use Agent for a single subagent.', + ); + } + return specs; +} + +function agentSpecDuplicateKey(spec: AgentSwarmSpec): string { + return JSON.stringify({ + prompt: spec.prompt, + subagent_type: spec.subagent_type ?? DEFAULT_SUBAGENT_TYPE, + }); +} + +function childDescription(swarmDescription: string, lineNo: number, profileName: string): string { + return `${swarmDescription} #${String(lineNo)} (${profileName})`; +} + +function renderSwarmResults( + args: AgentSwarmToolInput, + results: readonly SwarmRunResult[], +): string { + const completed = results.filter((result) => result.status === 'completed').length; + const failed = results.length - completed; + const lines = [ + `agent_swarm: ${failed > 0 ? 'failed' : 'completed'}`, + `description: ${args.description}`, + `source: ${args.agents_file}`, + `agents: ${String(results.length)}`, + `completed: ${String(completed)}`, + `failed: ${String(failed)}`, + ]; + + for (const [index, result] of results.entries()) { + lines.push( + '', + `[agent ${String(index + 1)}]`, + `line: ${String(result.spec.lineNo)}`, + ...(result.agentId === undefined ? [] : [`agent_id: ${result.agentId}`]), + `actual_subagent_type: ${result.profileName}`, + `status: ${result.status}`, + `description: ${result.description}`, + '', + ); + if (result.status === 'completed') { + lines.push('[summary]', result.result ?? ''); + } else { + lines.push(`subagent error: ${result.error ?? 'unknown error'}`); + } + } + + return lines.join('\n'); +} + +function swarmResultHasFailures(result: string): boolean { + return result.startsWith('agent_swarm: failed\n'); +} + +function formatSubagentError( + error: unknown, + signal: AbortSignal, + timedOut: () => boolean, + timeout: number | undefined, +): string { + if (timedOut() && timeout !== undefined) { + return `AgentSwarm timed out after ${String(timeout)}s.`; + } + if (isUserCancellation(signal.reason)) { + return 'The user manually interrupted this subagent swarm.'; + } + if (isAbortError(error)) { + return 'The subagent was stopped before it finished.'; + } + return errorMessage(error); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} 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..f7eaae8ae --- /dev/null +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -0,0 +1,281 @@ +import { z } from 'zod'; + +import type { BuiltinTool } from '../../../agent/tool'; +import type { SessionSubagentHost, SubagentHandle } from '../../../session/subagent-host'; +import { + createDeadlineAbortSignal, + isUserCancellation, + type DeadlineAbortSignal, +} from '../../../utils/abort'; +import { isAbortError } from '../../../loop/errors'; +import { ToolAccesses } from '../../../loop/tool-access'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; +import { toInputJsonSchema } from '../../support/input-schema'; +import { matchesGlobRuleSubject } from '../../support/rule-match'; +import AGENT_SWARM_DESCRIPTION from './agent-swarm.md'; + +const MAX_SWARM_AGENTS = 50; +const DEFAULT_SUBAGENT_TYPE = 'coder'; +const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}'; + +export const AgentSwarmToolInputSchema = z + .object({ + description: z + .string() + .trim() + .min(1) + .describe('Short description for the whole swarm.'), + timeout: z + .number() + .int() + .min(30) + .max(3600) + .optional() + .describe('Timeout in seconds for each subagent.'), + 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) + .refine((value) => value.includes(PROMPT_TEMPLATE_PLACEHOLDER), { + message: `prompt_template must include the ${PROMPT_TEMPLATE_PLACEHOLDER} placeholder.`, + }) + .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)) + .min(2) + .max(MAX_SWARM_AGENTS) + .describe( + `Values used to fill ${PROMPT_TEMPLATE_PLACEHOLDER}. Each item launches one subagent.`, + ), + }) + .strict(); + +export type AgentSwarmToolInput = z.infer; + +interface AgentSwarmSpec { + readonly index: number; + readonly item: string; + readonly prompt: string; +} + +interface SwarmRunResult { + readonly spec: AgentSwarmSpec; + readonly agentId?: string; + readonly profileName: string; + readonly description: string; + readonly status: 'completed' | 'failed'; + 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) {} + + resolveExecution(args: AgentSwarmToolInput): ToolExecution { + return { + accesses: ToolAccesses.none(), + description: `Launching agent swarm: ${args.description}`, + display: { + kind: 'agent_call', + agent_name: 'swarm', + prompt: args.description, + }, + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, 'swarm'), + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: AgentSwarmToolInput, + context: ExecutableToolContext, + ): Promise { + try { + const specs = createAgentSwarmSpecs(args); + const result = await this.runSwarm(args, specs, context.signal, context.toolCallId); + return { + output: result, + isError: swarmResultHasFailures(result) ? true : undefined, + }; + } catch (error) { + return { + output: errorMessage(error), + isError: true, + }; + } + } + + private async runSwarm( + args: AgentSwarmToolInput, + specs: readonly AgentSwarmSpec[], + signal: AbortSignal, + toolCallId: string, + ): Promise { + let foregroundDeadline: DeadlineAbortSignal | undefined; + try { + foregroundDeadline = + args.timeout !== undefined + ? createDeadlineAbortSignal(signal, args.timeout * 1000) + : undefined; + const runSignal = foregroundDeadline?.signal ?? signal; + const results = await Promise.all( + specs.map((spec) => + this.runOne( + args, + spec, + runSignal, + toolCallId, + () => foregroundDeadline?.timedOut() === true, + ), + ), + ); + return renderSwarmResults(args, results); + } finally { + foregroundDeadline?.clear(); + } + } + + private async runOne( + args: AgentSwarmToolInput, + spec: AgentSwarmSpec, + signal: AbortSignal, + toolCallId: string, + timedOut: () => boolean, + ): Promise { + const profileName = args.subagent_type ?? DEFAULT_SUBAGENT_TYPE; + const description = childDescription(args.description, spec.index, profileName); + let handle: SubagentHandle | undefined; + try { + signal.throwIfAborted(); + handle = await this.subagentHost.spawn(profileName, { + parentToolCallId: toolCallId, + prompt: spec.prompt, + description, + runInBackground: false, + signal, + }); + const completion = await handle.completion; + return { + spec, + agentId: handle.agentId, + profileName: handle.profileName, + description, + status: 'completed', + result: completion.result, + }; + } catch (error) { + return { + spec, + agentId: handle?.agentId, + profileName: handle?.profileName ?? profileName, + description, + status: 'failed', + error: formatSubagentError(error, signal, timedOut, args.timeout), + }; + } + } +} + +function createAgentSwarmSpecs(args: AgentSwarmToolInput): AgentSwarmSpec[] { + if (!args.prompt_template.includes(PROMPT_TEMPLATE_PLACEHOLDER)) { + throw new Error(`AgentSwarm prompt_template must include ${PROMPT_TEMPLATE_PLACEHOLDER}.`); + } + + const seenPrompts = new Map(); + return args.items.map((item, index) => { + const prompt = args.prompt_template.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); + return { + index: index + 1, + item, + prompt, + }; + }); +} + +function childDescription(swarmDescription: string, index: number, profileName: string): string { + return `${swarmDescription} #${String(index)} (${profileName})`; +} + +function renderSwarmResults( + args: AgentSwarmToolInput, + results: readonly SwarmRunResult[], +): string { + const completed = results.filter((result) => result.status === 'completed').length; + const failed = results.length - completed; + const lines = [ + `agent_swarm: ${failed > 0 ? 'failed' : 'completed'}`, + `description: ${args.description}`, + `subagent_type: ${args.subagent_type ?? DEFAULT_SUBAGENT_TYPE}`, + `placeholder: ${PROMPT_TEMPLATE_PLACEHOLDER}`, + `items: ${String(results.length)}`, + `completed: ${String(completed)}`, + `failed: ${String(failed)}`, + ]; + + for (const result of results) { + lines.push( + '', + `[agent ${String(result.spec.index)}]`, + ...(result.agentId === undefined ? [] : [`agent_id: ${result.agentId}`]), + `item: ${JSON.stringify(result.spec.item)}`, + `actual_subagent_type: ${result.profileName}`, + `status: ${result.status}`, + `description: ${result.description}`, + '', + ); + if (result.status === 'completed') { + lines.push('[summary]', result.result ?? ''); + } else { + lines.push(`subagent error: ${result.error ?? 'unknown error'}`); + } + } + + return lines.join('\n'); +} + +function swarmResultHasFailures(result: string): boolean { + return result.startsWith('agent_swarm: failed\n'); +} + +function formatSubagentError( + error: unknown, + signal: AbortSignal, + timedOut: () => boolean, + timeout: number | undefined, +): string { + if (timedOut() && timeout !== undefined) { + return `AgentSwarm timed out after ${String(timeout)}s.`; + } + if (isUserCancellation(signal.reason)) { + return 'The user manually interrupted this subagent swarm.'; + } + if (isAbortError(error)) { + return 'The subagent was stopped before it finished.'; + } + return errorMessage(error); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} 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/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index ad6c9a0c5..b178786c6 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -32,6 +32,10 @@ import type { WorkspaceConfig } from '../../src/tools/support/workspace'; import { createFakeKaos } from './fixtures/fake-kaos'; import { executeTool } from './fixtures/execute-tool'; import { createBackgroundManager } from '../agent/background/helpers'; +import { + AgentSwarmTool, + AgentSwarmToolInputSchema, +} from '../../src/tools/builtin/collaboration/agent-swarm'; const signal = new AbortController().signal; const workspace: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: [] }; @@ -285,6 +289,52 @@ describe('current builtin collaboration tools', () => { expect(result.output).toContain('child result'); }); + it('AgentSwarm applies one subagent_type across templated subagents', async () => { + const host = mockSubagentHost({ + spawn: vi.fn().mockImplementation((profileName: string) => + Promise.resolve({ + agentId: `agent-${profileName}`, + profileName, + resumed: false, + completion: Promise.resolve({ result: `${profileName} result` }), + }), + ), + }); + const tool = new AgentSwarmTool(host); + const input = { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + subagent_type: 'explore', + }; + + expect(AgentSwarmToolInputSchema.safeParse(input).success).toBe(true); + expect(tool.parameters).toMatchObject({ + type: 'object', + properties: { subagent_type: { type: 'string' } }, + }); + + const result = await executeTool(tool, context(input, 'call_swarm')); + + expect(host.spawn).toHaveBeenCalledTimes(2); + expect(host.spawn).toHaveBeenNthCalledWith(1, 'explore', { + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (explore)', + runInBackground: false, + signal, + }); + expect(host.spawn).toHaveBeenNthCalledWith(2, 'explore', { + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (explore)', + runInBackground: false, + signal, + }); + expect(result.output).toContain('subagent_type: explore'); + expect(result.output).toContain('explore result'); + }); + it('Skill exposes parameters and reports unknown skills as tool errors', async () => { const tool = new SkillTool({ skills: { diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index ed57965bb..8db6236ef 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -289,6 +289,15 @@ export class SDKRpcClient { }); } + async swarm(input: SessionPromptRpcInput): Promise { + const rpc = await this.getRpc(); + return rpc.runSwarm({ + sessionId: input.sessionId, + agentId: this.interactiveAgentId, + input: input.input, + }); + } + async getPlan(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); return rpc.getPlan({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index faa6b940d..5eede7807 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -93,6 +93,14 @@ export class Session { }); } + async swarm(input: string | PromptInput): Promise { + this.ensureOpen(); + await this.rpc.swarm({ + sessionId: this.id, + input: normalizePromptInput(input), + }); + } + async init(): Promise { this.ensureOpen(); await this.rpc.generateAgentsMd({ sessionId: this.id }); From d711cf0671c09b24e42baffecd7a9531f6e5a754 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 20:59:35 +0800 Subject: [PATCH 02/72] fix: redraw agent swarm progress updates --- apps/kimi-code/src/tui/kimi-tui.ts | 5 ++++- apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 76848eaac..dfd966d9d 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -999,7 +999,10 @@ export class KimiTUI { } setAgentSwarmProgress(component: AgentSwarmProgressComponent | null): void { - if (this.state.agentSwarmProgress === component) return; + if (this.state.agentSwarmProgress === component) { + if (component !== null) this.state.ui.requestRender(); + return; + } this.state.agentSwarmProgress = component; this.updateActivityPane(); this.state.ui.requestRender(); 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 67ff9578d..6b9b1335a 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 @@ -1538,6 +1538,7 @@ describe('KimiTUI message flow', () => { sendQueued, ); + vi.mocked(driver.state.ui.requestRender).mockClear(); driver.sessionEventHandler.handleEvent( { type: 'tool.call.started', @@ -1550,7 +1551,9 @@ describe('KimiTUI message flow', () => { } as Event, sendQueued, ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + vi.mocked(driver.state.ui.requestRender).mockClear(); driver.sessionEventHandler.handleEvent( { type: 'turn.ended', @@ -1561,6 +1564,7 @@ describe('KimiTUI message flow', () => { } as Event, sendQueued, ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); const activity = stripSgr(renderActivity(driver)); expect(activity).toContain('Agent swarm: Review changed files'); From 45a74b1a696b32c3bf7618f5aaf4a4554ee36cdb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 21:37:25 +0800 Subject: [PATCH 03/72] fix: refine agent swarm progress rendering --- .../messages/agent-swarm-progress.ts | 339 ++++++++++++++++-- .../tui/controllers/session-event-handler.ts | 49 ++- .../src/tui/controllers/streaming-ui.ts | 13 + apps/kimi-code/src/tui/kimi-tui.ts | 5 +- .../messages/agent-swarm-progress.test.ts | 164 ++++++++- .../test/tui/kimi-tui-message-flow.test.ts | 121 ++++++- 6 files changed, 643 insertions(+), 48 deletions(-) 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 index 6b5db757f..554ec2793 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -5,14 +5,21 @@ import type { ColorPalette } from '#/tui/theme/colors'; const MIN_CELL_WIDTH = 32; const CELL_GAP = ' '; +const FRAME_INTERVAL_MS = 80; const BRAILLE_BAR_MIN_WIDTH = 8; const BRAILLE_BAR_MAX_WIDTH = 24; const BRAILLE_EMPTY = '⣀'; +const BRAILLE_SPAWNING_RIGHT = '⣷'; +const BRAILLE_SPAWNING_LEFT = '⣾'; 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_ASSISTANT_CHARS = 1_000; +const ORCHESTRATING_LABEL = 'Orchestrating...'; +const SPAWNING_LABEL = 'Spawning...'; -type AgentSwarmPhase = 'spawning' | 'working' | 'completed' | 'failed'; +type AgentSwarmPhase = 'pending' | 'spawning' | 'working' | 'completed' | 'failed'; interface AgentSwarmMember { readonly index: number; @@ -20,11 +27,14 @@ interface AgentSwarmMember { agentId?: string; phase: AgentSwarmPhase; ticks: number; + itemText: string; + latestAssistantText: string; } interface AgentSwarmSnapshot { readonly phase: AgentSwarmPhase; readonly ticks: number; + readonly latestAssistantText: string; } interface AgentSwarmResultStatus { @@ -40,11 +50,12 @@ interface AgentSwarmSummary { export interface AgentSwarmProgressOptions { readonly description: string; - readonly items: readonly string[]; readonly colors: ColorPalette; + readonly requestRender?: () => void; } const PHASE_LABELS: Record = { + pending: 'Spawning', spawning: 'Spawning', working: 'Working', completed: 'Completed', @@ -52,25 +63,59 @@ const PHASE_LABELS: Record = { }; export class AgentSwarmProgressComponent implements Component { - private readonly members: AgentSwarmMember[]; + private members: AgentSwarmMember[]; private readonly seenToolCalls = new Set(); - private readonly description: string; + private description: string; private readonly colors: ColorPalette; + private readonly requestRender: (() => void) | undefined; + private inputComplete = false; + private frame = 0; + private timer: ReturnType | undefined; constructor(options: AgentSwarmProgressOptions) { this.description = options.description; this.colors = options.colors; - const safeItems = options.items.length > 0 ? options.items : ['agent']; - this.members = safeItems.map((_item, index) => ({ - index, - id: `swarm-${String(index + 1).padStart(3, '0')}`, - phase: 'spawning', - ticks: 0, - })); + this.requestRender = options.requestRender; + this.members = []; + } + + dispose(): void { + if (this.timer === undefined) return; + clearInterval(this.timer); + this.timer = undefined; } invalidate(): void {} + updateArgs( + args: Record, + options: { readonly streamingArguments?: string | undefined } = {}, + ): void { + const description = agentSwarmDescriptionFromArgs(args); + if (description.length > 0 || this.description.length === 0) { + this.description = description; + } + const fullItemsCount = agentSwarmItemsFromArgs(args).length; + const partialItems = + options.streamingArguments === undefined + ? [] + : agentSwarmPartialItemsFromArguments(options.streamingArguments); + const fullItems = agentSwarmItemsFromArgs(args); + const itemCount = Math.max(fullItemsCount, partialItems.length); + if (itemCount > 0) this.ensureMemberCount(itemCount); + this.updateItemTexts(fullItems, partialItems); + } + + markInputComplete(): void { + if (!this.inputComplete) { + this.inputComplete = true; + for (const member of this.members) { + if (member.phase === 'pending') member.phase = 'spawning'; + } + } + this.startAnimationIfNeeded(); + } + registerSubagent(input: { readonly agentId: string; readonly description?: string | undefined; @@ -78,6 +123,8 @@ export class AgentSwarmProgressComponent implements Component { const member = this.findMemberForSubagent(input.agentId, input.description); if (member === undefined) return; member.agentId = input.agentId; + if (this.inputComplete && member.phase === 'pending') member.phase = 'spawning'; + this.startAnimationIfNeeded(); } recordToolCall(input: { @@ -90,7 +137,20 @@ export class AgentSwarmProgressComponent implements Component { const member = this.findMemberByAgentId(input.agentId); if (member === undefined) return; member.ticks += 1; - if (member.phase === 'spawning') member.phase = 'working'; + if (member.phase === 'pending' || member.phase === 'spawning') member.phase = 'working'; + } + + appendAssistantDelta(input: { + readonly agentId: string; + readonly delta: string; + }): void { + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined || input.delta.length === 0) return; + if (member.latestAssistantText.length >= MAX_LATEST_ASSISTANT_CHARS) return; + member.latestAssistantText = `${member.latestAssistantText}${input.delta}`.slice( + 0, + MAX_LATEST_ASSISTANT_CHARS, + ); } markCompleted(agentId: string): void { @@ -107,6 +167,7 @@ export class AgentSwarmProgressComponent implements Component { applyResult(output: string): void { for (const entry of parseAgentSwarmResultStatuses(output)) { + this.ensureMemberCount(entry.index); const member = this.members[entry.index - 1]; if (member === undefined) continue; member.phase = entry.status; @@ -115,9 +176,22 @@ export class AgentSwarmProgressComponent implements Component { render(width: number): string[] { const innerWidth = Math.max(1, width); + if (this.members.length === 0) { + const lines = [ + this.renderHeader(innerWidth, undefined), + chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), + '', + chalk.hex(this.colors.textMuted)(` ${ORCHESTRATING_LABEL}`), + '', + chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), + ]; + return lines.map((line) => truncateToWidth(line, innerWidth)); + } + const snapshots = this.members.map((member): AgentSwarmSnapshot => ({ phase: member.phase, ticks: member.ticks, + latestAssistantText: member.latestAssistantText, })); const summary = summarizeSnapshots(snapshots); const lines = [ @@ -131,12 +205,13 @@ export class AgentSwarmProgressComponent implements Component { return lines.map((line) => truncateToWidth(line, innerWidth)); } - private renderHeader(width: number, summary: AgentSwarmSummary): string { + private renderHeader(width: number, summary: AgentSwarmSummary | undefined): string { const title = chalk.hex(this.colors.primary).bold(' Agent swarm'); const description = this.description.length > 0 ? chalk.hex(this.colors.text)(`: ${this.description}`) : ''; + if (summary === undefined) return truncateToWidth(title + description, width); const count = chalk.hex(this.colors.textMuted)(` agents=${String(this.members.length)}`); const activeLabel = chalk.hex(this.colors.accent)(` running=${String(summary.active)}`); const doneLabel = chalk.hex(this.colors.success)(` complete=${String(summary.completed)}`); @@ -172,19 +247,29 @@ export class AgentSwarmProgressComponent implements Component { } private renderCell(member: AgentSwarmMember, snapshot: AgentSwarmSnapshot, width: number): string { - const status = PHASE_LABELS[snapshot.phase]; - const fixedWidth = member.id.length + 2 + PHASE_LABEL_WIDTH + 1; + if (snapshot.phase === 'pending') { + return renderPendingCell(member, width, this.colors); + } + + const fixedWidth = member.id.length + 1 + 2 + 1 + MIN_LABEL_WIDTH; const availableForBar = width - fixedWidth - 2; const barWidth = availableForBar >= BRAILLE_BAR_MIN_WIDTH ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) : Math.max(1, availableForBar); - const id = chalk.hex(this.colors.textDim)(`${member.id}:`); - return [ - id, - stylePhase(status.padStart(PHASE_LABEL_WIDTH), snapshot.phase, this.colors), - brailleBar(snapshot.ticks, snapshot.phase, barWidth, this.colors), - ].join(' '); + const id = chalk.hex(this.colors.textDim)(member.id); + const bar = brailleBar( + snapshot.ticks, + snapshot.phase, + barWidth, + this.colors, + this.frame, + member.index, + ); + const prefix = `${id} ${bar} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + const label = renderCellLabel(snapshot, labelWidth, this.colors); + return prefix + label; } private findMemberForSubagent( @@ -196,16 +281,71 @@ export class AgentSwarmProgressComponent implements Component { const index = parseAgentSwarmDescriptionIndex(description); if (index !== undefined) { + this.ensureMemberCount(index); const byDescription = this.members[index - 1]; if (byDescription !== undefined) return byDescription; } - return this.members.find((member) => member.agentId === undefined); + 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; + this.members = [ + ...this.members, + ...createMembers(count, this.inputComplete ? 'spawning' : 'pending').slice(this.members.length), + ]; + } + + 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(() => { + if (!this.hasAnimatedMembers()) { + this.dispose(); + return; + } + this.frame += 1; + requestRender(); + }, FRAME_INTERVAL_MS); + if (typeof this.timer === 'object' && 'unref' in this.timer) { + this.timer.unref(); + } + } + + private hasAnimatedMembers(): boolean { + return this.members.some((member) => member.phase === 'spawning'); + } +} + +function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[] { + return Array.from({ length: count }, (_item, index) => ({ + index, + id: String(index + 1).padStart(2, '0'), + phase, + ticks: 0, + itemText: '', + latestAssistantText: '', + })); } export function agentSwarmItemsFromArgs(args: Record): string[] { @@ -214,6 +354,30 @@ export function agentSwarmItemsFromArgs(args: Record): string[] return items.map(String); } +export function agentSwarmPartialItemsCountFromArguments(argumentsText: string): number { + return agentSwarmPartialItemsFromArguments(argumentsText).length; +} + +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; +} + export function agentSwarmDescriptionFromArgs(args: Record): string { const description = args['description']; return typeof description === 'string' ? description : ''; @@ -268,10 +432,22 @@ function brailleBar( phase: AgentSwarmPhase, width: number, colors: ColorPalette, + frame: number, + memberIndex: number, ): string { const innerWidth = Math.max(1, width); - const fillColor = phase === 'failed' ? colors.error : colors.success; - return bracketBar(accumulatedBrailleBar(ticks, innerWidth, fillColor, colors), colors); + switch (phase) { + case 'pending': + return ''; + case 'spawning': + return bracketBar(spawningBrailleBar(innerWidth, frame, memberIndex, colors), colors); + case 'working': + return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.success, colors), colors); + case 'completed': + return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.success, colors), colors); + case 'failed': + return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.error, colors), colors); + } } function bracketBar(content: string, colors: ColorPalette): string { @@ -281,6 +457,8 @@ function bracketBar(content: string, colors: ColorPalette): string { function stylePhase(label: string, phase: AgentSwarmPhase, colors: ColorPalette): string { switch (phase) { + case 'pending': + return chalk.hex(colors.textDim)(label); case 'spawning': return chalk.hex(colors.textDim)(label); case 'working': @@ -292,11 +470,124 @@ function stylePhase(label: string, phase: AgentSwarmPhase, colors: ColorPalette) } } +function renderCellLabel( + snapshot: AgentSwarmSnapshot, + width: number, + colors: ColorPalette, +): string { + const assistantText = collapseWhitespace(snapshot.latestAssistantText); + if (snapshot.phase === 'working' && assistantText.length > 0) { + return chalk.hex(colors.text)(truncateToWidth(assistantText, width, '…')); + } + return stylePhase(truncateToWidth(PHASE_LABELS[snapshot.phase], width, '…'), snapshot.phase, colors); +} + +function renderPendingCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.textDim)(member.id); + const prefix = `${id} `; + const itemText = collapseWhitespace(member.itemText); + const label = itemText.length > 0 ? itemText : SPAWNING_LABEL; + const labelColor = itemText.length > 0 ? colors.text : colors.textDim; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + chalk.hex(labelColor)(truncateToWidth(label, labelWidth, '…')); +} + +function collapseWhitespace(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +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'; + i += 1; + break; + case 't': + value += '\t'; + i += 1; + break; + case 'r': + value += '\r'; + i += 1; + break; + case 'b': + value += '\b'; + i += 1; + break; + case 'f': + value += '\f'; + i += 1; + break; + case '"': + case '\\': + case '/': + value += escaped; + i += 1; + 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 += 5; + 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 spawningBrailleBar( + width: number, + frame: number, + memberIndex: number, + colors: ColorPalette, +): string { + if (width <= 1) { + return chalk.hex(colors.textMuted)(BRAILLE_SPAWNING_RIGHT); + } + let out = ''; + const maxPosition = width - 1; + const period = maxPosition * 2; + const position = (frame + memberIndex) % period; + const movingRight = position <= maxPosition; + const cursorCell = movingRight ? position : period - position; + const cursorChar = movingRight ? BRAILLE_SPAWNING_RIGHT : BRAILLE_SPAWNING_LEFT; + for (let i = 0; i < width; i += 1) { + out += chalk.hex(i === cursorCell ? colors.textMuted : colors.textDim)( + i === cursorCell ? cursorChar : BRAILLE_EMPTY, + ); + } + return out; +} + function accumulatedBrailleBar( ticks: number, width: number, 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 3ba1b18a9..bfb20f868 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -37,7 +37,6 @@ import { MoonLoader } from '../components/chrome/moon-loader'; import { AgentSwarmProgressComponent, agentSwarmDescriptionFromArgs, - agentSwarmItemsFromArgs, } from '../components/messages/agent-swarm-progress'; import { buildGoalMarker } from '../components/messages/goal-markers'; import { SwarmModeMarkerComponent } from '../components/messages/swarm-markers'; @@ -251,7 +250,12 @@ export class SessionEventHandler { const toolCall = streamingUI.getToolComponent(parentToolCallId); const swarmProgress = this.agentSwarmProgress.get(parentToolCallId); if (swarmProgress !== undefined) { - if (event.type === 'tool.call.started') { + if (event.type === 'assistant.delta') { + swarmProgress.appendAssistantDelta({ + agentId: subagentId, + delta: event.delta, + }); + } else if (event.type === 'tool.call.started') { swarmProgress.recordToolCall({ agentId: subagentId, toolCallId: event.toolCallId, @@ -513,12 +517,8 @@ export class SessionEventHandler { }; streamingUI.registerToolCall(toolCall); if (event.name === 'AgentSwarm') { - const progress = new AgentSwarmProgressComponent({ - description: agentSwarmDescriptionFromArgs(toolCall.args), - items: agentSwarmItemsFromArgs(toolCall.args), - colors: this.host.state.theme.colors, - }); - this.agentSwarmProgress.set(event.toolCallId, progress); + const progress = this.ensureAgentSwarmProgress(event.toolCallId, toolCall.args); + progress.markInputComplete(); this.host.setAgentSwarmProgress(progress); } else if (this.agentSwarmProgress.size > 0) { this.host.setAgentSwarmProgress(null); @@ -534,6 +534,16 @@ 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.agentSwarmProgress.has(event.toolCallId)) + ) { + const progress = this.ensureAgentSwarmProgress(event.toolCallId, preview.args, { + streamingArguments: preview.argumentsText, + }); + this.host.setAgentSwarmProgress(progress); + } this.host.patchLivePane({ mode: 'tool', @@ -546,6 +556,29 @@ export class SessionEventHandler { streamingUI.scheduleFlush(); } + 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, + requestRender: () => { + this.host.state.ui.requestRender(); + }, + }); + progress.updateArgs(args, options); + this.agentSwarmProgress.set(toolCallId, progress); + return progress; + } + private handleToolProgress(event: ToolProgressEvent): void { if (event.update.kind !== 'status') return; const text = event.update.text; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 095fe2b68..16ad85f97 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -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 { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index dfd966d9d..588fa6ea5 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -110,7 +110,7 @@ import { type TUIStartupState, } from './types'; import { createTUIState, type TUIState } from './tui-state'; -import { isExpandable, isPlanExpandable } from './utils/component-capabilities'; +import { hasDispose, isExpandable, isPlanExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; @@ -1003,6 +1003,9 @@ export class KimiTUI { if (component !== null) this.state.ui.requestRender(); return; } + if (hasDispose(this.state.agentSwarmProgress)) { + this.state.agentSwarmProgress.dispose(); + } this.state.agentSwarmProgress = component; this.updateActivityPane(); this.state.ui.requestRender(); 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 index d832c64a4..93c3ac7a5 100644 --- 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 @@ -1,9 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { AgentSwarmProgressComponent, agentSwarmDescriptionFromArgs, agentSwarmItemsFromArgs, + agentSwarmPartialItemsCountFromArguments, + agentSwarmPartialItemsFromArguments, } from '#/tui/components/messages/agent-swarm-progress'; import { darkColors } from '#/tui/theme/colors'; @@ -12,41 +14,181 @@ function strip(text: string): string { } describe('AgentSwarmProgressComponent', () => { - it('renders a full swarm panel with title, summary, and rows', () => { + it('renders an orchestrating panel before subagents spawn', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', - items: ['src/a.ts', 'src/b.ts'], colors: darkColors, }); const output = strip(component.render(100).join('\n')); expect(output).toContain('Agent swarm: Review changed files'); + expect(output).toContain('Orchestrating...'); + expect(output).not.toContain('01'); + }); + + it('renders spawned subagents as text-only spawning rows', () => { + 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('agents=2'); expect(output).toContain('running=2'); - expect(output).toContain('swarm-001: Spawning'); - expect(output).toContain('swarm-002: Spawning'); + expect(output).toContain('01 Spawning...'); + expect(output).toContain('02 Spawning...'); + expect(output).not.toContain('01 ['); }); - it('advances one step for each subagent tool call and marks terminal states', () => { + it('advances one step when a subagent tool call starts and marks terminal states', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', - items: ['src/a.ts', 'src/b.ts'], 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('01 ['); + expect(output).toContain('Working'); + expect(output).toContain('02 Spawning...'); + component.markCompleted('agent-1'); component.markFailed('agent-2'); - const output = strip(component.render(100).join('\n')); - + output = strip(component.render(100).join('\n')); expect(output).toContain('complete=1'); expect(output).toContain('failed=1'); - expect(output).toContain('swarm-001: Completed'); - expect(output).toContain('swarm-002: Failed'); + expect(output).toContain('01 ['); + expect(output).toContain('Completed'); + expect(output).toContain('02 ['); + expect(output).toContain('Failed'); + }); + + 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.appendAssistantDelta({ + 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('01 ['); + expect(output).toContain('Reviewing'); + expect(output).toContain('…'); + expect(output).not.toContain('Working'); + }); + + it('switches spawned rows to animated spawning once AgentSwarm input is complete', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const component = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: darkColors, + requestRender, + }); + + try { + component.registerSubagent({ + agentId: 'agent-1', + description: 'Review changed files #1 (coder)', + }); + let output = strip(component.render(100).join('\n')); + expect(output).toContain('01 Spawning...'); + expect(output).not.toContain('01 ['); + + component.markInputComplete(); + output = strip(component.render(100).join('\n')); + expect(output).toContain('01 ['); + expect(output).toContain('Spawning'); + + const before = output; + vi.advanceTimersByTime(80); + const after = strip(component.render(100).join('\n')); + expect(requestRender).toHaveBeenCalled(); + expect(after).not.toBe(before); + } finally { + component.dispose(); + vi.useRealTimers(); + } + }); + + 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: Review changed files'); + expect(output).toContain('agents=2'); + expect(output).toContain('01 src/a.ts'); + expect(output).toContain('02 src/b.ts'); + }); + + 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('agents=2'); + expect(output).toContain('01 src/a.ts'); + expect(output).toContain('02 src/b'); + }); + + 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('agents=1'); + expect(output).toContain('01 Spawning...'); + expect(output).not.toContain('02'); + + component.registerSubagent({ agentId: 'agent-2', description: 'Review changed files #2 (coder)' }); + output = strip(component.render(100).join('\n')); + expect(output).toContain('agents=2'); + expect(output).toContain('02 Spawning...'); }); it('extracts description and item list from AgentSwarm args', () => { 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 6b9b1335a..5e87bb221 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 @@ -1538,6 +1538,20 @@ describe('KimiTUI message flow', () => { 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)', + runInBackground: false, + } as Event, + sendQueued, + ); + vi.mocked(driver.state.ui.requestRender).mockClear(); driver.sessionEventHandler.handleEvent( { @@ -1553,6 +1567,20 @@ describe('KimiTUI message flow', () => { ); 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 activity = stripSgr(renderActivity(driver)); + expect(activity).toContain('01 ['); + expect(activity).toContain('Reviewing src/a.ts'); + vi.mocked(driver.state.ui.requestRender).mockClear(); driver.sessionEventHandler.handleEvent( { @@ -1566,14 +1594,99 @@ describe('KimiTUI message flow', () => { ); expect(driver.state.ui.requestRender).toHaveBeenCalled(); - const activity = stripSgr(renderActivity(driver)); + activity = stripSgr(renderActivity(driver)); expect(activity).toContain('Agent swarm: Review changed files'); - expect(activity).toContain('swarm-001: Completed'); - expect(activity).toContain('swarm-002: Spawning'); + expect(activity).toContain('01 ['); + expect(activity).toContain('Completed'); + expect(activity).toContain('02 ['); + expect(activity).toContain('Spawning'); const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Using AgentSwarm'); - expect(transcript).not.toContain('swarm-001'); + expect(transcript).not.toContain('01'); + }); + + 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 activity = stripSgr(renderActivity(driver)); + expect(activity).toContain('Agent swarm'); + expect(activity).toContain('Orchestrating...'); + expect(activity).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, + ); + + activity = stripSgr(renderActivity(driver)); + expect(activity).toContain('Agent swarm: Review changed files'); + expect(activity).toContain('agents=2'); + expect(activity).toContain('01 src/a.ts'); + expect(activity).toContain('02 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)', + runInBackground: false, + } as Event, + sendQueued, + ); + + activity = stripSgr(renderActivity(driver)); + expect(activity).toContain('agents=2'); + expect(activity).toContain('01 src/a.ts'); + expect(activity).toContain('02 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, + ); + + activity = stripSgr(renderActivity(driver)); + expect(activity).toContain('agents=2'); + expect(activity).toContain('01 ['); + expect(activity).toContain('02 ['); + expect(activity).toContain('Spawning'); }); it('shows plan review reject on the plan card without an approval notice', async () => { From 1cd720ab3b4801cbe1d8715ae5808267792e3298 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 23:04:47 +0800 Subject: [PATCH 04/72] upd --- .../src/tui/components/chrome/footer.ts | 10 +- .../messages/agent-swarm-progress.ts | 213 +++++++-- .../tui/controllers/session-event-handler.ts | 32 +- packages/agent-core/src/rpc/events.ts | 12 + packages/agent-core/src/session/index.ts | 3 +- .../agent-core/src/session/subagent-host.ts | 248 ++++++++-- .../src/session/subagent-launch-queue.ts | 447 ++++++++++++++++++ .../builtin/collaboration/agent-swarm.ts | 120 ++--- .../src/tools/builtin/collaboration/agent.ts | 40 +- .../test/session/subagent-host.test.ts | 177 ++++++- .../test/tools/builtin-current.test.ts | 92 +++- 11 files changed, 1194 insertions(+), 200 deletions(-) create mode 100644 packages/agent-core/src/session/subagent-launch-queue.ts diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 6a96bd9a3..d73731c05 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -287,10 +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')); - if (state.swarmMode) left.push(chalk.hex(colors.primary).bold('swarm')); + 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.success).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/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index 554ec2793..01c27971a 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -6,20 +6,24 @@ import type { ColorPalette } from '#/tui/theme/colors'; const MIN_CELL_WIDTH = 32; const CELL_GAP = ' '; const FRAME_INTERVAL_MS = 80; -const BRAILLE_BAR_MIN_WIDTH = 8; -const BRAILLE_BAR_MAX_WIDTH = 24; +const BRAILLE_BAR_MIN_WIDTH = 5; +const BRAILLE_BAR_MAX_WIDTH = 8; const BRAILLE_EMPTY = '⣀'; const BRAILLE_SPAWNING_RIGHT = '⣷'; const BRAILLE_SPAWNING_LEFT = '⣾'; const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; const BRAILLE_LEVELS = ['⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'] as const; +const SPAWNING_PHASE_GROUP_SIZE = 2; const PHASE_LABEL_WIDTH = 'Completed'.length; const MIN_LABEL_WIDTH = PHASE_LABEL_WIDTH; -const MAX_LATEST_ASSISTANT_CHARS = 1_000; +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 ORCHESTRATING_LABEL = 'Orchestrating...'; const SPAWNING_LABEL = 'Spawning...'; -type AgentSwarmPhase = 'pending' | 'spawning' | 'working' | 'completed' | 'failed'; +type AgentSwarmPhase = 'pending' | 'spawning' | 'working' | 'completed' | 'failed' | 'cancelled'; interface AgentSwarmMember { readonly index: number; @@ -28,13 +32,16 @@ interface AgentSwarmMember { phase: AgentSwarmPhase; ticks: number; itemText: string; - latestAssistantText: string; + latestModelText: string; + completedAtMs?: number; + failedAtMs?: number; } interface AgentSwarmSnapshot { readonly phase: AgentSwarmPhase; readonly ticks: number; - readonly latestAssistantText: string; + readonly latestModelText: string; + readonly phaseElapsedMs: number; } interface AgentSwarmResultStatus { @@ -46,6 +53,7 @@ interface AgentSwarmSummary { readonly active: number; readonly completed: number; readonly failed: number; + readonly cancelled: number; } export interface AgentSwarmProgressOptions { @@ -60,6 +68,7 @@ const PHASE_LABELS: Record = { working: 'Working', completed: 'Completed', failed: 'Failed', + cancelled: 'Cancelled', }; export class AgentSwarmProgressComponent implements Component { @@ -140,29 +149,48 @@ export class AgentSwarmProgressComponent implements Component { if (member.phase === 'pending' || member.phase === 'spawning') member.phase = 'working'; } - appendAssistantDelta(input: { + appendModelDelta(input: { readonly agentId: string; readonly delta: string; }): void { const member = this.findMemberByAgentId(input.agentId); if (member === undefined || input.delta.length === 0) return; - if (member.latestAssistantText.length >= MAX_LATEST_ASSISTANT_CHARS) return; - member.latestAssistantText = `${member.latestAssistantText}${input.delta}`.slice( - 0, - MAX_LATEST_ASSISTANT_CHARS, + member.latestModelText = `${member.latestModelText}${input.delta}`.slice( + -MAX_LATEST_MODEL_CHARS, ); } + appendAssistantDelta(input: { + readonly agentId: string; + readonly delta: string; + }): void { + this.appendModelDelta(input); + } + markCompleted(agentId: string): void { const member = this.findMemberByAgentId(agentId); - if (member === undefined || member.phase === 'failed') return; + if (member === undefined || member.phase === 'failed' || member.phase === 'cancelled') return; + if (member.phase !== 'completed') member.completedAtMs = Date.now(); + delete member.failedAtMs; member.phase = 'completed'; + this.startAnimationIfNeeded(); } markFailed(agentId: string): void { const member = this.findMemberByAgentId(agentId); if (member === undefined) return; + if (member.phase !== 'failed') member.failedAtMs = Date.now(); member.phase = 'failed'; + delete member.completedAtMs; + this.startAnimationIfNeeded(); + } + + markCancelled(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + member.phase = 'cancelled'; + delete member.completedAtMs; + delete member.failedAtMs; } applyResult(output: string): void { @@ -170,8 +198,17 @@ export class AgentSwarmProgressComponent implements Component { this.ensureMemberCount(entry.index); const member = this.members[entry.index - 1]; if (member === undefined) continue; + if (entry.status === 'completed' && member.phase !== 'completed') { + member.completedAtMs = Date.now(); + } + if (entry.status === 'completed') delete member.failedAtMs; + if (entry.status === 'failed' && member.phase !== 'failed') { + member.failedAtMs = Date.now(); + } + if (entry.status === 'failed') delete member.completedAtMs; member.phase = entry.status; } + this.startAnimationIfNeeded(); } render(width: number): string[] { @@ -191,7 +228,8 @@ export class AgentSwarmProgressComponent implements Component { const snapshots = this.members.map((member): AgentSwarmSnapshot => ({ phase: member.phase, ticks: member.ticks, - latestAssistantText: member.latestAssistantText, + latestModelText: member.latestModelText, + phaseElapsedMs: terminalPhaseElapsedMs(member), })); const summary = summarizeSnapshots(snapshots); const lines = [ @@ -211,15 +249,8 @@ export class AgentSwarmProgressComponent implements Component { this.description.length > 0 ? chalk.hex(this.colors.text)(`: ${this.description}`) : ''; - if (summary === undefined) return truncateToWidth(title + description, width); - const count = chalk.hex(this.colors.textMuted)(` agents=${String(this.members.length)}`); - const activeLabel = chalk.hex(this.colors.accent)(` running=${String(summary.active)}`); - const doneLabel = chalk.hex(this.colors.success)(` complete=${String(summary.completed)}`); - const failedLabel = chalk.hex(this.colors.error)(` failed=${String(summary.failed)}`); - return truncateToWidth( - title + description + count + activeLabel + doneLabel + failedLabel, - width, - ); + void summary; + return truncateToWidth(title + description, width); } private renderGrid(width: number, snapshots: readonly AgentSwarmSnapshot[]): string[] { @@ -265,6 +296,7 @@ export class AgentSwarmProgressComponent implements Component { this.colors, this.frame, member.index, + snapshot.phaseElapsedMs, ); const prefix = `${id} ${bar} `; const labelWidth = Math.max(1, width - visibleWidth(prefix)); @@ -320,12 +352,9 @@ export class AgentSwarmProgressComponent implements Component { if (!this.hasAnimatedMembers()) return; const requestRender = this.requestRender; this.timer = setInterval(() => { - if (!this.hasAnimatedMembers()) { - this.dispose(); - return; - } this.frame += 1; requestRender(); + if (!this.hasAnimatedMembers()) this.dispose(); }, FRAME_INTERVAL_MS); if (typeof this.timer === 'object' && 'unref' in this.timer) { this.timer.unref(); @@ -333,21 +362,42 @@ export class AgentSwarmProgressComponent implements Component { } private hasAnimatedMembers(): boolean { - return this.members.some((member) => member.phase === 'spawning'); + const now = Date.now(); + return this.members.some((member) => { + if (member.phase === 'spawning') return true; + return ( + member.phase === 'completed' && + member.completedAtMs !== undefined && + now - member.completedAtMs < COMPLETE_FILL_MS + ) || ( + member.phase === 'failed' && + member.failedAtMs !== undefined && + now - member.failedAtMs < COMPLETE_FILL_MS + ); + }); } } function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[] { return Array.from({ length: count }, (_item, index) => ({ index, - id: String(index + 1).padStart(2, '0'), + id: String(index + 1).padStart(3, '0'), phase, ticks: 0, itemText: '', - latestAssistantText: '', + latestModelText: '', })); } +function terminalPhaseElapsedMs(member: AgentSwarmMember): number { + const startedAtMs = member.phase === 'completed' + ? member.completedAtMs + : member.phase === 'failed' + ? member.failedAtMs + : undefined; + return startedAtMs === undefined ? 0 : Math.max(0, Date.now() - startedAtMs); +} + export function agentSwarmItemsFromArgs(args: Record): string[] { const items = args['items']; if (!Array.isArray(items)) return []; @@ -416,14 +466,17 @@ function columnsForWidth(width: number, count: number): number { 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, + active: snapshots.length - completed - failed - cancelled, completed, failed, + cancelled, }; } @@ -434,6 +487,7 @@ function brailleBar( colors: ColorPalette, frame: number, memberIndex: number, + phaseElapsedMs: number, ): string { const innerWidth = Math.max(1, width); switch (phase) { @@ -444,9 +498,19 @@ function brailleBar( case 'working': return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.success, colors), colors); case 'completed': - return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.success, colors), colors); + return bracketBar( + accumulatedBrailleBar( + completedDisplayTicks(ticks, innerWidth, phaseElapsedMs), + innerWidth, + colors.success, + colors, + ), + colors, + ); case 'failed': - return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.error, colors), colors); + return bracketBar(failedBrailleBar(ticks, innerWidth, phaseElapsedMs, colors), colors); + case 'cancelled': + return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.warning, colors), colors); } } @@ -455,18 +519,20 @@ function bracketBar(content: string, colors: ColorPalette): string { return bracket('[') + content + bracket(']'); } -function stylePhase(label: string, phase: AgentSwarmPhase, colors: ColorPalette): string { +function phaseColor(phase: AgentSwarmPhase, colors: ColorPalette): string { switch (phase) { case 'pending': - return chalk.hex(colors.textDim)(label); + return colors.textDim; case 'spawning': - return chalk.hex(colors.textDim)(label); + return colors.textDim; case 'working': - return chalk.hex(colors.primary)(label); + return colors.primary; case 'completed': - return chalk.hex(colors.success)(label); + return colors.success; case 'failed': - return chalk.hex(colors.error)(label); + return colors.error; + case 'cancelled': + return colors.warning; } } @@ -475,11 +541,11 @@ function renderCellLabel( width: number, colors: ColorPalette, ): string { - const assistantText = collapseWhitespace(snapshot.latestAssistantText); - if (snapshot.phase === 'working' && assistantText.length > 0) { - return chalk.hex(colors.text)(truncateToWidth(assistantText, width, '…')); + const latestLine = latestNonEmptyLine(snapshot.latestModelText); + if (snapshot.phase === 'working' && latestLine.length > 0) { + return truncateWithColor(latestLine, width, colors.textDim); } - return stylePhase(truncateToWidth(PHASE_LABELS[snapshot.phase], width, '…'), snapshot.phase, colors); + return truncateWithColor(PHASE_LABELS[snapshot.phase], width, phaseColor(snapshot.phase, colors)); } function renderPendingCell( @@ -491,15 +557,28 @@ function renderPendingCell( const prefix = `${id} `; const itemText = collapseWhitespace(member.itemText); const label = itemText.length > 0 ? itemText : SPAWNING_LABEL; - const labelColor = itemText.length > 0 ? colors.text : colors.textDim; const labelWidth = Math.max(1, width - visibleWidth(prefix)); - return prefix + chalk.hex(labelColor)(truncateToWidth(label, labelWidth, '…')); + return prefix + truncateWithColor(label, labelWidth, colors.textDim); +} + +function truncateWithColor(text: string, width: number, color: string): string { + const colorize = chalk.hex(color); + return truncateToWidth(colorize(text), width, colorize('…')); } function collapseWhitespace(text: string): string { return text.replaceAll(/\s+/g, ' ').trim(); } +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 parsePartialJsonString( text: string, startIndex: number, @@ -576,7 +655,8 @@ function spawningBrailleBar( let out = ''; const maxPosition = width - 1; const period = maxPosition * 2; - const position = (frame + memberIndex) % period; + const phaseOffset = Math.floor(memberIndex / SPAWNING_PHASE_GROUP_SIZE); + const position = (frame + phaseOffset) % period; const movingRight = position <= maxPosition; const cursorCell = movingRight ? position : period - position; const cursorChar = movingRight ? BRAILLE_SPAWNING_RIGHT : BRAILLE_SPAWNING_LEFT; @@ -588,11 +668,52 @@ function spawningBrailleBar( return out; } +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 { + const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); + if (match === null) return hex; + const [, red = '00', green = '00', blue = '00'] = match; + const darken = (channel: string, factor: number): string => { + const value = Math.max(0, Math.min(255, Math.round(Number.parseInt(channel, 16) * factor))); + return value.toString(16).padStart(2, '0'); + }; + return `#${darken(red, FAILED_PLACEHOLDER_RED_FACTOR)}${darken( + green, + FAILED_PLACEHOLDER_NON_RED_FACTOR, + )}${darken(blue, FAILED_PLACEHOLDER_NON_RED_FACTOR)}`; +} + function accumulatedBrailleBar( ticks: number, width: number, filledColor: string, colors: ColorPalette, + emptyColorForCell?: (cellIndex: number) => string, ): string { const dotsPerCell = BRAILLE_LEVELS.length; const cycleSize = width * dotsPerCell; @@ -631,7 +752,7 @@ function accumulatedBrailleBar( const count = countThisCycle > 0 ? countThisCycle : completedCycles > 0 ? dotsPerCell : 0; append( count === 0 ? BRAILLE_EMPTY : BRAILLE_LEVELS[count - 1]!, - count === 0 ? colors.textDim : filledColor, + count === 0 ? emptyColorForCell?.(i) ?? colors.textDim : filledColor, ); } flush(); 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 bfb20f868..e022e99cb 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -250,8 +250,8 @@ export class SessionEventHandler { const toolCall = streamingUI.getToolComponent(parentToolCallId); const swarmProgress = this.agentSwarmProgress.get(parentToolCallId); if (swarmProgress !== undefined) { - if (event.type === 'assistant.delta') { - swarmProgress.appendAssistantDelta({ + if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { + swarmProgress.appendModelDelta({ agentId: subagentId, delta: event.delta, }); @@ -261,9 +261,17 @@ export class SessionEventHandler { toolCallId: event.toolCallId, }); } else if (event.type === 'turn.ended') { - swarmProgress.markCompleted(subagentId); + if (event.reason === 'cancelled') { + swarmProgress.markCancelled(subagentId); + } else { + swarmProgress.markCompleted(subagentId); + } } else if (event.type === 'subagent.failed') { - swarmProgress.markFailed(event.subagentId); + if (isUserCancelledSubagentError(event.error)) { + swarmProgress.markCancelled(event.subagentId); + } else { + swarmProgress.markFailed(event.subagentId); + } } this.host.setAgentSwarmProgress(swarmProgress); return true; @@ -949,7 +957,11 @@ export class SessionEventHandler { } const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); if (swarmProgress !== undefined) { - swarmProgress.markFailed(event.subagentId); + if (isUserCancelledSubagentError(event.error)) { + swarmProgress.markCancelled(event.subagentId); + } else { + swarmProgress.markFailed(event.subagentId); + } this.host.setAgentSwarmProgress(swarmProgress); streamingUI.removeToolComponentIfInactive(event.parentToolCallId); return; @@ -1135,3 +1147,13 @@ export class SessionEventHandler { state.ui.requestRender(); } } + +function isUserCancelledSubagentError(error: string): boolean { + const normalized = error.trim(); + return ( + normalized === 'Aborted by the user' || + normalized === 'The user manually interrupted this subagent batch.' || + normalized.startsWith('The user manually interrupted this subagent ') || + normalized.includes('This was a deliberate user action, not a system error') + ); +} diff --git a/packages/agent-core/src/rpc/events.ts b/packages/agent-core/src/rpc/events.ts index ea0b3f017..ebf7997dd 100644 --- a/packages/agent-core/src/rpc/events.ts +++ b/packages/agent-core/src/rpc/events.ts @@ -212,6 +212,17 @@ export interface SubagentSpawnedEvent { readonly runInBackground: boolean; } +export interface SubagentStartedEvent { + readonly type: 'subagent.started'; + readonly subagentId: string; + readonly subagentName: string; + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string | undefined; + readonly parentAgentId?: string | undefined; + readonly description?: string | undefined; + readonly runInBackground: boolean; +} + export interface SubagentCompletedEvent { readonly type: 'subagent.completed'; readonly subagentId: string; @@ -308,6 +319,7 @@ export type AgentEvent = | ToolListUpdatedEvent | McpServerStatusEvent | SubagentSpawnedEvent + | SubagentStartedEvent | SubagentCompletedEvent | SubagentFailedEvent | CompactionStartedEvent diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 1abe7605b..616118e42 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -287,7 +287,8 @@ 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', diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index cabc8e101..b45b5c2af 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -8,11 +8,34 @@ import { prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; -import { linkAbortSignal, userCancellationReason } from '../utils/abort'; +import { + createDeadlineAbortSignal, + linkAbortSignal, + userCancellationReason, +} from '../utils/abort'; import { collectGitContext } from './git-context'; import type { Session } from './index'; +import { + SubagentLaunchQueue, + formatQueuedSubagentError, + isRateLimit429Error, + type PreparedQueuedSubagentTask, + type QueuedSubagentAttemptOutcome, + type QueuedSubagentRunHandle, + type QueuedSubagentRunOptions, + type QueuedSubagentRunResult, + type QueuedSubagentTask, +} from './subagent-launch-queue'; import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md'; +export type { + QueuedSubagentRunOptions, + QueuedSubagentRunResult, + QueuedSubagentTask, + PreparedQueuedSubagentTask, + QueuedSubagentRunHandle, +} from './subagent-launch-queue'; + /** * A subagent summary shorter than this many characters triggers one * follow-up turn that asks the subagent to expand it, so the parent @@ -34,6 +57,10 @@ type RunSubagentOptions = { readonly signal: AbortSignal; }; +type SpawnSubagentOptions = RunSubagentOptions & { + readonly profileName: string; +}; + type SubagentCompletion = { readonly result: string; readonly usage?: TokenUsage; @@ -44,6 +71,13 @@ type ActiveChild = { readonly runInBackground: boolean; }; +type PreparedSubagent = { + readonly agentId: string; + readonly profileName: string; + readonly start: () => SubagentHandle; + readonly cancel: (reason: unknown) => void; +}; + export type SubagentHandle = { readonly agentId: string; readonly profileName: string; @@ -53,22 +87,28 @@ export type SubagentHandle = { export class SessionSubagentHost { private readonly activeChildren = new Map(); + readonly launchQueue: SubagentLaunchQueue; constructor( private readonly session: Session, private readonly ownerAgentId: string, readonly backgroundTaskTimeoutMs?: number | undefined, - ) {} + ) { + this.launchQueue = new SubagentLaunchQueue(this); + } - async spawn(profileName: string, options: RunSubagentOptions): Promise { - options.signal.throwIfAborted(); + async spawn(options: SpawnSubagentOptions): Promise { + return (await this.prepareSubagent(options)).start(); + } + private async prepareSubagent(options: SpawnSubagentOptions): Promise { + options.signal.throwIfAborted(); const parent = this.session.agents.get(this.ownerAgentId); if (parent === undefined) { throw new Error(`Parent agent "${this.ownerAgentId}" was not found`); } - 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 }, undefined, @@ -81,26 +121,44 @@ export class SessionSubagentHost { runInBackground: options.runInBackground, }); - const completion = this.runChild( - parent, - id, - agent, - profile.name, - { - ...options, - signal: controller.signal, - }, - () => this.configureChild(parent, agent, profile), - ).finally(() => { + this.emitSubagentSpawned(parent, id, profile.name, options); + let started = false; + const cleanup = (): void => { unlinkAbortSignal(); this.activeChildren.delete(id); - }); + }; return { agentId: id, profileName: profile.name, - resumed: false, - completion, + start: () => { + if (started) { + throw new Error(`Subagent "${id}" has already been started`); + } + started = true; + const completion = this.runChild( + parent, + id, + agent, + profile.name, + { + ...options, + signal: controller.signal, + }, + () => this.configureChild(parent, agent, profile), + false, + ).finally(cleanup); + return { + agentId: id, + profileName: profile.name, + resumed: false, + completion, + }; + }, + cancel: (reason) => { + controller.abort(reason); + if (!started) cleanup(); + }, }; } @@ -167,6 +225,21 @@ export class SessionSubagentHost { }; } + async runQueued( + tasks: readonly QueuedSubagentTask[], + options: QueuedSubagentRunOptions, + ): Promise>> { + return await this.launchQueue.run(tasks, options); + } + + async runQueuedTask( + task: QueuedSubagentTask, + options: QueuedSubagentRunOptions, + ): Promise> { + const prepared = await this.prepareQueuedTask(task, options); + return this.launchQueue.runPrepared(prepared, options); + } + cancelAll(reason: unknown = userCancellationReason()): void { const foregroundChildren = Array.from(this.activeChildren).filter( ([, child]) => !child.runInBackground, @@ -187,6 +260,86 @@ export class SessionSubagentHost { return this.session.agents.get(agentId)?.config.profileName; } + async prepareQueuedTask( + task: QueuedSubagentTask, + options: QueuedSubagentRunOptions, + ): Promise> { + const prepared = await this.prepareSubagent({ + ...task, + signal: options.signal, + }); + return { + task, + agentId: prepared.agentId, + profileName: prepared.profileName, + start: (runOptions, totalTimedOut) => + this.startQueuedTask(task, prepared, runOptions, totalTimedOut), + cancel: prepared.cancel, + }; + } + + private async startQueuedTask( + task: QueuedSubagentTask, + prepared: PreparedSubagent, + options: QueuedSubagentRunOptions, + totalTimedOut: () => boolean, + ): Promise> { + const subagentDeadline = + options.timeoutMs === undefined + ? undefined + : createDeadlineAbortSignal(options.signal, options.timeoutMs); + const runSignal = subagentDeadline?.signal ?? options.signal; + const unlinkSubagentDeadline = + subagentDeadline === undefined + ? undefined + : this.linkChildAbortSignal(prepared.agentId, subagentDeadline.signal); + let handle: SubagentHandle | undefined; + try { + runSignal.throwIfAborted(); + handle = prepared.start(); + const completion = await handle.completion; + return { + kind: 'result', + result: { + task, + agentId: handle.agentId, + profileName: handle.profileName, + status: 'completed', + result: completion.result, + usage: completion.usage, + }, + }; + } catch (error) { + if (isRateLimit429Error(error)) { + return { kind: 'rate_limited', task }; + } + return { + kind: 'result', + result: { + task, + agentId: prepared.agentId, + profileName: prepared.profileName, + status: 'failed', + error: formatQueuedSubagentError(error, runSignal, { + subagentTimedOut: () => subagentDeadline?.timedOut() === true, + subagentTimeoutMs: options.timeoutMs, + totalTimedOut, + totalTimeoutMs: options.totalTimeoutMs, + }), + }, + }; + } finally { + unlinkSubagentDeadline?.(); + subagentDeadline?.clear(); + } + } + + private linkChildAbortSignal(agentId: string, signal: AbortSignal): () => void { + const child = this.activeChildren.get(agentId); + if (child === undefined) return () => undefined; + return linkAbortSignal(signal, child.controller); + } + private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile { const profile = DEFAULT_AGENT_PROFILES[parent.config.profileName ?? 'agent']?.subagents?.[profileName] ?? @@ -204,21 +357,9 @@ export class SessionSubagentHost { profileName: string, options: RunSubagentOptions, prepareChild: () => Promise, + emitSpawnedEvent = true, ): Promise { - parent.emitEvent({ - type: 'subagent.spawned', - subagentId: childId, - subagentName: profileName, - parentToolCallId: options.parentToolCallId, - parentToolCallUuid: options.parentToolCallUuid, - parentAgentId: this.ownerAgentId, - description: options.description, - runInBackground: options.runInBackground, - }); - parent.telemetry.track('subagent_created', { - subagent_name: profileName, - run_in_background: options.runInBackground, - }); + if (emitSpawnedEvent) this.emitSubagentSpawned(parent, childId, profileName, options); try { await prepareChild(); @@ -234,6 +375,7 @@ export class SessionSubagentHost { if (gitContext) childPrompt = `${gitContext}\n\n${childPrompt}`; } const origin: PromptOrigin = options.origin ?? { kind: 'system_trigger', name: 'subagent' }; + this.emitSubagentStarted(parent, childId, profileName, options); child.turn.prompt([{ type: 'text', text: childPrompt }], origin); await runChildTurnToCompletion(child, options.signal); @@ -315,6 +457,46 @@ export class SessionSubagentHost { }, }); } + + 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, + runInBackground: options.runInBackground, + }); + parent.telemetry.track('subagent_created', { + subagent_name: profileName, + run_in_background: options.runInBackground, + }); + } + + private emitSubagentStarted( + parent: Agent, + childId: string, + profileName: string, + options: RunSubagentOptions, + ): void { + parent.emitEvent({ + type: 'subagent.started', + subagentId: childId, + subagentName: profileName, + parentToolCallId: options.parentToolCallId, + parentToolCallUuid: options.parentToolCallUuid, + parentAgentId: this.ownerAgentId, + description: options.description, + runInBackground: options.runInBackground, + }); + } } async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Promise { diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts new file mode 100644 index 000000000..d905f4555 --- /dev/null +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -0,0 +1,447 @@ +import type { TokenUsage } from '@moonshot-ai/kosong'; + +import type { PromptOrigin } from '../agent/context'; +import { isAbortError } from '../loop/errors'; +import { + abortError, + createDeadlineAbortSignal, + isUserCancellation, +} from '../utils/abort'; + +const SUBAGENT_LAUNCH_BATCH_SIZE = 10; +const SUBAGENT_MAX_INITIAL_LAUNCHES = 30; +const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; +const SUBAGENT_RAMP_BATCH_DELAY_MS = 500; +const RATE_LIMIT_429_MESSAGE = + "429 We're receiving too many requests at the moment. Please wait a moment and try again."; +const RATE_LIMIT_429_BODY = + "We're receiving too many requests at the moment. Please wait a moment and try again."; + +export type QueuedSubagentTask = { + readonly data: T; + readonly profileName: string; + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string; + readonly prompt: string; + readonly description: string; + readonly runInBackground: boolean; + readonly origin?: PromptOrigin; +}; + +export type QueuedSubagentRunOptions = { + readonly signal: AbortSignal; + readonly timeoutMs?: number; + readonly totalTimeoutMs?: number; +}; + +export type QueuedSubagentRunResult = { + readonly task: QueuedSubagentTask; + readonly agentId?: string; + readonly profileName: string; + readonly status: 'completed' | 'failed'; + readonly result?: string; + readonly usage?: TokenUsage; + readonly error?: string; +}; + +export type QueuedSubagentAttemptOutcome = + | { + readonly kind: 'rate_limited'; + readonly task: QueuedSubagentTask; + } + | { + readonly kind: 'result'; + readonly result: QueuedSubagentRunResult; + }; + +type QueuedSubagentAttempt = { + readonly task: QueuedSubagentTask; + readonly promise: Promise>; + settled: boolean; +}; + +export type PreparedQueuedSubagentTask = { + readonly task: QueuedSubagentTask; + readonly agentId: string; + readonly profileName: string; + readonly start: ( + options: QueuedSubagentRunOptions, + totalTimedOut: () => boolean, + ) => Promise>; + readonly cancel: (reason: unknown) => void; +}; + +export type QueuedSubagentRunHandle = { + readonly task: QueuedSubagentTask; + readonly agentId: string; + readonly profileName: string; + readonly completion: Promise>; +}; + +type SubagentLaunchQueueHost = { + readonly prepareQueuedTask: ( + task: QueuedSubagentTask, + options: QueuedSubagentRunOptions, + ) => Promise>; +}; + +export class SubagentLaunchQueue { + constructor(private readonly host: SubagentLaunchQueueHost) {} + + async run( + tasks: readonly QueuedSubagentTask[], + options: QueuedSubagentRunOptions, + ): Promise>> { + const prepared = await Promise.all( + tasks.map((task) => this.host.prepareQueuedTask(task, options)), + ); + return await this.runPreparedBatch(prepared, options); + } + + runPrepared( + prepared: PreparedQueuedSubagentTask, + options: QueuedSubagentRunOptions, + ): QueuedSubagentRunHandle { + return { + task: prepared.task, + agentId: prepared.agentId, + profileName: prepared.profileName, + completion: this.runPreparedBatch([prepared], options).then((results) => { + const result = results[0]; + if (result === undefined) { + throw new Error('Queued subagent finished without a result.'); + } + return result; + }), + }; + } + + private async runPreparedBatch( + prepared: readonly PreparedQueuedSubagentTask[], + options: QueuedSubagentRunOptions, + ): Promise>> { + let totalDeadline: ReturnType | undefined; + try { + totalDeadline = + options.totalTimeoutMs === undefined + ? undefined + : createDeadlineAbortSignal(options.signal, options.totalTimeoutMs); + return await this.runWithSignal( + prepared, + { + signal: totalDeadline?.signal ?? options.signal, + timeoutMs: options.timeoutMs, + totalTimeoutMs: options.totalTimeoutMs, + }, + () => totalDeadline?.timedOut() === true, + ); + } finally { + totalDeadline?.clear(); + } + } + + private async runWithSignal( + prepared: readonly PreparedQueuedSubagentTask[], + options: QueuedSubagentRunOptions, + totalTimedOut: () => boolean, + ): Promise>> { + const tasks = prepared.map((task) => task.task); + const pending = [...tasks]; + const queued: Array> = []; + const queuedTasks = new Set>(); + const active: Array> = []; + const results: Array | undefined> = Array.from({ + length: tasks.length, + }); + const preparedTasks = new Map, PreparedQueuedSubagentTask>(); + const taskIndexes = new Map(tasks.map((task, index) => [task, index])); + let completedResults = 0; + let launchedDuringRamp = 0; + let rateLimitSeen = false; + + const resultIndex = (task: QueuedSubagentTask): number => { + const index = taskIndexes.get(task); + if (index === undefined) { + throw new Error('Queued subagent task was not registered'); + } + return index; + }; + + const enqueue = (task: QueuedSubagentTask): void => { + if (results[resultIndex(task)] !== undefined || queuedTasks.has(task)) return; + queuedTasks.add(task); + queued.push(task); + queued.sort((left, right) => resultIndex(left) - resultIndex(right)); + }; + + const dequeue = (): QueuedSubagentTask | undefined => { + const task = queued.shift(); + if (task !== undefined) queuedTasks.delete(task); + return task; + }; + + const launch = async (task: QueuedSubagentTask): Promise => { + const prepared = + preparedTasks.get(task) ?? (await this.host.prepareQueuedTask(task, options)); + preparedTasks.delete(task); + const attempt: QueuedSubagentAttempt = { + task, + settled: false, + promise: prepared.start(options, totalTimedOut), + }; + void attempt.promise.then( + () => { + attempt.settled = true; + }, + () => { + attempt.settled = true; + }, + ); + active.push(attempt); + }; + + const processAttempt = async (attempt: QueuedSubagentAttempt): Promise => { + const activeIndex = active.indexOf(attempt); + if (activeIndex !== -1) active.splice(activeIndex, 1); + const outcome = await attempt.promise; + if (outcome.kind === 'rate_limited') { + rateLimitSeen = true; + enqueue(outcome.task); + return false; + } + results[resultIndex(outcome.result.task)] = outcome.result; + completedResults += 1; + return true; + }; + + const processSettledAttempts = async (): Promise => { + while (true) { + const settled = active.find((attempt) => attempt.settled); + if (settled === undefined) return; + await processAttempt(settled); + } + }; + + try { + for (const task of prepared) { + preparedTasks.set(task.task, task); + } + + while ( + pending.length > 0 && + launchedDuringRamp < SUBAGENT_MAX_INITIAL_LAUNCHES && + !rateLimitSeen + ) { + const batchSize = Math.min( + SUBAGENT_LAUNCH_BATCH_SIZE, + pending.length, + SUBAGENT_MAX_INITIAL_LAUNCHES - launchedDuringRamp, + ); + for (let i = 0; i < batchSize; i += 1) { + const task = pending.shift(); + if (task === undefined) break; + await launch(task); + launchedDuringRamp += 1; + } + if (pending.length === 0 || launchedDuringRamp >= SUBAGENT_MAX_INITIAL_LAUNCHES) break; + await waitForRateLimitOrDelay(active, options.signal); + await processSettledAttempts(); + } + + for (const task of pending) { + enqueue(task); + } + pending.length = 0; + + while (completedResults < tasks.length) { + options.signal.throwIfAborted(); + if (active.length === 0) { + if (queued.length === 0) break; + if (completedResults === 0) { + throw new Error( + 'Could not start any subagents because every launch attempt was rate limited.', + ); + } + while (queued.length > 0) { + const task = dequeue(); + if (task === undefined) break; + results[resultIndex(task)] = failedQueuedResult( + task, + 'No running subagents remained to open queue slots after rate-limited launches.', + ); + completedResults += 1; + } + break; + } + + const attempt = await nextSettledAttempt(active, options.signal); + const openedSlot = await processAttempt(attempt); + if (!openedSlot || queued.length === 0) continue; + await sleepWithSignal(SUBAGENT_QUEUE_LAUNCH_DELAY_MS, options.signal); + const task = dequeue(); + if (task !== undefined) await launch(task); + } + } catch (error) { + if (!totalTimedOut()) throw error; + const message = totalTimeoutMessage(options.totalTimeoutMs); + for (const task of tasks) { + const index = resultIndex(task); + if (results[index] !== undefined) continue; + results[index] = failedQueuedResult(task, message); + } + } finally { + cancelPreparedTasks(preparedTasks); + } + + return results.map((result, index) => { + if (result !== undefined) return result; + return failedQueuedResult(tasks[index]!, 'Subagent stopped before it could finish.'); + }); + } +} + +function failedQueuedResult( + task: QueuedSubagentTask, + error: string, +): QueuedSubagentRunResult { + return { + task, + profileName: task.profileName, + status: 'failed', + error, + }; +} + +function cancelPreparedTasks( + preparedTasks: Map, PreparedQueuedSubagentTask>, +): void { + const reason = new Error('Subagent queue stopped before the prompt was launched.'); + for (const task of preparedTasks.values()) { + task.cancel(reason); + } + preparedTasks.clear(); +} + +async function waitForRateLimitOrDelay( + active: ReadonlyArray>, + signal: AbortSignal, +): Promise { + if (active.length === 0) { + await sleepWithSignal(SUBAGENT_RAMP_BATCH_DELAY_MS, signal); + return; + } + const rateLimited = Promise.race( + active.map((attempt) => + attempt.promise.then((outcome) => { + if (outcome.kind === 'rate_limited') return; + return new Promise(() => {}); + }), + ), + ); + await Promise.race([sleepWithSignal(SUBAGENT_RAMP_BATCH_DELAY_MS, signal), rateLimited]); +} + +async function nextSettledAttempt( + active: ReadonlyArray>, + signal: AbortSignal, +): Promise> { + const settled = active.find((attempt) => attempt.settled); + if (settled !== undefined) return settled; + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const cleanup = () => { + signal.removeEventListener('abort', onAbort); + }; + const onAbort = () => { + cleanup(); + reject(signal.reason instanceof Error ? signal.reason : abortError()); + }; + signal.addEventListener('abort', onAbort, { once: true }); + for (const attempt of active) { + void attempt.promise.then( + () => { + cleanup(); + resolve(attempt); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + } + }); +} + +function sleepWithSignal(ms: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + let timeout: ReturnType | undefined = setTimeout(() => { + timeout = undefined; + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + if (timeout !== undefined) clearTimeout(timeout); + timeout = undefined; + signal.removeEventListener('abort', onAbort); + reject(signal.reason instanceof Error ? signal.reason : abortError()); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +export function totalTimeoutMessage(timeoutMs: number | undefined): string { + return timeoutMs === undefined + ? 'Subagent batch total timeout elapsed.' + : `Subagent batch total timeout after ${formatTimeoutMs(timeoutMs)}.`; +} + +export function formatTimeoutMs(timeoutMs: number): string { + return `${String(timeoutMs / 1000)}s`; +} + +export function formatQueuedSubagentError( + error: unknown, + signal: AbortSignal, + timeouts: { + readonly subagentTimedOut: () => boolean; + readonly subagentTimeoutMs?: number; + readonly totalTimedOut: () => boolean; + readonly totalTimeoutMs?: number; + }, +): string { + if (timeouts.subagentTimedOut() && timeouts.subagentTimeoutMs !== undefined) { + return `Subagent timed out after ${formatTimeoutMs(timeouts.subagentTimeoutMs)}.`; + } + if (timeouts.totalTimedOut() && timeouts.totalTimeoutMs !== undefined) { + return totalTimeoutMessage(timeouts.totalTimeoutMs); + } + if (isUserCancellation(signal.reason)) { + return 'The user manually interrupted this subagent batch.'; + } + if (isAbortError(error)) { + return 'The subagent was stopped before it finished.'; + } + return errorMessage(error); +} + +export function isRateLimit429Error(error: unknown): boolean { + const message = errorMessage(error); + if (message.includes(RATE_LIMIT_429_MESSAGE)) return true; + if (!message.includes(RATE_LIMIT_429_BODY)) return false; + if (message.includes('429')) return true; + if (message.includes('provider.rate_limit')) return true; + return maybeStatusCode(error) === 429; +} + +function maybeStatusCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const statusCode = (error as { readonly statusCode?: unknown }).statusCode; + if (typeof statusCode === 'number') return statusCode; + const status = (error as { readonly status?: unknown }).status; + return typeof status === 'number' ? status : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index f7eaae8ae..fd8a62a1d 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -1,20 +1,17 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../../agent/tool'; -import type { SessionSubagentHost, SubagentHandle } from '../../../session/subagent-host'; -import { - createDeadlineAbortSignal, - isUserCancellation, - type DeadlineAbortSignal, -} from '../../../utils/abort'; -import { isAbortError } from '../../../loop/errors'; +import type { + QueuedSubagentRunResult, + QueuedSubagentTask, + 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 { matchesGlobRuleSubject } from '../../support/rule-match'; import AGENT_SWARM_DESCRIPTION from './agent-swarm.md'; -const MAX_SWARM_AGENTS = 50; const DEFAULT_SUBAGENT_TYPE = 'coder'; const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}'; @@ -32,6 +29,15 @@ export const AgentSwarmToolInputSchema = z .max(3600) .optional() .describe('Timeout in seconds for each subagent.'), + total_timeout: z + .number() + .int() + .min(30) + .max(3600) + .optional() + .describe( + 'Timeout in seconds for the whole swarm, including queued and running subagents.', + ), subagent_type: z .string() .trim() @@ -53,7 +59,6 @@ export const AgentSwarmToolInputSchema = z items: z .array(z.string().trim().min(1)) .min(2) - .max(MAX_SWARM_AGENTS) .describe( `Values used to fill ${PROMPT_TEMPLATE_PLACEHOLDER}. Each item launches one subagent.`, ), @@ -125,68 +130,23 @@ export class AgentSwarmTool implements BuiltinTool { signal: AbortSignal, toolCallId: string, ): Promise { - let foregroundDeadline: DeadlineAbortSignal | undefined; - try { - foregroundDeadline = - args.timeout !== undefined - ? createDeadlineAbortSignal(signal, args.timeout * 1000) - : undefined; - const runSignal = foregroundDeadline?.signal ?? signal; - const results = await Promise.all( - specs.map((spec) => - this.runOne( - args, - spec, - runSignal, - toolCallId, - () => foregroundDeadline?.timedOut() === true, - ), - ), - ); - return renderSwarmResults(args, results); - } finally { - foregroundDeadline?.clear(); - } - } - - private async runOne( - args: AgentSwarmToolInput, - spec: AgentSwarmSpec, - signal: AbortSignal, - toolCallId: string, - timedOut: () => boolean, - ): Promise { const profileName = args.subagent_type ?? DEFAULT_SUBAGENT_TYPE; - const description = childDescription(args.description, spec.index, profileName); - let handle: SubagentHandle | undefined; - try { - signal.throwIfAborted(); - handle = await this.subagentHost.spawn(profileName, { + const tasks = specs.map((spec): QueuedSubagentTask => { + return { + data: spec, + profileName, parentToolCallId: toolCallId, prompt: spec.prompt, - description, + description: childDescription(args.description, spec.index, profileName), runInBackground: false, - signal, - }); - const completion = await handle.completion; - return { - spec, - agentId: handle.agentId, - profileName: handle.profileName, - description, - status: 'completed', - result: completion.result, }; - } catch (error) { - return { - spec, - agentId: handle?.agentId, - profileName: handle?.profileName ?? profileName, - description, - status: 'failed', - error: formatSubagentError(error, signal, timedOut, args.timeout), - }; - } + }); + const results = await this.subagentHost.runQueued(tasks, { + signal, + timeoutMs: args.timeout === undefined ? undefined : args.timeout * 1000, + totalTimeoutMs: args.total_timeout === undefined ? undefined : args.total_timeout * 1000, + }); + return renderSwarmResults(args, results.map(toSwarmRunResult)); } } @@ -258,22 +218,18 @@ function swarmResultHasFailures(result: string): boolean { return result.startsWith('agent_swarm: failed\n'); } -function formatSubagentError( - error: unknown, - signal: AbortSignal, - timedOut: () => boolean, - timeout: number | undefined, -): string { - if (timedOut() && timeout !== undefined) { - return `AgentSwarm timed out after ${String(timeout)}s.`; - } - if (isUserCancellation(signal.reason)) { - return 'The user manually interrupted this subagent swarm.'; - } - if (isAbortError(error)) { - return 'The subagent was stopped before it finished.'; - } - return errorMessage(error); +function toSwarmRunResult( + result: QueuedSubagentRunResult, +): SwarmRunResult { + return { + spec: result.task.data, + agentId: result.agentId, + profileName: result.profileName, + description: result.task.description, + status: result.status, + result: result.result, + error: result.error, + }; } function errorMessage(error: unknown): string { diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 633f48863..76e452110 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 type { + QueuedSubagentRunResult, + QueuedSubagentTask, + SessionSubagentHost, + SubagentHandle, +} from '../../../session/subagent-host'; import { createDeadlineAbortSignal, isUserCancellation, @@ -212,7 +217,26 @@ export class AgentTool implements BuiltinTool { handle = await this.subagentHost.resume(resumeAgentId, options); } else { const profileName = requestedProfileName ?? 'coder'; - handle = await this.subagentHost.spawn(profileName, options); + const queued = await this.subagentHost.runQueuedTask( + { + data: undefined, + profileName, + parentToolCallId: options.parentToolCallId, + prompt: options.prompt, + description: options.description, + runInBackground: options.runInBackground, + } satisfies QueuedSubagentTask, + { + signal: options.signal, + timeoutMs, + }, + ); + handle = { + agentId: queued.agentId, + profileName: queued.profileName, + resumed: false, + completion: queued.completion.then(queuedResultToCompletion), + }; } } catch (error) { this.log?.warn('subagent launch failed', { @@ -319,6 +343,18 @@ export class AgentTool implements BuiltinTool { } } +function queuedResultToCompletion( + result: QueuedSubagentRunResult, +): Awaited { + if (result.status === 'completed') { + return { + result: result.result ?? '', + usage: result.usage, + }; + } + throw new Error(result.error ?? 'Subagent failed.'); +} + function buildSubagentDescriptions(subagents: ResolvedAgentProfile['subagents']): string { if (subagents === undefined) return ''; return Object.entries(subagents) diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 2098dc7ed..a0cd9137e 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -12,7 +12,11 @@ import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { collectGitContext } from '../../src/session/git-context'; -import { SessionSubagentHost } from '../../src/session/subagent-host'; +import { + SessionSubagentHost, + type QueuedSubagentTask, + type SubagentHandle, +} from '../../src/session/subagent-host'; import { abortError, userCancellationReason } from '../../src/utils/abort'; import { testAgent, type AgentTestContext } from '../agent/harness/agent'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; @@ -26,6 +30,8 @@ vi.mock('../../src/session/git-context', () => ({ })); const signal = new AbortController().signal; +const rateLimit429Message = + "429 We're receiving too many requests at the moment. Please wait a moment and try again."; const tempDirs: string[] = []; afterEach(async () => { @@ -35,6 +41,154 @@ afterEach(async () => { }); describe('SessionSubagentHost', () => { + it('runQueued ramps launches in batches of ten up to thirty', async () => { + vi.useFakeTimers(); + try { + const host = new SessionSubagentHost({} as Session, 'main'); + const completions: Array>> = []; + const spawn = vi.spyOn(host, 'spawn').mockImplementation((options) => { + const profileName = typeof options === 'string' ? options : options.profileName; + const completion = deferred<{ result: string }>(); + completions.push(completion); + return Promise.resolve({ + agentId: `agent-${String(completions.length)}`, + profileName, + resumed: false, + completion: completion.promise, + } satisfies SubagentHandle); + }); + + const running = host.runQueued( + Array.from({ length: 30 }, (_, index) => queuedTask(index + 1)), + { signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(spawn).toHaveBeenCalledTimes(10); + + await vi.advanceTimersByTimeAsync(499); + expect(spawn).toHaveBeenCalledTimes(10); + + await vi.advanceTimersByTimeAsync(1); + expect(spawn).toHaveBeenCalledTimes(20); + + await vi.advanceTimersByTimeAsync(500); + expect(spawn).toHaveBeenCalledTimes(30); + + completions.forEach((completion, index) => { + completion.resolve({ result: `result ${String(index + 1)}` }); + }); + const results = await running; + + expect(results).toHaveLength(30); + expect(results.every((result) => result.status === 'completed')).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('runQueued queues 429 and launches one queued subagent 500ms after a slot opens', async () => { + vi.useFakeTimers(); + try { + const host = new SessionSubagentHost({} as Session, 'main'); + const completions: Array< + ReturnType> & { readonly prompt: string } + > = []; + let firstItemRateLimited = false; + const spawn = vi + .spyOn(host, 'spawn') + .mockImplementation((options, legacyOptions?: { readonly prompt: string }) => { + const profileName = typeof options === 'string' ? options : options.profileName; + const prompt = typeof options === 'string' ? legacyOptions?.prompt : options.prompt; + if (prompt === undefined) { + throw new Error('mocked subagent prompt is required'); + } + if (prompt === 'Review item-1' && !firstItemRateLimited) { + firstItemRateLimited = true; + return Promise.resolve({ + agentId: 'agent-rate-limited', + profileName, + resumed: false, + completion: Promise.resolve().then(() => { + throw new Error(rateLimit429Message); + }), + } satisfies SubagentHandle); + } + const completion = { + ...deferred<{ result: string }>(), + prompt, + }; + completions.push(completion); + return Promise.resolve({ + agentId: `agent-${String(completions.length)}`, + profileName, + resumed: false, + completion: completion.promise, + } satisfies SubagentHandle); + }); + + const running = host.runQueued( + Array.from({ length: 11 }, (_, index) => queuedTask(index + 1)), + { signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(spawn).toHaveBeenCalledTimes(10); + + completions[0]!.resolve({ result: 'opened one slot' }); + await vi.advanceTimersByTimeAsync(499); + expect(spawn).toHaveBeenCalledTimes(10); + + await vi.advanceTimersByTimeAsync(1); + expect(spawn).toHaveBeenCalledTimes(11); + expect(spawn).toHaveBeenLastCalledWith({ + data: 1, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review item-1', + description: 'Review #1', + runInBackground: false, + signal, + }); + + for (const completion of [...completions]) { + completion.resolve({ result: `${completion.prompt} done` }); + } + await vi.advanceTimersByTimeAsync(500); + expect(spawn).toHaveBeenCalledTimes(12); + completions.at(-1)!.resolve({ result: 'Review item-11 done' }); + + const results = await running; + + expect(results).toHaveLength(11); + expect(results.every((result) => result.status === 'completed')).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('runQueued reports an error when every initial launch hits 429', async () => { + const host = new SessionSubagentHost({} as Session, 'main'); + vi.spyOn(host, 'spawn').mockImplementation((options) => { + const profileName = typeof options === 'string' ? options : options.profileName; + return Promise.resolve({ + agentId: 'agent-rate-limited', + profileName, + resumed: false, + completion: Promise.resolve().then(() => { + throw new Error(rateLimit429Message); + }), + } satisfies SubagentHandle); + }); + + await expect( + host.runQueued( + Array.from({ length: 3 }, (_, index) => queuedTask(index + 1)), + { signal }, + ), + ).rejects.toThrow('Could not start any subagents'); + }); + it('fires subagent lifecycle hooks around the child turn', async () => { const child = testAgent(); const calls: Array<{ readonly event: string; readonly childLlmCallCount: number }> = []; @@ -1187,6 +1341,27 @@ function stat(kind: 'dir' | 'file') { }; } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +function queuedTask(index: number): QueuedSubagentTask { + return { + data: index, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: `Review item-${String(index)}`, + description: `Review #${String(index)}`, + runInBackground: false, + }; +} + async function writeWire(homedir: string, records: readonly Record[]) { await mkdir(homedir, { recursive: true }); const wireRecords = diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index b178786c6..9afe826dc 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -60,10 +60,11 @@ function context(args: Input, toolCallId = 'call_1') { return { turnId: '0', toolCallId, args, signal }; } -function mockSubagentHost & Partial>( +function mockSubagentHost>( host: T, ): T & SessionSubagentHost { - return { resume: vi.fn(), ...host } as unknown as T & SessionSubagentHost; + return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T & + SessionSubagentHost; } function processWithOutput(stdout: string, exitCode = 0): KaosProcess { @@ -291,14 +292,36 @@ describe('current builtin collaboration tools', () => { it('AgentSwarm applies one subagent_type across templated subagents', async () => { const host = mockSubagentHost({ - spawn: vi.fn().mockImplementation((profileName: string) => - Promise.resolve({ - agentId: `agent-${profileName}`, - profileName, - resumed: false, - completion: Promise.resolve({ result: `${profileName} result` }), - }), - ), + runQueued: vi.fn().mockResolvedValue([ + { + task: { + data: { index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (explore)', + runInBackground: false, + }, + agentId: 'agent-explore-1', + profileName: 'explore', + status: 'completed', + result: 'explore result a', + }, + { + task: { + data: { index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (explore)', + runInBackground: false, + }, + agentId: 'agent-explore-2', + profileName: 'explore', + status: 'completed', + result: 'explore result b', + }, + ]), }); const tool = new AgentSwarmTool(host); const input = { @@ -309,28 +332,45 @@ describe('current builtin collaboration tools', () => { }; expect(AgentSwarmToolInputSchema.safeParse(input).success).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + ...input, + items: Array.from({ length: 31 }, (_, index) => `src/${String(index + 1)}.ts`), + }).success, + ).toBe(true); expect(tool.parameters).toMatchObject({ type: 'object', - properties: { subagent_type: { type: 'string' } }, + properties: { subagent_type: { type: 'string' }, total_timeout: { type: 'integer' } }, }); const result = await executeTool(tool, context(input, 'call_swarm')); - expect(host.spawn).toHaveBeenCalledTimes(2); - expect(host.spawn).toHaveBeenNthCalledWith(1, 'explore', { - parentToolCallId: 'call_swarm', - prompt: 'Review src/a.ts', - description: 'Review files #1 (explore)', - runInBackground: false, - signal, - }); - expect(host.spawn).toHaveBeenNthCalledWith(2, 'explore', { - parentToolCallId: 'call_swarm', - prompt: 'Review src/b.ts', - description: 'Review files #2 (explore)', - runInBackground: false, - signal, - }); + expect(host.runQueued).toHaveBeenCalledTimes(1); + expect(host.runQueued).toHaveBeenCalledWith( + [ + { + data: { index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (explore)', + runInBackground: false, + }, + { + data: { index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (explore)', + runInBackground: false, + }, + ], + { + signal, + timeoutMs: undefined, + totalTimeoutMs: undefined, + }, + ); expect(result.output).toContain('subagent_type: explore'); expect(result.output).toContain('explore result'); }); From 6f898197565f22f6a2829eae08ecff97690610d3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 3 Jun 2026 23:12:23 +0800 Subject: [PATCH 05/72] fix --- .../agent-core/src/session/subagent-host.ts | 131 +++++------------- .../src/session/subagent-launch-queue.ts | 107 +++----------- .../src/tools/builtin/collaboration/agent.ts | 38 +---- 3 files changed, 56 insertions(+), 220 deletions(-) diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index b45b5c2af..5526ac812 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -19,9 +19,7 @@ import { SubagentLaunchQueue, formatQueuedSubagentError, isRateLimit429Error, - type PreparedQueuedSubagentTask, type QueuedSubagentAttemptOutcome, - type QueuedSubagentRunHandle, type QueuedSubagentRunOptions, type QueuedSubagentRunResult, type QueuedSubagentTask, @@ -32,8 +30,6 @@ export type { QueuedSubagentRunOptions, QueuedSubagentRunResult, QueuedSubagentTask, - PreparedQueuedSubagentTask, - QueuedSubagentRunHandle, } from './subagent-launch-queue'; /** @@ -71,13 +67,6 @@ type ActiveChild = { readonly runInBackground: boolean; }; -type PreparedSubagent = { - readonly agentId: string; - readonly profileName: string; - readonly start: () => SubagentHandle; - readonly cancel: (reason: unknown) => void; -}; - export type SubagentHandle = { readonly agentId: string; readonly profileName: string; @@ -98,16 +87,8 @@ export class SessionSubagentHost { } async spawn(options: SpawnSubagentOptions): Promise { - return (await this.prepareSubagent(options)).start(); - } - - private async prepareSubagent(options: SpawnSubagentOptions): Promise { options.signal.throwIfAborted(); - const parent = this.session.agents.get(this.ownerAgentId); - if (parent === undefined) { - throw new Error(`Parent agent "${this.ownerAgentId}" was not found`); - } - + const parent = this.requireParentAgent(); const profile = this.resolveProfile(parent, options.profileName); const { id, agent } = await this.session.createAgent( { type: 'sub', generate: parent.rawGenerate }, @@ -122,54 +103,32 @@ export class SessionSubagentHost { }); this.emitSubagentSpawned(parent, id, profile.name, options); - let started = false; - const cleanup = (): void => { + const completion = this.runChild( + parent, + id, + agent, + profile.name, + { + ...options, + signal: controller.signal, + }, + () => this.configureChild(parent, agent, profile), + false, + ).finally(() => { unlinkAbortSignal(); this.activeChildren.delete(id); - }; - + }); return { agentId: id, profileName: profile.name, - start: () => { - if (started) { - throw new Error(`Subagent "${id}" has already been started`); - } - started = true; - const completion = this.runChild( - parent, - id, - agent, - profile.name, - { - ...options, - signal: controller.signal, - }, - () => this.configureChild(parent, agent, profile), - false, - ).finally(cleanup); - return { - agentId: id, - profileName: profile.name, - resumed: false, - completion, - }; - }, - cancel: (reason) => { - controller.abort(reason); - if (!started) cleanup(); - }, + resumed: false, + completion, }; } async resume(agentId: string, options: RunSubagentOptions): Promise { options.signal.throwIfAborted(); - - const parent = this.session.agents.get(this.ownerAgentId); - if (parent === undefined) { - throw new Error(`Parent agent "${this.ownerAgentId}" was not found`); - } - + const parent = this.requireParentAgent(); const child = this.session.agents.get(agentId); if (child === undefined) { throw new Error(`Agent instance "${agentId}" was not found`); @@ -232,14 +191,6 @@ export class SessionSubagentHost { return await this.launchQueue.run(tasks, options); } - async runQueuedTask( - task: QueuedSubagentTask, - options: QueuedSubagentRunOptions, - ): Promise> { - const prepared = await this.prepareQueuedTask(task, options); - return this.launchQueue.runPrepared(prepared, options); - } - cancelAll(reason: unknown = userCancellationReason()): void { const foregroundChildren = Array.from(this.activeChildren).filter( ([, child]) => !child.runInBackground, @@ -260,28 +211,9 @@ export class SessionSubagentHost { return this.session.agents.get(agentId)?.config.profileName; } - async prepareQueuedTask( + async runQueuedTaskAttempt( task: QueuedSubagentTask, options: QueuedSubagentRunOptions, - ): Promise> { - const prepared = await this.prepareSubagent({ - ...task, - signal: options.signal, - }); - return { - task, - agentId: prepared.agentId, - profileName: prepared.profileName, - start: (runOptions, totalTimedOut) => - this.startQueuedTask(task, prepared, runOptions, totalTimedOut), - cancel: prepared.cancel, - }; - } - - private async startQueuedTask( - task: QueuedSubagentTask, - prepared: PreparedSubagent, - options: QueuedSubagentRunOptions, totalTimedOut: () => boolean, ): Promise> { const subagentDeadline = @@ -289,14 +221,13 @@ export class SessionSubagentHost { ? undefined : createDeadlineAbortSignal(options.signal, options.timeoutMs); const runSignal = subagentDeadline?.signal ?? options.signal; - const unlinkSubagentDeadline = - subagentDeadline === undefined - ? undefined - : this.linkChildAbortSignal(prepared.agentId, subagentDeadline.signal); let handle: SubagentHandle | undefined; try { runSignal.throwIfAborted(); - handle = prepared.start(); + handle = await this.spawn({ + ...task, + signal: runSignal, + }); const completion = await handle.completion; return { kind: 'result', @@ -313,12 +244,15 @@ export class SessionSubagentHost { if (isRateLimit429Error(error)) { return { kind: 'rate_limited', task }; } + if (handle === undefined) { + throw error; + } return { kind: 'result', result: { task, - agentId: prepared.agentId, - profileName: prepared.profileName, + agentId: handle.agentId, + profileName: handle.profileName, status: 'failed', error: formatQueuedSubagentError(error, runSignal, { subagentTimedOut: () => subagentDeadline?.timedOut() === true, @@ -329,15 +263,16 @@ export class SessionSubagentHost { }, }; } finally { - unlinkSubagentDeadline?.(); subagentDeadline?.clear(); } } - private linkChildAbortSignal(agentId: string, signal: AbortSignal): () => void { - const child = this.activeChildren.get(agentId); - if (child === undefined) return () => undefined; - return linkAbortSignal(signal, child.controller); + private requireParentAgent(): Agent { + const parent = this.session.agents.get(this.ownerAgentId); + if (parent === undefined) { + throw new Error(`Parent agent "${this.ownerAgentId}" was not found`); + } + return parent; } private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile { diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index d905f4555..fbcb9365c 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -60,29 +60,12 @@ type QueuedSubagentAttempt = { settled: boolean; }; -export type PreparedQueuedSubagentTask = { - readonly task: QueuedSubagentTask; - readonly agentId: string; - readonly profileName: string; - readonly start: ( - options: QueuedSubagentRunOptions, - totalTimedOut: () => boolean, - ) => Promise>; - readonly cancel: (reason: unknown) => void; -}; - -export type QueuedSubagentRunHandle = { - readonly task: QueuedSubagentTask; - readonly agentId: string; - readonly profileName: string; - readonly completion: Promise>; -}; - type SubagentLaunchQueueHost = { - readonly prepareQueuedTask: ( + readonly runQueuedTaskAttempt: ( task: QueuedSubagentTask, options: QueuedSubagentRunOptions, - ) => Promise>; + totalTimedOut: () => boolean, + ) => Promise>; }; export class SubagentLaunchQueue { @@ -91,34 +74,6 @@ export class SubagentLaunchQueue { async run( tasks: readonly QueuedSubagentTask[], options: QueuedSubagentRunOptions, - ): Promise>> { - const prepared = await Promise.all( - tasks.map((task) => this.host.prepareQueuedTask(task, options)), - ); - return await this.runPreparedBatch(prepared, options); - } - - runPrepared( - prepared: PreparedQueuedSubagentTask, - options: QueuedSubagentRunOptions, - ): QueuedSubagentRunHandle { - return { - task: prepared.task, - agentId: prepared.agentId, - profileName: prepared.profileName, - completion: this.runPreparedBatch([prepared], options).then((results) => { - const result = results[0]; - if (result === undefined) { - throw new Error('Queued subagent finished without a result.'); - } - return result; - }), - }; - } - - private async runPreparedBatch( - prepared: readonly PreparedQueuedSubagentTask[], - options: QueuedSubagentRunOptions, ): Promise>> { let totalDeadline: ReturnType | undefined; try { @@ -127,7 +82,7 @@ export class SubagentLaunchQueue { ? undefined : createDeadlineAbortSignal(options.signal, options.totalTimeoutMs); return await this.runWithSignal( - prepared, + tasks, { signal: totalDeadline?.signal ?? options.signal, timeoutMs: options.timeoutMs, @@ -141,19 +96,16 @@ export class SubagentLaunchQueue { } private async runWithSignal( - prepared: readonly PreparedQueuedSubagentTask[], + tasks: readonly QueuedSubagentTask[], options: QueuedSubagentRunOptions, totalTimedOut: () => boolean, ): Promise>> { - const tasks = prepared.map((task) => task.task); const pending = [...tasks]; const queued: Array> = []; - const queuedTasks = new Set>(); const active: Array> = []; const results: Array | undefined> = Array.from({ length: tasks.length, }); - const preparedTasks = new Map, PreparedQueuedSubagentTask>(); const taskIndexes = new Map(tasks.map((task, index) => [task, index])); let completedResults = 0; let launchedDuringRamp = 0; @@ -168,26 +120,24 @@ export class SubagentLaunchQueue { }; const enqueue = (task: QueuedSubagentTask): void => { - if (results[resultIndex(task)] !== undefined || queuedTasks.has(task)) return; - queuedTasks.add(task); - queued.push(task); - queued.sort((left, right) => resultIndex(left) - resultIndex(right)); + if (results[resultIndex(task)] !== undefined || queued.includes(task)) return; + const insertAt = queued.findIndex((queuedTask) => resultIndex(queuedTask) > resultIndex(task)); + if (insertAt === -1) { + queued.push(task); + } else { + queued.splice(insertAt, 0, task); + } }; const dequeue = (): QueuedSubagentTask | undefined => { - const task = queued.shift(); - if (task !== undefined) queuedTasks.delete(task); - return task; + return queued.shift(); }; - const launch = async (task: QueuedSubagentTask): Promise => { - const prepared = - preparedTasks.get(task) ?? (await this.host.prepareQueuedTask(task, options)); - preparedTasks.delete(task); + const launch = (task: QueuedSubagentTask): void => { const attempt: QueuedSubagentAttempt = { task, settled: false, - promise: prepared.start(options, totalTimedOut), + promise: this.host.runQueuedTaskAttempt(task, options, totalTimedOut), }; void attempt.promise.then( () => { @@ -223,15 +173,8 @@ export class SubagentLaunchQueue { }; try { - for (const task of prepared) { - preparedTasks.set(task.task, task); - } - - while ( - pending.length > 0 && - launchedDuringRamp < SUBAGENT_MAX_INITIAL_LAUNCHES && - !rateLimitSeen - ) { + while (pending.length > 0 && launchedDuringRamp < SUBAGENT_MAX_INITIAL_LAUNCHES) { + if (rateLimitSeen) break; const batchSize = Math.min( SUBAGENT_LAUNCH_BATCH_SIZE, pending.length, @@ -240,7 +183,7 @@ export class SubagentLaunchQueue { for (let i = 0; i < batchSize; i += 1) { const task = pending.shift(); if (task === undefined) break; - await launch(task); + launch(task); launchedDuringRamp += 1; } if (pending.length === 0 || launchedDuringRamp >= SUBAGENT_MAX_INITIAL_LAUNCHES) break; @@ -279,7 +222,7 @@ export class SubagentLaunchQueue { if (!openedSlot || queued.length === 0) continue; await sleepWithSignal(SUBAGENT_QUEUE_LAUNCH_DELAY_MS, options.signal); const task = dequeue(); - if (task !== undefined) await launch(task); + if (task !== undefined) launch(task); } } catch (error) { if (!totalTimedOut()) throw error; @@ -289,8 +232,6 @@ export class SubagentLaunchQueue { if (results[index] !== undefined) continue; results[index] = failedQueuedResult(task, message); } - } finally { - cancelPreparedTasks(preparedTasks); } return results.map((result, index) => { @@ -312,16 +253,6 @@ function failedQueuedResult( }; } -function cancelPreparedTasks( - preparedTasks: Map, PreparedQueuedSubagentTask>, -): void { - const reason = new Error('Subagent queue stopped before the prompt was launched.'); - for (const task of preparedTasks.values()) { - task.cancel(reason); - } - preparedTasks.clear(); -} - async function waitForRateLimitOrDelay( active: ReadonlyArray>, signal: AbortSignal, diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 76e452110..3d50bd576 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -25,8 +25,6 @@ import { isAbortError } from '../../../loop/errors'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; import type { ResolvedAgentProfile } from '../../../profile'; import type { - QueuedSubagentRunResult, - QueuedSubagentTask, SessionSubagentHost, SubagentHandle, } from '../../../session/subagent-host'; @@ -217,26 +215,10 @@ export class AgentTool implements BuiltinTool { handle = await this.subagentHost.resume(resumeAgentId, options); } else { const profileName = requestedProfileName ?? 'coder'; - const queued = await this.subagentHost.runQueuedTask( - { - data: undefined, - profileName, - parentToolCallId: options.parentToolCallId, - prompt: options.prompt, - description: options.description, - runInBackground: options.runInBackground, - } satisfies QueuedSubagentTask, - { - signal: options.signal, - timeoutMs, - }, - ); - handle = { - agentId: queued.agentId, - profileName: queued.profileName, - resumed: false, - completion: queued.completion.then(queuedResultToCompletion), - }; + handle = await this.subagentHost.spawn({ + profileName, + ...options, + }); } } catch (error) { this.log?.warn('subagent launch failed', { @@ -343,18 +325,6 @@ export class AgentTool implements BuiltinTool { } } -function queuedResultToCompletion( - result: QueuedSubagentRunResult, -): Awaited { - if (result.status === 'completed') { - return { - result: result.result ?? '', - usage: result.usage, - }; - } - throw new Error(result.error ?? 'Subagent failed.'); -} - function buildSubagentDescriptions(subagents: ResolvedAgentProfile['subagents']): string { if (subagents === undefined) return ''; return Object.entries(subagents) From 9e58f5cd043e432d1eaa52b6a2290fd876efa688 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 12:40:54 +0800 Subject: [PATCH 06/72] update --- apps/kimi-code/src/cli/run-prompt.ts | 1 + .../tui/components/messages/agent-group.ts | 3 +- .../messages/agent-swarm-progress.ts | 115 +++++----- .../tui/components/messages/swarm-markers.ts | 2 +- .../src/tui/components/messages/tool-call.ts | 80 +++++-- .../tui/controllers/session-event-handler.ts | 64 +++++- packages/agent-core/src/agent/index.ts | 9 + .../agent-core/src/session/subagent-host.ts | 45 +++- .../src/session/subagent-launch-queue.ts | 215 ++++++++++++++---- .../builtin/collaboration/agent-swarm.md | 4 +- .../test/session/subagent-host.test.ts | 204 +++++++++++------ packages/agent-core/test/tools/agent.test.ts | 35 ++- .../test/tools/builtin-current.test.ts | 3 +- packages/node-sdk/src/events.ts | 1 + .../node-sdk/test/session-event-types.test.ts | 1 + 15 files changed, 573 insertions(+), 209 deletions(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 7bc720d61..ce4e18d4b 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -471,6 +471,7 @@ function runPromptTurn( case 'subagent.completed': case 'subagent.failed': case 'subagent.spawned': + case 'subagent.started': case 'tool.list.updated': case 'turn.started': case 'turn.step.completed': 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.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index 01c27971a..c9177e91d 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -9,11 +9,8 @@ const FRAME_INTERVAL_MS = 80; const BRAILLE_BAR_MIN_WIDTH = 5; const BRAILLE_BAR_MAX_WIDTH = 8; const BRAILLE_EMPTY = '⣀'; -const BRAILLE_SPAWNING_RIGHT = '⣷'; -const BRAILLE_SPAWNING_LEFT = '⣾'; const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; -const BRAILLE_LEVELS = ['⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'] as const; -const SPAWNING_PHASE_GROUP_SIZE = 2; +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; @@ -21,12 +18,17 @@ const COMPLETE_FILL_MS = 360; const FAILED_PLACEHOLDER_RED_FACTOR = 0.75; const FAILED_PLACEHOLDER_NON_RED_FACTOR = 0.25; const ORCHESTRATING_LABEL = 'Orchestrating...'; -const SPAWNING_LABEL = 'Spawning...'; +const QUEUED_LABEL = 'Queued...'; -type AgentSwarmPhase = 'pending' | 'spawning' | 'working' | 'completed' | 'failed' | 'cancelled'; +type AgentSwarmPhase = + | 'pending' + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; interface AgentSwarmMember { - readonly index: number; readonly id: string; agentId?: string; phase: AgentSwarmPhase; @@ -63,9 +65,9 @@ export interface AgentSwarmProgressOptions { } const PHASE_LABELS: Record = { - pending: 'Spawning', - spawning: 'Spawning', - working: 'Working', + pending: 'Queued', + queued: 'Queued', + running: 'Running', completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled', @@ -78,7 +80,6 @@ export class AgentSwarmProgressComponent implements Component { private readonly colors: ColorPalette; private readonly requestRender: (() => void) | undefined; private inputComplete = false; - private frame = 0; private timer: ReturnType | undefined; constructor(options: AgentSwarmProgressOptions) { @@ -119,7 +120,7 @@ export class AgentSwarmProgressComponent implements Component { if (!this.inputComplete) { this.inputComplete = true; for (const member of this.members) { - if (member.phase === 'pending') member.phase = 'spawning'; + if (member.phase === 'pending') member.phase = 'queued'; } } this.startAnimationIfNeeded(); @@ -132,7 +133,16 @@ export class AgentSwarmProgressComponent implements Component { const member = this.findMemberForSubagent(input.agentId, input.description); if (member === undefined) return; member.agentId = input.agentId; - if (this.inputComplete && member.phase === 'pending') member.phase = 'spawning'; + if (member.phase === 'pending') member.phase = 'queued'; + this.startAnimationIfNeeded(); + } + + markStarted(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + if (member.phase === 'pending' || member.phase === 'queued') { + member.phase = 'running'; + } this.startAnimationIfNeeded(); } @@ -146,7 +156,9 @@ export class AgentSwarmProgressComponent implements Component { const member = this.findMemberByAgentId(input.agentId); if (member === undefined) return; member.ticks += 1; - if (member.phase === 'pending' || member.phase === 'spawning') member.phase = 'working'; + if (member.phase === 'pending' || member.phase === 'queued') { + member.phase = 'running'; + } } appendModelDelta(input: { @@ -158,6 +170,9 @@ export class AgentSwarmProgressComponent implements Component { member.latestModelText = `${member.latestModelText}${input.delta}`.slice( -MAX_LATEST_MODEL_CHARS, ); + if (member.phase === 'pending' || member.phase === 'queued') { + member.phase = 'running'; + } } appendAssistantDelta(input: { @@ -193,6 +208,22 @@ export class AgentSwarmProgressComponent implements Component { delete member.failedAtMs; } + markActiveCancelled(): void { + for (const member of this.members) { + if ( + member.phase === 'completed' || + member.phase === 'failed' || + member.phase === 'cancelled' + ) { + continue; + } + member.phase = 'cancelled'; + delete member.completedAtMs; + delete member.failedAtMs; + } + this.startAnimationIfNeeded(); + } + applyResult(output: string): void { for (const entry of parseAgentSwarmResultStatuses(output)) { this.ensureMemberCount(entry.index); @@ -294,13 +325,11 @@ export class AgentSwarmProgressComponent implements Component { snapshot.phase, barWidth, this.colors, - this.frame, - member.index, snapshot.phaseElapsedMs, ); const prefix = `${id} ${bar} `; const labelWidth = Math.max(1, width - visibleWidth(prefix)); - const label = renderCellLabel(snapshot, labelWidth, this.colors); + const label = renderCellLabel(member, snapshot, labelWidth, this.colors); return prefix + label; } @@ -333,7 +362,7 @@ export class AgentSwarmProgressComponent implements Component { if (count <= this.members.length) return; this.members = [ ...this.members, - ...createMembers(count, this.inputComplete ? 'spawning' : 'pending').slice(this.members.length), + ...createMembers(count, this.inputComplete ? 'queued' : 'pending').slice(this.members.length), ]; } @@ -352,7 +381,6 @@ export class AgentSwarmProgressComponent implements Component { if (!this.hasAnimatedMembers()) return; const requestRender = this.requestRender; this.timer = setInterval(() => { - this.frame += 1; requestRender(); if (!this.hasAnimatedMembers()) this.dispose(); }, FRAME_INTERVAL_MS); @@ -364,7 +392,6 @@ export class AgentSwarmProgressComponent implements Component { private hasAnimatedMembers(): boolean { const now = Date.now(); return this.members.some((member) => { - if (member.phase === 'spawning') return true; return ( member.phase === 'completed' && member.completedAtMs !== undefined && @@ -380,7 +407,6 @@ export class AgentSwarmProgressComponent implements Component { function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[] { return Array.from({ length: count }, (_item, index) => ({ - index, id: String(index + 1).padStart(3, '0'), phase, ticks: 0, @@ -485,17 +511,15 @@ function brailleBar( phase: AgentSwarmPhase, width: number, colors: ColorPalette, - frame: number, - memberIndex: number, phaseElapsedMs: number, ): string { const innerWidth = Math.max(1, width); switch (phase) { case 'pending': return ''; - case 'spawning': - return bracketBar(spawningBrailleBar(innerWidth, frame, memberIndex, colors), colors); - case 'working': + case 'queued': + return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.textDim, colors), colors); + case 'running': return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.success, colors), colors); case 'completed': return bracketBar( @@ -522,11 +546,10 @@ function bracketBar(content: string, colors: ColorPalette): string { function phaseColor(phase: AgentSwarmPhase, colors: ColorPalette): string { switch (phase) { case 'pending': + case 'queued': return colors.textDim; - case 'spawning': + case 'running': return colors.textDim; - case 'working': - return colors.primary; case 'completed': return colors.success; case 'failed': @@ -537,13 +560,16 @@ function phaseColor(phase: AgentSwarmPhase, colors: ColorPalette): string { } function renderCellLabel( + member: AgentSwarmMember, snapshot: AgentSwarmSnapshot, width: number, colors: ColorPalette, ): string { const latestLine = latestNonEmptyLine(snapshot.latestModelText); - if (snapshot.phase === 'working' && latestLine.length > 0) { - return truncateWithColor(latestLine, width, colors.textDim); + if (snapshot.phase === 'running') { + const itemText = collapseWhitespace(member.itemText); + const text = latestLine.length > 0 ? latestLine : itemText; + if (text.length > 0) return truncateWithColor(text, width, colors.textDim); } return truncateWithColor(PHASE_LABELS[snapshot.phase], width, phaseColor(snapshot.phase, colors)); } @@ -556,7 +582,7 @@ function renderPendingCell( const id = chalk.hex(colors.textDim)(member.id); const prefix = `${id} `; const itemText = collapseWhitespace(member.itemText); - const label = itemText.length > 0 ? itemText : SPAWNING_LABEL; + const label = itemText.length > 0 ? itemText : QUEUED_LABEL; const labelWidth = Math.max(1, width - visibleWidth(prefix)); return prefix + truncateWithColor(label, labelWidth, colors.textDim); } @@ -643,31 +669,6 @@ function padAnsi(text: string, width: number): string { return truncated + ' '.repeat(Math.max(0, width - visibleWidth(truncated))); } -function spawningBrailleBar( - width: number, - frame: number, - memberIndex: number, - colors: ColorPalette, -): string { - if (width <= 1) { - return chalk.hex(colors.textMuted)(BRAILLE_SPAWNING_RIGHT); - } - let out = ''; - const maxPosition = width - 1; - const period = maxPosition * 2; - const phaseOffset = Math.floor(memberIndex / SPAWNING_PHASE_GROUP_SIZE); - const position = (frame + phaseOffset) % period; - const movingRight = position <= maxPosition; - const cursorCell = movingRight ? position : period - position; - const cursorChar = movingRight ? BRAILLE_SPAWNING_RIGHT : BRAILLE_SPAWNING_LEFT; - for (let i = 0; i < width; i += 1) { - out += chalk.hex(i === cursorCell ? colors.textMuted : colors.textDim)( - i === cursorCell ? cursorChar : BRAILLE_EMPTY, - ); - } - return out; -} - function completedDisplayTicks(ticks: number, width: number, phaseElapsedMs: number): number { const fullBarTicks = width * BRAILLE_LEVELS.length; if (ticks >= fullBarTicks) return fullBarTicks; diff --git a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts index 7e71f06a4..e018e1ff9 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -13,7 +13,7 @@ export class SwarmModeMarkerComponent implements Component { invalidate(): void {} render(_width: number): string[] { - const color = this.active ? this.colors.primary : this.colors.textDim; + const color = this.active ? this.colors.success : this.colors.textDim; const marker = chalk.hex(color).bold(STATUS_BULLET); const label = chalk.hex(color).bold(this.active ? 'Swarm activated' : 'Swarm deactivated'); return ['', marker + label]; 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 12beac0ed..e9843c0da 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -37,7 +37,7 @@ const SUBAGENT_ELAPSED_INTERVAL_MS = 1000; const PROGRESS_URL_RE = /https?:\/\/\S+/g; type SubagentTextKind = 'thinking' | 'text'; - +type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded'; interface FinishedSubCall { readonly name: string; @@ -75,7 +75,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 +508,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 +874,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 +882,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 +909,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 +921,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 +931,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 +1099,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 +1122,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 +1152,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 +1402,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 +1414,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 +1465,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 +1497,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 +1507,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'); 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 e022e99cb..68bd55ca3 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -19,6 +19,7 @@ import type { SubagentCompletedEvent, SubagentFailedEvent, SubagentSpawnedEvent, + SubagentStartedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -215,6 +216,7 @@ export class SessionEventHandler { case 'compaction.blocked': break; case 'compaction.cancelled': this.handleCompactionCancel(event, sendQueued); break; case 'subagent.spawned': this.handleSubagentSpawned(event); break; + case 'subagent.started': this.handleSubagentStarted(event); break; case 'subagent.completed': this.handleSubagentCompleted(event); break; case 'subagent.failed': this.handleSubagentFailed(event); break; case 'background.task.started': @@ -260,6 +262,8 @@ export class SessionEventHandler { agentId: subagentId, toolCallId: event.toolCallId, }); + } else if (event.type === 'subagent.started') { + swarmProgress.markStarted(event.subagentId); } else if (event.type === 'turn.ended') { if (event.reason === 'cancelled') { swarmProgress.markCancelled(subagentId); @@ -334,6 +338,7 @@ export class SessionEventHandler { case 'subagent.completed': case 'subagent.failed': case 'subagent.spawned': + case 'subagent.started': case 'tool.progress': case 'tool.list.updated': case 'mcp.server.status': @@ -384,9 +389,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([]); @@ -437,6 +444,17 @@ export class SessionEventHandler { if (text !== undefined) this.host.showStatus(text); } + private markActiveAgentSwarmsCancelled(): void { + let visible: AgentSwarmProgressComponent | undefined; + for (const progress of this.agentSwarmProgress.values()) { + progress.markActiveCancelled(); + visible = progress; + } + if (visible !== undefined) { + this.host.setAgentSwarmProgress(visible); + } + } + private isAnthropicSessionActive(): boolean { const { state } = this.host; const providerKey = state.appState.availableModels[state.appState.model]?.provider; @@ -451,6 +469,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; } @@ -608,7 +627,11 @@ export class SessionEventHandler { const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); const progress = this.agentSwarmProgress.get(event.toolCallId); if (progress !== undefined) { - progress.applyResult(resultData.output); + if (event.isError === true && isUserCancelledSubagentError(resultData.output)) { + progress.markActiveCancelled(); + } else { + progress.applyResult(resultData.output); + } this.host.setAgentSwarmProgress(progress); } if (matchedCall !== undefined && matchedCall.name === 'TodoList' && !event.isError) { @@ -887,6 +910,41 @@ export class SessionEventHandler { }); } + private handleSubagentStarted(event: SubagentStartedEvent): void { + const { streamingUI } = this.host; + const existing = this.subagentInfo.get(event.subagentId); + if (existing === undefined) { + this.subagentInfo.set(event.subagentId, { + parentToolCallId: event.parentToolCallId, + name: event.subagentName, + }); + } + + if (event.runInBackground) return; + + const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); + if (swarmProgress !== undefined) { + swarmProgress.markStarted(event.subagentId); + this.host.setAgentSwarmProgress(swarmProgress); + 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); + } + } + if (tc === undefined) return; + tc.onSubagentStarted({ + 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); diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index b5c396fb5..0f683fcdb 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -130,6 +130,7 @@ export class Agent { readonly replayBuilder: ReplayBuilder; private lastLlmConfigLogSignature?: string; + private readonly eventListeners = new Set<(event: AgentEvent) => void>(); constructor(options: AgentOptions) { this.type = options.type ?? 'main'; @@ -406,9 +407,17 @@ export class Agent { emitEvent(event: AgentEvent): void { if (this.records.restoring) return; + for (const listener of this.eventListeners) listener(event); void this.rpc?.emitEvent?.(event); } + onEvent(listener: (event: AgentEvent) => void): () => void { + this.eventListeners.add(listener); + return () => { + this.eventListeners.delete(listener); + }; + } + emitStatusUpdated(): void { if (this.records.restoring) return; if (!this.config.hasModel) return; diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 5526ac812..d806676ec 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -8,6 +8,7 @@ import { prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; +import type { AgentEvent } from '../rpc'; import { createDeadlineAbortSignal, linkAbortSignal, @@ -51,6 +52,7 @@ type RunSubagentOptions = { readonly runInBackground: boolean; readonly origin?: PromptOrigin | undefined; readonly signal: AbortSignal; + readonly onFirstOutput?: (() => void) | undefined; }; type SpawnSubagentOptions = RunSubagentOptions & { @@ -86,7 +88,16 @@ export class SessionSubagentHost { this.launchQueue = new SubagentLaunchQueue(this); } - async spawn(options: SpawnSubagentOptions): Promise { + async spawn(profileName: string, options: RunSubagentOptions): Promise; + async spawn(options: SpawnSubagentOptions): Promise; + async spawn( + profileNameOrOptions: string | SpawnSubagentOptions, + legacyOptions?: RunSubagentOptions, + ): Promise { + const options = + typeof profileNameOrOptions === 'string' + ? { ...legacyOptions!, profileName: profileNameOrOptions } + : profileNameOrOptions; options.signal.throwIfAborted(); const parent = this.requireParentAgent(); const profile = this.resolveProfile(parent, options.profileName); @@ -215,6 +226,7 @@ export class SessionSubagentHost { task: QueuedSubagentTask, options: QueuedSubagentRunOptions, totalTimedOut: () => boolean, + markReady: () => void, ): Promise> { const subagentDeadline = options.timeoutMs === undefined @@ -227,6 +239,7 @@ export class SessionSubagentHost { handle = await this.spawn({ ...task, signal: runSignal, + onFirstOutput: markReady, }); const completion = await handle.completion; return { @@ -295,6 +308,7 @@ export class SessionSubagentHost { emitSpawnedEvent = true, ): Promise { if (emitSpawnedEvent) this.emitSubagentSpawned(parent, childId, profileName, options); + const unwatchFirstOutput = this.watchFirstOutput(child, options.onFirstOutput); try { await prepareChild(); @@ -347,6 +361,8 @@ export class SessionSubagentHost { error: message, }); throw error; + } finally { + unwatchFirstOutput?.(); } } @@ -393,6 +409,19 @@ export class SessionSubagentHost { }); } + private watchFirstOutput( + child: Agent, + onFirstOutput: (() => void) | undefined, + ): (() => void) | undefined { + if (onFirstOutput === undefined) return undefined; + let emitted = false; + return child.onEvent((event) => { + if (emitted || !isFirstOutputEvent(event)) return; + emitted = true; + onFirstOutput(); + }); + } + private emitSubagentSpawned( parent: Agent, childId: string, @@ -464,3 +493,17 @@ function lastAssistantText(agent: Agent): string { } return ''; } + +function isFirstOutputEvent(event: AgentEvent): boolean { + switch (event.type) { + case 'assistant.delta': + case 'thinking.delta': + return event.delta.length > 0; + case 'tool.call.delta': + return (event.name?.length ?? 0) > 0 || (event.argumentsPart?.length ?? 0) > 0; + case 'tool.call.started': + return true; + default: + return false; + } +} diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index fbcb9365c..8c5b5bb0f 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -9,9 +9,7 @@ import { } from '../utils/abort'; const SUBAGENT_LAUNCH_BATCH_SIZE = 10; -const SUBAGENT_MAX_INITIAL_LAUNCHES = 30; const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; -const SUBAGENT_RAMP_BATCH_DELAY_MS = 500; const RATE_LIMIT_429_MESSAGE = "429 We're receiving too many requests at the moment. Please wait a moment and try again."; const RATE_LIMIT_429_BODY = @@ -54,10 +52,24 @@ export type QueuedSubagentAttemptOutcome = readonly result: QueuedSubagentRunResult; }; +type QueuedSubagentReadinessOutcome = + | { + readonly kind: 'ready'; + readonly task: QueuedSubagentTask; + } + | { + readonly kind: 'rate_limited'; + readonly task: QueuedSubagentTask; + }; + type QueuedSubagentAttempt = { readonly task: QueuedSubagentTask; readonly promise: Promise>; + readonly readiness: Promise>; + readonly rateLimit: Promise>; settled: boolean; + ready: boolean; + readinessSettled: boolean; }; type SubagentLaunchQueueHost = { @@ -65,6 +77,7 @@ type SubagentLaunchQueueHost = { task: QueuedSubagentTask, options: QueuedSubagentRunOptions, totalTimedOut: () => boolean, + markReady: () => void, ) => Promise>; }; @@ -108,8 +121,9 @@ export class SubagentLaunchQueue { }); const taskIndexes = new Map(tasks.map((task, index) => [task, index])); let completedResults = 0; - let launchedDuringRamp = 0; + let launchedAttempts = 0; let rateLimitSeen = false; + let lockedSlotCount: number | undefined; const resultIndex = (task: QueuedSubagentTask): number => { const index = taskIndexes.get(task); @@ -133,21 +147,53 @@ export class SubagentLaunchQueue { return queued.shift(); }; - const launch = (task: QueuedSubagentTask): void => { - const attempt: QueuedSubagentAttempt = { + const lockSlotCount = (): void => { + lockedSlotCount ??= Math.max(0, launchedAttempts - 2); + }; + + const launch = (task: QueuedSubagentTask): QueuedSubagentAttempt => { + const readiness = deferred>(); + let attempt!: QueuedSubagentAttempt; + const promise = this.host.runQueuedTaskAttempt(task, options, totalTimedOut, () => { + readiness.resolve({ kind: 'ready', task }); + }); + attempt = { task, + promise, + readiness: readiness.promise, + rateLimit: promise.then((outcome) => { + if (outcome.kind === 'rate_limited') return attempt; + return new Promise(() => {}); + }), settled: false, - promise: this.host.runQueuedTaskAttempt(task, options, totalTimedOut), + ready: false, + readinessSettled: false, }; - void attempt.promise.then( - () => { + launchedAttempts += 1; + void promise.then( + (outcome) => { attempt.settled = true; + if (outcome.kind === 'rate_limited') { + readiness.resolve({ kind: 'rate_limited', task: outcome.task }); + } else { + readiness.resolve({ kind: 'ready', task: outcome.result.task }); + } }, - () => { + (error) => { attempt.settled = true; + readiness.reject(error); + }, + ); + void attempt.readiness.then( + () => { + attempt.readinessSettled = true; + }, + () => { + attempt.readinessSettled = true; }, ); active.push(attempt); + return attempt; }; const processAttempt = async (attempt: QueuedSubagentAttempt): Promise => { @@ -156,6 +202,7 @@ export class SubagentLaunchQueue { const outcome = await attempt.promise; if (outcome.kind === 'rate_limited') { rateLimitSeen = true; + lockSlotCount(); enqueue(outcome.task); return false; } @@ -172,29 +219,59 @@ export class SubagentLaunchQueue { } }; + const waitForRampBatch = async ( + batch: readonly QueuedSubagentAttempt[], + ): Promise => { + while (batch.some((attempt) => !attempt.ready)) { + const event = await nextRampEvent(batch, active, options.signal); + if (event.kind === 'rate_limited') { + await processAttempt(event.attempt); + return false; + } + if (event.outcome.kind === 'rate_limited') { + await processAttempt(event.attempt); + return false; + } + event.attempt.ready = true; + await processSettledAttempts(); + if (rateLimitSeen) return false; + } + return true; + }; + + const launchQueuedUpToSlotLimit = async (): Promise => { + if (lockedSlotCount === undefined) return; + if (active.length === 0 && completedResults === 0) return; + while (queued.length > 0 && active.length < lockedSlotCount) { + await sleepWithSignal(SUBAGENT_QUEUE_LAUNCH_DELAY_MS, options.signal); + if (active.length >= lockedSlotCount) return; + const task = dequeue(); + if (task !== undefined) launch(task); + } + }; + try { - while (pending.length > 0 && launchedDuringRamp < SUBAGENT_MAX_INITIAL_LAUNCHES) { + while (pending.length > 0) { if (rateLimitSeen) break; - const batchSize = Math.min( - SUBAGENT_LAUNCH_BATCH_SIZE, - pending.length, - SUBAGENT_MAX_INITIAL_LAUNCHES - launchedDuringRamp, - ); + const batch: Array> = []; + const batchSize = Math.min(SUBAGENT_LAUNCH_BATCH_SIZE, pending.length); for (let i = 0; i < batchSize; i += 1) { const task = pending.shift(); if (task === undefined) break; - launch(task); - launchedDuringRamp += 1; + batch.push(launch(task)); } - if (pending.length === 0 || launchedDuringRamp >= SUBAGENT_MAX_INITIAL_LAUNCHES) break; - await waitForRateLimitOrDelay(active, options.signal); - await processSettledAttempts(); + if (pending.length === 0) break; + const rampCanContinue = await waitForRampBatch(batch); + if (!rampCanContinue) break; } for (const task of pending) { enqueue(task); } pending.length = 0; + if (active.length > 0 || completedResults > 0) { + await launchQueuedUpToSlotLimit(); + } while (completedResults < tasks.length) { options.signal.throwIfAborted(); @@ -218,11 +295,8 @@ export class SubagentLaunchQueue { } const attempt = await nextSettledAttempt(active, options.signal); - const openedSlot = await processAttempt(attempt); - if (!openedSlot || queued.length === 0) continue; - await sleepWithSignal(SUBAGENT_QUEUE_LAUNCH_DELAY_MS, options.signal); - const task = dequeue(); - if (task !== undefined) launch(task); + await processAttempt(attempt); + await launchQueuedUpToSlotLimit(); } } catch (error) { if (!totalTimedOut()) throw error; @@ -253,23 +327,39 @@ function failedQueuedResult( }; } -async function waitForRateLimitOrDelay( +type RampEvent = + | { + readonly kind: 'readiness'; + readonly attempt: QueuedSubagentAttempt; + readonly outcome: QueuedSubagentReadinessOutcome; + } + | { + readonly kind: 'rate_limited'; + readonly attempt: QueuedSubagentAttempt; + }; + +async function nextRampEvent( + batch: ReadonlyArray>, active: ReadonlyArray>, signal: AbortSignal, -): Promise { - if (active.length === 0) { - await sleepWithSignal(SUBAGENT_RAMP_BATCH_DELAY_MS, signal); - return; +): Promise> { + const ready = batch.find((attempt) => !attempt.ready && attempt.readinessSettled); + if (ready !== undefined) { + return { kind: 'readiness', attempt: ready, outcome: await ready.readiness }; } - const rateLimited = Promise.race( - active.map((attempt) => - attempt.promise.then((outcome) => { - if (outcome.kind === 'rate_limited') return; - return new Promise(() => {}); - }), - ), + signal.throwIfAborted(); + const readiness = batch + .filter((attempt) => !attempt.ready) + .map((attempt) => + attempt.readiness.then((outcome): RampEvent => ({ kind: 'readiness', attempt, outcome })), + ); + const rateLimited = active.map((attempt) => + attempt.rateLimit.then((rateLimitedAttempt): RampEvent => ({ + kind: 'rate_limited', + attempt: rateLimitedAttempt, + })), ); - await Promise.race([sleepWithSignal(SUBAGENT_RAMP_BATCH_DELAY_MS, signal), rateLimited]); + return await raceWithSignal([...readiness, ...rateLimited], signal); } async function nextSettledAttempt( @@ -303,6 +393,32 @@ async function nextSettledAttempt( }); } +function raceWithSignal(promises: Array>, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const cleanup = () => { + signal.removeEventListener('abort', onAbort); + }; + const onAbort = () => { + cleanup(); + reject(signal.reason instanceof Error ? signal.reason : abortError()); + }; + signal.addEventListener('abort', onAbort, { once: true }); + for (const promise of promises) { + void promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + } + }); +} + function sleepWithSignal(ms: number, signal: AbortSignal): Promise { signal.throwIfAborted(); return new Promise((resolve, reject) => { @@ -321,6 +437,29 @@ function sleepWithSignal(ms: number, signal: AbortSignal): Promise { }); } +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (reason: unknown) => void; +} { + let settled = false; + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = (value) => { + if (settled) return; + settled = true; + innerResolve(value); + }; + reject = (reason) => { + if (settled) return; + settled = true; + innerReject(reason); + }; + }); + return { promise, resolve, reject }; +} + export function totalTimeoutMessage(timeoutMs: number | undefined): string { return timeoutMs === undefined ? 'Subagent batch total timeout elapsed.' diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md index 586223d40..41f342966 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md @@ -1,5 +1,3 @@ Launch multiple subagents from one prompt template and a list of item values. -Use AgentSwarm when many subagents should run the same kind of task over different inputs. Do not create a JSONL file for this tool. - -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 subagents with those two concrete prompts. When a non-default subagent profile is needed, pass `subagent_type` once for the whole swarm. +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 subagents with those two concrete prompts. diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index a0cd9137e..1d6267b8a 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -41,60 +41,86 @@ afterEach(async () => { }); describe('SessionSubagentHost', () => { - it('runQueued ramps launches in batches of ten up to thirty', async () => { - vi.useFakeTimers(); - try { - const host = new SessionSubagentHost({} as Session, 'main'); - const completions: Array>> = []; - const spawn = vi.spyOn(host, 'spawn').mockImplementation((options) => { - const profileName = typeof options === 'string' ? options : options.profileName; - const completion = deferred<{ result: string }>(); - completions.push(completion); - return Promise.resolve({ - agentId: `agent-${String(completions.length)}`, - profileName, - resumed: false, - completion: completion.promise, - } satisfies SubagentHandle); - }); + it('runQueued launches the next batch after every current batch member emits output', async () => { + const host = new SessionSubagentHost({} as Session, 'main'); + const launches: Array< + ReturnType> & { readonly ready: () => void } + > = []; + const spawn = vi.spyOn(host, 'spawn').mockImplementation((options) => { + const profileName = typeof options === 'string' ? options : options.profileName; + const completion = { + ...deferred<{ result: string }>(), + ready: typeof options === 'string' ? () => {} : options.onFirstOutput ?? (() => {}), + }; + launches.push(completion); + return Promise.resolve({ + agentId: `agent-${String(launches.length)}`, + profileName, + resumed: false, + completion: completion.promise, + } satisfies SubagentHandle); + }); - const running = host.runQueued( - Array.from({ length: 30 }, (_, index) => queuedTask(index + 1)), - { signal }, - ); + const running = host.runQueued( + Array.from({ length: 41 }, (_, index) => queuedTask(index + 1)), + { signal }, + ); - await vi.advanceTimersByTimeAsync(0); - expect(spawn).toHaveBeenCalledTimes(10); + await flushPromises(); + expect(spawn).toHaveBeenCalledTimes(10); - await vi.advanceTimersByTimeAsync(499); - expect(spawn).toHaveBeenCalledTimes(10); + launches.slice(0, 9).forEach((launch) => { + launch.ready(); + }); + await flushPromises(); + expect(spawn).toHaveBeenCalledTimes(10); - await vi.advanceTimersByTimeAsync(1); + launches[9]!.ready(); + await vi.waitFor(() => { expect(spawn).toHaveBeenCalledTimes(20); + }); - await vi.advanceTimersByTimeAsync(500); + launches.slice(10, 20).forEach((launch) => { + launch.ready(); + }); + await vi.waitFor(() => { expect(spawn).toHaveBeenCalledTimes(30); + }); - completions.forEach((completion, index) => { - completion.resolve({ result: `result ${String(index + 1)}` }); - }); - const results = await running; + launches.slice(20, 30).forEach((launch) => { + launch.ready(); + }); + await vi.waitFor(() => { + expect(spawn).toHaveBeenCalledTimes(40); + }); - expect(results).toHaveLength(30); - expect(results.every((result) => result.status === 'completed')).toBe(true); - } finally { - vi.useRealTimers(); - } + launches.slice(30, 40).forEach((launch) => { + launch.ready(); + }); + await vi.waitFor(() => { + expect(spawn).toHaveBeenCalledTimes(41); + }); + + launches.forEach((completion, index) => { + completion.resolve({ result: `result ${String(index + 1)}` }); + }); + const results = await running; + + expect(results).toHaveLength(41); + expect(results.every((result) => result.status === 'completed')).toBe(true); }); - it('runQueued queues 429 and launches one queued subagent 500ms after a slot opens', async () => { + it('runQueued locks slots to launched minus two after the first 429', async () => { vi.useFakeTimers(); try { + const controller = new AbortController(); const host = new SessionSubagentHost({} as Session, 'main'); - const completions: Array< - ReturnType> & { readonly prompt: string } + const launches: Array< + ReturnType> & { + readonly prompt: string; + readonly ready: () => void; + } > = []; - let firstItemRateLimited = false; const spawn = vi .spyOn(host, 'spawn') .mockImplementation((options, legacyOptions?: { readonly prompt: string }) => { @@ -103,24 +129,14 @@ describe('SessionSubagentHost', () => { if (prompt === undefined) { throw new Error('mocked subagent prompt is required'); } - if (prompt === 'Review item-1' && !firstItemRateLimited) { - firstItemRateLimited = true; - return Promise.resolve({ - agentId: 'agent-rate-limited', - profileName, - resumed: false, - completion: Promise.resolve().then(() => { - throw new Error(rateLimit429Message); - }), - } satisfies SubagentHandle); - } const completion = { ...deferred<{ result: string }>(), prompt, + ready: typeof options === 'string' ? () => {} : options.onFirstOutput ?? (() => {}), }; - completions.push(completion); + launches.push(completion); return Promise.resolve({ - agentId: `agent-${String(completions.length)}`, + agentId: `agent-${String(launches.length)}`, profileName, resumed: false, completion: completion.promise, @@ -128,40 +144,47 @@ describe('SessionSubagentHost', () => { }); const running = host.runQueued( - Array.from({ length: 11 }, (_, index) => queuedTask(index + 1)), - { signal }, + Array.from({ length: 21 }, (_, index) => queuedTask(index + 1)), + { signal: controller.signal }, ); + void running.catch(() => {}); await vi.advanceTimersByTimeAsync(0); expect(spawn).toHaveBeenCalledTimes(10); - completions[0]!.resolve({ result: 'opened one slot' }); + launches.slice(0, 10).forEach((launch) => { + launch.ready(); + }); + await vi.advanceTimersByTimeAsync(0); + expect(spawn).toHaveBeenCalledTimes(20); + + launches[14]!.reject(new Error(rateLimit429Message)); + await vi.advanceTimersByTimeAsync(0); + expect(spawn).toHaveBeenCalledTimes(20); + + launches[0]!.resolve({ result: 'opened slot 1' }); + await vi.advanceTimersByTimeAsync(500); + expect(spawn).toHaveBeenCalledTimes(20); + + launches[1]!.resolve({ result: 'opened slot 2' }); await vi.advanceTimersByTimeAsync(499); - expect(spawn).toHaveBeenCalledTimes(10); + expect(spawn).toHaveBeenCalledTimes(20); await vi.advanceTimersByTimeAsync(1); - expect(spawn).toHaveBeenCalledTimes(11); + expect(spawn).toHaveBeenCalledTimes(21); expect(spawn).toHaveBeenLastCalledWith({ - data: 1, + data: 15, profileName: 'coder', parentToolCallId: 'call_swarm', - prompt: 'Review item-1', - description: 'Review #1', + prompt: 'Review item-15', + description: 'Review #15', runInBackground: false, - signal, + signal: controller.signal, + onFirstOutput: expect.any(Function), }); - for (const completion of [...completions]) { - completion.resolve({ result: `${completion.prompt} done` }); - } - await vi.advanceTimersByTimeAsync(500); - expect(spawn).toHaveBeenCalledTimes(12); - completions.at(-1)!.resolve({ result: 'Review item-11 done' }); - - const results = await running; - - expect(results).toHaveLength(11); - expect(results.every((result) => result.status === 'completed')).toBe(true); + controller.abort(); + await expect(running).rejects.toThrow(); } finally { vi.useRealTimers(); } @@ -290,6 +313,37 @@ describe('SessionSubagentHost', () => { ); }); + it('marks a queued child ready when the model emits thinking output', async () => { + const parent = testAgent(); + parent.configure(); + parent.newEvents(); + + const child = testAgent(); + const summary = + 'Completed the delegated subagent task with enough concrete detail for the parent agent to continue without repeating the work. '.repeat( + 2, + ); + child.mockNextResponse({ type: 'think', think: 'I can start.' }, { type: 'text', text: summary }); + const session = fakeSession(parent.agent, child.agent); + const host = new SessionSubagentHost(session, 'main'); + const onFirstOutput = vi.fn(); + + const handle = await host.spawn('coder', { + parentToolCallId: 'call_agent', + prompt: 'Implement the fix', + description: 'Fix bug', + runInBackground: false, + signal, + onFirstOutput, + }); + + await vi.waitFor(() => { + expect(onFirstOutput).toHaveBeenCalledTimes(1); + }); + await expect(handle.completion).resolves.toMatchObject({ result: summary.trim() }); + expect(onFirstOutput).toHaveBeenCalledTimes(1); + }); + it('runs a child agent turn and returns the last assistant text', async () => { const telemetryTrack = vi.fn(); const parent = testAgent({ telemetry: { track: telemetryTrack } }); @@ -1351,6 +1405,12 @@ function deferred() { return { promise, resolve, reject }; } +async function flushPromises(count = 2): Promise { + for (let i = 0; i < count; i += 1) { + await Promise.resolve(); + } +} + function queuedTask(index: number): QueuedSubagentTask { return { data: index, diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index 805086b42..a88022715 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -219,7 +219,8 @@ describe('AgentTool', () => { }), ); - expect(host.spawn).toHaveBeenCalledWith('explore', { + expect(host.spawn).toHaveBeenCalledWith({ + profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Investigate', description: 'Find cause', @@ -251,9 +252,9 @@ describe('AgentTool', () => { ); expect(host.spawn).toHaveBeenCalledWith( - 'coder', expect.objectContaining({ parentToolCallId: 'call_agent', + profileName: 'coder', }), ); }); @@ -671,17 +672,25 @@ describe('AgentTool', () => { it('reports a deliberate user interruption when a foreground subagent is cancelled by the user', async () => { const controller = new AbortController(); const host = mockSubagentHost({ - spawn: vi.fn((_profileName: string, options: { signal: AbortSignal }) => + spawn: vi.fn( + ( + profileNameOrOptions: string | { readonly signal: AbortSignal }, + legacyOptions?: { readonly signal: AbortSignal }, + ) => Promise.resolve({ agentId: 'agent-child', profileName: 'coder', resumed: false, completion: new Promise<{ result: string }>((_resolve, reject) => { + const signal = + typeof profileNameOrOptions === 'string' + ? legacyOptions!.signal + : profileNameOrOptions.signal; const onAbort = (): void => { - reject(options.signal.reason); + reject(signal.reason); }; - if (options.signal.aborted) onAbort(); - else options.signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); }), }), ), @@ -713,16 +722,24 @@ describe('AgentTool', () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); try { const host = mockSubagentHost({ - spawn: vi.fn((_profileName: string, options: { signal: AbortSignal }) => + spawn: vi.fn( + ( + profileNameOrOptions: string | { readonly signal: AbortSignal }, + legacyOptions?: { readonly signal: AbortSignal }, + ) => Promise.resolve({ agentId: 'agent-child', profileName: 'coder', resumed: false, completion: new Promise<{ result: string }>((_resolve, reject) => { - options.signal.addEventListener( + const signal = + typeof profileNameOrOptions === 'string' + ? legacyOptions!.signal + : profileNameOrOptions.signal; + signal.addEventListener( 'abort', () => { - reject(options.signal.reason); + reject(signal.reason); }, { once: true }, ); diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 9afe826dc..def24e68a 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -280,7 +280,8 @@ describe('current builtin collaboration tools', () => { }); const result = await executeTool(tool, context(input, 'call_agent')); - expect(host.spawn).toHaveBeenCalledWith('coder', { + expect(host.spawn).toHaveBeenCalledWith({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Investigate', description: 'Find cause', diff --git a/packages/node-sdk/src/events.ts b/packages/node-sdk/src/events.ts index 6bebc4e0c..2ea7b2a75 100644 --- a/packages/node-sdk/src/events.ts +++ b/packages/node-sdk/src/events.ts @@ -82,6 +82,7 @@ export type { // Subagent lifecycle events. export type { SubagentSpawnedEvent, + SubagentStartedEvent, SubagentCompletedEvent, SubagentFailedEvent, } from '@moonshot-ai/agent-core'; diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts index 227a0d3ba..2bc9c8a1e 100644 --- a/packages/node-sdk/test/session-event-types.test.ts +++ b/packages/node-sdk/test/session-event-types.test.ts @@ -75,6 +75,7 @@ describe('Event public types', () => { case 'tool.list.updated': case 'mcp.server.status': case 'subagent.spawned': + case 'subagent.started': case 'subagent.completed': case 'subagent.failed': case 'compaction.started': From d3f92ef53aa8210f97ee8b4fd7e505b22be6f431 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 13:03:35 +0800 Subject: [PATCH 07/72] fix --- .../agent-core/src/session/subagent-host.ts | 43 ++- .../src/session/subagent-launch-queue.ts | 330 ++++-------------- .../builtin/collaboration/agent-swarm.ts | 2 +- packages/agent-core/src/utils/abort.ts | 17 + .../test/session/subagent-host.test.ts | 101 +++--- .../test/tools/builtin-current.test.ts | 2 - 6 files changed, 155 insertions(+), 340 deletions(-) diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index d806676ec..4db8a8560 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -3,6 +3,7 @@ import type { TokenUsage } from '@moonshot-ai/kosong'; import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; import type { LoopTurnStopReason } from '../loop'; +import { isAbortError } from '../loop/errors'; import { DEFAULT_AGENT_PROFILES, prepareSystemPromptContext, @@ -11,6 +12,7 @@ import { import type { AgentEvent } from '../rpc'; import { createDeadlineAbortSignal, + isUserCancellation, linkAbortSignal, userCancellationReason, } from '../utils/abort'; @@ -18,8 +20,9 @@ import { collectGitContext } from './git-context'; import type { Session } from './index'; import { SubagentLaunchQueue, - formatQueuedSubagentError, + formatTimeoutMs, isRateLimit429Error, + totalTimeoutMessage, type QueuedSubagentAttemptOutcome, type QueuedSubagentRunOptions, type QueuedSubagentRunResult, @@ -46,13 +49,13 @@ const SUBAGENT_MAX_TOKENS_ERROR = type RunSubagentOptions = { readonly parentToolCallId: string; - readonly parentToolCallUuid?: string | undefined; + readonly parentToolCallUuid?: string; readonly prompt: string; readonly description: string; readonly runInBackground: boolean; - readonly origin?: PromptOrigin | undefined; + readonly origin?: PromptOrigin; readonly signal: AbortSignal; - readonly onFirstOutput?: (() => void) | undefined; + readonly onFirstOutput?: () => void; }; type SpawnSubagentOptions = RunSubagentOptions & { @@ -88,16 +91,7 @@ export class SessionSubagentHost { this.launchQueue = new SubagentLaunchQueue(this); } - async spawn(profileName: string, options: RunSubagentOptions): Promise; - async spawn(options: SpawnSubagentOptions): Promise; - async spawn( - profileNameOrOptions: string | SpawnSubagentOptions, - legacyOptions?: RunSubagentOptions, - ): Promise { - const options = - typeof profileNameOrOptions === 'string' - ? { ...legacyOptions!, profileName: profileNameOrOptions } - : profileNameOrOptions; + async spawn(options: SpawnSubagentOptions): Promise { options.signal.throwIfAborted(); const parent = this.requireParentAgent(); const profile = this.resolveProfile(parent, options.profileName); @@ -247,7 +241,6 @@ export class SessionSubagentHost { result: { task, agentId: handle.agentId, - profileName: handle.profileName, status: 'completed', result: completion.result, usage: completion.usage, @@ -260,19 +253,25 @@ export class SessionSubagentHost { if (handle === undefined) { throw error; } + let message: string; + if (subagentDeadline?.timedOut() === true && options.timeoutMs !== undefined) { + message = `Subagent timed out after ${formatTimeoutMs(options.timeoutMs)}.`; + } else if (totalTimedOut() && options.totalTimeoutMs !== undefined) { + message = totalTimeoutMessage(options.totalTimeoutMs); + } else if (isUserCancellation(runSignal.reason)) { + message = 'The user manually interrupted this subagent batch.'; + } else if (isAbortError(error)) { + message = 'The subagent was stopped before it finished.'; + } else { + message = error instanceof Error ? error.message : String(error); + } return { kind: 'result', result: { task, agentId: handle.agentId, - profileName: handle.profileName, status: 'failed', - error: formatQueuedSubagentError(error, runSignal, { - subagentTimedOut: () => subagentDeadline?.timedOut() === true, - subagentTimeoutMs: options.timeoutMs, - totalTimedOut, - totalTimeoutMs: options.totalTimeoutMs, - }), + error: message, }, }; } finally { diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index 8c5b5bb0f..4f7a259a2 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -1,11 +1,10 @@ +import { createControlledPromise, sleep } from '@antfu/utils'; import type { TokenUsage } from '@moonshot-ai/kosong'; import type { PromptOrigin } from '../agent/context'; -import { isAbortError } from '../loop/errors'; import { - abortError, createDeadlineAbortSignal, - isUserCancellation, + raceWithSignal, } from '../utils/abort'; const SUBAGENT_LAUNCH_BATCH_SIZE = 10; @@ -35,7 +34,6 @@ export type QueuedSubagentRunOptions = { export type QueuedSubagentRunResult = { readonly task: QueuedSubagentTask; readonly agentId?: string; - readonly profileName: string; readonly status: 'completed' | 'failed'; readonly result?: string; readonly usage?: TokenUsage; @@ -52,24 +50,14 @@ export type QueuedSubagentAttemptOutcome = readonly result: QueuedSubagentRunResult; }; -type QueuedSubagentReadinessOutcome = - | { - readonly kind: 'ready'; - readonly task: QueuedSubagentTask; - } - | { - readonly kind: 'rate_limited'; - readonly task: QueuedSubagentTask; - }; - type QueuedSubagentAttempt = { readonly task: QueuedSubagentTask; readonly promise: Promise>; - readonly readiness: Promise>; - readonly rateLimit: Promise>; - settled: boolean; - ready: boolean; - readinessSettled: boolean; + readonly readiness: Promise; + readonly state: { + settled: boolean; + ready: boolean; + }; }; type SubagentLaunchQueueHost = { @@ -122,8 +110,7 @@ export class SubagentLaunchQueue { const taskIndexes = new Map(tasks.map((task, index) => [task, index])); let completedResults = 0; let launchedAttempts = 0; - let rateLimitSeen = false; - let lockedSlotCount: number | undefined; + let slotLimit: number | undefined; const resultIndex = (task: QueuedSubagentTask): number => { const index = taskIndexes.get(task); @@ -135,7 +122,8 @@ export class SubagentLaunchQueue { const enqueue = (task: QueuedSubagentTask): void => { if (results[resultIndex(task)] !== undefined || queued.includes(task)) return; - const insertAt = queued.findIndex((queuedTask) => resultIndex(queuedTask) > resultIndex(task)); + const index = resultIndex(task); + const insertAt = queued.findIndex((queuedTask) => resultIndex(queuedTask) > index); if (insertAt === -1) { queued.push(task); } else { @@ -143,53 +131,33 @@ export class SubagentLaunchQueue { } }; - const dequeue = (): QueuedSubagentTask | undefined => { - return queued.shift(); - }; - - const lockSlotCount = (): void => { - lockedSlotCount ??= Math.max(0, launchedAttempts - 2); - }; - const launch = (task: QueuedSubagentTask): QueuedSubagentAttempt => { - const readiness = deferred>(); - let attempt!: QueuedSubagentAttempt; - const promise = this.host.runQueuedTaskAttempt(task, options, totalTimedOut, () => { - readiness.resolve({ kind: 'ready', task }); - }); - attempt = { - task, - promise, - readiness: readiness.promise, - rateLimit: promise.then((outcome) => { - if (outcome.kind === 'rate_limited') return attempt; - return new Promise(() => {}); - }), + const readiness = createControlledPromise(); + const state = { settled: false, ready: false, - readinessSettled: false, + }; + const markReady = (): void => { + if (state.ready) return; + state.ready = true; + readiness.resolve(); + }; + const promise = this.host.runQueuedTaskAttempt(task, options, totalTimedOut, markReady); + const attempt: QueuedSubagentAttempt = { + task, + promise, + readiness, + state, }; launchedAttempts += 1; void promise.then( - (outcome) => { - attempt.settled = true; - if (outcome.kind === 'rate_limited') { - readiness.resolve({ kind: 'rate_limited', task: outcome.task }); - } else { - readiness.resolve({ kind: 'ready', task: outcome.result.task }); - } - }, - (error) => { - attempt.settled = true; - readiness.reject(error); - }, - ); - void attempt.readiness.then( () => { - attempt.readinessSettled = true; + state.settled = true; + markReady(); }, () => { - attempt.readinessSettled = true; + state.settled = true; + markReady(); }, ); active.push(attempt); @@ -201,8 +169,7 @@ export class SubagentLaunchQueue { if (activeIndex !== -1) active.splice(activeIndex, 1); const outcome = await attempt.promise; if (outcome.kind === 'rate_limited') { - rateLimitSeen = true; - lockSlotCount(); + slotLimit ??= Math.max(0, launchedAttempts - 2); enqueue(outcome.task); return false; } @@ -211,48 +178,42 @@ export class SubagentLaunchQueue { return true; }; - const processSettledAttempts = async (): Promise => { + const processSettledAttempts = async (): Promise => { while (true) { - const settled = active.find((attempt) => attempt.settled); - if (settled === undefined) return; - await processAttempt(settled); + const settled = active.find((attempt) => attempt.state.settled); + if (settled === undefined) return true; + if (!(await processAttempt(settled))) return false; } }; const waitForRampBatch = async ( batch: readonly QueuedSubagentAttempt[], ): Promise => { - while (batch.some((attempt) => !attempt.ready)) { - const event = await nextRampEvent(batch, active, options.signal); - if (event.kind === 'rate_limited') { - await processAttempt(event.attempt); - return false; - } - if (event.outcome.kind === 'rate_limited') { - await processAttempt(event.attempt); - return false; - } - event.attempt.ready = true; - await processSettledAttempts(); - if (rateLimitSeen) return false; + while (batch.some((attempt) => !attempt.state.ready)) { + const readiness = batch + .filter((attempt) => !attempt.state.ready) + .map((attempt) => attempt.readiness.then(() => undefined)); + const settled = active.map((attempt) => attempt.promise.then(() => undefined)); + options.signal.throwIfAborted(); + await raceWithSignal(Promise.race([...readiness, ...settled]), options.signal); + if (!(await processSettledAttempts())) return false; } - return true; + return await processSettledAttempts(); }; const launchQueuedUpToSlotLimit = async (): Promise => { - if (lockedSlotCount === undefined) return; + if (slotLimit === undefined) return; if (active.length === 0 && completedResults === 0) return; - while (queued.length > 0 && active.length < lockedSlotCount) { - await sleepWithSignal(SUBAGENT_QUEUE_LAUNCH_DELAY_MS, options.signal); - if (active.length >= lockedSlotCount) return; - const task = dequeue(); + while (queued.length > 0 && active.length < slotLimit) { + await raceWithSignal(sleep(SUBAGENT_QUEUE_LAUNCH_DELAY_MS), options.signal); + if (active.length >= slotLimit) return; + const task = queued.shift(); if (task !== undefined) launch(task); } }; try { - while (pending.length > 0) { - if (rateLimitSeen) break; + while (pending.length > 0 && slotLimit === undefined) { const batch: Array> = []; const batchSize = Math.min(SUBAGENT_LAUNCH_BATCH_SIZE, pending.length); for (let i = 0; i < batchSize; i += 1) { @@ -283,18 +244,25 @@ export class SubagentLaunchQueue { ); } while (queued.length > 0) { - const task = dequeue(); + const task = queued.shift(); if (task === undefined) break; - results[resultIndex(task)] = failedQueuedResult( + results[resultIndex(task)] = { task, - 'No running subagents remained to open queue slots after rate-limited launches.', - ); + status: 'failed', + error: 'No running subagents remained to open queue slots after rate-limited launches.', + }; completedResults += 1; } break; } - const attempt = await nextSettledAttempt(active, options.signal); + const settled = active.find((attempt) => attempt.state.settled); + const attempt = + settled ?? + (await raceWithSignal( + Promise.race(active.map((candidate) => candidate.promise.then(() => candidate))), + options.signal, + )); await processAttempt(attempt); await launchQueuedUpToSlotLimit(); } @@ -304,162 +272,21 @@ export class SubagentLaunchQueue { for (const task of tasks) { const index = resultIndex(task); if (results[index] !== undefined) continue; - results[index] = failedQueuedResult(task, message); + results[index] = { task, status: 'failed', error: message }; } } return results.map((result, index) => { if (result !== undefined) return result; - return failedQueuedResult(tasks[index]!, 'Subagent stopped before it could finish.'); + return { + task: tasks[index]!, + status: 'failed', + error: 'Subagent stopped before it could finish.', + }; }); } } -function failedQueuedResult( - task: QueuedSubagentTask, - error: string, -): QueuedSubagentRunResult { - return { - task, - profileName: task.profileName, - status: 'failed', - error, - }; -} - -type RampEvent = - | { - readonly kind: 'readiness'; - readonly attempt: QueuedSubagentAttempt; - readonly outcome: QueuedSubagentReadinessOutcome; - } - | { - readonly kind: 'rate_limited'; - readonly attempt: QueuedSubagentAttempt; - }; - -async function nextRampEvent( - batch: ReadonlyArray>, - active: ReadonlyArray>, - signal: AbortSignal, -): Promise> { - const ready = batch.find((attempt) => !attempt.ready && attempt.readinessSettled); - if (ready !== undefined) { - return { kind: 'readiness', attempt: ready, outcome: await ready.readiness }; - } - signal.throwIfAborted(); - const readiness = batch - .filter((attempt) => !attempt.ready) - .map((attempt) => - attempt.readiness.then((outcome): RampEvent => ({ kind: 'readiness', attempt, outcome })), - ); - const rateLimited = active.map((attempt) => - attempt.rateLimit.then((rateLimitedAttempt): RampEvent => ({ - kind: 'rate_limited', - attempt: rateLimitedAttempt, - })), - ); - return await raceWithSignal([...readiness, ...rateLimited], signal); -} - -async function nextSettledAttempt( - active: ReadonlyArray>, - signal: AbortSignal, -): Promise> { - const settled = active.find((attempt) => attempt.settled); - if (settled !== undefined) return settled; - signal.throwIfAborted(); - return new Promise((resolve, reject) => { - const cleanup = () => { - signal.removeEventListener('abort', onAbort); - }; - const onAbort = () => { - cleanup(); - reject(signal.reason instanceof Error ? signal.reason : abortError()); - }; - signal.addEventListener('abort', onAbort, { once: true }); - for (const attempt of active) { - void attempt.promise.then( - () => { - cleanup(); - resolve(attempt); - }, - (error) => { - cleanup(); - reject(error); - }, - ); - } - }); -} - -function raceWithSignal(promises: Array>, signal: AbortSignal): Promise { - signal.throwIfAborted(); - return new Promise((resolve, reject) => { - const cleanup = () => { - signal.removeEventListener('abort', onAbort); - }; - const onAbort = () => { - cleanup(); - reject(signal.reason instanceof Error ? signal.reason : abortError()); - }; - signal.addEventListener('abort', onAbort, { once: true }); - for (const promise of promises) { - void promise.then( - (value) => { - cleanup(); - resolve(value); - }, - (error) => { - cleanup(); - reject(error); - }, - ); - } - }); -} - -function sleepWithSignal(ms: number, signal: AbortSignal): Promise { - signal.throwIfAborted(); - return new Promise((resolve, reject) => { - let timeout: ReturnType | undefined = setTimeout(() => { - timeout = undefined; - signal.removeEventListener('abort', onAbort); - resolve(); - }, ms); - const onAbort = () => { - if (timeout !== undefined) clearTimeout(timeout); - timeout = undefined; - signal.removeEventListener('abort', onAbort); - reject(signal.reason instanceof Error ? signal.reason : abortError()); - }; - signal.addEventListener('abort', onAbort, { once: true }); - }); -} - -function deferred(): { - readonly promise: Promise; - readonly resolve: (value: T) => void; - readonly reject: (reason: unknown) => void; -} { - let settled = false; - let resolve!: (value: T) => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((innerResolve, innerReject) => { - resolve = (value) => { - if (settled) return; - settled = true; - innerResolve(value); - }; - reject = (reason) => { - if (settled) return; - settled = true; - innerReject(reason); - }; - }); - return { promise, resolve, reject }; -} - export function totalTimeoutMessage(timeoutMs: number | undefined): string { return timeoutMs === undefined ? 'Subagent batch total timeout elapsed.' @@ -470,31 +297,6 @@ export function formatTimeoutMs(timeoutMs: number): string { return `${String(timeoutMs / 1000)}s`; } -export function formatQueuedSubagentError( - error: unknown, - signal: AbortSignal, - timeouts: { - readonly subagentTimedOut: () => boolean; - readonly subagentTimeoutMs?: number; - readonly totalTimedOut: () => boolean; - readonly totalTimeoutMs?: number; - }, -): string { - if (timeouts.subagentTimedOut() && timeouts.subagentTimeoutMs !== undefined) { - return `Subagent timed out after ${formatTimeoutMs(timeouts.subagentTimeoutMs)}.`; - } - if (timeouts.totalTimedOut() && timeouts.totalTimeoutMs !== undefined) { - return totalTimeoutMessage(timeouts.totalTimeoutMs); - } - if (isUserCancellation(signal.reason)) { - return 'The user manually interrupted this subagent batch.'; - } - if (isAbortError(error)) { - return 'The subagent was stopped before it finished.'; - } - return errorMessage(error); -} - export function isRateLimit429Error(error: unknown): boolean { const message = errorMessage(error); if (message.includes(RATE_LIMIT_429_MESSAGE)) return true; diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index fd8a62a1d..2401ad0b6 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -224,7 +224,7 @@ function toSwarmRunResult( return { spec: result.task.data, agentId: result.agentId, - profileName: result.profileName, + profileName: result.task.profileName, description: result.task.description, status: result.status, result: result.result, diff --git a/packages/agent-core/src/utils/abort.ts b/packages/agent-core/src/utils/abort.ts index ba5f8770f..d657f0c02 100644 --- a/packages/agent-core/src/utils/abort.ts +++ b/packages/agent-core/src/utils/abort.ts @@ -45,6 +45,19 @@ export function abortable(promise: Promise, signal: AbortSignal): Promise< }); } +export function raceWithSignal(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortReason(signal)); + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(abortReason(signal)); + }; + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} + export function linkAbortSignal(source: AbortSignal, target: AbortController): () => void { const onAbort = () => { target.abort(source.reason); @@ -59,6 +72,10 @@ export function linkAbortSignal(source: AbortSignal, target: AbortController): ( }; } +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : abortError(); +} + export interface DeadlineAbortSignal { readonly signal: AbortSignal; readonly timedOut: () => boolean; diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 1d6267b8a..94442344b 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; +import { createControlledPromise } from '@antfu/utils'; import { testKaos } from '../fixtures/test-kaos'; import type { ToolCall } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -44,20 +45,18 @@ describe('SessionSubagentHost', () => { it('runQueued launches the next batch after every current batch member emits output', async () => { const host = new SessionSubagentHost({} as Session, 'main'); const launches: Array< - ReturnType> & { readonly ready: () => void } + ReturnType> & { readonly ready: () => void } > = []; const spawn = vi.spyOn(host, 'spawn').mockImplementation((options) => { - const profileName = typeof options === 'string' ? options : options.profileName; - const completion = { - ...deferred<{ result: string }>(), - ready: typeof options === 'string' ? () => {} : options.onFirstOutput ?? (() => {}), - }; + const completion = Object.assign(createControlledPromise<{ result: string }>(), { + ready: options.onFirstOutput ?? (() => {}), + }); launches.push(completion); return Promise.resolve({ agentId: `agent-${String(launches.length)}`, - profileName, + profileName: options.profileName, resumed: false, - completion: completion.promise, + completion, } satisfies SubagentHandle); }); @@ -116,30 +115,24 @@ describe('SessionSubagentHost', () => { const controller = new AbortController(); const host = new SessionSubagentHost({} as Session, 'main'); const launches: Array< - ReturnType> & { + ReturnType> & { readonly prompt: string; readonly ready: () => void; } > = []; const spawn = vi .spyOn(host, 'spawn') - .mockImplementation((options, legacyOptions?: { readonly prompt: string }) => { - const profileName = typeof options === 'string' ? options : options.profileName; - const prompt = typeof options === 'string' ? legacyOptions?.prompt : options.prompt; - if (prompt === undefined) { - throw new Error('mocked subagent prompt is required'); - } - const completion = { - ...deferred<{ result: string }>(), - prompt, - ready: typeof options === 'string' ? () => {} : options.onFirstOutput ?? (() => {}), - }; + .mockImplementation((options) => { + const completion = Object.assign(createControlledPromise<{ result: string }>(), { + prompt: options.prompt, + ready: options.onFirstOutput ?? (() => {}), + }); launches.push(completion); return Promise.resolve({ agentId: `agent-${String(launches.length)}`, - profileName, + profileName: options.profileName, resumed: false, - completion: completion.promise, + completion, } satisfies SubagentHandle); }); @@ -193,10 +186,9 @@ describe('SessionSubagentHost', () => { it('runQueued reports an error when every initial launch hits 429', async () => { const host = new SessionSubagentHost({} as Session, 'main'); vi.spyOn(host, 'spawn').mockImplementation((options) => { - const profileName = typeof options === 'string' ? options : options.profileName; return Promise.resolve({ agentId: 'agent-rate-limited', - profileName, + profileName: options.profileName, resumed: false, completion: Promise.resolve().then(() => { throw new Error(rateLimit429Message); @@ -237,7 +229,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('coder', { + const handle = await host.spawn({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Implement the fix', description: 'Fix bug', @@ -289,7 +282,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('coder', { + const handle = await host.spawn({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Implement the fix', description: 'Fix bug', @@ -328,7 +322,8 @@ describe('SessionSubagentHost', () => { const host = new SessionSubagentHost(session, 'main'); const onFirstOutput = vi.fn(); - const handle = await host.spawn('coder', { + const handle = await host.spawn({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Implement the fix', description: 'Fix bug', @@ -366,7 +361,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('explore', { + const handle = await host.spawn({ + profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Find the cause', description: 'Find cause', @@ -450,7 +446,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('coder', { + const handle = await host.spawn({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Use the available lookup tool', description: 'Use lookup', @@ -498,7 +495,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('coder', { + const handle = await host.spawn({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Implement the fix', description: 'Fix bug', @@ -541,7 +539,8 @@ describe('SessionSubagentHost', () => { ); await expect( - host.spawn('missing', { + host.spawn({ + profileName: 'missing', parentToolCallId: 'call_agent', prompt: 'Find the cause', description: 'Find cause', @@ -563,7 +562,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('explore', { + const handle = await host.spawn({ + profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Keep working', description: 'Long task', @@ -605,7 +605,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('explore', { + const handle = await host.spawn({ + profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Keep working', description: 'Long task', @@ -637,7 +638,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('explore', { + const handle = await host.spawn({ + profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Keep working', description: 'Long task', @@ -668,7 +670,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('explore', { + const handle = await host.spawn({ + profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Keep working', description: 'Long task', @@ -700,7 +703,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('explore', { + const handle = await host.spawn({ + profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Keep working', description: 'Long task', @@ -745,7 +749,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('coder', { + const handle = await host.spawn({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Investigate', description: 'Investigate', @@ -777,7 +782,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('coder', { + const handle = await host.spawn({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Investigate', description: 'Investigate', @@ -821,7 +827,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('coder', { + const handle = await host.spawn({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Investigate', description: 'Investigate', @@ -848,7 +855,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('explore', { + const handle = await host.spawn({ + profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Find the cause', description: 'Find cause', @@ -880,7 +888,8 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const handle = await host.spawn('coder', { + const handle = await host.spawn({ + profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Implement the fix', description: 'Fix bug', @@ -1395,16 +1404,6 @@ function stat(kind: 'dir' | 'file') { }; } -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve; - reject = innerReject; - }); - return { promise, resolve, reject }; -} - async function flushPromises(count = 2): Promise { for (let i = 0; i < count; i += 1) { await Promise.resolve(); diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index def24e68a..4c6423348 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -304,7 +304,6 @@ describe('current builtin collaboration tools', () => { runInBackground: false, }, agentId: 'agent-explore-1', - profileName: 'explore', status: 'completed', result: 'explore result a', }, @@ -318,7 +317,6 @@ describe('current builtin collaboration tools', () => { runInBackground: false, }, agentId: 'agent-explore-2', - profileName: 'explore', status: 'completed', result: 'explore result b', }, From 8bc6b5fcf00077219ada16a2595d70b16277253d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 13:13:02 +0800 Subject: [PATCH 08/72] update --- .../agent-core/src/session/subagent-host.ts | 26 +- .../src/session/subagent-launch-queue.ts | 363 ++++++++---------- 2 files changed, 171 insertions(+), 218 deletions(-) diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 4db8a8560..4af4b309d 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -237,18 +237,15 @@ export class SessionSubagentHost { }); const completion = await handle.completion; return { - kind: 'result', - result: { - task, - agentId: handle.agentId, - status: 'completed', - result: completion.result, - usage: completion.usage, - }, + task, + agentId: handle.agentId, + status: 'completed', + result: completion.result, + usage: completion.usage, }; } catch (error) { if (isRateLimit429Error(error)) { - return { kind: 'rate_limited', task }; + return 'rate_limited'; } if (handle === undefined) { throw error; @@ -266,13 +263,10 @@ export class SessionSubagentHost { message = error instanceof Error ? error.message : String(error); } return { - kind: 'result', - result: { - task, - agentId: handle.agentId, - status: 'failed', - error: message, - }, + task, + agentId: handle.agentId, + status: 'failed', + error: message, }; } finally { subagentDeadline?.clear(); diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index 4f7a259a2..dc7b7634d 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -2,10 +2,7 @@ import { createControlledPromise, sleep } from '@antfu/utils'; import type { TokenUsage } from '@moonshot-ai/kosong'; import type { PromptOrigin } from '../agent/context'; -import { - createDeadlineAbortSignal, - raceWithSignal, -} from '../utils/abort'; +import { createDeadlineAbortSignal, raceWithSignal } from '../utils/abort'; const SUBAGENT_LAUNCH_BATCH_SIZE = 10; const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; @@ -40,24 +37,16 @@ export type QueuedSubagentRunResult = { readonly error?: string; }; -export type QueuedSubagentAttemptOutcome = - | { - readonly kind: 'rate_limited'; - readonly task: QueuedSubagentTask; - } - | { - readonly kind: 'result'; - readonly result: QueuedSubagentRunResult; - }; +export type QueuedSubagentAttemptOutcome = 'rate_limited' | QueuedSubagentRunResult; type QueuedSubagentAttempt = { - readonly task: QueuedSubagentTask; + readonly index: number; readonly promise: Promise>; readonly readiness: Promise; readonly state: { - settled: boolean; ready: boolean; }; + settled: boolean; }; type SubagentLaunchQueueHost = { @@ -74,216 +63,186 @@ export class SubagentLaunchQueue { async run( tasks: readonly QueuedSubagentTask[], - options: QueuedSubagentRunOptions, + runOptions: QueuedSubagentRunOptions, ): Promise>> { let totalDeadline: ReturnType | undefined; try { totalDeadline = - options.totalTimeoutMs === undefined + runOptions.totalTimeoutMs === undefined ? undefined - : createDeadlineAbortSignal(options.signal, options.totalTimeoutMs); - return await this.runWithSignal( - tasks, - { - signal: totalDeadline?.signal ?? options.signal, - timeoutMs: options.timeoutMs, - totalTimeoutMs: options.totalTimeoutMs, - }, - () => totalDeadline?.timedOut() === true, - ); - } finally { - totalDeadline?.clear(); - } - } - - private async runWithSignal( - tasks: readonly QueuedSubagentTask[], - options: QueuedSubagentRunOptions, - totalTimedOut: () => boolean, - ): Promise>> { - const pending = [...tasks]; - const queued: Array> = []; - const active: Array> = []; - const results: Array | undefined> = Array.from({ - length: tasks.length, - }); - const taskIndexes = new Map(tasks.map((task, index) => [task, index])); - let completedResults = 0; - let launchedAttempts = 0; - let slotLimit: number | undefined; - - const resultIndex = (task: QueuedSubagentTask): number => { - const index = taskIndexes.get(task); - if (index === undefined) { - throw new Error('Queued subagent task was not registered'); - } - return index; - }; + : createDeadlineAbortSignal(runOptions.signal, runOptions.totalTimeoutMs); + const options: QueuedSubagentRunOptions = { + signal: totalDeadline?.signal ?? runOptions.signal, + timeoutMs: runOptions.timeoutMs, + totalTimeoutMs: runOptions.totalTimeoutMs, + }; + const totalTimedOut = (): boolean => totalDeadline?.timedOut() === true; - const enqueue = (task: QueuedSubagentTask): void => { - if (results[resultIndex(task)] !== undefined || queued.includes(task)) return; - const index = resultIndex(task); - const insertAt = queued.findIndex((queuedTask) => resultIndex(queuedTask) > index); - if (insertAt === -1) { - queued.push(task); - } else { - queued.splice(insertAt, 0, task); - } - }; + const queued = tasks.map((_, index) => index); + const active: Array> = []; + const results: Array | undefined> = Array.from({ + length: tasks.length, + }); + let completedResults = 0; + let launchedAttempts = 0; + let slotLimit: number | undefined; - const launch = (task: QueuedSubagentTask): QueuedSubagentAttempt => { - const readiness = createControlledPromise(); - const state = { - settled: false, - ready: false, + const enqueue = (index: number): void => { + if (results[index] !== undefined || queued.includes(index)) return; + const insertAt = queued.findIndex((queuedIndex) => queuedIndex > index); + queued.splice(insertAt === -1 ? queued.length : insertAt, 0, index); }; - const markReady = (): void => { - if (state.ready) return; - state.ready = true; - readiness.resolve(); + + const fail = (index: number, error: string): void => { + if (results[index] !== undefined) return; + results[index] = { task: tasks[index]!, status: 'failed', error }; + completedResults += 1; }; - const promise = this.host.runQueuedTaskAttempt(task, options, totalTimedOut, markReady); - const attempt: QueuedSubagentAttempt = { - task, - promise, - readiness, - state, + + const launch = (index: number): QueuedSubagentAttempt => { + const readiness = createControlledPromise(); + const task = tasks[index]!; + const state = { ready: false }; + const markReady = (): void => { + if (state.ready) return; + state.ready = true; + readiness.resolve(); + }; + const promise = this.host.runQueuedTaskAttempt(task, options, totalTimedOut, markReady); + const attempt: QueuedSubagentAttempt = { + index, + promise, + readiness, + state, + settled: false, + }; + launchedAttempts += 1; + void promise.then( + () => { + attempt.settled = true; + markReady(); + }, + () => { + attempt.settled = true; + markReady(); + }, + ); + active.push(attempt); + return attempt; }; - launchedAttempts += 1; - void promise.then( - () => { - state.settled = true; - markReady(); - }, - () => { - state.settled = true; - markReady(); - }, - ); - active.push(attempt); - return attempt; - }; - const processAttempt = async (attempt: QueuedSubagentAttempt): Promise => { - const activeIndex = active.indexOf(attempt); - if (activeIndex !== -1) active.splice(activeIndex, 1); - const outcome = await attempt.promise; - if (outcome.kind === 'rate_limited') { - slotLimit ??= Math.max(0, launchedAttempts - 2); - enqueue(outcome.task); - return false; - } - results[resultIndex(outcome.result.task)] = outcome.result; - completedResults += 1; - return true; - }; + const processAttempt = async (attempt: QueuedSubagentAttempt): Promise => { + const activeIndex = active.indexOf(attempt); + if (activeIndex !== -1) active.splice(activeIndex, 1); + const outcome = await attempt.promise; + if (outcome === 'rate_limited') { + slotLimit ??= Math.max(0, launchedAttempts - 2); + enqueue(attempt.index); + return false; + } + results[attempt.index] = outcome; + completedResults += 1; + return true; + }; - const processSettledAttempts = async (): Promise => { - while (true) { - const settled = active.find((attempt) => attempt.state.settled); - if (settled === undefined) return true; - if (!(await processAttempt(settled))) return false; - } - }; + const processSettledAttempts = async (): Promise => { + while (true) { + const settled = active.find((attempt) => attempt.settled); + if (settled === undefined) return true; + if (!(await processAttempt(settled))) return false; + } + }; - const waitForRampBatch = async ( - batch: readonly QueuedSubagentAttempt[], - ): Promise => { - while (batch.some((attempt) => !attempt.state.ready)) { - const readiness = batch - .filter((attempt) => !attempt.state.ready) - .map((attempt) => attempt.readiness.then(() => undefined)); - const settled = active.map((attempt) => attempt.promise.then(() => undefined)); - options.signal.throwIfAborted(); - await raceWithSignal(Promise.race([...readiness, ...settled]), options.signal); - if (!(await processSettledAttempts())) return false; - } - return await processSettledAttempts(); - }; + const waitForRampBatch = async ( + batch: readonly QueuedSubagentAttempt[], + ): Promise => { + while (batch.some((attempt) => !attempt.state.ready)) { + const readiness = batch + .filter((attempt) => !attempt.state.ready) + .map((attempt) => attempt.readiness.then(() => undefined)); + const settled = active.map((attempt) => attempt.promise.then(() => undefined)); + options.signal.throwIfAborted(); + await raceWithSignal(Promise.race([...readiness, ...settled]), options.signal); + if (!(await processSettledAttempts())) return false; + } + return await processSettledAttempts(); + }; - const launchQueuedUpToSlotLimit = async (): Promise => { - if (slotLimit === undefined) return; - if (active.length === 0 && completedResults === 0) return; - while (queued.length > 0 && active.length < slotLimit) { - await raceWithSignal(sleep(SUBAGENT_QUEUE_LAUNCH_DELAY_MS), options.signal); - if (active.length >= slotLimit) return; - const task = queued.shift(); - if (task !== undefined) launch(task); - } - }; + const launchQueuedUpToSlotLimit = async (): Promise => { + if (slotLimit === undefined || (active.length === 0 && completedResults === 0)) return; + while (queued.length > 0 && active.length < slotLimit) { + await raceWithSignal(sleep(SUBAGENT_QUEUE_LAUNCH_DELAY_MS), options.signal); + if (active.length < slotLimit) launch(queued.shift()!); + } + }; - try { - while (pending.length > 0 && slotLimit === undefined) { + const launchRampBatch = (): Array> => { const batch: Array> = []; - const batchSize = Math.min(SUBAGENT_LAUNCH_BATCH_SIZE, pending.length); - for (let i = 0; i < batchSize; i += 1) { - const task = pending.shift(); - if (task === undefined) break; - batch.push(launch(task)); + while (batch.length < SUBAGENT_LAUNCH_BATCH_SIZE && queued.length > 0) { + batch.push(launch(queued.shift()!)); } - if (pending.length === 0) break; - const rampCanContinue = await waitForRampBatch(batch); - if (!rampCanContinue) break; - } - - for (const task of pending) { - enqueue(task); - } - pending.length = 0; - if (active.length > 0 || completedResults > 0) { - await launchQueuedUpToSlotLimit(); - } + return batch; + }; - while (completedResults < tasks.length) { - options.signal.throwIfAborted(); - if (active.length === 0) { + try { + while (queued.length > 0) { + if (slotLimit !== undefined) break; + const batch = launchRampBatch(); if (queued.length === 0) break; - if (completedResults === 0) { - throw new Error( - 'Could not start any subagents because every launch attempt was rate limited.', - ); - } - while (queued.length > 0) { - const task = queued.shift(); - if (task === undefined) break; - results[resultIndex(task)] = { - task, - status: 'failed', - error: 'No running subagents remained to open queue slots after rate-limited launches.', - }; - completedResults += 1; - } - break; + if (!(await waitForRampBatch(batch))) break; } - const settled = active.find((attempt) => attempt.state.settled); - const attempt = - settled ?? - (await raceWithSignal( - Promise.race(active.map((candidate) => candidate.promise.then(() => candidate))), - options.signal, - )); - await processAttempt(attempt); - await launchQueuedUpToSlotLimit(); - } - } catch (error) { - if (!totalTimedOut()) throw error; - const message = totalTimeoutMessage(options.totalTimeoutMs); - for (const task of tasks) { - const index = resultIndex(task); - if (results[index] !== undefined) continue; - results[index] = { task, status: 'failed', error: message }; + if (active.length > 0 || completedResults > 0) { + await launchQueuedUpToSlotLimit(); + } + + while (completedResults < tasks.length) { + options.signal.throwIfAborted(); + if (active.length === 0) { + if (queued.length === 0) break; + if (completedResults === 0) { + throw new Error( + 'Could not start any subagents because every launch attempt was rate limited.', + ); + } + while (queued.length > 0) { + fail( + queued.shift()!, + 'No running subagents remained to open queue slots after rate-limited launches.', + ); + } + break; + } + + const settled = active.find((attempt) => attempt.settled); + const attempt = + settled ?? + (await raceWithSignal( + Promise.race(active.map((candidate) => candidate.promise.then(() => candidate))), + options.signal, + )); + await processAttempt(attempt); + await launchQueuedUpToSlotLimit(); + } + } catch (error) { + if (!totalTimedOut()) throw error; + const message = totalTimeoutMessage(options.totalTimeoutMs); + for (const [index, task] of tasks.entries()) { + results[index] ??= { task, status: 'failed', error: message }; + } } - } - return results.map((result, index) => { - if (result !== undefined) return result; - return { - task: tasks[index]!, - status: 'failed', - error: 'Subagent stopped before it could finish.', - }; - }); + return results.map((result, index) => { + if (result !== undefined) return result; + return { + task: tasks[index]!, + status: 'failed', + error: 'Subagent stopped before it could finish.', + }; + }); + } finally { + totalDeadline?.clear(); + } } } From 329238bb3dbf251c1b155c857ac4d4374ee2181c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 13:31:27 +0800 Subject: [PATCH 09/72] upd --- .../agent-core/src/session/subagent-host.ts | 33 +- .../src/session/subagent-launch-queue.ts | 356 ++++++++---------- packages/agent-core/src/utils/abort.ts | 22 +- packages/agent-core/test/utils/abort.test.ts | 48 ++- 4 files changed, 234 insertions(+), 225 deletions(-) diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 4af4b309d..e1a46d499 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -21,8 +21,8 @@ import type { Session } from './index'; import { SubagentLaunchQueue, formatTimeoutMs, - isRateLimit429Error, totalTimeoutMessage, + type QueuedSubagentAttemptOptions, type QueuedSubagentAttemptOutcome, type QueuedSubagentRunOptions, type QueuedSubagentRunResult, @@ -46,6 +46,10 @@ const SUMMARY_CONTINUATION_ATTEMPTS = 1; const HOOK_TEXT_PREVIEW_LENGTH = 500; const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; +const RATE_LIMIT_429_MESSAGE = + "429 We're receiving too many requests at the moment. Please wait a moment and try again."; +const RATE_LIMIT_429_BODY = + "We're receiving too many requests at the moment. Please wait a moment and try again."; type RunSubagentOptions = { readonly parentToolCallId: string; @@ -88,7 +92,9 @@ export class SessionSubagentHost { private readonly ownerAgentId: string, readonly backgroundTaskTimeoutMs?: number | undefined, ) { - this.launchQueue = new SubagentLaunchQueue(this); + this.launchQueue = new SubagentLaunchQueue((task, options) => + this.runQueuedTaskAttempt(task, options), + ); } async spawn(options: SpawnSubagentOptions): Promise { @@ -193,7 +199,7 @@ export class SessionSubagentHost { tasks: readonly QueuedSubagentTask[], options: QueuedSubagentRunOptions, ): Promise>> { - return await this.launchQueue.run(tasks, options); + return this.launchQueue.run(tasks, options); } cancelAll(reason: unknown = userCancellationReason()): void { @@ -216,11 +222,9 @@ export class SessionSubagentHost { return this.session.agents.get(agentId)?.config.profileName; } - async runQueuedTaskAttempt( + private async runQueuedTaskAttempt( task: QueuedSubagentTask, - options: QueuedSubagentRunOptions, - totalTimedOut: () => boolean, - markReady: () => void, + options: QueuedSubagentAttemptOptions, ): Promise> { const subagentDeadline = options.timeoutMs === undefined @@ -233,7 +237,7 @@ export class SessionSubagentHost { handle = await this.spawn({ ...task, signal: runSignal, - onFirstOutput: markReady, + onFirstOutput: options.markReady, }); const completion = await handle.completion; return { @@ -253,7 +257,7 @@ export class SessionSubagentHost { let message: string; if (subagentDeadline?.timedOut() === true && options.timeoutMs !== undefined) { message = `Subagent timed out after ${formatTimeoutMs(options.timeoutMs)}.`; - } else if (totalTimedOut() && options.totalTimeoutMs !== undefined) { + } else if (options.totalTimedOut() && options.totalTimeoutMs !== undefined) { message = totalTimeoutMessage(options.totalTimeoutMs); } else if (isUserCancellation(runSignal.reason)) { message = 'The user manually interrupted this subagent batch.'; @@ -500,3 +504,14 @@ function isFirstOutputEvent(event: AgentEvent): boolean { return false; } } + +function isRateLimit429Error(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(RATE_LIMIT_429_MESSAGE)) return true; + if (!message.includes(RATE_LIMIT_429_BODY)) return false; + if (message.includes('429') || message.includes('provider.rate_limit')) return true; + if (typeof error !== 'object' || error === null) return false; + const statusCode = (error as { readonly statusCode?: unknown }).statusCode; + const status = (error as { readonly status?: unknown }).status; + return statusCode === 429 || status === 429; +} diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index dc7b7634d..f9502e300 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -2,14 +2,10 @@ import { createControlledPromise, sleep } from '@antfu/utils'; import type { TokenUsage } from '@moonshot-ai/kosong'; import type { PromptOrigin } from '../agent/context'; -import { createDeadlineAbortSignal, raceWithSignal } from '../utils/abort'; +import { abortable, createDeadlineAbortSignal } from '../utils/abort'; const SUBAGENT_LAUNCH_BATCH_SIZE = 10; const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; -const RATE_LIMIT_429_MESSAGE = - "429 We're receiving too many requests at the moment. Please wait a moment and try again."; -const RATE_LIMIT_429_BODY = - "We're receiving too many requests at the moment. Please wait a moment and try again."; export type QueuedSubagentTask = { readonly data: T; @@ -41,205 +37,184 @@ export type QueuedSubagentAttemptOutcome = 'rate_limited' | QueuedSubagentRun type QueuedSubagentAttempt = { readonly index: number; - readonly promise: Promise>; + readonly outcome: Promise>; readonly readiness: Promise; - readonly state: { - ready: boolean; - }; + readonly ready: boolean; settled: boolean; }; -type SubagentLaunchQueueHost = { - readonly runQueuedTaskAttempt: ( - task: QueuedSubagentTask, - options: QueuedSubagentRunOptions, - totalTimedOut: () => boolean, - markReady: () => void, - ) => Promise>; +export type QueuedSubagentAttemptOptions = QueuedSubagentRunOptions & { + readonly totalTimedOut: () => boolean; + readonly markReady: () => void; }; +type RunQueuedSubagentAttempt = ( + task: QueuedSubagentTask, + options: QueuedSubagentAttemptOptions, +) => Promise>; + export class SubagentLaunchQueue { - constructor(private readonly host: SubagentLaunchQueueHost) {} + constructor(private readonly runAttempt: RunQueuedSubagentAttempt) {} async run( tasks: readonly QueuedSubagentTask[], runOptions: QueuedSubagentRunOptions, ): Promise>> { - let totalDeadline: ReturnType | undefined; - try { - totalDeadline = - runOptions.totalTimeoutMs === undefined - ? undefined - : createDeadlineAbortSignal(runOptions.signal, runOptions.totalTimeoutMs); - const options: QueuedSubagentRunOptions = { - signal: totalDeadline?.signal ?? runOptions.signal, - timeoutMs: runOptions.timeoutMs, - totalTimeoutMs: runOptions.totalTimeoutMs, - }; - const totalTimedOut = (): boolean => totalDeadline?.timedOut() === true; - - const queued = tasks.map((_, index) => index); - const active: Array> = []; - const results: Array | undefined> = Array.from({ - length: tasks.length, - }); - let completedResults = 0; - let launchedAttempts = 0; - let slotLimit: number | undefined; - - const enqueue = (index: number): void => { - if (results[index] !== undefined || queued.includes(index)) return; - const insertAt = queued.findIndex((queuedIndex) => queuedIndex > index); - queued.splice(insertAt === -1 ? queued.length : insertAt, 0, index); - }; - - const fail = (index: number, error: string): void => { - if (results[index] !== undefined) return; + const totalDeadline = + runOptions.totalTimeoutMs === undefined + ? undefined + : createDeadlineAbortSignal(runOptions.signal, runOptions.totalTimeoutMs); + const options: QueuedSubagentRunOptions = { + signal: totalDeadline?.signal ?? runOptions.signal, + timeoutMs: runOptions.timeoutMs, + totalTimeoutMs: runOptions.totalTimeoutMs, + }; + const totalTimedOut = (): boolean => totalDeadline?.timedOut() === true; + + const queued = tasks.map((_, index) => index); + const active: Array> = []; + const results: Array | undefined> = Array.from({ + length: tasks.length, + }); + let launchedAttempts = 0; + let slotLimit: number | undefined; + const hasResults = (): boolean => results.some((result) => result !== undefined); + + const finish = (fallback: string): Array> => + results.map( + (result, index) => result ?? { task: tasks[index]!, status: 'failed', error: fallback }, + ); + + const enqueue = (index: number): void => { + if (results[index] !== undefined) return; + queued.push(index); + queued.sort((a, b) => a - b); + }; + + const failQueued = (error: string): void => { + for (const index of queued.splice(0)) { results[index] = { task: tasks[index]!, status: 'failed', error }; - completedResults += 1; - }; - - const launch = (index: number): QueuedSubagentAttempt => { - const readiness = createControlledPromise(); - const task = tasks[index]!; - const state = { ready: false }; - const markReady = (): void => { - if (state.ready) return; - state.ready = true; - readiness.resolve(); - }; - const promise = this.host.runQueuedTaskAttempt(task, options, totalTimedOut, markReady); - const attempt: QueuedSubagentAttempt = { - index, - promise, - readiness, - state, - settled: false, - }; - launchedAttempts += 1; - void promise.then( - () => { - attempt.settled = true; - markReady(); - }, - () => { - attempt.settled = true; - markReady(); - }, - ); - active.push(attempt); - return attempt; - }; - - const processAttempt = async (attempt: QueuedSubagentAttempt): Promise => { - const activeIndex = active.indexOf(attempt); - if (activeIndex !== -1) active.splice(activeIndex, 1); - const outcome = await attempt.promise; - if (outcome === 'rate_limited') { - slotLimit ??= Math.max(0, launchedAttempts - 2); - enqueue(attempt.index); - return false; - } - results[attempt.index] = outcome; - completedResults += 1; - return true; + } + }; + + const launch = (index: number): QueuedSubagentAttempt => { + const readiness = createControlledPromise(); + let ready = false; + const markReady = (): void => { + if (ready) return; + ready = true; + readiness.resolve(); }; - - const processSettledAttempts = async (): Promise => { - while (true) { - const settled = active.find((attempt) => attempt.settled); - if (settled === undefined) return true; - if (!(await processAttempt(settled))) return false; - } + const outcome = this.runAttempt(tasks[index]!, { ...options, totalTimedOut, markReady }); + const attempt: QueuedSubagentAttempt = { + index, + outcome, + readiness, + get ready() { + return ready; + }, + settled: false, }; + launchedAttempts += 1; + void outcome.then( + () => { + attempt.settled = true; + markReady(); + }, + () => { + attempt.settled = true; + markReady(); + }, + ); + active.push(attempt); + return attempt; + }; + + const processAttempt = async (attempt: QueuedSubagentAttempt): Promise => { + active.splice(active.indexOf(attempt), 1); + const outcome = await attempt.outcome; + if (outcome === 'rate_limited') { + slotLimit ??= Math.max(0, launchedAttempts - 2); + enqueue(attempt.index); + return false; + } + results[attempt.index] = outcome; + return true; + }; + + const processSettledAttempts = async (): Promise => { + for (let attempt = active.find((item) => item.settled); attempt !== undefined; ) { + if (!(await processAttempt(attempt))) return false; + attempt = active.find((item) => item.settled); + } + return true; + }; + + const nextSettled = (): Promise => + Promise.race(active.map((attempt) => attempt.outcome.then(() => undefined))); + + const nextSettledAttempt = async (): Promise> => { + await nextSettled(); + return active.find((attempt) => attempt.settled)!; + }; + + const waitForRampBatch = async ( + batch: readonly QueuedSubagentAttempt[], + ): Promise => { + const batchReady = Promise.all(batch.map((attempt) => attempt.readiness)); + while (batch.some((attempt) => !attempt.ready)) { + options.signal.throwIfAborted(); + await abortable(Promise.race([batchReady, nextSettled()]), options.signal); + if (!(await processSettledAttempts())) return false; + } + return processSettledAttempts(); + }; + + const launchQueuedUpToSlotLimit = async (): Promise => { + if (slotLimit === undefined || (active.length === 0 && !hasResults())) return; + while (queued.length > 0 && active.length < slotLimit) { + await abortable(sleep(SUBAGENT_QUEUE_LAUNCH_DELAY_MS), options.signal); + if (active.length < slotLimit) launch(queued.shift()!); + } + }; - const waitForRampBatch = async ( - batch: readonly QueuedSubagentAttempt[], - ): Promise => { - while (batch.some((attempt) => !attempt.state.ready)) { - const readiness = batch - .filter((attempt) => !attempt.state.ready) - .map((attempt) => attempt.readiness.then(() => undefined)); - const settled = active.map((attempt) => attempt.promise.then(() => undefined)); - options.signal.throwIfAborted(); - await raceWithSignal(Promise.race([...readiness, ...settled]), options.signal); - if (!(await processSettledAttempts())) return false; - } - return await processSettledAttempts(); - }; + const launchRampBatch = (): Array> => + queued.splice(0, SUBAGENT_LAUNCH_BATCH_SIZE).map(launch); - const launchQueuedUpToSlotLimit = async (): Promise => { - if (slotLimit === undefined || (active.length === 0 && completedResults === 0)) return; - while (queued.length > 0 && active.length < slotLimit) { - await raceWithSignal(sleep(SUBAGENT_QUEUE_LAUNCH_DELAY_MS), options.signal); - if (active.length < slotLimit) launch(queued.shift()!); - } - }; + try { + while (queued.length > 0) { + if (slotLimit !== undefined) break; + const batch = launchRampBatch(); + if (queued.length === 0) break; + if (!(await waitForRampBatch(batch))) break; + } - const launchRampBatch = (): Array> => { - const batch: Array> = []; - while (batch.length < SUBAGENT_LAUNCH_BATCH_SIZE && queued.length > 0) { - batch.push(launch(queued.shift()!)); - } - return batch; - }; + if (active.length > 0 || hasResults()) await launchQueuedUpToSlotLimit(); - try { - while (queued.length > 0) { - if (slotLimit !== undefined) break; - const batch = launchRampBatch(); + while (active.length > 0 || queued.length > 0) { + options.signal.throwIfAborted(); + if (active.length === 0) { if (queued.length === 0) break; - if (!(await waitForRampBatch(batch))) break; - } - - if (active.length > 0 || completedResults > 0) { - await launchQueuedUpToSlotLimit(); - } - - while (completedResults < tasks.length) { - options.signal.throwIfAborted(); - if (active.length === 0) { - if (queued.length === 0) break; - if (completedResults === 0) { - throw new Error( - 'Could not start any subagents because every launch attempt was rate limited.', - ); - } - while (queued.length > 0) { - fail( - queued.shift()!, - 'No running subagents remained to open queue slots after rate-limited launches.', - ); - } - break; + if (!hasResults()) { + throw new Error( + 'Could not start any subagents because every launch attempt was rate limited.', + ); } - - const settled = active.find((attempt) => attempt.settled); - const attempt = - settled ?? - (await raceWithSignal( - Promise.race(active.map((candidate) => candidate.promise.then(() => candidate))), - options.signal, - )); - await processAttempt(attempt); - await launchQueuedUpToSlotLimit(); - } - } catch (error) { - if (!totalTimedOut()) throw error; - const message = totalTimeoutMessage(options.totalTimeoutMs); - for (const [index, task] of tasks.entries()) { - results[index] ??= { task, status: 'failed', error: message }; + failQueued('No running subagents remained to open queue slots after rate-limited launches.'); + break; } + + const settled = active.find((attempt) => attempt.settled); + const attempt = + settled ?? (await abortable(nextSettledAttempt(), options.signal)); + await processAttempt(attempt); + await launchQueuedUpToSlotLimit(); } - return results.map((result, index) => { - if (result !== undefined) return result; - return { - task: tasks[index]!, - status: 'failed', - error: 'Subagent stopped before it could finish.', - }; - }); + return finish('Subagent stopped before it could finish.'); + } catch (error) { + if (!totalTimedOut()) throw error; + return finish(totalTimeoutMessage(options.totalTimeoutMs)); } finally { totalDeadline?.clear(); } @@ -255,24 +230,3 @@ export function totalTimeoutMessage(timeoutMs: number | undefined): string { export function formatTimeoutMs(timeoutMs: number): string { return `${String(timeoutMs / 1000)}s`; } - -export function isRateLimit429Error(error: unknown): boolean { - const message = errorMessage(error); - if (message.includes(RATE_LIMIT_429_MESSAGE)) return true; - if (!message.includes(RATE_LIMIT_429_BODY)) return false; - if (message.includes('429')) return true; - if (message.includes('provider.rate_limit')) return true; - return maybeStatusCode(error) === 429; -} - -function maybeStatusCode(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) return undefined; - const statusCode = (error as { readonly statusCode?: unknown }).statusCode; - if (typeof statusCode === 'number') return statusCode; - const status = (error as { readonly status?: unknown }).status; - return typeof status === 'number' ? status : undefined; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/agent-core/src/utils/abort.ts b/packages/agent-core/src/utils/abort.ts index d657f0c02..6ef6d433b 100644 --- a/packages/agent-core/src/utils/abort.ts +++ b/packages/agent-core/src/utils/abort.ts @@ -33,19 +33,6 @@ export function isUserCancellation(value: unknown): value is UserCancellationErr } export function abortable(promise: Promise, signal: AbortSignal): Promise { - signal.throwIfAborted(); - return new Promise((resolve, reject) => { - const onAbort = () => { - reject(abortError()); - }; - signal.addEventListener('abort', onAbort, { once: true }); - promise.then(resolve, reject).finally(() => { - signal.removeEventListener('abort', onAbort); - }); - }); -} - -export function raceWithSignal(promise: Promise, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(abortReason(signal)); return new Promise((resolve, reject) => { const onAbort = () => { @@ -73,7 +60,14 @@ export function linkAbortSignal(source: AbortSignal, target: AbortController): ( } function abortReason(signal: AbortSignal): Error { - return signal.reason instanceof Error ? signal.reason : abortError(); + 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 { diff --git a/packages/agent-core/test/utils/abort.test.ts b/packages/agent-core/test/utils/abort.test.ts index 51a148344..2f0699fbc 100644 --- a/packages/agent-core/test/utils/abort.test.ts +++ b/packages/agent-core/test/utils/abort.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; import { isAbortError } from '../../src/loop/errors'; -import { abortError, isUserCancellation, userCancellationReason } from '../../src/utils/abort'; +import { + abortError, + abortable, + isUserCancellation, + userCancellationReason, +} from '../../src/utils/abort'; describe('userCancellationReason', () => { it('is recognised as a deliberate user cancellation', () => { @@ -21,3 +26,44 @@ describe('userCancellationReason', () => { expect(isUserCancellation(undefined)).toBe(false); }); }); + +describe('abortable', () => { + it('rejects with the signal reason when already aborted', async () => { + const controller = new AbortController(); + const reason = userCancellationReason(); + controller.abort(reason); + + await expect(abortable(Promise.resolve('ok'), controller.signal)).rejects.toBe(reason); + }); + + it('rejects with the signal reason when aborted while pending', async () => { + const controller = new AbortController(); + const reason = userCancellationReason(); + const pending = new Promise(() => {}); + const result = abortable(pending, controller.signal); + + controller.abort(reason); + + await expect(result).rejects.toBe(reason); + }); + + it('normalizes the default AbortController reason to a generic AbortError', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(abortable(Promise.resolve('ok'), controller.signal)).rejects.toMatchObject({ + name: 'AbortError', + message: 'Aborted', + }); + }); + + it('falls back to a generic AbortError when the signal reason is not an Error', async () => { + const controller = new AbortController(); + controller.abort('cancelled'); + + await expect(abortable(Promise.resolve('ok'), controller.signal)).rejects.toMatchObject({ + name: 'AbortError', + message: 'Aborted', + }); + }); +}); From 9db2cd2320f83ae2536d600eb0c900164373bbe0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 13:52:17 +0800 Subject: [PATCH 10/72] fix --- apps/kimi-code/src/main.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index d60c0fb7e..b46a0719b 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -37,7 +37,6 @@ import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; import { runSwarmDemo } from './tui/swarm-demo'; -import { initProcessName } from './utils/process/proctitle'; export async function handleMainCommand(opts: CLIOptions, version: string): Promise { let validated: ReturnType; From 4d76bf0799a4f339ad1d6f89e3054810c736ac78 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 14:43:24 +0800 Subject: [PATCH 11/72] update --- .../agent-swarm-progress-estimator.ts | 368 ++++++++++++++++++ .../messages/agent-swarm-progress.ts | 212 ++++++++-- apps/kimi-code/src/tui/constant/symbols.ts | 1 + .../tui/controllers/session-event-handler.ts | 7 +- .../messages/agent-swarm-progress.test.ts | 282 +++++++++++++- .../test/tui/kimi-tui-message-flow.test.ts | 34 +- packages/agent-core/src/agent/turn/index.ts | 54 ++- .../agent-core/src/session/subagent-host.ts | 182 +++++++-- .../src/session/subagent-launch-queue.ts | 152 ++++++-- .../test/session/subagent-host.test.ts | 364 +++++++++++++---- 10 files changed, 1443 insertions(+), 213 deletions(-) create mode 100644 apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts 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..8e9f20369 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress-estimator.ts @@ -0,0 +1,368 @@ +const DEFAULT_RATE_WINDOW_MS = 45_000; +const DEFAULT_CATCHUP_TIME_MS = 1_500; +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 COMPLETED_SAMPLE_CONFIDENCE_SCALE = 3; +const MIN_RATE_FACTOR = 0.25; +const HALF_TICK = 0.5; + +export type AgentSwarmProgressEstimatorPhase = + | 'pending' + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface AgentSwarmProgressEstimatorOptions { + readonly rateWindowMs?: number; + readonly catchupTimeMs?: number; + readonly maxCatchupTicksPerSecond?: 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; + terminalAtMs?: number; + terminalKind?: 'completed' | 'failed' | 'cancelled'; + rawTicks: number; + readonly seenToolCallIds: Set; + toolCallTimesMs: number[]; + displayTicks: number; + lastEstimateAtMs?: number; + lastTargetTicks?: number; +} + +interface CompletedSample { + readonly totalMs: number; + readonly rawTicks: number; +} + +interface EstimatePrior { + readonly completedCount: number; + readonly typicalTotalMs: 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 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.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); + state.startedAtMs ??= nowMs; + if (state.rawTicks === 0) { + state.rawTicks = 1; + state.displayTicks = Math.max(state.displayTicks, 1); + } + delete state.terminalAtMs; + delete state.terminalKind; + } + + recordToolCall(input: { + readonly memberKey: string; + readonly toolCallId: string; + readonly nowMs: number; + }): { readonly accepted: boolean; readonly rawTicks: number } { + const state = this.getOrCreateMember(input.memberKey); + state.startedAtMs ??= input.nowMs; + if (state.seenToolCallIds.has(input.toolCallId)) { + return { accepted: false, rawTicks: state.rawTicks }; + } + state.seenToolCallIds.add(input.toolCallId); + state.toolCallTimesMs.push(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 estimatedTotalToolCalls = this.estimateTotalToolCalls(state, prior, input.nowMs); + 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 completedConfidence = confidence(prior.completedCount, COMPLETED_SAMPLE_CONFIDENCE_SCALE); + 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 { + for (const state of this.members.values()) { + if (state.lastTargetTicks !== undefined && state.lastTargetTicks > state.displayTicks + 0.1) { + return true; + } + } + return false; + } + + private markTerminal( + memberKey: string, + nowMs: number, + terminalKind: 'completed' | 'failed' | 'cancelled', + ): void { + const state = this.getOrCreateMember(memberKey); + state.terminalAtMs = nowMs; + state.terminalKind = terminalKind; + state.displayTicks = Math.max(state.displayTicks, state.rawTicks); + delete state.lastTargetTicks; + } + + private getOrCreateMember(memberKey: string): MemberProgressState { + const existing = this.members.get(memberKey); + if (existing !== undefined) return existing; + const state: MemberProgressState = { + rawTicks: 0, + seenToolCallIds: new Set(), + toolCallTimesMs: [], + 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)), + 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 = state.terminalAtMs - state.startedAtMs; + if (totalMs <= 0) continue; + samples.push({ totalMs, rawTicks: state.rawTicks }); + } + return samples; + } + + private estimateTotalToolCalls( + state: MemberProgressState, + prior: EstimatePrior, + nowMs: number, + ): number { + const elapsedMs = Math.max(0, nowMs - (state.startedAtMs ?? nowMs)); + const localRatePerMs = this.estimateLocalRatePerMs(state, elapsedMs, nowMs); + 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; + return Math.max( + estimatedTotalToolCalls, + state.rawTicks / this.unfinishedProgressCap, + 1, + ); + } + + private estimateLocalRatePerMs( + state: MemberProgressState, + elapsedMs: number, + nowMs: number, + ): number { + if (elapsedMs <= 0 || state.toolCallTimesMs.length === 0) return 0; + let decayedToolCalls = 0; + for (const timeMs of state.toolCallTimesMs) { + decayedToolCalls += Math.exp(-(nowMs - 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); + } +} + +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 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 index c9177e91d..939f07381 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -1,10 +1,15 @@ 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 { SUCCESS_MARK } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; const MIN_CELL_WIDTH = 32; -const CELL_GAP = ' '; +const CELL_GAP = ' '; const FRAME_INTERVAL_MS = 80; const BRAILLE_BAR_MIN_WIDTH = 5; const BRAILLE_BAR_MAX_WIDTH = 8; @@ -20,13 +25,7 @@ const FAILED_PLACEHOLDER_NON_RED_FACTOR = 0.25; const ORCHESTRATING_LABEL = 'Orchestrating...'; const QUEUED_LABEL = 'Queued...'; -type AgentSwarmPhase = - | 'pending' - | 'queued' - | 'running' - | 'completed' - | 'failed' - | 'cancelled'; +type AgentSwarmPhase = AgentSwarmProgressEstimatorPhase; interface AgentSwarmMember { readonly id: string; @@ -35,6 +34,8 @@ interface AgentSwarmMember { ticks: number; itemText: string; latestModelText: string; + completedText?: string; + failureText?: string; completedAtMs?: number; failedAtMs?: number; } @@ -49,6 +50,8 @@ interface AgentSwarmSnapshot { interface AgentSwarmResultStatus { readonly index: number; readonly status: 'completed' | 'failed'; + readonly completedText?: string; + readonly failureText?: string; } interface AgentSwarmSummary { @@ -65,8 +68,8 @@ export interface AgentSwarmProgressOptions { } const PHASE_LABELS: Record = { - pending: 'Queued', - queued: 'Queued', + pending: QUEUED_LABEL, + queued: QUEUED_LABEL, running: 'Running', completed: 'Completed', failed: 'Failed', @@ -75,7 +78,7 @@ const PHASE_LABELS: Record = { export class AgentSwarmProgressComponent implements Component { private members: AgentSwarmMember[]; - private readonly seenToolCalls = new Set(); + private readonly progressEstimator = new AgentSwarmProgressEstimator(); private description: string; private readonly colors: ColorPalette; private readonly requestRender: (() => void) | undefined; @@ -140,6 +143,9 @@ export class AgentSwarmProgressComponent implements Component { 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); if (member.phase === 'pending' || member.phase === 'queued') { member.phase = 'running'; } @@ -150,15 +156,19 @@ export class AgentSwarmProgressComponent implements Component { readonly agentId: string; readonly toolCallId: string; }): void { - const key = `${input.agentId}:${input.toolCallId}`; - if (this.seenToolCalls.has(key)) return; - this.seenToolCalls.add(key); const member = this.findMemberByAgentId(input.agentId); if (member === undefined) return; - member.ticks += 1; + const result = this.progressEstimator.recordToolCall({ + memberKey: member.id, + toolCallId: input.toolCallId, + nowMs: Date.now(), + }); + if (!result.accepted) return; + member.ticks = result.rawTicks; if (member.phase === 'pending' || member.phase === 'queued') { member.phase = 'running'; } + this.startAnimationIfNeeded(); } appendModelDelta(input: { @@ -171,6 +181,8 @@ export class AgentSwarmProgressComponent implements Component { -MAX_LATEST_MODEL_CHARS, ); if (member.phase === 'pending' || member.phase === 'queued') { + this.progressEstimator.markStarted(member.id, Date.now()); + member.ticks = Math.max(member.ticks, 1); member.phase = 'running'; } } @@ -182,33 +194,51 @@ export class AgentSwarmProgressComponent implements Component { this.appendModelDelta(input); } - markCompleted(agentId: string): void { + markCompleted(agentId: string, completedText?: string): void { const member = this.findMemberByAgentId(agentId); if (member === undefined || member.phase === 'failed' || member.phase === 'cancelled') return; - if (member.phase !== 'completed') member.completedAtMs = Date.now(); + const nowMs = Date.now(); + if (member.phase !== 'completed') { + this.progressEstimator.markCompleted(member.id, nowMs); + member.completedAtMs = nowMs; + } + const normalizedCompletedText = normalizeFinalOutputText(completedText); + if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; delete member.failedAtMs; + delete member.failureText; member.phase = 'completed'; this.startAnimationIfNeeded(); } - markFailed(agentId: string): void { + markFailed(agentId: string, failureText?: string): void { const member = this.findMemberByAgentId(agentId); if (member === undefined) return; - if (member.phase !== 'failed') member.failedAtMs = Date.now(); + const nowMs = Date.now(); + 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'; delete member.completedAtMs; + delete member.completedText; this.startAnimationIfNeeded(); } markCancelled(agentId: string): void { const member = this.findMemberByAgentId(agentId); if (member === undefined) return; + this.progressEstimator.markCancelled(member.id, Date.now()); member.phase = 'cancelled'; delete member.completedAtMs; + delete member.completedText; delete member.failedAtMs; + delete member.failureText; } markActiveCancelled(): void { + const nowMs = Date.now(); for (const member of this.members) { if ( member.phase === 'completed' || @@ -217,26 +247,42 @@ export class AgentSwarmProgressComponent implements Component { ) { continue; } + this.progressEstimator.markCancelled(member.id, nowMs); member.phase = 'cancelled'; delete member.completedAtMs; + delete member.completedText; delete member.failedAtMs; + delete member.failureText; } this.startAnimationIfNeeded(); } applyResult(output: string): void { + const nowMs = Date.now(); for (const entry of parseAgentSwarmResultStatuses(output)) { this.ensureMemberCount(entry.index); const member = this.members[entry.index - 1]; if (member === undefined) continue; if (entry.status === 'completed' && member.phase !== 'completed') { - member.completedAtMs = Date.now(); + this.progressEstimator.markCompleted(member.id, nowMs); + member.completedAtMs = nowMs; + } + if (entry.status === 'completed') { + const normalizedCompletedText = normalizeFinalOutputText(entry.completedText); + if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; } if (entry.status === 'completed') delete member.failedAtMs; + if (entry.status === 'completed') delete member.failureText; if (entry.status === 'failed' && member.phase !== 'failed') { - member.failedAtMs = Date.now(); + this.progressEstimator.markFailed(member.id, nowMs); + member.failedAtMs = nowMs; + } + if (entry.status === 'failed') { + const normalizedFailureText = normalizeFailureText(entry.failureText); + if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; } if (entry.status === 'failed') delete member.completedAtMs; + if (entry.status === 'failed') delete member.completedText; member.phase = entry.status; } this.startAnimationIfNeeded(); @@ -256,21 +302,23 @@ export class AgentSwarmProgressComponent implements Component { return lines.map((line) => truncateToWidth(line, innerWidth)); } + const nowMs = Date.now(); const snapshots = this.members.map((member): AgentSwarmSnapshot => ({ phase: member.phase, ticks: member.ticks, latestModelText: member.latestModelText, - phaseElapsedMs: terminalPhaseElapsedMs(member), + phaseElapsedMs: terminalPhaseElapsedMs(member, nowMs), })); const summary = summarizeSnapshots(snapshots); const lines = [ this.renderHeader(innerWidth, summary), chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), '', - ...this.renderGrid(innerWidth, snapshots), + ...this.renderGrid(innerWidth, snapshots, nowMs), '', chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), ]; + this.startAnimationIfNeeded(); return lines.map((line) => truncateToWidth(line, innerWidth)); } @@ -284,7 +332,11 @@ export class AgentSwarmProgressComponent implements Component { return truncateToWidth(title + description, width); } - private renderGrid(width: number, snapshots: readonly AgentSwarmSnapshot[]): string[] { + private renderGrid( + width: number, + snapshots: readonly AgentSwarmSnapshot[], + nowMs: number, + ): string[] { const columns = columnsForWidth(width, this.members.length); const gapWidth = visibleWidth(CELL_GAP); const cellWidth = Math.max( @@ -301,17 +353,25 @@ export class AgentSwarmProgressComponent implements Component { const member = this.members[index]; const snapshot = snapshots[index]; if (member === undefined || snapshot === undefined) continue; - cells.push(padAnsi(this.renderCell(member, snapshot, cellWidth), cellWidth)); + cells.push(padAnsi(this.renderCell(member, snapshot, cellWidth, nowMs), cellWidth)); } lines.push(cells.join(CELL_GAP)); } return lines; } - private renderCell(member: AgentSwarmMember, snapshot: AgentSwarmSnapshot, width: number): string { + private renderCell( + member: AgentSwarmMember, + snapshot: AgentSwarmSnapshot, + width: number, + nowMs: number, + ): string { if (snapshot.phase === 'pending') { return renderPendingCell(member, width, this.colors); } + if (snapshot.phase === 'queued' && snapshot.ticks <= 0) { + return renderQueuedCell(member, width, this.colors); + } const fixedWidth = member.id.length + 1 + 2 + 1 + MIN_LABEL_WIDTH; const availableForBar = width - fixedWidth - 2; @@ -319,9 +379,15 @@ export class AgentSwarmProgressComponent implements Component { availableForBar >= BRAILLE_BAR_MIN_WIDTH ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) : Math.max(1, availableForBar); + const estimate = this.progressEstimator.estimate({ + memberKey: member.id, + phase: snapshot.phase, + capacityTicks: barWidth * BRAILLE_LEVELS.length, + nowMs, + }); const id = chalk.hex(this.colors.textDim)(member.id); const bar = brailleBar( - snapshot.ticks, + estimate.displayTicks, snapshot.phase, barWidth, this.colors, @@ -360,10 +426,16 @@ export class AgentSwarmProgressComponent implements Component { 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 { @@ -391,17 +463,21 @@ export class AgentSwarmProgressComponent implements Component { private hasAnimatedMembers(): boolean { const now = Date.now(); - return this.members.some((member) => { - return ( - member.phase === 'completed' && - member.completedAtMs !== undefined && - now - member.completedAtMs < COMPLETE_FILL_MS - ) || ( - member.phase === 'failed' && - member.failedAtMs !== undefined && - now - member.failedAtMs < COMPLETE_FILL_MS - ); - }); + 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 + ), + ) + ); } } @@ -415,13 +491,13 @@ function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[ })); } -function terminalPhaseElapsedMs(member: AgentSwarmMember): number { +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, Date.now() - startedAtMs); + return startedAtMs === undefined ? 0 : Math.max(0, nowMs - startedAtMs); } export function agentSwarmItemsFromArgs(args: Record): string[] { @@ -477,11 +553,26 @@ function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] result.push({ index: Number(indexMatch[1]), status: statusMatch[1] as 'completed' | 'failed', + completedText: parseAgentSwarmCompletedText(block), + failureText: parseAgentSwarmFailureText(block), }); } return result; } +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 columnsForWidth(width: number, count: number): number { if (count <= 1) return 1; const gapWidth = visibleWidth(CELL_GAP); @@ -571,9 +662,25 @@ function renderCellLabel( const text = latestLine.length > 0 ? latestLine : itemText; if (text.length > 0) return truncateWithColor(text, width, colors.textDim); } + if (snapshot.phase === 'failed' && member.failureText !== undefined) { + return truncateWithColor(`Failed: ${member.failureText}`, width, colors.error); + } + if (snapshot.phase === 'completed') { + return renderCompletedCellLabel(member.completedText ?? latestLine, width, colors); + } return truncateWithColor(PHASE_LABELS[snapshot.phase], width, phaseColor(snapshot.phase, colors)); } +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 renderPendingCell( member: AgentSwarmMember, width: number, @@ -587,6 +694,17 @@ function renderPendingCell( return prefix + truncateWithColor(label, labelWidth, colors.textDim); } +function renderQueuedCell( + member: AgentSwarmMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.textDim)(member.id); + const prefix = `${id} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + truncateWithColor(QUEUED_LABEL, labelWidth, colors.textDim); +} + function truncateWithColor(text: string, width: number, color: string): string { const colorize = chalk.hex(color); return truncateToWidth(colorize(text), width, colorize('…')); @@ -596,6 +714,18 @@ function collapseWhitespace(text: string): string { return text.replaceAll(/\s+/g, ' ').trim(); } +function normalizeFailureText(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + const normalized = collapseWhitespace(text); + return normalized.length > 0 ? normalized : undefined; +} + +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) { @@ -718,7 +848,7 @@ function accumulatedBrailleBar( ): string { const dotsPerCell = BRAILLE_LEVELS.length; const cycleSize = width * dotsPerCell; - const safeTicks = Math.max(0, ticks); + 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); 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/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 571d733fe..a957263ff 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -279,7 +279,7 @@ export class SessionEventHandler { if (isUserCancelledSubagentError(event.error)) { swarmProgress.markCancelled(event.subagentId); } else { - swarmProgress.markFailed(event.subagentId); + swarmProgress.markFailed(event.subagentId, event.error); } } this.host.setAgentSwarmProgress(swarmProgress); @@ -337,7 +337,6 @@ export class SessionEventHandler { case 'cron.fired': case 'error': case 'warning': - case 'goal.updated': case 'session.meta.updated': case 'skill.activated': case 'goal.updated': @@ -971,7 +970,7 @@ export class SessionEventHandler { } const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); if (swarmProgress !== undefined) { - swarmProgress.markCompleted(event.subagentId); + swarmProgress.markCompleted(event.subagentId, event.resultSummary); this.host.setAgentSwarmProgress(swarmProgress); streamingUI.removeToolComponentIfInactive(event.parentToolCallId); return; @@ -1024,7 +1023,7 @@ export class SessionEventHandler { if (isUserCancelledSubagentError(event.error)) { swarmProgress.markCancelled(event.subagentId); } else { - swarmProgress.markFailed(event.subagentId); + swarmProgress.markFailed(event.subagentId, event.error); } this.host.setAgentSwarmProgress(swarmProgress); streamingUI.removeToolComponentIfInactive(event.parentToolCallId); 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 index d2b10125a..3a036cec0 100644 --- 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 @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { AgentSwarmProgressComponent, @@ -7,12 +7,17 @@ import { agentSwarmPartialItemsCountFromArguments, agentSwarmPartialItemsFromArguments, } 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, ''); } +afterEach(() => { + vi.useRealTimers(); +}); + describe('AgentSwarmProgressComponent', () => { it('renders an orchestrating panel before subagents spawn', () => { const component = new AgentSwarmProgressComponent({ @@ -27,7 +32,7 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('01'); }); - it('renders spawned subagents as queued progress rows', () => { + it('renders spawned subagents as queued rows without empty progress bars', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', colors: darkColors, @@ -38,13 +43,14 @@ describe('AgentSwarmProgressComponent', () => { const output = strip(component.render(100).join('\n')); - expect(output).toContain('001 ['); - expect(output).toContain('002 ['); - expect(output).toContain('Queued'); + 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('advances one step when a subagent tool call starts and marks terminal states', () => { + it('advances from queued when a subagent tool call starts and marks terminal states', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', colors: darkColors, @@ -57,19 +63,134 @@ describe('AgentSwarmProgressComponent', () => { let output = strip(component.render(100).join('\n')); expect(output).toContain('001 ['); expect(output).toContain('Running'); - expect(output).toContain('002 ['); - expect(output).toContain('Queued'); + 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('Completed'); + expect(output).toContain('✓'); + expect(output).not.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).not.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('Failed: Provider request failed Retry budget exhausted'); + }); + + 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([ + 'agent_swarm: failed', + 'description: Review changed files', + 'items: 1', + 'completed: 0', + 'failed: 1', + '', + '[agent 1]', + 'agent_id: agent-1', + 'item: "src/a.ts"', + 'actual_subagent_type: coder', + 'status: failed', + 'description: Review changed files #1 (coder)', + '', + 'subagent error: Agent timed out after 30s.', + ].join('\n')); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('Failed: Agent timed out after 30s.'); + }); + + 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([ + 'agent_swarm: completed', + 'description: Review changed files', + 'items: 1', + 'completed: 1', + 'failed: 0', + '', + '[agent 1]', + 'agent_id: agent-1', + 'item: "src/a.ts"', + 'actual_subagent_type: coder', + 'status: completed', + 'description: Review changed files #1 (coder)', + '', + '[summary]', + '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).not.toContain('Completed'); + }); + + 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.appendAssistantDelta({ + 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).not.toContain('Completed'); + }); + it('shows latest assistant text after the progress bar with ellipsis truncation', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', @@ -91,6 +212,39 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('Working'); }); + 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', @@ -102,13 +256,13 @@ describe('AgentSwarmProgressComponent', () => { description: 'Review changed files #1 (coder)', }); let output = strip(component.render(100).join('\n')); - expect(output).toContain('001 ['); - expect(output).toContain('Queued'); + expect(output).toContain('001 Queued...'); + expect(output).not.toContain('001 ['); component.markInputComplete(); output = strip(component.render(100).join('\n')); - expect(output).toContain('001 ['); - expect(output).toContain('Queued'); + expect(output).toContain('001 Queued...'); + expect(output).not.toContain('001 ['); }); it('creates pending rows from streamed args items', () => { @@ -163,15 +317,16 @@ describe('AgentSwarmProgressComponent', () => { component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); let output = strip(component.render(100).join('\n')); - expect(output).toContain('001 ['); - expect(output).toContain('Queued'); + 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 ['); - expect(output).toContain('002 ['); - expect(output).toContain('Queued'); + expect(output).toContain('001 Queued...'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002 ['); }); it('extracts description and item list from AgentSwarm args', () => { @@ -184,3 +339,94 @@ describe('AgentSwarmProgressComponent', () => { 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('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/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index f05c50aa5..2cd5ff37d 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 @@ -2309,9 +2309,26 @@ command = "vim" activity = stripSgr(renderActivity(driver)); expect(activity).toContain('Agent swarm: Review changed files'); expect(activity).toContain('001 ['); - expect(activity).toContain('Completed'); - expect(activity).toContain('002 ['); - expect(activity).toContain('Queued'); + expect(activity).toContain('✓ Reviewing src/a.ts'); + expect(activity).not.toContain('Completed'); + expect(activity).toContain('002 Queued...'); + expect(activity).not.toContain('002 ['); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.completed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + parentToolCallId: 'call_swarm', + resultSummary: 'Imports are stable', + } as Event, + sendQueued, + ); + + activity = stripSgr(renderActivity(driver)); + expect(activity).toContain('✓ Imports are stable'); + expect(activity).not.toContain('Completed'); const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Using AgentSwarm'); @@ -2372,8 +2389,8 @@ command = "vim" ); activity = stripSgr(renderActivity(driver)); - expect(activity).toContain('001 ['); - expect(activity).toContain('Queued'); + expect(activity).toContain('001 Queued...'); + expect(activity).not.toContain('001 ['); expect(activity).toContain('002 src/b'); driver.sessionEventHandler.handleEvent( @@ -2394,9 +2411,10 @@ command = "vim" ); activity = stripSgr(renderActivity(driver)); - expect(activity).toContain('001 ['); - expect(activity).toContain('002 ['); - expect(activity).toContain('Queued'); + expect(activity).toContain('001 Queued...'); + expect(activity).toContain('002 Queued...'); + expect(activity).not.toContain('001 ['); + expect(activity).not.toContain('002 ['); }); it('shows plan review reject on the plan card without an approval notice', async () => { diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index cf4d905e5..fd633ca2d 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -66,6 +66,7 @@ const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; /** Origin tag for the synthetic "continue" prompt that drives each goal turn. */ const GOAL_CONTINUATION_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'goal_continuation' }; +const TURN_RETRY_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'turn_retry' }; const GOAL_RATE_LIMIT_PAUSE_REASON = 'Paused after provider rate limit'; /** @@ -161,6 +162,31 @@ export class TurnFlow { return turnId; } + retry(origin: PromptOrigin = TURN_RETRY_ORIGIN): number | null { + this.agent.records.logRecord({ + type: 'turn.prompt', + input: [], + origin, + }); + if (this.activeTurn) { + this.agent.emitEvent({ + type: 'error', + ...makeErrorPayload( + 'turn.agent_busy', + `Cannot retry the latest turn while another turn (ID ${this.turnId}) is active`, + { details: { turnId: this.turnId } }, + ), + }); + return null; + } + + const turnId = this.allocateTurnId(); + const controller = new AbortController(); + const promise = this.retryWorker(turnId, origin, controller.signal); + this.activeTurn = { controller, promise }; + return turnId; + } + /** Allocates the next monotonic turn id. */ private allocateTurnId(): number { this.turnId += 1; @@ -300,6 +326,24 @@ export class TurnFlow { } } + private async retryWorker( + turnId: number, + origin: PromptOrigin, + signal: AbortSignal, + ): Promise { + const ownsActiveTurn = (): boolean => + this.activeTurn !== null && + this.activeTurn !== 'resuming' && + this.activeTurn.controller.signal === signal; + try { + return await this.runOneTurn(turnId, null, origin, signal, true); + } finally { + if (ownsActiveTurn()) { + this.activeTurn = null; + } + } + } + /** * Drives an active goal as a sequence of ordinary turns — the autonomous * equivalent of the user repeatedly typing "continue". Each iteration runs one @@ -396,7 +440,7 @@ export class TurnFlow { */ private async runOneTurn( turnId: number, - input: readonly ContentPart[], + input: readonly ContentPart[] | null, origin: PromptOrigin, signal: AbortSignal, standalone: boolean, @@ -411,7 +455,9 @@ export class TurnFlow { this.agent.fullCompaction.resetForTurn(); this.agent.usage.beginTurn(); this.agent.emitEvent({ type: 'turn.started', turnId, origin }); - this.agent.context.appendUserMessage(input, origin); + if (input !== null) { + this.agent.context.appendUserMessage(input, origin); + } const startedAt = Date.now(); let ended: TurnEndedEvent; @@ -421,7 +467,9 @@ export class TurnFlow { // sits just past the turn.ended boundary that consumers watch for. let errorEvent: AgentEvent | undefined; try { - const promptHookEnded = await this.applyUserPromptHook(turnId, input, origin, signal); + const promptHookEnded = input !== null + ? await this.applyUserPromptHook(turnId, input, origin, signal) + : undefined; if (promptHookEnded !== undefined) { ended = promptHookEnded.event; blockedByUserPromptHook = promptHookEnded.blocked; diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 180874aa1..8e2771454 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -78,6 +78,7 @@ type RunSubagentOptions = { readonly origin?: PromptOrigin; readonly signal: AbortSignal; readonly onFirstOutput?: () => void; + readonly suppressRateLimitFailureEvent?: boolean; }; type SpawnSubagentOptions = RunSubagentOptions & { @@ -211,6 +212,49 @@ export class SessionSubagentHost { }; } + async retry(agentId: string, options: RunSubagentOptions): Promise { + options.signal.throwIfAborted(); + + const parent = await this.session.ensureAgentResumed(this.ownerAgentId); + const metadata = this.session.metadata.agents[agentId]; + if (metadata?.type !== 'sub') { + throw new Error(`Agent instance "${agentId}" is not a subagent`); + } + if (metadata.parentAgentId !== this.ownerAgentId) { + throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`); + } + 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 retried concurrently`, + ); + } + + const profileName = child.config.profileName ?? 'subagent'; + + const controller = new AbortController(); + const unlinkAbortSignal = linkAbortSignal(options.signal, controller); + this.activeChildren.set(agentId, { + controller, + runInBackground: options.runInBackground, + }); + + const completion = this.runChildRetry(parent, agentId, child, profileName, { + ...options, + signal: controller.signal, + }).finally(() => { + unlinkAbortSignal(); + this.activeChildren.delete(agentId); + }); + + return { + agentId, + profileName, + resumed: true, + completion, + }; + } + async runQueued( tasks: readonly QueuedSubagentTask[], options: QueuedSubagentRunOptions, @@ -276,11 +320,20 @@ export class SessionSubagentHost { let handle: SubagentHandle | undefined; try { runSignal.throwIfAborted(); - handle = await this.spawn({ - ...task, - signal: runSignal, - onFirstOutput: options.markReady, - }); + handle = + options.retryAgentId === undefined + ? await this.spawn({ + ...task, + signal: runSignal, + onFirstOutput: options.markReady, + suppressRateLimitFailureEvent: true, + }) + : await this.retry(options.retryAgentId, { + ...task, + signal: runSignal, + onFirstOutput: options.markReady, + suppressRateLimitFailureEvent: true, + }); const completion = await handle.completion; return { task, @@ -291,7 +344,7 @@ export class SessionSubagentHost { }; } catch (error) { if (isRateLimit429Error(error)) { - return 'rate_limited'; + return { type: 'rate_limited', agentId: handle?.agentId }; } if (handle === undefined) { throw error; @@ -357,46 +410,93 @@ export class SessionSubagentHost { const origin: PromptOrigin = options.origin ?? { kind: 'system_trigger', name: 'subagent' }; this.emitSubagentStarted(parent, childId, profileName, options); child.turn.prompt([{ type: 'text', text: childPrompt }], origin); - await runChildTurnToCompletion(child, options.signal); + return await this.waitForChildCompletion(parent, childId, child, profileName, options, origin); + } catch (error) { + if (!(options.suppressRateLimitFailureEvent === true && isRateLimit429Error(error))) { + const message = error instanceof Error ? error.message : String(error); + parent.emitEvent({ + type: 'subagent.failed', + subagentId: childId, + parentToolCallId: options.parentToolCallId, + error: message, + }); + } + throw error; + } finally { + unwatchFirstOutput?.(); + } + } - // 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); + private async runChildRetry( + parent: Agent, + childId: string, + child: Agent, + profileName: string, + options: RunSubagentOptions, + ): Promise { + const unwatchFirstOutput = this.watchFirstOutput(child, options.onFirstOutput); + + try { + options.signal.throwIfAborted(); + child.config.update({ modelAlias: parent.config.modelAlias }); + const origin: PromptOrigin = options.origin ?? { kind: 'system_trigger', name: 'subagent' }; + this.emitSubagentStarted(parent, childId, profileName, options); + if (child.turn.retry(origin) === null) { + throw new Error(`Agent instance "${childId}" could not start a retry turn`); } - 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 }; + return await this.waitForChildCompletion(parent, childId, child, profileName, options, origin); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - parent.emitEvent({ - type: 'subagent.failed', - subagentId: childId, - parentToolCallId: options.parentToolCallId, - error: message, - }); + if (!(options.suppressRateLimitFailureEvent === true && isRateLimit429Error(error))) { + const message = error instanceof Error ? error.message : String(error); + parent.emitEvent({ + type: 'subagent.failed', + subagentId: childId, + parentToolCallId: options.parentToolCallId, + error: message, + }); + } throw error; } finally { unwatchFirstOutput?.(); } } + private async waitForChildCompletion( + parent: Agent, + childId: string, + child: Agent, + profileName: string, + options: RunSubagentOptions, + origin: PromptOrigin, + ): 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 }], 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 }; + } + private async configureChild( parent: Agent, child: Agent, @@ -542,8 +642,14 @@ function isFirstOutputEvent(event: AgentEvent): boolean { function isRateLimit429Error(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); if (message.includes(RATE_LIMIT_429_MESSAGE)) return true; - if (!message.includes(RATE_LIMIT_429_BODY)) return false; + if (message.includes('provider.rate_limit')) return true; + if (message.includes('429') && message.toLowerCase().includes('rate limit')) return true; + if (!message.includes(RATE_LIMIT_429_BODY)) return hasRateLimitStatus(error); if (message.includes('429') || message.includes('provider.rate_limit')) return true; + return hasRateLimitStatus(error); +} + +function hasRateLimitStatus(error: unknown): boolean { if (typeof error !== 'object' || error === null) return false; const statusCode = (error as { readonly statusCode?: unknown }).statusCode; const status = (error as { readonly status?: unknown }).status; diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index f9502e300..f8ac7325b 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -6,6 +6,8 @@ import { abortable, createDeadlineAbortSignal } from '../utils/abort'; const SUBAGENT_LAUNCH_BATCH_SIZE = 10; const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; +const RATE_LIMIT_SLOT_REDUCTION_WINDOW_MS = 1000; +const RATE_LIMIT_SLOT_REDUCTION_MAX_PER_WINDOW = 3; export type QueuedSubagentTask = { readonly data: T; @@ -33,10 +35,22 @@ export type QueuedSubagentRunResult = { readonly error?: string; }; -export type QueuedSubagentAttemptOutcome = 'rate_limited' | QueuedSubagentRunResult; +export type QueuedSubagentRateLimitOutcome = { + readonly type: 'rate_limited'; + readonly agentId?: string; +}; -type QueuedSubagentAttempt = { +export type QueuedSubagentAttemptOutcome = + | QueuedSubagentRateLimitOutcome + | QueuedSubagentRunResult; + +type QueuedSubagentPending = { readonly index: number; + readonly agentId?: string; +}; + +type QueuedSubagentAttempt = { + readonly pending: QueuedSubagentPending; readonly outcome: Promise>; readonly readiness: Promise; readonly ready: boolean; @@ -46,6 +60,7 @@ type QueuedSubagentAttempt = { export type QueuedSubagentAttemptOptions = QueuedSubagentRunOptions & { readonly totalTimedOut: () => boolean; readonly markReady: () => void; + readonly retryAgentId?: string; }; type RunQueuedSubagentAttempt = ( @@ -71,43 +86,78 @@ export class SubagentLaunchQueue { }; const totalTimedOut = (): boolean => totalDeadline?.timedOut() === true; - const queued = tasks.map((_, index) => index); + const queued = tasks.map((_, index): QueuedSubagentPending => ({ index })); const active: Array> = []; const results: Array | undefined> = Array.from({ length: tasks.length, }); - let launchedAttempts = 0; let slotLimit: number | undefined; + let rateLimitReductionWindowStartMs: number | undefined; + let rateLimitReductionsInWindow = 0; const hasResults = (): boolean => results.some((result) => result !== undefined); + const hasRetriableQueued = (): boolean => + queued.some((pending) => pending.agentId !== undefined); const finish = (fallback: string): Array> => results.map( (result, index) => result ?? { task: tasks[index]!, status: 'failed', error: fallback }, ); - const enqueue = (index: number): void => { - if (results[index] !== undefined) return; - queued.push(index); - queued.sort((a, b) => a - b); + const requeueRateLimited = (pending: QueuedSubagentPending): void => { + if (results[pending.index] !== undefined) return; + queued.unshift(pending); }; const failQueued = (error: string): void => { - for (const index of queued.splice(0)) { + for (const { index } of queued.splice(0)) { results[index] = { task: tasks[index]!, status: 'failed', error }; } }; - const launch = (index: number): QueuedSubagentAttempt => { + const unreadyActiveCount = (): number => + active.reduce((count, attempt) => count + (attempt.ready ? 0 : 1), 0); + + const reduceSlotsAfterRateLimit = (): void => { + const now = Date.now(); + if ( + rateLimitReductionWindowStartMs === undefined || + now - rateLimitReductionWindowStartMs >= RATE_LIMIT_SLOT_REDUCTION_WINDOW_MS + ) { + rateLimitReductionWindowStartMs = now; + rateLimitReductionsInWindow = 0; + } + + const currentLimit = slotLimit ?? SUBAGENT_LAUNCH_BATCH_SIZE; + if ( + currentLimit <= 1 || + rateLimitReductionsInWindow >= RATE_LIMIT_SLOT_REDUCTION_MAX_PER_WINDOW + ) { + slotLimit = currentLimit; + return; + } + + slotLimit = currentLimit - 1; + rateLimitReductionsInWindow += 1; + }; + + const launch = (pending: QueuedSubagentPending): QueuedSubagentAttempt => { const readiness = createControlledPromise(); let ready = false; const markReady = (): void => { if (ready) return; ready = true; + clearTimeout(readinessTimer); readiness.resolve(); }; - const outcome = this.runAttempt(tasks[index]!, { ...options, totalTimedOut, markReady }); + const readinessTimer = setTimeout(markReady, SUBAGENT_QUEUE_LAUNCH_DELAY_MS); + const outcome = this.runAttempt(tasks[pending.index]!, { + ...options, + totalTimedOut, + markReady, + retryAgentId: pending.agentId, + }); const attempt: QueuedSubagentAttempt = { - index, + pending, outcome, readiness, get ready() { @@ -115,7 +165,6 @@ export class SubagentLaunchQueue { }, settled: false, }; - launchedAttempts += 1; void outcome.then( () => { attempt.settled = true; @@ -133,12 +182,15 @@ export class SubagentLaunchQueue { const processAttempt = async (attempt: QueuedSubagentAttempt): Promise => { active.splice(active.indexOf(attempt), 1); const outcome = await attempt.outcome; - if (outcome === 'rate_limited') { - slotLimit ??= Math.max(0, launchedAttempts - 2); - enqueue(attempt.index); + if (isRateLimitedOutcome(outcome)) { + reduceSlotsAfterRateLimit(); + requeueRateLimited({ + index: attempt.pending.index, + agentId: outcome.agentId ?? attempt.pending.agentId, + }); return false; } - results[attempt.index] = outcome; + results[attempt.pending.index] = outcome; return true; }; @@ -153,6 +205,12 @@ export class SubagentLaunchQueue { const nextSettled = (): Promise => Promise.race(active.map((attempt) => attempt.outcome.then(() => undefined))); + const nextReadiness = (): Promise => { + const unready = active.filter((attempt) => !attempt.ready); + if (unready.length === 0) return Promise.resolve(); + return Promise.race(unready.map((attempt) => attempt.readiness)); + }; + const nextSettledAttempt = async (): Promise> => { await nextSettled(); return active.find((attempt) => attempt.settled)!; @@ -170,12 +228,27 @@ export class SubagentLaunchQueue { return processSettledAttempts(); }; - const launchQueuedUpToSlotLimit = async (): Promise => { - if (slotLimit === undefined || (active.length === 0 && !hasResults())) return; - while (queued.length > 0 && active.length < slotLimit) { - await abortable(sleep(SUBAGENT_QUEUE_LAUNCH_DELAY_MS), options.signal); - if (active.length < slotLimit) launch(queued.shift()!); + const launchQueuedUpToSlotLimit = async (): Promise => { + if (slotLimit === undefined || (!hasResults() && !hasRetriableQueued())) return 0; + let launched = 0; + while (queued.length > 0 && unreadyActiveCount() < slotLimit) { + const delay = sleep(SUBAGENT_QUEUE_LAUNCH_DELAY_MS).then(() => 'delay' as const); + const settled = + active.length === 0 + ? undefined + : nextSettled().then(() => 'settled' as const); + const waitResult = await abortable( + settled === undefined ? delay : Promise.race([delay, settled]), + options.signal, + ); + if (waitResult === 'settled') break; + if (active.some((attempt) => attempt.settled)) break; + if (unreadyActiveCount() < slotLimit) { + launch(queued.shift()!); + launched += 1; + } } + return launched; }; const launchRampBatch = (): Array> => @@ -189,26 +262,43 @@ export class SubagentLaunchQueue { if (!(await waitForRampBatch(batch))) break; } - if (active.length > 0 || hasResults()) await launchQueuedUpToSlotLimit(); - while (active.length > 0 || queued.length > 0) { options.signal.throwIfAborted(); if (active.length === 0) { if (queued.length === 0) break; - if (!hasResults()) { + if (!hasResults() && !hasRetriableQueued()) { throw new Error( 'Could not start any subagents because every launch attempt was rate limited.', ); } + await launchQueuedUpToSlotLimit(); + if (active.length > 0) continue; failQueued('No running subagents remained to open queue slots after rate-limited launches.'); break; } const settled = active.find((attempt) => attempt.settled); - const attempt = - settled ?? (await abortable(nextSettledAttempt(), options.signal)); + if (settled !== undefined) { + await processAttempt(settled); + await launchQueuedUpToSlotLimit(); + continue; + } + + const launched = await launchQueuedUpToSlotLimit(); + if (launched > 0) continue; + + if ( + queued.length > 0 && + slotLimit !== undefined && + unreadyActiveCount() >= slotLimit && + active.some((attempt) => !attempt.ready) + ) { + await abortable(Promise.race([nextSettled(), nextReadiness()]), options.signal); + continue; + } + + const attempt = await abortable(nextSettledAttempt(), options.signal); await processAttempt(attempt); - await launchQueuedUpToSlotLimit(); } return finish('Subagent stopped before it could finish.'); @@ -227,6 +317,12 @@ export function totalTimeoutMessage(timeoutMs: number | undefined): string { : `Subagent batch total timeout after ${formatTimeoutMs(timeoutMs)}.`; } +function isRateLimitedOutcome( + outcome: QueuedSubagentAttemptOutcome, +): outcome is QueuedSubagentRateLimitOutcome { + return 'type' in outcome && outcome.type === 'rate_limited'; +} + export function formatTimeoutMs(timeoutMs: number): string { return `${String(timeoutMs / 1000)}s`; } diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index c69f5d9da..e80338cde 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -4,10 +4,10 @@ import { join } from 'pathe'; import { createControlledPromise } from '@antfu/utils'; import { testKaos } from '../fixtures/test-kaos'; -import type { ToolCall } from '@moonshot-ai/kosong'; +import { APIStatusError, type Message, type ToolCall } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { Agent } from '../../src/agent'; +import type { Agent, AgentOptions } from '../../src/agent'; import { AGENT_WIRE_PROTOCOL_VERSION } from '../../src/agent/records'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; @@ -34,6 +34,7 @@ const signal = new AbortController().signal; const rateLimit429Message = "429 We're receiving too many requests at the moment. Please wait a moment and try again."; const tempDirs: string[] = []; +type GenerateFn = NonNullable; afterEach(async () => { for (const dir of tempDirs.splice(0)) { @@ -42,74 +43,58 @@ afterEach(async () => { }); describe('SessionSubagentHost', () => { - it('runQueued launches the next batch after every current batch member emits output', async () => { - const host = new SessionSubagentHost({} as Session, 'main'); - const launches: Array< - ReturnType> & { readonly ready: () => void } - > = []; - const spawn = vi.spyOn(host, 'spawn').mockImplementation((options) => { - const completion = Object.assign(createControlledPromise<{ result: string }>(), { - ready: options.onFirstOutput ?? (() => {}), + it('runQueued keeps launching batches after the readiness window elapses', async () => { + vi.useFakeTimers(); + try { + const host = new SessionSubagentHost({} as Session, 'main'); + const launches: Array>> = []; + const spawn = vi.spyOn(host, 'spawn').mockImplementation((options) => { + const completion = createControlledPromise<{ result: string }>(); + launches.push(completion); + return Promise.resolve({ + agentId: `agent-${String(launches.length)}`, + profileName: options.profileName, + resumed: false, + completion, + } satisfies SubagentHandle); }); - launches.push(completion); - return Promise.resolve({ - agentId: `agent-${String(launches.length)}`, - profileName: options.profileName, - resumed: false, - completion, - } satisfies SubagentHandle); - }); - const running = host.runQueued( - Array.from({ length: 41 }, (_, index) => queuedTask(index + 1)), - { signal }, - ); + const running = host.runQueued( + Array.from({ length: 41 }, (_, index) => queuedTask(index + 1)), + { signal }, + ); - await flushPromises(); - expect(spawn).toHaveBeenCalledTimes(10); + await vi.advanceTimersByTimeAsync(0); + expect(spawn).toHaveBeenCalledTimes(10); - launches.slice(0, 9).forEach((launch) => { - launch.ready(); - }); - await flushPromises(); - expect(spawn).toHaveBeenCalledTimes(10); + await vi.advanceTimersByTimeAsync(499); + expect(spawn).toHaveBeenCalledTimes(10); - launches[9]!.ready(); - await vi.waitFor(() => { + await vi.advanceTimersByTimeAsync(1); expect(spawn).toHaveBeenCalledTimes(20); - }); - launches.slice(10, 20).forEach((launch) => { - launch.ready(); - }); - await vi.waitFor(() => { + await vi.advanceTimersByTimeAsync(500); expect(spawn).toHaveBeenCalledTimes(30); - }); - launches.slice(20, 30).forEach((launch) => { - launch.ready(); - }); - await vi.waitFor(() => { + await vi.advanceTimersByTimeAsync(500); expect(spawn).toHaveBeenCalledTimes(40); - }); - launches.slice(30, 40).forEach((launch) => { - launch.ready(); - }); - await vi.waitFor(() => { + await vi.advanceTimersByTimeAsync(500); expect(spawn).toHaveBeenCalledTimes(41); - }); - launches.forEach((completion, index) => { - completion.resolve({ result: `result ${String(index + 1)}` }); - }); - const results = await running; + launches.forEach((completion, index) => { + completion.resolve({ result: `result ${String(index + 1)}` }); + }); + const results = await running; - expect(results).toHaveLength(41); - expect(results.every((result) => result.status === 'completed')).toBe(true); + expect(results).toHaveLength(41); + expect(results.every((result) => result.status === 'completed')).toBe(true); + } finally { + vi.useRealTimers(); + } }); - it('runQueued locks slots to launched minus two after the first 429', async () => { + it('runQueued retries the same subagent and decrements slots by one after a 429', async () => { vi.useFakeTimers(); try { const controller = new AbortController(); @@ -135,6 +120,21 @@ describe('SessionSubagentHost', () => { completion, } satisfies SubagentHandle); }); + const retry = vi + .spyOn(host, 'retry') + .mockImplementation((agentId, options) => { + const completion = Object.assign(createControlledPromise<{ result: string }>(), { + prompt: options.prompt, + ready: options.onFirstOutput ?? (() => {}), + }); + launches.push(completion); + return Promise.resolve({ + agentId, + profileName: 'coder', + resumed: true, + completion, + } satisfies SubagentHandle); + }); const running = host.runQueued( Array.from({ length: 21 }, (_, index) => queuedTask(index + 1)), @@ -154,18 +154,13 @@ describe('SessionSubagentHost', () => { launches[14]!.reject(new Error(rateLimit429Message)); await vi.advanceTimersByTimeAsync(0); expect(spawn).toHaveBeenCalledTimes(20); + expect(retry).not.toHaveBeenCalled(); launches[0]!.resolve({ result: 'opened slot 1' }); - await vi.advanceTimersByTimeAsync(500); - expect(spawn).toHaveBeenCalledTimes(20); - - launches[1]!.resolve({ result: 'opened slot 2' }); - await vi.advanceTimersByTimeAsync(499); + await vi.advanceTimersByTimeAsync(1000); expect(spawn).toHaveBeenCalledTimes(20); - - await vi.advanceTimersByTimeAsync(1); - expect(spawn).toHaveBeenCalledTimes(21); - expect(spawn).toHaveBeenLastCalledWith({ + expect(retry).toHaveBeenCalledTimes(1); + expect(retry).toHaveBeenLastCalledWith('agent-15', { data: 15, profileName: 'coder', parentToolCallId: 'call_swarm', @@ -174,7 +169,148 @@ describe('SessionSubagentHost', () => { runInBackground: false, signal: controller.signal, onFirstOutput: expect.any(Function), + suppressRateLimitFailureEvent: true, + }); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('runQueued puts rate-limited subagents at the front of the queue', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const host = new SessionSubagentHost({} as Session, 'main'); + const launches: Array< + ReturnType> & { + readonly ready: () => void; + } + > = []; + vi.spyOn(host, 'spawn').mockImplementation((options) => { + const completion = Object.assign(createControlledPromise<{ result: string }>(), { + ready: options.onFirstOutput ?? (() => {}), + }); + launches.push(completion); + return Promise.resolve({ + agentId: `agent-${String(launches.length)}`, + profileName: options.profileName, + resumed: false, + completion, + } satisfies SubagentHandle); + }); + const retry = vi.spyOn(host, 'retry').mockImplementation((agentId, options) => { + const completion = Object.assign(createControlledPromise<{ result: string }>(), { + ready: options.onFirstOutput ?? (() => {}), + }); + launches.push(completion); + return Promise.resolve({ + agentId, + profileName: 'coder', + resumed: true, + completion, + } satisfies SubagentHandle); + }); + + const running = host.runQueued( + Array.from({ length: 12 }, (_, index) => queuedTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(launches).toHaveLength(10); + launches.slice(0, 10).forEach((launch) => { + launch.ready(); + }); + await vi.advanceTimersByTimeAsync(0); + expect(launches).toHaveLength(12); + + launches[0]!.reject(new Error(rateLimit429Message)); + await vi.advanceTimersByTimeAsync(0); + launches[4]!.reject(new Error(rateLimit429Message)); + await vi.advanceTimersByTimeAsync(0); + expect(retry).not.toHaveBeenCalled(); + + launches[1]!.resolve({ result: 'opened retry slot' }); + await vi.advanceTimersByTimeAsync(500); + expect(retry).toHaveBeenCalledTimes(1); + expect(retry).toHaveBeenLastCalledWith('agent-5', { + data: 5, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review item-5', + description: 'Review #5', + runInBackground: false, + signal: controller.signal, + onFirstOutput: expect.any(Function), + suppressRateLimitFailureEvent: true, + }); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } + }); + + it('runQueued caps 429 slot reductions at three per second', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const host = new SessionSubagentHost({} as Session, 'main'); + const launches: Array< + ReturnType> & { + readonly ready: () => void; + } + > = []; + vi.spyOn(host, 'spawn').mockImplementation((options) => { + const completion = Object.assign(createControlledPromise<{ result: string }>(), { + ready: options.onFirstOutput ?? (() => {}), + }); + launches.push(completion); + return Promise.resolve({ + agentId: `agent-${String(launches.length)}`, + profileName: options.profileName, + resumed: false, + completion, + } satisfies SubagentHandle); }); + const retry = vi.spyOn(host, 'retry').mockImplementation((agentId, options) => { + const completion = Object.assign(createControlledPromise<{ result: string }>(), { + ready: options.onFirstOutput ?? (() => {}), + }); + launches.push(completion); + return Promise.resolve({ + agentId, + profileName: 'coder', + resumed: true, + completion, + } satisfies SubagentHandle); + }); + + const running = host.runQueued( + Array.from({ length: 14 }, (_, index) => queuedTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(launches).toHaveLength(10); + launches.slice(0, 10).forEach((launch) => { + launch.ready(); + }); + + for (const launch of launches.slice(0, 4)) { + launch.reject(new Error(rateLimit429Message)); + await vi.advanceTimersByTimeAsync(0); + } + + expect(retry).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(500); + expect(retry).toHaveBeenCalledTimes(1); controller.abort(); await expect(running).rejects.toThrow(); @@ -185,16 +321,7 @@ describe('SessionSubagentHost', () => { it('runQueued reports an error when every initial launch hits 429', async () => { const host = new SessionSubagentHost({} as Session, 'main'); - vi.spyOn(host, 'spawn').mockImplementation((options) => { - return Promise.resolve({ - agentId: 'agent-rate-limited', - profileName: options.profileName, - resumed: false, - completion: Promise.resolve().then(() => { - throw new Error(rateLimit429Message); - }), - } satisfies SubagentHandle); - }); + vi.spyOn(host, 'spawn').mockRejectedValue(new Error(rateLimit429Message)); await expect( host.runQueued( @@ -997,6 +1124,67 @@ describe('SessionSubagentHost', () => { ); }); + it('retries a rate-limited child turn without appending the original prompt again', async () => { + const parent = testAgent(); + parent.configure(); + parent.newEvents(); + + const summary = + 'Recovered from a provider rate limit by retrying the latest subagent step with the original context intact, then completed the delegated work with a detailed enough summary for the parent to continue confidently. '.repeat( + 2, + ); + const histories: Message[][] = []; + let generateCalls = 0; + const generate: GenerateFn = async ( + _provider, + _systemPrompt, + _tools, + history, + callbacks, + ) => { + histories.push(structuredClone(history)); + generateCalls += 1; + if (generateCalls === 1) { + throw new APIStatusError(429, 'Rate limited', 'req-429'); + } + await callbacks?.onMessagePart?.({ type: 'text', text: summary }); + return textResult(summary); + }; + const child = testAgent({ + generate, + initialConfig: { + providers: {}, + loopControl: { maxRetriesPerStep: 1 }, + }, + }); + child.configure(); + + const session = fakeSession(parent.agent, child.agent); + const host = new SessionSubagentHost(session, 'main'); + + const handle = await host.spawn({ + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Implement the retry-safe change', + description: 'Fix rate-limit retry', + runInBackground: false, + signal, + }); + await expect(handle.completion).rejects.toThrow('provider.rate_limit'); + + const retryHandle = await host.retry(handle.agentId, { + parentToolCallId: 'call_agent', + prompt: 'Implement the retry-safe change', + description: 'Fix rate-limit retry', + runInBackground: false, + signal, + }); + + await expect(retryHandle.completion).resolves.toMatchObject({ result: summary.trim() }); + expect(generateCalls).toBe(2); + expect(userTextMessages(histories[1] ?? [])).toEqual(['Implement the retry-safe change']); + }); + it('realigns a resumed subagent to the parent agent current model', async () => { const parent = testAgent(); parent.configure(); @@ -1460,6 +1648,36 @@ function queuedTask(index: number): QueuedSubagentTask { }; } +function textResult(text: string): Awaited> { + return { + id: 'mock-text', + message: { + role: 'assistant', + content: [{ type: 'text', text }], + toolCalls: [], + }, + usage: { + inputOther: 0, + output: 0, + inputCacheRead: 0, + inputCacheCreation: 0, + }, + finishReason: 'completed', + rawFinishReason: 'stop', + }; +} + +function userTextMessages(history: readonly Message[]): string[] { + return history + .filter((message) => message.role === 'user') + .map((message) => + message.content + .filter((part) => part.type === 'text') + .map((part) => part.text) + .join(''), + ); +} + async function writeWire(homedir: string, records: readonly Record[]) { await mkdir(homedir, { recursive: true }); const wireRecords = From 4797e90fdc9e8f560883d3c1773c5c72d4e31258 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 16:23:36 +0800 Subject: [PATCH 12/72] fix --- apps/kimi-code/src/tui/commands/dispatch.ts | 1 + apps/kimi-code/src/tui/commands/undo.ts | 1 + .../agent-swarm-progress-estimator.ts | 47 ++++- .../messages/agent-swarm-progress.ts | 174 +++++++++++++++++- .../tui/controllers/session-event-handler.ts | 17 +- apps/kimi-code/src/tui/kimi-tui.ts | 4 + 6 files changed, 226 insertions(+), 18 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index ef16bef3a..73358acf2 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -124,6 +124,7 @@ export interface SlashCommandHost { showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; showProgressSpinner(label: string): LoginProgressSpinnerHandle; + clearAgentSwarmProgress(): void; // Theme applyTheme(theme: Theme, resolved?: ResolvedTheme): void; diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 752fd27aa..e5aff587d 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -67,6 +67,7 @@ export async function handleUndoCommand( (entry) => !isUndoContextEntry(entry), ); entries.splice(lastUserIndex, entries.length - lastUserIndex, ...preservedEntries); + host.clearAgentSwarmProgress(); if (entries.length === 0) { renderWelcome(host); 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 index 8e9f20369..e883b7291 100644 --- 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 @@ -1,10 +1,10 @@ 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 COMPLETED_SAMPLE_CONFIDENCE_SCALE = 3; const MIN_RATE_FACTOR = 0.25; const HALF_TICK = 0.5; @@ -20,6 +20,7 @@ export interface AgentSwarmProgressEstimatorOptions { readonly rateWindowMs?: number; readonly catchupTimeMs?: number; readonly maxCatchupTicksPerSecond?: number; + readonly workloadSpreadFactor?: number; readonly unfinishedProgressCap?: number; readonly maxBoostGain?: number; } @@ -62,6 +63,7 @@ interface CompletedSample { interface EstimatePrior { readonly completedCount: number; readonly typicalTotalMs: number; + readonly typicalToolCalls: number; readonly typicalRatePerMs: number; } @@ -70,6 +72,7 @@ export class AgentSwarmProgressEstimator { 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; @@ -77,6 +80,10 @@ export class AgentSwarmProgressEstimator { 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, @@ -157,7 +164,13 @@ export class AgentSwarmProgressEstimator { return baseEstimate; } - const estimatedTotalToolCalls = this.estimateTotalToolCalls(state, prior, input.nowMs); + const completedConfidence = this.completedSampleConfidence(prior.completedCount); + const estimatedTotalToolCalls = this.estimateTotalToolCalls( + state, + prior, + input.nowMs, + completedConfidence, + ); const estimatedProgress = Math.min( this.unfinishedProgressCap, rawTicks / estimatedTotalToolCalls, @@ -175,7 +188,6 @@ export class AgentSwarmProgressEstimator { }; } - const completedConfidence = confidence(prior.completedCount, COMPLETED_SAMPLE_CONFIDENCE_SCALE); const toolConfidence = confidence(rawTicks, BOOST_TOOL_CONFIDENCE_SCALE); const boostConfidence = completedConfidence * toolConfidence; const boostGain = this.maxBoostGain * boostConfidence; @@ -254,6 +266,7 @@ export class AgentSwarmProgressEstimator { 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), ), @@ -277,6 +290,7 @@ export class AgentSwarmProgressEstimator { state: MemberProgressState, prior: EstimatePrior, nowMs: number, + completedConfidence: number, ): number { const elapsedMs = Math.max(0, nowMs - (state.startedAtMs ?? nowMs)); const localRatePerMs = this.estimateLocalRatePerMs(state, elapsedMs, nowMs); @@ -292,13 +306,30 @@ export class AgentSwarmProgressEstimator { ); const totalMs = Math.max(prior.typicalTotalMs, elapsedMs / this.unfinishedProgressCap); const estimatedTotalToolCalls = ratePerMs * totalMs; - return Math.max( + 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, @@ -331,6 +362,10 @@ export class AgentSwarmProgressEstimator { 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 { @@ -341,6 +376,10 @@ 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)); 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 index 939f07381..724b9b5c7 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -22,10 +22,15 @@ 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 ORCHESTRATING_LABEL = 'Orchestrating...'; +const WORKING_LABEL = 'Working...'; const QUEUED_LABEL = 'Queued...'; +const STATUS_BAR_ORDER = ['completed', 'working', 'queued', 'cancelled', 'failed'] as const; + type AgentSwarmPhase = AgentSwarmProgressEstimatorPhase; +type StatusBarPhase = typeof STATUS_BAR_ORDER[number]; interface AgentSwarmMember { readonly id: string; @@ -83,6 +88,7 @@ export class AgentSwarmProgressComponent implements Component { private readonly colors: ColorPalette; private readonly requestRender: (() => void) | undefined; private inputComplete = false; + private promptTemplateText = ''; private timer: ReturnType | undefined; constructor(options: AgentSwarmProgressOptions) { @@ -108,13 +114,23 @@ export class AgentSwarmProgressComponent implements Component { if (description.length > 0 || this.description.length === 0) { this.description = description; } - const fullItemsCount = agentSwarmItemsFromArgs(args).length; + const fullItems = agentSwarmItemsFromArgs(args); const partialItems = options.streamingArguments === undefined ? [] : agentSwarmPartialItemsFromArguments(options.streamingArguments); - const fullItems = agentSwarmItemsFromArgs(args); - const itemCount = Math.max(fullItemsCount, partialItems.length); + const fullPromptTemplate = agentSwarmPromptTemplateFromArgs(args); + const partialPromptTemplate = + options.streamingArguments === undefined + ? '' + : agentSwarmPartialPromptTemplateFromArguments(options.streamingArguments); + const promptTemplate = + fullPromptTemplate.length > 0 ? fullPromptTemplate : partialPromptTemplate; + if (promptTemplate.length > 0 || this.promptTemplateText.length === 0) { + this.promptTemplateText = promptTemplate; + } + + const itemCount = Math.max(fullItems.length, partialItems.length); if (itemCount > 0) this.ensureMemberCount(itemCount); this.updateItemTexts(fullItems, partialItems); } @@ -295,7 +311,7 @@ export class AgentSwarmProgressComponent implements Component { this.renderHeader(innerWidth, undefined), chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), '', - chalk.hex(this.colors.textMuted)(` ${ORCHESTRATING_LABEL}`), + this.renderStatusLine(innerWidth), '', chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), ]; @@ -316,6 +332,8 @@ export class AgentSwarmProgressComponent implements Component { '', ...this.renderGrid(innerWidth, snapshots, nowMs), '', + this.renderStatusLine(innerWidth), + '', chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), ]; this.startAnimationIfNeeded(); @@ -332,6 +350,33 @@ export class AgentSwarmProgressComponent implements Component { return truncateToWidth(title + description, width); } + private renderStatusLine(width: number): string { + if (!this.inputComplete) { + return this.renderOrchestratingStatusLine(width); + } + + const labelText = ` ${WORKING_LABEL}`; + const label = chalk.hex(this.colors.success)(labelText); + const barWidth = Math.max(0, width - visibleWidth(labelText) - 2); + if (barWidth <= 0) return truncateToWidth(label, width); + return truncateToWidth( + `${label} ${renderStatusPipBar(this.members, barWidth, this.colors)} `, + width, + ); + } + + private renderOrchestratingStatusLine(width: number): string { + const labelText = ` ${ORCHESTRATING_LABEL}`; + const label = chalk.hex(this.colors.textMuted)(labelText); + const promptTemplate = collapseWhitespace(this.promptTemplateText); + if (promptTemplate.length === 0) return truncateToWidth(label, width); + + const promptWidth = Math.max(0, width - visibleWidth(labelText) - 1); + if (promptWidth <= 0) return truncateToWidth(label, width); + const prompt = truncateStartWithColor(promptTemplate, promptWidth, this.colors.textDim); + return truncateToWidth(`${label} ${prompt}`, width); + } + private renderGrid( width: number, snapshots: readonly AgentSwarmSnapshot[], @@ -535,6 +580,17 @@ export function agentSwarmDescriptionFromArgs(args: Record): st 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; +} + function parseAgentSwarmDescriptionIndex(description: string | undefined): number | undefined { if (description === undefined) return undefined; const match = /#(\d+)(?:\s|$|\()/.exec(description); @@ -650,6 +706,92 @@ function phaseColor(phase: AgentSwarmPhase, colors: ColorPalette): string { } } +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 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 { + switch (phase) { + case 'pending': + case 'queued': + return 'queued'; + case 'running': + return 'working'; + case 'completed': + return 'completed'; + case 'failed': + return 'failed'; + case 'cancelled': + return 'cancelled'; + } +} + +function statusBarColor(phase: StatusBarPhase, colors: ColorPalette): string { + switch (phase) { + case 'queued': + return colors.textMuted; + case 'working': + return colors.primary; + case 'completed': + return colors.success; + case 'failed': + return colors.error; + case 'cancelled': + return colors.warning; + } +} + +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, @@ -710,6 +852,30 @@ function truncateWithColor(text: string, width: number, color: string): string { return truncateToWidth(colorize(text), width, colorize('…')); } +function truncateStartWithColor(text: string, width: number, color: string): string { + return chalk.hex(color)(truncateStartToWidth(text, width)); +} + +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(); } 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 aa5f3d002..33e163ac8 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -126,9 +126,13 @@ export class SessionEventHandler { this.renderedSkillActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); this.mcpServers.clear(); + this.clearAgentSwarmProgress(); + this.stopAllMcpServerStatusSpinners(); + } + + clearAgentSwarmProgress(): void { this.agentSwarmProgress.clear(); this.host.setAgentSwarmProgress(null); - this.stopAllMcpServerStatusSpinners(); } startSubscription(): void { @@ -269,12 +273,6 @@ export class SessionEventHandler { }); } else if (event.type === 'subagent.started') { swarmProgress.markStarted(event.subagentId); - } else if (event.type === 'turn.ended') { - if (event.reason === 'cancelled') { - swarmProgress.markCancelled(subagentId); - } else { - swarmProgress.markCompleted(subagentId); - } } else if (event.type === 'subagent.failed') { if (isUserCancelledSubagentError(event.error)) { swarmProgress.markCancelled(event.subagentId); @@ -336,10 +334,10 @@ export class SessionEventHandler { case 'compaction.started': case 'cron.fired': case 'error': + case 'goal.updated': case 'warning': case 'session.meta.updated': case 'skill.activated': - case 'goal.updated': case 'subagent.completed': case 'subagent.failed': case 'subagent.spawned': @@ -361,8 +359,7 @@ export class SessionEventHandler { private handleTurnBegin(_event: TurnStartedEvent): void { void _event; - this.agentSwarmProgress.clear(); - this.host.setAgentSwarmProgress(null); + this.clearAgentSwarmProgress(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); this.host.patchLivePane({ diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 5789a2153..ffd6baded 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1017,6 +1017,10 @@ export class KimiTUI { this.state.ui.requestRender(); } + clearAgentSwarmProgress(): void { + this.sessionEventHandler.clearAgentSwarmProgress(); + } + // ========================================================================= // Session Runtime // ========================================================================= From 0a93675de3abb551d97227101097b2401daa512e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 17:54:13 +0800 Subject: [PATCH 13/72] fix --- .../messages/agent-swarm-progress.ts | 140 +++++++++++++++--- .../tui/controllers/session-event-handler.ts | 63 +++++--- .../src/tui/controllers/streaming-ui.ts | 4 +- apps/kimi-code/src/tui/kimi-tui.ts | 26 +--- apps/kimi-code/src/tui/tui-state.ts | 3 - .../src/session/subagent-launch-queue.ts | 22 ++- .../builtin/collaboration/agent-swarm.ts | 10 -- .../test/session/subagent-host.test.ts | 31 ++++ 8 files changed, 217 insertions(+), 82 deletions(-) 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 index 724b9b5c7..d551775ab 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -24,13 +24,17 @@ const FAILED_PLACEHOLDER_RED_FACTOR = 0.75; const FAILED_PLACEHOLDER_NON_RED_FACTOR = 0.25; const STATUS_BAR_CHAR = '━'; const ORCHESTRATING_LABEL = 'Orchestrating...'; +const PROMPTING_LABEL = 'Prompting...'; const WORKING_LABEL = 'Working...'; +const FAILED_LABEL = 'Failed.'; +const CANCELLED_LABEL = 'Cancelled.'; const QUEUED_LABEL = 'Queued...'; const STATUS_BAR_ORDER = ['completed', 'working', 'queued', 'cancelled', 'failed'] as const; type AgentSwarmPhase = AgentSwarmProgressEstimatorPhase; type StatusBarPhase = typeof STATUS_BAR_ORDER[number]; +type TotalStatus = 'working' | 'failed' | 'cancelled'; interface AgentSwarmMember { readonly id: string; @@ -78,7 +82,7 @@ const PHASE_LABELS: Record = { running: 'Running', completed: 'Completed', failed: 'Failed', - cancelled: 'Cancelled', + cancelled: 'Cancelled.', }; export class AgentSwarmProgressComponent implements Component { @@ -88,6 +92,9 @@ export class AgentSwarmProgressComponent implements Component { private readonly colors: ColorPalette; private readonly requestRender: (() => void) | undefined; private inputComplete = false; + private failed = false; + private cancelled = false; + private itemsStarted = false; private promptTemplateText = ''; private timer: ReturnType | undefined; @@ -106,6 +113,10 @@ export class AgentSwarmProgressComponent implements Component { invalidate(): void {} + isRequestStreaming(): boolean { + return !this.inputComplete; + } + updateArgs( args: Record, options: { readonly streamingArguments?: string | undefined } = {}, @@ -119,6 +130,16 @@ export class AgentSwarmProgressComponent implements Component { options.streamingArguments === undefined ? [] : agentSwarmPartialItemsFromArguments(options.streamingArguments); + if ( + fullItems.length > 0 || + partialItems.length > 0 || + ( + options.streamingArguments !== undefined && + agentSwarmItemsStartedFromArguments(options.streamingArguments) + ) + ) { + this.itemsStarted = true; + } const fullPromptTemplate = agentSwarmPromptTemplateFromArgs(args); const partialPromptTemplate = options.streamingArguments === undefined @@ -242,9 +263,33 @@ export class AgentSwarmProgressComponent implements Component { this.startAnimationIfNeeded(); } + markSwarmFailed(failureText?: string): void { + this.failed = true; + this.cancelled = false; + const normalizedFailureText = normalizeFailureText(failureText); + const nowMs = Date.now(); + for (const member of this.members) { + if ( + member.phase === 'completed' || + member.phase === 'failed' || + member.phase === 'cancelled' + ) { + continue; + } + this.progressEstimator.markFailed(member.id, nowMs); + member.failedAtMs = nowMs; + if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; + member.phase = 'failed'; + delete member.completedAtMs; + delete member.completedText; + } + this.startAnimationIfNeeded(); + } + markCancelled(agentId: string): void { const member = this.findMemberByAgentId(agentId); if (member === undefined) return; + this.cancelled = true; this.progressEstimator.markCancelled(member.id, Date.now()); member.phase = 'cancelled'; delete member.completedAtMs; @@ -254,6 +299,7 @@ export class AgentSwarmProgressComponent implements Component { } markActiveCancelled(): void { + this.cancelled = true; const nowMs = Date.now(); for (const member of this.members) { if ( @@ -309,7 +355,6 @@ export class AgentSwarmProgressComponent implements Component { if (this.members.length === 0) { const lines = [ this.renderHeader(innerWidth, undefined), - chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), '', this.renderStatusLine(innerWidth), '', @@ -328,7 +373,6 @@ export class AgentSwarmProgressComponent implements Component { const summary = summarizeSnapshots(snapshots); const lines = [ this.renderHeader(innerWidth, summary), - chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), '', ...this.renderGrid(innerWidth, snapshots, nowMs), '', @@ -340,23 +384,40 @@ export class AgentSwarmProgressComponent implements Component { return lines.map((line) => truncateToWidth(line, innerWidth)); } - private renderHeader(width: number, summary: AgentSwarmSummary | undefined): string { - const title = chalk.hex(this.colors.primary).bold(' Agent swarm'); + private renderHeader(width: number, _summary: AgentSwarmSummary | undefined): string { + if (width <= 3) return chalk.hex(this.colors.primary)('─'.repeat(width)); + + const title = chalk.hex(this.colors.primary).bold('Agent swarm'); const description = this.description.length > 0 - ? chalk.hex(this.colors.text)(`: ${this.description}`) + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) : ''; - void summary; - return truncateToWidth(title + description, width); + 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, + cancelled: this.cancelled, + }); + if (status !== 'working') return this.renderProgressStatusLine(width, status); + if (!this.inputComplete) { return this.renderOrchestratingStatusLine(width); } - const labelText = ` ${WORKING_LABEL}`; - const label = chalk.hex(this.colors.success)(labelText); + return this.renderProgressStatusLine(width, status); + } + + private renderProgressStatusLine(width: number, status: TotalStatus): string { + const labelText = ` ${totalStatusLabel(status)}`; + const label = chalk.hex(totalStatusColor(status, this.colors))(labelText); + if (this.members.length === 0) return truncateToWidth(label, width); const barWidth = Math.max(0, width - visibleWidth(labelText) - 2); if (barWidth <= 0) return truncateToWidth(label, width); return truncateToWidth( @@ -366,15 +427,21 @@ export class AgentSwarmProgressComponent implements Component { } private renderOrchestratingStatusLine(width: number): string { - const labelText = ` ${ORCHESTRATING_LABEL}`; - const label = chalk.hex(this.colors.textMuted)(labelText); + if (this.itemsStarted) { + return truncateToWidth(chalk.hex(this.colors.textMuted)(` ${ORCHESTRATING_LABEL}`), width); + } + const promptTemplate = collapseWhitespace(this.promptTemplateText); + const labelText = ` ${promptTemplate.length > 0 ? PROMPTING_LABEL : ORCHESTRATING_LABEL}`; + const label = chalk.hex(this.colors.textMuted)(labelText); if (promptTemplate.length === 0) return truncateToWidth(label, width); - const promptWidth = Math.max(0, width - visibleWidth(labelText) - 1); + const availablePromptWidth = Math.max(0, width - visibleWidth(labelText)); + const separator = visibleWidth(promptTemplate) <= availablePromptWidth - 1 ? ' ' : ' '; + const promptWidth = Math.max(0, availablePromptWidth - visibleWidth(separator)); if (promptWidth <= 0) return truncateToWidth(label, width); - const prompt = truncateStartWithColor(promptTemplate, promptWidth, this.colors.textDim); - return truncateToWidth(`${label} ${prompt}`, width); + const prompt = chalk.hex(this.colors.textDim)(truncateStartToWidth(promptTemplate, promptWidth)); + return truncateToWidth(`${label}${separator}${prompt}`, width); } private renderGrid( @@ -555,6 +622,10 @@ export function agentSwarmPartialItemsCountFromArguments(argumentsText: string): return agentSwarmPartialItemsFromArguments(argumentsText).length; } +function agentSwarmItemsStartedFromArguments(argumentsText: string): boolean { + return /"items"\s*:/.test(argumentsText); +} + export function agentSwarmPartialItemsFromArguments(argumentsText: string): string[] { const match = /"items"\s*:\s*\[/.exec(argumentsText); if (match === null) return []; @@ -773,6 +844,41 @@ function statusBarColor(phase: StatusBarPhase, colors: ColorPalette): string { } } +function totalStatus( + members: readonly AgentSwarmMember[], + force: { readonly failed: boolean; readonly cancelled: boolean }, +): TotalStatus { + if (force.failed) return 'failed'; + if (force.cancelled && members.length === 0) return 'cancelled'; + const hasCancelled = members.some((member) => member.phase === 'cancelled'); + const hasActive = members.some((member) => + member.phase === 'pending' || member.phase === 'queued' || member.phase === 'running' + ); + return (force.cancelled || hasCancelled) && !hasActive ? 'cancelled' : 'working'; +} + +function totalStatusLabel(status: TotalStatus): string { + switch (status) { + case 'working': + return WORKING_LABEL; + case 'failed': + return FAILED_LABEL; + case 'cancelled': + return CANCELLED_LABEL; + } +} + +function totalStatusColor(status: TotalStatus, colors: ColorPalette): string { + switch (status) { + case 'working': + return colors.success; + case 'failed': + return colors.error; + case 'cancelled': + return colors.warning; + } +} + 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); @@ -852,10 +958,6 @@ function truncateWithColor(text: string, width: number, color: string): string { return truncateToWidth(colorize(text), width, colorize('…')); } -function truncateStartWithColor(text: string, width: number, color: string): string { - return chalk.hex(color)(truncateStartToWidth(text, width)); -} - function truncateStartToWidth(text: string, width: number): string { if (visibleWidth(text) <= width) return text; const ellipsis = '…'; 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 33e163ac8..a14b7bcf5 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -95,7 +95,6 @@ export interface SessionEventHost { showError(msg: string): void; showStatus(msg: string, color?: string): void; showNotice(title: string, detail?: string): void; - setAgentSwarmProgress(component: AgentSwarmProgressComponent | null): void; appendTranscriptEntry(entry: TranscriptEntry): void; updateTerminalTitle(): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; @@ -131,8 +130,10 @@ export class SessionEventHandler { } clearAgentSwarmProgress(): void { + for (const progress of this.agentSwarmProgress.values()) { + progress.dispose(); + } this.agentSwarmProgress.clear(); - this.host.setAgentSwarmProgress(null); } startSubscription(): void { @@ -280,7 +281,7 @@ export class SessionEventHandler { swarmProgress.markFailed(event.subagentId, event.error); } } - this.host.setAgentSwarmProgress(swarmProgress); + this.host.state.ui.requestRender(); return true; } if (toolCall === undefined) return true; @@ -447,13 +448,30 @@ export class SessionEventHandler { } private markActiveAgentSwarmsCancelled(): void { - let visible: AgentSwarmProgressComponent | undefined; - for (const progress of this.agentSwarmProgress.values()) { + let updated = false; + for (const [toolCallId, progress] of this.agentSwarmProgress) { + if (progress.isRequestStreaming()) { + this.removeAgentSwarmProgress(toolCallId, progress); + updated = true; + continue; + } progress.markActiveCancelled(); - visible = progress; + updated = true; } - if (visible !== undefined) { - this.host.setAgentSwarmProgress(visible); + if (updated) this.host.state.ui.requestRender(); + } + + 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(); } } @@ -548,9 +566,7 @@ export class SessionEventHandler { if (event.name === 'AgentSwarm') { const progress = this.ensureAgentSwarmProgress(event.toolCallId, toolCall.args); progress.markInputComplete(); - this.host.setAgentSwarmProgress(progress); - } else if (this.agentSwarmProgress.size > 0) { - this.host.setAgentSwarmProgress(null); + this.host.state.ui.requestRender(); } this.host.patchLivePane({ mode: 'tool', @@ -568,10 +584,10 @@ export class SessionEventHandler { preview !== undefined && (preview.name === 'AgentSwarm' || this.agentSwarmProgress.has(event.toolCallId)) ) { - const progress = this.ensureAgentSwarmProgress(event.toolCallId, preview.args, { + this.ensureAgentSwarmProgress(event.toolCallId, preview.args, { streamingArguments: preview.argumentsText, }); - this.host.setAgentSwarmProgress(progress); + this.host.state.ui.requestRender(); } this.host.patchLivePane({ @@ -605,6 +621,9 @@ export class SessionEventHandler { }); progress.updateArgs(args, options); this.agentSwarmProgress.set(toolCallId, progress); + this.host.streamingUI.finalizeLiveTextBuffers('tool'); + this.host.state.transcriptContainer.addChild(progress); + this.host.state.ui.requestRender(); return progress; } @@ -630,11 +649,17 @@ export class SessionEventHandler { const progress = this.agentSwarmProgress.get(event.toolCallId); if (progress !== undefined) { if (event.isError === true && isUserCancelledSubagentError(resultData.output)) { - progress.markActiveCancelled(); + if (progress.isRequestStreaming()) { + this.removeAgentSwarmProgress(event.toolCallId, progress); + } else { + progress.markActiveCancelled(); + } + } else if (event.isError === true) { + progress.markSwarmFailed(resultData.output); } else { progress.applyResult(resultData.output); } - this.host.setAgentSwarmProgress(progress); + this.host.state.ui.requestRender(); } if (matchedCall !== undefined && matchedCall.name === 'TodoList' && !event.isError) { const rawTodos = (matchedCall.args as { todos?: unknown }).todos; @@ -891,7 +916,7 @@ export class SessionEventHandler { agentId: event.subagentId, description: event.description, }); - this.host.setAgentSwarmProgress(swarmProgress); + this.host.state.ui.requestRender(); return; } @@ -927,7 +952,7 @@ export class SessionEventHandler { const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); if (swarmProgress !== undefined) { swarmProgress.markStarted(event.subagentId); - this.host.setAgentSwarmProgress(swarmProgress); + this.host.state.ui.requestRender(); return; } @@ -968,7 +993,7 @@ export class SessionEventHandler { const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); if (swarmProgress !== undefined) { swarmProgress.markCompleted(event.subagentId, event.resultSummary); - this.host.setAgentSwarmProgress(swarmProgress); + this.host.state.ui.requestRender(); streamingUI.removeToolComponentIfInactive(event.parentToolCallId); return; } @@ -1022,7 +1047,7 @@ export class SessionEventHandler { } else { swarmProgress.markFailed(event.subagentId, event.error); } - this.host.setAgentSwarmProgress(swarmProgress); + this.host.state.ui.requestRender(); streamingUI.removeToolComponentIfInactive(event.parentToolCallId); return; } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 16ad85f97..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); } } @@ -733,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/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index ffd6baded..41d042bfc 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -68,7 +68,6 @@ import { TasksBrowserController } from './controllers/tasks-browser'; import { installRainbowDance } from './easter-eggs/dance'; import { FileMentionProvider } from './components/editor/file-mention-provider'; import { AssistantMessageComponent } from './components/messages/assistant-message'; -import type { AgentSwarmProgressComponent } from './components/messages/agent-swarm-progress'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; import { CronMessageComponent } from './components/messages/cron-message'; import { GoalCompletionMessageComponent } from './components/messages/goal-panel'; @@ -113,7 +112,7 @@ import { type TUIStartupState, } from './types'; import { createTUIState, type TUIState } from './tui-state'; -import { hasDispose, isExpandable, isPlanExpandable } from './utils/component-capabilities'; +import { isExpandable, isPlanExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; @@ -1004,19 +1003,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - setAgentSwarmProgress(component: AgentSwarmProgressComponent | null): void { - if (this.state.agentSwarmProgress === component) { - if (component !== null) this.state.ui.requestRender(); - return; - } - if (hasDispose(this.state.agentSwarmProgress)) { - this.state.agentSwarmProgress.dispose(); - } - this.state.agentSwarmProgress = component; - this.updateActivityPane(); - this.state.ui.requestRender(); - } - clearAgentSwarmProgress(): void { this.sessionEventHandler.clearAgentSwarmProgress(); } @@ -1498,16 +1484,6 @@ export class KimiTUI { // ========================================================================= updateActivityPane(): void { - if (this.state.agentSwarmProgress !== null) { - this.lastActivityMode = 'hidden'; - this.stopActivitySpinner(); - this.state.activityContainer.clear(); - this.state.activityContainer.addChild(this.state.agentSwarmProgress); - this.syncTerminalProgress(true); - this.state.ui.requestRender(); - return; - } - const effectiveMode = this.resolveActivityPaneMode(); this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 7dc1764f8..41c714639 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -2,7 +2,6 @@ import { Container, ProcessTerminal, TUI, - type Component, } from '@earendil-works/pi-tui'; import { FooterComponent } from './components/chrome/footer'; @@ -41,7 +40,6 @@ export interface TUIState { appState: AppState; startupState: TUIStartupState; livePane: LivePaneState; - agentSwarmProgress: Component | null; transcriptEntries: TranscriptEntry[]; terminalState: TerminalState; activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null; @@ -90,7 +88,6 @@ export function createTUIState(options: KimiTUIOptions): TUIState { appState: { ...initialAppState }, startupState: 'pending', livePane: { ...INITIAL_LIVE_PANE }, - agentSwarmProgress: null, transcriptEntries: [], terminalState: createTerminalState(), activitySpinner: null, diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index f8ac7325b..eed33dcff 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -8,6 +8,8 @@ const SUBAGENT_LAUNCH_BATCH_SIZE = 10; const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; const RATE_LIMIT_SLOT_REDUCTION_WINDOW_MS = 1000; const RATE_LIMIT_SLOT_REDUCTION_MAX_PER_WINDOW = 3; +const RATE_LIMIT_RETRY_EXHAUSTED_MESSAGE = + 'Subagent failed after another 429 with only one retry slot remaining.'; export type QueuedSubagentTask = { readonly data: T; @@ -117,7 +119,7 @@ export class SubagentLaunchQueue { const unreadyActiveCount = (): number => active.reduce((count, attempt) => count + (attempt.ready ? 0 : 1), 0); - const reduceSlotsAfterRateLimit = (): void => { + const reduceSlotsAfterRateLimit = (): boolean => { const now = Date.now(); if ( rateLimitReductionWindowStartMs === undefined || @@ -128,16 +130,20 @@ export class SubagentLaunchQueue { } const currentLimit = slotLimit ?? SUBAGENT_LAUNCH_BATCH_SIZE; + if (currentLimit <= 1) { + slotLimit = currentLimit; + return false; + } if ( - currentLimit <= 1 || rateLimitReductionsInWindow >= RATE_LIMIT_SLOT_REDUCTION_MAX_PER_WINDOW ) { slotLimit = currentLimit; - return; + return true; } slotLimit = currentLimit - 1; rateLimitReductionsInWindow += 1; + return true; }; const launch = (pending: QueuedSubagentPending): QueuedSubagentAttempt => { @@ -183,7 +189,15 @@ export class SubagentLaunchQueue { active.splice(active.indexOf(attempt), 1); const outcome = await attempt.outcome; if (isRateLimitedOutcome(outcome)) { - reduceSlotsAfterRateLimit(); + if (!reduceSlotsAfterRateLimit()) { + results[attempt.pending.index] = { + task: tasks[attempt.pending.index]!, + agentId: outcome.agentId ?? attempt.pending.agentId, + status: 'failed', + error: RATE_LIMIT_RETRY_EXHAUSTED_MESSAGE, + }; + return true; + } requeueRateLimited({ index: attempt.pending.index, agentId: outcome.agentId ?? attempt.pending.agentId, diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 2401ad0b6..db541d906 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -29,15 +29,6 @@ export const AgentSwarmToolInputSchema = z .max(3600) .optional() .describe('Timeout in seconds for each subagent.'), - total_timeout: z - .number() - .int() - .min(30) - .max(3600) - .optional() - .describe( - 'Timeout in seconds for the whole swarm, including queued and running subagents.', - ), subagent_type: z .string() .trim() @@ -144,7 +135,6 @@ export class AgentSwarmTool implements BuiltinTool { const results = await this.subagentHost.runQueued(tasks, { signal, timeoutMs: args.timeout === undefined ? undefined : args.timeout * 1000, - totalTimeoutMs: args.total_timeout === undefined ? undefined : args.total_timeout * 1000, }); return renderSwarmResults(args, results.map(toSwarmRunResult)); } diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index e80338cde..bd332418e 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -18,6 +18,7 @@ import { type QueuedSubagentTask, type SubagentHandle, } from '../../src/session/subagent-host'; +import { SubagentLaunchQueue } from '../../src/session/subagent-launch-queue'; import { abortError, userCancellationReason } from '../../src/utils/abort'; import { testAgent, type AgentTestContext } from '../agent/harness/agent'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; @@ -331,6 +332,36 @@ describe('SessionSubagentHost', () => { ).rejects.toThrow('Could not start any subagents'); }); + it('runQueued fails a 429 retry when only one retry slot remains', async () => { + vi.useFakeTimers(); + try { + let attempts = 0; + const queue = new SubagentLaunchQueue(async (_task, options) => { + attempts += 1; + options.markReady(); + return { type: 'rate_limited', agentId: 'agent-1' }; + }); + + const running = queue.run([queuedTask(1)], { signal }); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(5000); + + await expect(running).resolves.toEqual([ + { + task: queuedTask(1), + agentId: 'agent-1', + status: 'failed', + error: 'Subagent failed after another 429 with only one retry slot remaining.', + }, + ]); + expect(attempts).toBe(10); + } finally { + vi.useRealTimers(); + } + }); + it('fires subagent lifecycle hooks around the child turn', async () => { const child = testAgent(); const calls: Array<{ readonly event: string; readonly childLlmCallCount: number }> = []; From c0bfa7302d9216842e96cfa93760f6d68fcd5172 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 17:56:57 +0800 Subject: [PATCH 14/72] fix: emit suspended subagent events --- .../agent-swarm-progress-estimator.ts | 1 + packages/agent-core/src/rpc/events.ts | 13 +++++ .../agent-core/src/session/subagent-host.ts | 25 +++++++++- .../src/session/subagent-launch-queue.ts | 50 ++++++++++++++++--- packages/node-sdk/src/events.ts | 1 + .../node-sdk/test/session-event-types.test.ts | 2 + 6 files changed, 82 insertions(+), 10 deletions(-) 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 index e883b7291..9fb4b0ea5 100644 --- 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 @@ -11,6 +11,7 @@ const HALF_TICK = 0.5; export type AgentSwarmProgressEstimatorPhase = | 'pending' | 'queued' + | 'suspended' | 'running' | 'completed' | 'failed' diff --git a/packages/agent-core/src/rpc/events.ts b/packages/agent-core/src/rpc/events.ts index ebf7997dd..78df2a045 100644 --- a/packages/agent-core/src/rpc/events.ts +++ b/packages/agent-core/src/rpc/events.ts @@ -223,6 +223,18 @@ export interface SubagentStartedEvent { readonly runInBackground: boolean; } +export interface SubagentSuspendedEvent { + readonly type: 'subagent.suspended'; + readonly subagentId: string; + readonly subagentName: string; + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string | undefined; + readonly parentAgentId?: string | undefined; + readonly description?: string | undefined; + readonly runInBackground: boolean; + readonly reason: string; +} + export interface SubagentCompletedEvent { readonly type: 'subagent.completed'; readonly subagentId: string; @@ -320,6 +332,7 @@ export type AgentEvent = | McpServerStatusEvent | SubagentSpawnedEvent | SubagentStartedEvent + | SubagentSuspendedEvent | SubagentCompletedEvent | SubagentFailedEvent | CompactionStartedEvent diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 8e2771454..35fbaf0f6 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -28,6 +28,7 @@ import { type QueuedSubagentAttemptOutcome, type QueuedSubagentRunOptions, type QueuedSubagentRunResult, + type QueuedSubagentSuspended, type QueuedSubagentTask, } from './subagent-launch-queue'; import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md'; @@ -111,8 +112,13 @@ export class SessionSubagentHost { private readonly ownerAgentId: string, readonly backgroundTaskTimeoutMs?: number | undefined, ) { - this.launchQueue = new SubagentLaunchQueue((task, options) => - this.runQueuedTaskAttempt(task, options), + this.launchQueue = new SubagentLaunchQueue( + (task, options) => this.runQueuedTaskAttempt(task, options), + { + onSuspended: (event) => { + this.emitSubagentSuspended(event); + }, + }, ); } @@ -592,6 +598,21 @@ export class SessionSubagentHost { runInBackground: options.runInBackground, }); } + + private emitSubagentSuspended(event: QueuedSubagentSuspended): void { + const parent = this.session.getReadyAgent(this.ownerAgentId); + parent?.emitEvent({ + type: 'subagent.suspended', + subagentId: event.agentId, + subagentName: event.task.profileName, + parentToolCallId: event.task.parentToolCallId, + parentToolCallUuid: event.task.parentToolCallUuid, + parentAgentId: this.ownerAgentId, + description: event.task.description, + runInBackground: event.task.runInBackground, + reason: event.reason, + }); + } } async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Promise { diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index eed33dcff..96f83c952 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -10,6 +10,7 @@ const RATE_LIMIT_SLOT_REDUCTION_WINDOW_MS = 1000; const RATE_LIMIT_SLOT_REDUCTION_MAX_PER_WINDOW = 3; const RATE_LIMIT_RETRY_EXHAUSTED_MESSAGE = 'Subagent failed after another 429 with only one retry slot remaining.'; +const RATE_LIMIT_SUSPENDED_REASON = 'Provider rate limit; subagent requeued for retry.'; export type QueuedSubagentTask = { readonly data: T; @@ -42,6 +43,13 @@ export type QueuedSubagentRateLimitOutcome = { readonly agentId?: string; }; +export type QueuedSubagentSuspended = { + readonly task: QueuedSubagentTask; + readonly agentId: string; + readonly reason: string; + readonly retryAttempt: number; +}; + export type QueuedSubagentAttemptOutcome = | QueuedSubagentRateLimitOutcome | QueuedSubagentRunResult; @@ -49,6 +57,7 @@ export type QueuedSubagentAttemptOutcome = type QueuedSubagentPending = { readonly index: number; readonly agentId?: string; + readonly rateLimitAttempts?: number; }; type QueuedSubagentAttempt = { @@ -70,8 +79,21 @@ type RunQueuedSubagentAttempt = ( options: QueuedSubagentAttemptOptions, ) => Promise>; +type SubagentLaunchQueueEvents = { + readonly onSuspended?: (event: QueuedSubagentSuspended) => void; +}; + +type SlotReductionResult = { + readonly canRetry: boolean; + readonly slotLimitBefore: number; + readonly slotLimitAfter: number; +}; + export class SubagentLaunchQueue { - constructor(private readonly runAttempt: RunQueuedSubagentAttempt) {} + constructor( + private readonly runAttempt: RunQueuedSubagentAttempt, + private readonly events: SubagentLaunchQueueEvents = {}, + ) {} async run( tasks: readonly QueuedSubagentTask[], @@ -119,7 +141,7 @@ export class SubagentLaunchQueue { const unreadyActiveCount = (): number => active.reduce((count, attempt) => count + (attempt.ready ? 0 : 1), 0); - const reduceSlotsAfterRateLimit = (): boolean => { + const reduceSlotsAfterRateLimit = (): SlotReductionResult => { const now = Date.now(); if ( rateLimitReductionWindowStartMs === undefined || @@ -132,18 +154,18 @@ export class SubagentLaunchQueue { const currentLimit = slotLimit ?? SUBAGENT_LAUNCH_BATCH_SIZE; if (currentLimit <= 1) { slotLimit = currentLimit; - return false; + return { canRetry: false, slotLimitBefore: currentLimit, slotLimitAfter: slotLimit }; } if ( rateLimitReductionsInWindow >= RATE_LIMIT_SLOT_REDUCTION_MAX_PER_WINDOW ) { slotLimit = currentLimit; - return true; + return { canRetry: true, slotLimitBefore: currentLimit, slotLimitAfter: slotLimit }; } slotLimit = currentLimit - 1; rateLimitReductionsInWindow += 1; - return true; + return { canRetry: true, slotLimitBefore: currentLimit, slotLimitAfter: slotLimit }; }; const launch = (pending: QueuedSubagentPending): QueuedSubagentAttempt => { @@ -189,18 +211,30 @@ export class SubagentLaunchQueue { active.splice(active.indexOf(attempt), 1); const outcome = await attempt.outcome; if (isRateLimitedOutcome(outcome)) { - if (!reduceSlotsAfterRateLimit()) { + const reduction = reduceSlotsAfterRateLimit(); + const agentId = outcome.agentId ?? attempt.pending.agentId; + if (!reduction.canRetry) { results[attempt.pending.index] = { task: tasks[attempt.pending.index]!, - agentId: outcome.agentId ?? attempt.pending.agentId, + agentId, status: 'failed', error: RATE_LIMIT_RETRY_EXHAUSTED_MESSAGE, }; return true; } + const retryAttempt = (attempt.pending.rateLimitAttempts ?? 0) + 1; + if (agentId !== undefined) { + this.events.onSuspended?.({ + task: tasks[attempt.pending.index]!, + agentId, + reason: RATE_LIMIT_SUSPENDED_REASON, + retryAttempt, + }); + } requeueRateLimited({ index: attempt.pending.index, - agentId: outcome.agentId ?? attempt.pending.agentId, + agentId, + rateLimitAttempts: retryAttempt, }); return false; } diff --git a/packages/node-sdk/src/events.ts b/packages/node-sdk/src/events.ts index 2ea7b2a75..8dae536da 100644 --- a/packages/node-sdk/src/events.ts +++ b/packages/node-sdk/src/events.ts @@ -83,6 +83,7 @@ export type { export type { SubagentSpawnedEvent, SubagentStartedEvent, + SubagentSuspendedEvent, SubagentCompletedEvent, SubagentFailedEvent, } from '@moonshot-ai/agent-core'; diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts index 2bc9c8a1e..c3c04e6e8 100644 --- a/packages/node-sdk/test/session-event-types.test.ts +++ b/packages/node-sdk/test/session-event-types.test.ts @@ -33,6 +33,7 @@ describe('Event public types', () => { it('narrows subagent lifecycle events by type', () => { expectTypeOf['subagentId']>().toEqualTypeOf(); expectTypeOf['runInBackground']>().toEqualTypeOf(); + expectTypeOf['reason']>().toEqualTypeOf(); }); it('narrows cron fired events by type', () => { @@ -76,6 +77,7 @@ describe('Event public types', () => { case 'mcp.server.status': case 'subagent.spawned': case 'subagent.started': + case 'subagent.suspended': case 'subagent.completed': case 'subagent.failed': case 'compaction.started': From 5729fbc5cb44ad11cd848669eef498f96d389068 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 17:57:36 +0800 Subject: [PATCH 15/72] fix: show suspended swarm progress --- .../messages/agent-swarm-progress.ts | 74 +++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) 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 index d551775ab..849542478 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -29,12 +29,20 @@ const WORKING_LABEL = 'Working...'; const FAILED_LABEL = 'Failed.'; const CANCELLED_LABEL = 'Cancelled.'; const QUEUED_LABEL = 'Queued...'; +const SUSPENDED_LABEL = 'Suspended...'; -const STATUS_BAR_ORDER = ['completed', 'working', 'queued', 'cancelled', 'failed'] as const; +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' | 'failed' | 'cancelled'; +type TotalStatus = 'working' | 'suspended' | 'failed' | 'cancelled'; interface AgentSwarmMember { readonly id: string; @@ -45,6 +53,7 @@ interface AgentSwarmMember { latestModelText: string; completedText?: string; failureText?: string; + suspendedReason?: string; completedAtMs?: number; failedAtMs?: number; } @@ -79,6 +88,7 @@ export interface AgentSwarmProgressOptions { const PHASE_LABELS: Record = { pending: QUEUED_LABEL, queued: QUEUED_LABEL, + suspended: SUSPENDED_LABEL, running: 'Running', completed: 'Completed', failed: 'Failed', @@ -183,9 +193,10 @@ export class AgentSwarmProgressComponent implements Component { const nowMs = Date.now(); this.progressEstimator.markStarted(member.id, nowMs); member.ticks = Math.max(member.ticks, 1); - if (member.phase === 'pending' || member.phase === 'queued') { + if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { member.phase = 'running'; } + delete member.suspendedReason; this.startAnimationIfNeeded(); } @@ -202,9 +213,10 @@ export class AgentSwarmProgressComponent implements Component { }); if (!result.accepted) return; member.ticks = result.rawTicks; - if (member.phase === 'pending' || member.phase === 'queued') { + if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { member.phase = 'running'; } + delete member.suspendedReason; this.startAnimationIfNeeded(); } @@ -217,11 +229,12 @@ export class AgentSwarmProgressComponent implements Component { member.latestModelText = `${member.latestModelText}${input.delta}`.slice( -MAX_LATEST_MODEL_CHARS, ); - if (member.phase === 'pending' || member.phase === 'queued') { + if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { this.progressEstimator.markStarted(member.id, Date.now()); member.ticks = Math.max(member.ticks, 1); member.phase = 'running'; } + delete member.suspendedReason; } appendAssistantDelta(input: { @@ -243,10 +256,30 @@ export class AgentSwarmProgressComponent implements Component { if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; delete member.failedAtMs; delete member.failureText; + delete member.suspendedReason; member.phase = 'completed'; this.startAnimationIfNeeded(); } + markSuspended(input: { + readonly agentId: string; + readonly reason: string; + readonly description?: string | undefined; + }): void { + const member = this.findMemberByAgentId(input.agentId) ?? + this.findMemberForSubagent(input.agentId, input.description); + if (member === undefined || member.phase === 'completed' || member.phase === 'cancelled') return; + member.agentId = input.agentId; + member.phase = 'suspended'; + const reason = normalizeStatusText(input.reason); + if (reason !== undefined) member.suspendedReason = reason; + delete member.completedAtMs; + delete member.completedText; + delete member.failedAtMs; + delete member.failureText; + this.startAnimationIfNeeded(); + } + markFailed(agentId: string, failureText?: string): void { const member = this.findMemberByAgentId(agentId); if (member === undefined) return; @@ -260,6 +293,7 @@ export class AgentSwarmProgressComponent implements Component { member.phase = 'failed'; delete member.completedAtMs; delete member.completedText; + delete member.suspendedReason; this.startAnimationIfNeeded(); } @@ -282,6 +316,7 @@ export class AgentSwarmProgressComponent implements Component { member.phase = 'failed'; delete member.completedAtMs; delete member.completedText; + delete member.suspendedReason; } this.startAnimationIfNeeded(); } @@ -296,6 +331,7 @@ export class AgentSwarmProgressComponent implements Component { delete member.completedText; delete member.failedAtMs; delete member.failureText; + delete member.suspendedReason; } markActiveCancelled(): void { @@ -315,6 +351,7 @@ export class AgentSwarmProgressComponent implements Component { delete member.completedText; delete member.failedAtMs; delete member.failureText; + delete member.suspendedReason; } this.startAnimationIfNeeded(); } @@ -335,6 +372,7 @@ export class AgentSwarmProgressComponent implements Component { } if (entry.status === 'completed') delete member.failedAtMs; if (entry.status === 'completed') delete member.failureText; + if (entry.status === 'completed') delete member.suspendedReason; if (entry.status === 'failed' && member.phase !== 'failed') { this.progressEstimator.markFailed(member.id, nowMs); member.failedAtMs = nowMs; @@ -345,6 +383,7 @@ export class AgentSwarmProgressComponent implements Component { } if (entry.status === 'failed') delete member.completedAtMs; if (entry.status === 'failed') delete member.completedText; + if (entry.status === 'failed') delete member.suspendedReason; member.phase = entry.status; } this.startAnimationIfNeeded(); @@ -737,6 +776,8 @@ function brailleBar( return ''; case 'queued': return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.textDim, colors), colors); + case 'suspended': + return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.warning, colors), colors); case 'running': return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.success, colors), colors); case 'completed': @@ -766,6 +807,8 @@ function phaseColor(phase: AgentSwarmPhase, colors: ColorPalette): string { case 'pending': case 'queued': return colors.textDim; + case 'suspended': + return colors.warning; case 'running': return colors.textDim; case 'completed': @@ -818,6 +861,8 @@ function statusBarPhase(phase: AgentSwarmPhase): StatusBarPhase { case 'pending': case 'queued': return 'queued'; + case 'suspended': + return 'suspended'; case 'running': return 'working'; case 'completed': @@ -835,6 +880,8 @@ function statusBarColor(phase: StatusBarPhase, colors: ColorPalette): string { return colors.textMuted; case 'working': return colors.primary; + case 'suspended': + return colors.warning; case 'completed': return colors.success; case 'failed': @@ -851,9 +898,17 @@ function totalStatus( if (force.failed) return 'failed'; if (force.cancelled && members.length === 0) return 'cancelled'; const hasCancelled = members.some((member) => member.phase === 'cancelled'); + const hasSuspended = members.some((member) => member.phase === 'suspended'); + const hasRunning = members.some((member) => member.phase === 'running'); const hasActive = members.some((member) => - member.phase === 'pending' || member.phase === 'queued' || member.phase === 'running' + ( + member.phase === 'pending' || + member.phase === 'queued' || + member.phase === 'suspended' || + member.phase === 'running' + ) ); + if (hasSuspended && !hasRunning) return 'suspended'; return (force.cancelled || hasCancelled) && !hasActive ? 'cancelled' : 'working'; } @@ -861,6 +916,8 @@ function totalStatusLabel(status: TotalStatus): string { switch (status) { case 'working': return WORKING_LABEL; + case 'suspended': + return SUSPENDED_LABEL; case 'failed': return FAILED_LABEL; case 'cancelled': @@ -872,6 +929,8 @@ function totalStatusColor(status: TotalStatus, colors: ColorPalette): string { switch (status) { case 'working': return colors.success; + case 'suspended': + return colors.warning; case 'failed': return colors.error; case 'cancelled': @@ -913,6 +972,9 @@ function renderCellLabel( if (snapshot.phase === 'failed' && member.failureText !== undefined) { return truncateWithColor(`Failed: ${member.failureText}`, width, colors.error); } + if (snapshot.phase === 'suspended' && member.suspendedReason !== undefined) { + return truncateWithColor(`Suspended: ${member.suspendedReason}`, width, colors.warning); + } if (snapshot.phase === 'completed') { return renderCompletedCellLabel(member.completedText ?? latestLine, width, colors); } From 42ff90bfbcd7e680f5a84f3f7c579ccdad434fe7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 17:58:25 +0800 Subject: [PATCH 16/72] fix: normalize suspended swarm status --- .../src/tui/components/messages/agent-swarm-progress.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 index 849542478..14eebdece 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -1050,6 +1050,11 @@ function normalizeFailureText(text: string | undefined): string | undefined { return normalized.length > 0 ? normalized : undefined; } +function normalizeStatusText(text: string): string | undefined { + const normalized = collapseWhitespace(text); + return normalized.length > 0 ? normalized : undefined; +} + function normalizeFinalOutputText(text: string | undefined): string | undefined { if (text === undefined) return undefined; const normalized = collapseWhitespace(text); From a11622746111ce00b7c0c6d6fe377edd355dd85c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 18:20:35 +0800 Subject: [PATCH 17/72] fix --- apps/kimi-code/src/cli/run-prompt.ts | 2 + .../messages/agent-swarm-progress.ts | 20 +- .../src/tui/controllers/btw-panel.ts | 3 + .../tui/controllers/session-event-handler.ts | 31 ++ .../messages/agent-swarm-progress.test.ts | 80 ++++- .../test/tui/kimi-tui-message-flow.test.ts | 109 +++++-- .../agent-core/src/session/subagent-host.ts | 35 +- .../builtin/collaboration/agent-swarm.old.md | 9 - .../collaboration/agent-swarm.old.ts.txt | 308 ------------------ .../test/session/subagent-host.test.ts | 84 ++++- .../examples/kimi-harness-prompt-demo.ts | 5 + .../examples/runtime-smoke-helpers.ts | 5 + 12 files changed, 315 insertions(+), 376 deletions(-) delete mode 100644 packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.md delete mode 100644 packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.ts.txt diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 389267f92..10bdb3fbc 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -466,6 +466,7 @@ function runPromptTurn( case 'compaction.completed': case 'compaction.started': case 'cron.fired': + case 'goal.updated': case 'mcp.server.status': case 'session.meta.updated': case 'skill.activated': @@ -473,6 +474,7 @@ function runPromptTurn( 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/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index 14eebdece..c947687c8 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -5,7 +5,7 @@ import { AgentSwarmProgressEstimator, type AgentSwarmProgressEstimatorPhase, } from '#/tui/components/messages/agent-swarm-progress-estimator'; -import { SUCCESS_MARK } from '#/tui/constant/symbols'; +import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; const MIN_CELL_WIDTH = 32; @@ -970,7 +970,7 @@ function renderCellLabel( if (text.length > 0) return truncateWithColor(text, width, colors.textDim); } if (snapshot.phase === 'failed' && member.failureText !== undefined) { - return truncateWithColor(`Failed: ${member.failureText}`, width, colors.error); + return truncateWithColor(`${FAILURE_MARK}${member.failureText}`, width, colors.error); } if (snapshot.phase === 'suspended' && member.suspendedReason !== undefined) { return truncateWithColor(`Suspended: ${member.suspendedReason}`, width, colors.warning); @@ -1046,10 +1046,24 @@ function collapseWhitespace(text: string): string { function normalizeFailureText(text: string | undefined): string | undefined { if (text === undefined) return undefined; - const normalized = collapseWhitespace(text); + const nestedFailureText = nestedAgentSwarmFailureText(text); + const normalized = stripAgentSwarmPrefix(collapseWhitespace(nestedFailureText ?? text)); return normalized.length > 0 ? normalized : undefined; } +function nestedAgentSwarmFailureText(text: string): string | undefined { + 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 stripAgentSwarmPrefix(text: string): string { + return text.replace(/^agent_swarm:\s*(?:failed|completed)?\s*/i, '').trim(); +} + function normalizeStatusText(text: string): string | undefined { const normalized = collapseWhitespace(text); return normalized.length > 0 ? normalized : undefined; diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index a1fc1e140..b22de92e8 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -133,12 +133,15 @@ export class BtwPanelController { case 'compaction.started': case 'cron.fired': case 'error': + case 'goal.updated': case 'mcp.server.status': case 'session.meta.updated': case 'skill.activated': case 'subagent.completed': case 'subagent.failed': case 'subagent.spawned': + case 'subagent.started': + case 'subagent.suspended': case 'tool.call.delta': case 'tool.call.started': case 'tool.list.updated': 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 a14b7bcf5..4f1b997c6 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -20,6 +20,7 @@ import type { SubagentFailedEvent, SubagentSpawnedEvent, SubagentStartedEvent, + SubagentSuspendedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -224,6 +225,7 @@ export class SessionEventHandler { case 'compaction.cancelled': this.handleCompactionCancel(event, sendQueued); break; case 'subagent.spawned': this.handleSubagentSpawned(event); break; case 'subagent.started': this.handleSubagentStarted(event); break; + case 'subagent.suspended': this.handleSubagentSuspended(event); break; case 'subagent.completed': this.handleSubagentCompleted(event); break; case 'subagent.failed': this.handleSubagentFailed(event); break; case 'background.task.started': @@ -274,6 +276,12 @@ export class SessionEventHandler { }); } else if (event.type === 'subagent.started') { swarmProgress.markStarted(event.subagentId); + } else if (event.type === 'subagent.suspended') { + swarmProgress.markSuspended({ + agentId: event.subagentId, + reason: event.reason, + description: event.description, + }); } else if (event.type === 'subagent.failed') { if (isUserCancelledSubagentError(event.error)) { swarmProgress.markCancelled(event.subagentId); @@ -343,6 +351,7 @@ export class SessionEventHandler { case 'subagent.failed': case 'subagent.spawned': case 'subagent.started': + case 'subagent.suspended': case 'tool.progress': case 'tool.list.updated': case 'mcp.server.status': @@ -972,6 +981,28 @@ export class SessionEventHandler { }); } + private handleSubagentSuspended(event: SubagentSuspendedEvent): void { + const existing = this.subagentInfo.get(event.subagentId); + if (existing === undefined) { + this.subagentInfo.set(event.subagentId, { + parentToolCallId: event.parentToolCallId, + name: event.subagentName, + }); + } + + if (event.runInBackground) return; + + const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); + if (swarmProgress !== undefined) { + swarmProgress.markSuspended({ + agentId: event.subagentId, + reason: event.reason, + description: event.description, + }); + this.host.state.ui.requestRender(); + } + } + private handleSubagentCompleted(event: SubagentCompletedEvent): void { const { streamingUI } = this.host; const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); 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 index 3a036cec0..0d2f7a211 100644 --- 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 @@ -27,7 +27,8 @@ describe('AgentSwarmProgressComponent', () => { const output = strip(component.render(100).join('\n')); - expect(output).toContain('Agent swarm: Review changed files'); + expect(output).toContain('Agent swarm'); + expect(output).toContain('Review changed files'); expect(output).toContain('Orchestrating...'); expect(output).not.toContain('01'); }); @@ -103,7 +104,32 @@ describe('AgentSwarmProgressComponent', () => { const output = strip(component.render(100).join('\n')); - expect(output).toContain('Failed: Provider request failed Retry budget exhausted'); + expect(output).toContain('✗ Provider request failed Retry budget exhausted'); + expect(output).not.toContain('Failed:'); + }); + + it('renders suspended subagents 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('Suspended: Provider rate limit; subagent requeued for retry.'); + expect(output).not.toContain('Failed'); + + component.markStarted('agent-1'); + + output = strip(component.render(100).join('\n')); + expect(output).toContain('Running'); + expect(output).not.toContain('Suspended'); }); it('renders failure details from AgentSwarm result output', () => { @@ -135,7 +161,51 @@ describe('AgentSwarmProgressComponent', () => { const output = strip(component.render(100).join('\n')); - expect(output).toContain('Failed: Agent timed out after 30s.'); + expect(output).toContain('✗ Agent timed out after 30s.'); + expect(output).not.toContain('Failed:'); + }); + + 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([ + 'agent_swarm: failed', + 'description: Review changed files', + 'items: 1', + 'completed: 0', + 'failed: 1', + '', + '[agent 1]', + 'agent_id: agent-1', + 'item: "src/a.ts"', + 'actual_subagent_type: coder', + 'status: failed', + 'description: Review changed files #1 (coder)', + '', + 'subagent error: 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', () => { @@ -209,7 +279,6 @@ describe('AgentSwarmProgressComponent', () => { expect(output).toContain('001 ['); expect(output).toContain('Reviewing'); expect(output).toContain('…'); - expect(output).not.toContain('Working'); }); it('renders boosted fractional progress ticks without leaking undefined cells', () => { @@ -277,7 +346,8 @@ describe('AgentSwarmProgressComponent', () => { }); const output = strip(component.render(100).join('\n')); - expect(output).toContain('Agent swarm: Review changed files'); + 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'); }); 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 f598720ce..7465729d8 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 @@ -2216,7 +2216,7 @@ command = "vim" }); }); - it('renders AgentSwarm progress as the activity pane instead of tool-card body', async () => { + it('renders AgentSwarm progress in the transcript instead of the tool-card body', async () => { const { driver } = await makeDriver(); const sendQueued = vi.fn(); @@ -2290,9 +2290,50 @@ command = "vim" } as Event, sendQueued, ); - let activity = stripSgr(renderActivity(driver)); - expect(activity).toContain('01 ['); - expect(activity).toContain('Reviewing src/a.ts'); + 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', + parentToolCallId: 'call_swarm', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + runInBackground: false, + reason: 'Provider rate limit; subagent requeued for retry.', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Suspended: Provider rate limit; subagent'); + expect(transcript).not.toContain('Failed'); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.started', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + runInBackground: false, + } 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( @@ -2307,13 +2348,14 @@ command = "vim" ); expect(driver.state.ui.requestRender).toHaveBeenCalled(); - activity = stripSgr(renderActivity(driver)); - expect(activity).toContain('Agent swarm: Review changed files'); - expect(activity).toContain('001 ['); - expect(activity).toContain('✓ Reviewing src/a.ts'); - expect(activity).not.toContain('Completed'); - expect(activity).toContain('002 Queued...'); - expect(activity).not.toContain('002 ['); + 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( { @@ -2327,13 +2369,9 @@ command = "vim" sendQueued, ); - activity = stripSgr(renderActivity(driver)); - expect(activity).toContain('✓ Imports are stable'); - expect(activity).not.toContain('Completed'); - - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Using AgentSwarm'); - expect(transcript).not.toContain('01'); + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('✓ Imports are stable'); + expect(transcript).not.toContain('Completed'); }); it('renders AgentSwarm progress while tool args are still streaming', async () => { @@ -2353,10 +2391,10 @@ command = "vim" sendQueued, ); - let activity = stripSgr(renderActivity(driver)); - expect(activity).toContain('Agent swarm'); - expect(activity).toContain('Orchestrating...'); - expect(activity).not.toContain('01'); + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent swarm'); + expect(transcript).toContain('Orchestrating...'); + expect(transcript).not.toContain('01'); driver.sessionEventHandler.handleEvent( { @@ -2370,10 +2408,11 @@ command = "vim" sendQueued, ); - activity = stripSgr(renderActivity(driver)); - expect(activity).toContain('Agent swarm: Review changed files'); - expect(activity).toContain('001 src/a.ts'); - expect(activity).toContain('002 src/b'); + 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( { @@ -2389,10 +2428,10 @@ command = "vim" sendQueued, ); - activity = stripSgr(renderActivity(driver)); - expect(activity).toContain('001 Queued...'); - expect(activity).not.toContain('001 ['); - expect(activity).toContain('002 src/b'); + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 Queued...'); + expect(transcript).not.toContain('001 ['); + expect(transcript).toContain('002 src/b'); driver.sessionEventHandler.handleEvent( { @@ -2411,11 +2450,11 @@ command = "vim" sendQueued, ); - activity = stripSgr(renderActivity(driver)); - expect(activity).toContain('001 Queued...'); - expect(activity).toContain('002 Queued...'); - expect(activity).not.toContain('001 ['); - expect(activity).not.toContain('002 ['); + 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 () => { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 35fbaf0f6..60e006f4e 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -600,7 +600,7 @@ export class SessionSubagentHost { } private emitSubagentSuspended(event: QueuedSubagentSuspended): void { - const parent = this.session.getReadyAgent(this.ownerAgentId); + const parent = this.session.getReadyAgent?.(this.ownerAgentId); parent?.emitEvent({ type: 'subagent.suspended', subagentId: event.agentId, @@ -647,27 +647,32 @@ function lastAssistantText(agent: Agent): string { } function isFirstOutputEvent(event: AgentEvent): boolean { - switch (event.type) { - case 'assistant.delta': - case 'thinking.delta': - return event.delta.length > 0; - case 'tool.call.delta': - return (event.name?.length ?? 0) > 0 || (event.argumentsPart?.length ?? 0) > 0; - case 'tool.call.started': - return true; - default: - return false; + if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { + return event.delta.length > 0; } + if (event.type === 'tool.call.delta') { + return (event.name?.length ?? 0) > 0 || (event.argumentsPart?.length ?? 0) > 0; + } + return event.type === 'tool.call.started'; } function isRateLimit429Error(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); + if (hasRateLimitStatus(error)) return true; if (message.includes(RATE_LIMIT_429_MESSAGE)) return true; if (message.includes('provider.rate_limit')) return true; - if (message.includes('429') && message.toLowerCase().includes('rate limit')) return true; - if (!message.includes(RATE_LIMIT_429_BODY)) return hasRateLimitStatus(error); - if (message.includes('429') || message.includes('provider.rate_limit')) return true; - return hasRateLimitStatus(error); + const normalized = message.toLowerCase(); + if (!/\b429\b/.test(normalized)) return false; + if (normalized.includes('apistatuserror')) return true; + if (normalized.includes('too many requests')) return true; + if (normalized.includes('rate limit')) return true; + if (normalized.includes('rate_limit')) return true; + if (normalized.includes('rate-limited')) return true; + if (normalized.includes('max rpm')) return true; + if (normalized.includes('max tpm')) return true; + if (normalized.includes('requests per minute')) return true; + if (normalized.includes('tokens per minute')) return true; + return message.includes(RATE_LIMIT_429_BODY); } function hasRateLimitStatus(error: unknown): boolean { diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.md b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.md deleted file mode 100644 index 322b0b176..000000000 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.md +++ /dev/null @@ -1,9 +0,0 @@ -Launch multiple subagents from a JSONL agents file. This tool is for avoiding manually writing out many similar subagent prompts. - -Before calling AgentSwarm, generate the `agents_file` in a temporary directory with a code script. The JSONL file must be produced programmatically, and the script must use a loop to generate the agents instead of hard-coding every agent. DO NOT hand-write the JSONL file. - -The agents file: -- A file must define more than 3 subagents, no upper limit. -- Each line must be one JSON object with `prompt` and optional `subagent_type`. -- `prompt` is the task prompt sent as that subagent's first user message. -- `subagent_type` is one of the available subagent types, such as `coder`, `explore`, or `plan`. It defaults to `coder` when omitted. diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.ts.txt b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.ts.txt deleted file mode 100644 index 08b6dd7ad..000000000 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.old.ts.txt +++ /dev/null @@ -1,308 +0,0 @@ -import type { Kaos } from '@moonshot-ai/kaos'; -import { z } from 'zod'; - -import type { BuiltinTool } from '../../../agent/tool'; -import type { SessionSubagentHost, SubagentHandle } from '../../../session/subagent-host'; -import { - createDeadlineAbortSignal, - isUserCancellation, - type DeadlineAbortSignal, -} from '../../../utils/abort'; -import { isAbortError } from '../../../loop/errors'; -import { ToolAccesses } from '../../../loop/tool-access'; -import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types'; -import { resolvePathAccessPath } from '../../policies/path-access'; -import { toInputJsonSchema } from '../../support/input-schema'; -import { literalRulePattern, matchesPathRuleSubject } from '../../support/rule-match'; -import type { WorkspaceConfig } from '../../support/workspace'; -import AGENT_SWARM_DESCRIPTION from './agent-swarm.md'; - -const MAX_SWARM_AGENTS = 50; -const DEFAULT_SUBAGENT_TYPE = 'coder'; - -export const AgentSwarmToolInputSchema = z.object({ - agents_file: z - .string() - .describe('Path to the JSONL agents file.'), - description: z - .string() - .trim() - .min(1) - .describe('Short description for the whole swarm.'), - timeout: z - .number() - .int() - .min(30) - .max(3600) - .optional() - .describe('Timeout in seconds for each subagent.'), -}); - -export type AgentSwarmToolInput = z.infer; - -const AgentSwarmSpecSchema = z - .object({ - prompt: z.string().trim().min(1), - subagent_type: z.string().trim().min(1).optional(), - }) - .strict(); - -type AgentSwarmSpec = z.infer; - -interface ParsedAgentSwarmSpec extends AgentSwarmSpec { - readonly lineNo: number; -} - -interface SwarmRunResult { - readonly spec: ParsedAgentSwarmSpec; - readonly agentId?: string; - readonly profileName: string; - readonly description: string; - readonly status: 'completed' | 'failed'; - 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 kaos: Kaos, - private readonly workspace: WorkspaceConfig, - private readonly subagentHost: SessionSubagentHost, - ) {} - - resolveExecution(args: AgentSwarmToolInput): ToolExecution { - const path = resolvePathAccessPath(args.agents_file, { - kaos: this.kaos, - workspace: this.workspace, - operation: 'read', - }); - return { - accesses: ToolAccesses.readFile(path), - description: `Launching agent swarm: ${args.description}`, - display: { - kind: 'agent_call', - agent_name: 'swarm', - prompt: args.agents_file, - }, - approvalRule: literalRulePattern(this.name, path), - matchesRule: (ruleArgs) => - matchesPathRuleSubject(ruleArgs, path, { - cwd: this.workspace.workspaceDir, - pathClass: this.kaos.pathClass(), - homeDir: this.kaos.gethome(), - }), - execute: (ctx) => this.execution(args, path, ctx), - }; - } - - private async execution( - args: AgentSwarmToolInput, - safePath: string, - context: ExecutableToolContext, - ): Promise { - try { - const text = await this.kaos.readText(safePath, { errors: 'strict' }); - const specs = parseAgentSwarmSpecs(text); - - const result = await this.runSwarm(args, specs, context.signal, context.toolCallId); - return { - output: result, - isError: swarmResultHasFailures(result) ? true : undefined, - }; - } catch (error) { - return { - output: error instanceof Error ? error.message : String(error), - isError: true, - }; - } - } - - private async runSwarm( - args: AgentSwarmToolInput, - specs: readonly ParsedAgentSwarmSpec[], - signal: AbortSignal, - toolCallId: string, - ): Promise { - let foregroundDeadline: DeadlineAbortSignal | undefined; - try { - foregroundDeadline = - args.timeout !== undefined - ? createDeadlineAbortSignal(signal, args.timeout * 1000) - : undefined; - const runSignal = foregroundDeadline?.signal ?? signal; - const results = await Promise.all( - specs.map((spec) => - this.runOne( - args, - spec, - runSignal, - toolCallId, - () => foregroundDeadline?.timedOut() === true, - ), - ), - ); - return renderSwarmResults(args, results); - } finally { - foregroundDeadline?.clear(); - } - } - - private async runOne( - args: AgentSwarmToolInput, - spec: ParsedAgentSwarmSpec, - signal: AbortSignal, - toolCallId: string, - timedOut: () => boolean, - ): Promise { - const profileName = spec.subagent_type ?? DEFAULT_SUBAGENT_TYPE; - const description = childDescription(args.description, spec.lineNo, profileName); - let handle: SubagentHandle | undefined; - try { - signal.throwIfAborted(); - handle = await this.subagentHost.spawn(profileName, { - parentToolCallId: toolCallId, - prompt: spec.prompt, - description, - runInBackground: false, - signal, - }); - const completion = await handle.completion; - return { - spec, - agentId: handle.agentId, - profileName: handle.profileName, - description, - status: 'completed', - result: completion.result, - }; - } catch (error) { - return { - spec, - agentId: handle?.agentId, - profileName: handle?.profileName ?? profileName, - description, - status: 'failed', - error: formatSubagentError(error, signal, timedOut, args.timeout), - }; - } - } -} - -function parseAgentSwarmSpecs(text: string): ParsedAgentSwarmSpec[] { - const specs: ParsedAgentSwarmSpec[] = []; - const seenAgents = new Map(); - const lines = text.split(/\r?\n/u); - for (const [index, rawLine] of lines.entries()) { - const lineNo = index + 1; - const line = rawLine.trim(); - if (line.length === 0) continue; - if (specs.length >= MAX_SWARM_AGENTS) { - throw new Error(`AgentSwarm supports at most ${String(MAX_SWARM_AGENTS)} agents per file.`); - } - let parsed: unknown; - try { - parsed = JSON.parse(line) as unknown; - } catch (error) { - throw new Error(`Invalid JSON on line ${String(lineNo)}: ${errorMessage(error)}`, { - cause: error, - }); - } - const result = AgentSwarmSpecSchema.safeParse(parsed); - if (!result.success) { - throw new Error(`Invalid subagent spec on line ${String(lineNo)}: ${result.error.message}`); - } - const duplicateKey = agentSpecDuplicateKey(result.data); - const duplicateLineNo = seenAgents.get(duplicateKey); - if (duplicateLineNo !== undefined) { - const subagentType = result.data.subagent_type ?? DEFAULT_SUBAGENT_TYPE; - throw new Error( - `Duplicate subagent spec on lines ${String(duplicateLineNo)} and ${String(lineNo)}: prompt and subagent_type "${subagentType}" are identical. AgentSwarm requires distinct subagents.`, - ); - } - seenAgents.set(duplicateKey, lineNo); - specs.push({ ...result.data, lineNo }); - } - if (specs.length < 2) { - throw new Error( - 'AgentSwarm requires at least 2 subagent specs. Use Agent for a single subagent.', - ); - } - return specs; -} - -function agentSpecDuplicateKey(spec: AgentSwarmSpec): string { - return JSON.stringify({ - prompt: spec.prompt, - subagent_type: spec.subagent_type ?? DEFAULT_SUBAGENT_TYPE, - }); -} - -function childDescription(swarmDescription: string, lineNo: number, profileName: string): string { - return `${swarmDescription} #${String(lineNo)} (${profileName})`; -} - -function renderSwarmResults( - args: AgentSwarmToolInput, - results: readonly SwarmRunResult[], -): string { - const completed = results.filter((result) => result.status === 'completed').length; - const failed = results.length - completed; - const lines = [ - `agent_swarm: ${failed > 0 ? 'failed' : 'completed'}`, - `description: ${args.description}`, - `source: ${args.agents_file}`, - `agents: ${String(results.length)}`, - `completed: ${String(completed)}`, - `failed: ${String(failed)}`, - ]; - - for (const [index, result] of results.entries()) { - lines.push( - '', - `[agent ${String(index + 1)}]`, - `line: ${String(result.spec.lineNo)}`, - ...(result.agentId === undefined ? [] : [`agent_id: ${result.agentId}`]), - `actual_subagent_type: ${result.profileName}`, - `status: ${result.status}`, - `description: ${result.description}`, - '', - ); - if (result.status === 'completed') { - lines.push('[summary]', result.result ?? ''); - } else { - lines.push(`subagent error: ${result.error ?? 'unknown error'}`); - } - } - - return lines.join('\n'); -} - -function swarmResultHasFailures(result: string): boolean { - return result.startsWith('agent_swarm: failed\n'); -} - -function formatSubagentError( - error: unknown, - signal: AbortSignal, - timedOut: () => boolean, - timeout: number | undefined, -): string { - if (timedOut() && timeout !== undefined) { - return `AgentSwarm timed out after ${String(timeout)}s.`; - } - if (isUserCancellation(signal.reason)) { - return 'The user manually interrupted this subagent swarm.'; - } - if (isAbortError(error)) { - return 'The subagent was stopped before it finished.'; - } - return errorMessage(error); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index bd332418e..364369902 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -362,6 +362,86 @@ describe('SessionSubagentHost', () => { } }); + it('runQueued emits a suspended event when a rate-limited child is requeued', async () => { + const parent = testAgent(); + parent.configure(); + parent.newEvents(); + + const summary = + 'Recovered from a provider rate limit by retrying the queued subagent with its existing context, then completed the delegated review with enough concrete details for the parent to continue. '.repeat( + 2, + ); + let generateCalls = 0; + const generate: GenerateFn = async ( + _provider, + _systemPrompt, + _tools, + _history, + callbacks, + ) => { + generateCalls += 1; + if (generateCalls === 1) { + throw new Error( + 'APIStatusError: 429 request id: req-429, Your account example-account request reached user+model max RPM: 50 (current: 389) for model example-model, please try again later', + ); + } + await callbacks?.onMessagePart?.({ type: 'text', text: summary }); + return textResult(summary); + }; + const child = testAgent({ + generate, + initialConfig: { + providers: {}, + loopControl: { maxRetriesPerStep: 1 }, + }, + }); + child.configure(); + + const session = fakeSession(parent.agent, child.agent); + const host = new SessionSubagentHost(session, 'main'); + + await expect(host.runQueued([queuedTask(1)], { signal })).resolves.toMatchObject([ + { + agentId: 'agent-0', + status: 'completed', + result: summary.trim(), + }, + ]); + + expect(generateCalls).toBe(2); + expect(parent.allEvents).toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'subagent.suspended', + args: expect.objectContaining({ + subagentId: 'agent-0', + subagentName: 'coder', + parentToolCallId: 'call_swarm', + runInBackground: false, + reason: 'Provider rate limit; subagent requeued for retry.', + }), + }), + ); + expect( + parent.allEvents + .filter((event) => event.type === '[rpc]') + .map((event) => event.event) + .filter((event) => typeof event === 'string' && event.startsWith('subagent.')), + ).toEqual([ + 'subagent.spawned', + 'subagent.started', + 'subagent.suspended', + 'subagent.started', + 'subagent.completed', + ]); + expect(parent.allEvents).not.toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'subagent.failed', + }), + ); + }); + it('fires subagent lifecycle hooks around the child turn', async () => { const child = testAgent(); const calls: Array<{ readonly event: string; readonly childLlmCallCount: number }> = []; @@ -634,7 +714,9 @@ describe('SessionSubagentHost', () => { const routedTo = await Promise.race([ child.untilToolCall({ output: 'moon-result' }).then(() => 'child'), parent.untilToolCall({ output: 'moon-result' }).then(() => 'parent'), - new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 50)), + new Promise<'timeout'>((resolve) => setTimeout(() => { + resolve('timeout'); + }, 50)), ]); expect(routedTo).toBe('child'); diff --git a/packages/node-sdk/examples/kimi-harness-prompt-demo.ts b/packages/node-sdk/examples/kimi-harness-prompt-demo.ts index eebbdccfb..692a7e9cf 100644 --- a/packages/node-sdk/examples/kimi-harness-prompt-demo.ts +++ b/packages/node-sdk/examples/kimi-harness-prompt-demo.ts @@ -99,6 +99,8 @@ function handleEvent( process.stderr.write(`\nerror: ${event.code}: ${event.message}\n`); break; case 'agent.status.updated': + case 'cron.fired': + case 'goal.updated': case 'session.meta.updated': case 'skill.activated': case 'turn.step.started': @@ -112,14 +114,17 @@ function handleEvent( case 'tool.list.updated': case 'mcp.server.status': case 'subagent.spawned': + case 'subagent.started': case 'subagent.completed': case 'subagent.failed': + case 'subagent.suspended': case 'compaction.started': case 'compaction.blocked': case 'compaction.cancelled': case 'compaction.completed': case 'background.task.started': case 'background.task.terminated': + case 'warning': break; } } diff --git a/packages/node-sdk/examples/runtime-smoke-helpers.ts b/packages/node-sdk/examples/runtime-smoke-helpers.ts index 022422870..784fa9923 100644 --- a/packages/node-sdk/examples/runtime-smoke-helpers.ts +++ b/packages/node-sdk/examples/runtime-smoke-helpers.ts @@ -215,6 +215,8 @@ function logEvent(event: Event): void { process.stderr.write(`\nerror: ${event.code}: ${event.message}\n`); break; case 'agent.status.updated': + case 'cron.fired': + case 'goal.updated': case 'session.meta.updated': case 'skill.activated': case 'turn.step.started': @@ -228,14 +230,17 @@ function logEvent(event: Event): void { case 'tool.list.updated': case 'mcp.server.status': case 'subagent.spawned': + case 'subagent.started': case 'subagent.completed': case 'subagent.failed': + case 'subagent.suspended': case 'compaction.started': case 'compaction.blocked': case 'compaction.cancelled': case 'compaction.completed': case 'background.task.started': case 'background.task.terminated': + case 'warning': break; } } From 68ec6654cdb8d83153d3a35bdf73dbf55b060c04 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 20:57:16 +0800 Subject: [PATCH 18/72] fix --- apps/kimi-code/src/tui/commands/undo.ts | 2 + .../messages/agent-swarm-progress.ts | 54 +- apps/kimi-code/src/tui/swarm-demo.ts | 2 +- .../messages/agent-swarm-progress.test.ts | 76 +- .../test/tui/kimi-tui-message-flow.test.ts | 44 +- .../agent-core/src/session/subagent-host.ts | 31 +- .../src/session/subagent-launch-queue.ts | 242 +++---- .../test/session/subagent-host.test.ts | 651 ++++++++++++------ 8 files changed, 730 insertions(+), 372 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index e5aff587d..a02e80046 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'; @@ -168,6 +169,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/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index c947687c8..0944ae7c4 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -8,8 +8,8 @@ import { import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; -const MIN_CELL_WIDTH = 32; -const CELL_GAP = ' '; +const MIN_CELL_WIDTH = 30; +const CELL_GAP = ' '; const FRAME_INTERVAL_MS = 80; const BRAILLE_BAR_MIN_WIDTH = 5; const BRAILLE_BAR_MAX_WIDTH = 8; @@ -23,6 +23,7 @@ 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 PROMPTING_TEXT_TRAILING_GAP = 1; const ORCHESTRATING_LABEL = 'Orchestrating...'; const PROMPTING_LABEL = 'Prompting...'; const WORKING_LABEL = 'Working...'; @@ -30,6 +31,14 @@ const FAILED_LABEL = 'Failed.'; const CANCELLED_LABEL = 'Cancelled.'; const QUEUED_LABEL = 'Queued...'; const SUSPENDED_LABEL = 'Suspended...'; +const TOTAL_STATUS_LABEL_WIDTH = Math.max( + visibleWidth(ORCHESTRATING_LABEL), + visibleWidth(PROMPTING_LABEL), + visibleWidth(WORKING_LABEL), + visibleWidth(FAILED_LABEL), + visibleWidth(CANCELLED_LABEL), + visibleWidth(SUSPENDED_LABEL), +); const STATUS_BAR_ORDER = [ 'completed', @@ -270,9 +279,8 @@ export class AgentSwarmProgressComponent implements Component { this.findMemberForSubagent(input.agentId, input.description); if (member === undefined || member.phase === 'completed' || member.phase === 'cancelled') return; member.agentId = input.agentId; - member.phase = 'suspended'; - const reason = normalizeStatusText(input.reason); - if (reason !== undefined) member.suspendedReason = reason; + member.phase = 'queued'; + delete member.suspendedReason; delete member.completedAtMs; delete member.completedText; delete member.failedAtMs; @@ -454,10 +462,12 @@ export class AgentSwarmProgressComponent implements Component { } private renderProgressStatusLine(width: number, status: TotalStatus): string { - const labelText = ` ${totalStatusLabel(status)}`; - const label = chalk.hex(totalStatusColor(status, this.colors))(labelText); + const label = renderTotalStatusLabel( + totalStatusLabel(status), + totalStatusColor(status, this.colors), + ); if (this.members.length === 0) return truncateToWidth(label, width); - const barWidth = Math.max(0, width - visibleWidth(labelText) - 2); + const barWidth = Math.max(0, width - visibleWidth(label) - 2); if (barWidth <= 0) return truncateToWidth(label, width); return truncateToWidth( `${label} ${renderStatusPipBar(this.members, barWidth, this.colors)} `, @@ -467,15 +477,23 @@ export class AgentSwarmProgressComponent implements Component { private renderOrchestratingStatusLine(width: number): string { if (this.itemsStarted) { - return truncateToWidth(chalk.hex(this.colors.textMuted)(` ${ORCHESTRATING_LABEL}`), width); + return truncateToWidth( + renderTotalStatusLabel(ORCHESTRATING_LABEL, this.colors.textMuted), + width, + ); } const promptTemplate = collapseWhitespace(this.promptTemplateText); - const labelText = ` ${promptTemplate.length > 0 ? PROMPTING_LABEL : ORCHESTRATING_LABEL}`; - const label = chalk.hex(this.colors.textMuted)(labelText); + const label = renderTotalStatusLabel( + promptTemplate.length > 0 ? PROMPTING_LABEL : ORCHESTRATING_LABEL, + this.colors.textMuted, + ); if (promptTemplate.length === 0) return truncateToWidth(label, width); - const availablePromptWidth = Math.max(0, width - visibleWidth(labelText)); + 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); @@ -844,6 +862,10 @@ function renderStatusPipBar( }).join(''); } +function renderTotalStatusLabel(label: string, color: string): string { + return ` ${padAnsi(chalk.hex(color)(label), TOTAL_STATUS_LABEL_WIDTH)}`; +} + function statusBarCounts(members: readonly AgentSwarmMember[]): StatusBarCount[] { const counts = new Map(); for (const member of members) { @@ -972,9 +994,6 @@ function renderCellLabel( if (snapshot.phase === 'failed' && member.failureText !== undefined) { return truncateWithColor(`${FAILURE_MARK}${member.failureText}`, width, colors.error); } - if (snapshot.phase === 'suspended' && member.suspendedReason !== undefined) { - return truncateWithColor(`Suspended: ${member.suspendedReason}`, width, colors.warning); - } if (snapshot.phase === 'completed') { return renderCompletedCellLabel(member.completedText ?? latestLine, width, colors); } @@ -1064,11 +1083,6 @@ function stripAgentSwarmPrefix(text: string): string { return text.replace(/^agent_swarm:\s*(?:failed|completed)?\s*/i, '').trim(); } -function normalizeStatusText(text: string): string | undefined { - const normalized = collapseWhitespace(text); - return normalized.length > 0 ? normalized : undefined; -} - function normalizeFinalOutputText(text: string | undefined): string | undefined { if (text === undefined) return undefined; const normalized = collapseWhitespace(text); diff --git a/apps/kimi-code/src/tui/swarm-demo.ts b/apps/kimi-code/src/tui/swarm-demo.ts index 976d18cb8..20d8c6c53 100644 --- a/apps/kimi-code/src/tui/swarm-demo.ts +++ b/apps/kimi-code/src/tui/swarm-demo.ts @@ -21,7 +21,7 @@ import { printableChar } from './utils/printable-key'; const DEFAULT_SWARM_COUNT = 32; const MAX_SWARM_COUNT = 256; const FRAME_INTERVAL_MS = 80; -const MIN_CELL_WIDTH = 32; +const MIN_CELL_WIDTH = 30; const CELL_GAP = ' '; const BRAILLE_BAR_MIN_WIDTH = 8; const BRAILLE_BAR_MAX_WIDTH = 24; 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 index 0d2f7a211..9226cf4b5 100644 --- 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 @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { visibleWidth } from '@earendil-works/pi-tui'; import { AgentSwarmProgressComponent, @@ -51,6 +52,24 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('agents=2'); }); + 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(94).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('advances from queued when a subagent tool call starts and marks terminal states', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', @@ -108,7 +127,7 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('Failed:'); }); - it('renders suspended subagents and clears the state when they start again', () => { + it('renders suspended subagents as queued and clears the state when they start again', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', colors: darkColors, @@ -122,7 +141,9 @@ describe('AgentSwarmProgressComponent', () => { }); let output = strip(component.render(100).join('\n')); - expect(output).toContain('Suspended: Provider rate limit; subagent requeued for retry.'); + expect(output).toContain('Queued...'); + expect(output).not.toContain('Suspended'); + expect(output).not.toContain('Provider rate limit'); expect(output).not.toContain('Failed'); component.markStarted('agent-1'); @@ -281,6 +302,57 @@ describe('AgentSwarmProgressComponent', () => { expect(output).toContain('…'); }); + it('keeps total status labels fixed before bars and streaming 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(progressBarIndex); + }); + + 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(49); + }); + it('renders boosted fractional progress ticks without leaking undefined cells', () => { vi.useFakeTimers(); const component = new AgentSwarmProgressComponent({ 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 7465729d8..4aeb654e2 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 @@ -903,6 +903,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 @@ -2312,7 +2352,9 @@ command = "vim" expect(driver.state.ui.requestRender).toHaveBeenCalled(); transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Suspended: Provider rate limit; subagent'); + 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(); diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 60e006f4e..6239e292c 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -78,6 +78,7 @@ type RunSubagentOptions = { readonly runInBackground: boolean; readonly origin?: PromptOrigin; readonly signal: AbortSignal; + readonly onStarted?: () => void; readonly onFirstOutput?: () => void; readonly suppressRateLimitFailureEvent?: boolean; }; @@ -331,12 +332,14 @@ export class SessionSubagentHost { ? await this.spawn({ ...task, signal: runSignal, + onStarted: options.markReady, onFirstOutput: options.markReady, suppressRateLimitFailureEvent: true, }) : await this.retry(options.retryAgentId, { ...task, signal: runSignal, + onStarted: options.markReady, onFirstOutput: options.markReady, suppressRateLimitFailureEvent: true, }); @@ -415,10 +418,11 @@ export class SessionSubagentHost { } const origin: PromptOrigin = options.origin ?? { kind: 'system_trigger', name: 'subagent' }; this.emitSubagentStarted(parent, childId, profileName, options); + options.onStarted?.(); child.turn.prompt([{ type: 'text', text: childPrompt }], origin); return await this.waitForChildCompletion(parent, childId, child, profileName, options, origin); } catch (error) { - if (!(options.suppressRateLimitFailureEvent === true && isRateLimit429Error(error))) { + if (!shouldSuppressQueuedAttemptFailureEvent(options, error)) { const message = error instanceof Error ? error.message : String(error); parent.emitEvent({ type: 'subagent.failed', @@ -447,12 +451,13 @@ export class SessionSubagentHost { child.config.update({ modelAlias: parent.config.modelAlias }); const origin: PromptOrigin = options.origin ?? { kind: 'system_trigger', name: 'subagent' }; this.emitSubagentStarted(parent, childId, profileName, options); + options.onStarted?.(); if (child.turn.retry(origin) === null) { throw new Error(`Agent instance "${childId}" could not start a retry turn`); } return await this.waitForChildCompletion(parent, childId, child, profileName, options, origin); } catch (error) { - if (!(options.suppressRateLimitFailureEvent === true && isRateLimit429Error(error))) { + if (!shouldSuppressQueuedAttemptFailureEvent(options, error)) { const message = error instanceof Error ? error.message : String(error); parent.emitEvent({ type: 'subagent.failed', @@ -660,19 +665,29 @@ function isRateLimit429Error(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); if (hasRateLimitStatus(error)) return true; if (message.includes(RATE_LIMIT_429_MESSAGE)) return true; + if (message.includes(RATE_LIMIT_429_BODY)) return true; if (message.includes('provider.rate_limit')) return true; const normalized = message.toLowerCase(); - if (!/\b429\b/.test(normalized)) return false; - if (normalized.includes('apistatuserror')) return true; if (normalized.includes('too many requests')) return true; - if (normalized.includes('rate limit')) return true; - if (normalized.includes('rate_limit')) return true; - if (normalized.includes('rate-limited')) return true; if (normalized.includes('max rpm')) return true; if (normalized.includes('max tpm')) return true; if (normalized.includes('requests per minute')) return true; if (normalized.includes('tokens per minute')) return true; - return message.includes(RATE_LIMIT_429_BODY); + if (!/\b429\b/.test(normalized)) return false; + if (normalized.includes('apistatuserror')) return true; + if (normalized.includes('rate limit')) return true; + if (normalized.includes('rate_limit')) return true; + if (normalized.includes('rate-limited')) return true; + return false; +} + +function shouldSuppressQueuedAttemptFailureEvent( + options: RunSubagentOptions, + error: unknown, +): boolean { + if (options.suppressRateLimitFailureEvent !== true) return false; + if (isRateLimit429Error(error)) return true; + return isAbortError(error) || options.signal.aborted; } function hasRateLimitStatus(error: unknown): boolean { diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index 96f83c952..ccb5179f0 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -4,12 +4,11 @@ import type { TokenUsage } from '@moonshot-ai/kosong'; import type { PromptOrigin } from '../agent/context'; import { abortable, createDeadlineAbortSignal } from '../utils/abort'; -const SUBAGENT_LAUNCH_BATCH_SIZE = 10; +const SUBAGENT_LAUNCH_BATCH_SIZE = 5; const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; -const RATE_LIMIT_SLOT_REDUCTION_WINDOW_MS = 1000; -const RATE_LIMIT_SLOT_REDUCTION_MAX_PER_WINDOW = 3; -const RATE_LIMIT_RETRY_EXHAUSTED_MESSAGE = - 'Subagent failed after another 429 with only one retry slot remaining.'; +const SUBAGENT_INITIAL_INCREMENT_DELAY_MS = 700; +const RATE_LIMIT_SLOT_REDUCTION_WINDOW_MS = 2000; +const RATE_LIMIT_RETRY_BASE_DELAY_MS = 3000; const RATE_LIMIT_SUSPENDED_REASON = 'Provider rate limit; subagent requeued for retry.'; export type QueuedSubagentTask = { @@ -58,6 +57,7 @@ type QueuedSubagentPending = { readonly index: number; readonly agentId?: string; readonly rateLimitAttempts?: number; + readonly nextRetryAtMs?: number; }; type QueuedSubagentAttempt = { @@ -65,6 +65,7 @@ type QueuedSubagentAttempt = { readonly outcome: Promise>; readonly readiness: Promise; readonly ready: boolean; + readonly launchSucceeded: boolean; settled: boolean; }; @@ -83,12 +84,6 @@ type SubagentLaunchQueueEvents = { readonly onSuspended?: (event: QueuedSubagentSuspended) => void; }; -type SlotReductionResult = { - readonly canRetry: boolean; - readonly slotLimitBefore: number; - readonly slotLimitAfter: number; -}; - export class SubagentLaunchQueue { constructor( private readonly runAttempt: RunQueuedSubagentAttempt, @@ -115,12 +110,12 @@ export class SubagentLaunchQueue { const results: Array | undefined> = Array.from({ length: tasks.length, }); - let slotLimit: number | undefined; + let slotLimit = SUBAGENT_LAUNCH_BATCH_SIZE; + let rateLimitMode = false; let rateLimitReductionWindowStartMs: number | undefined; - let rateLimitReductionsInWindow = 0; - const hasResults = (): boolean => results.some((result) => result !== undefined); - const hasRetriableQueued = (): boolean => - queued.some((pending) => pending.agentId !== undefined); + let nextRateLimitedLaunchAtMs = 0; + let rateLimitedLaunchDelayMs = RATE_LIMIT_RETRY_BASE_DELAY_MS; + let initialSuccessfulLaunches = 0; const finish = (fallback: string): Array> => results.map( @@ -138,46 +133,41 @@ export class SubagentLaunchQueue { } }; - const unreadyActiveCount = (): number => - active.reduce((count, attempt) => count + (attempt.ready ? 0 : 1), 0); - - const reduceSlotsAfterRateLimit = (): SlotReductionResult => { + const reduceSlotsAfterRateLimit = (): void => { const now = Date.now(); if ( rateLimitReductionWindowStartMs === undefined || now - rateLimitReductionWindowStartMs >= RATE_LIMIT_SLOT_REDUCTION_WINDOW_MS ) { rateLimitReductionWindowStartMs = now; - rateLimitReductionsInWindow = 0; - } - - const currentLimit = slotLimit ?? SUBAGENT_LAUNCH_BATCH_SIZE; - if (currentLimit <= 1) { - slotLimit = currentLimit; - return { canRetry: false, slotLimitBefore: currentLimit, slotLimitAfter: slotLimit }; - } - if ( - rateLimitReductionsInWindow >= RATE_LIMIT_SLOT_REDUCTION_MAX_PER_WINDOW - ) { - slotLimit = currentLimit; - return { canRetry: true, slotLimitBefore: currentLimit, slotLimitAfter: slotLimit }; + slotLimit = Math.max(1, slotLimit - 1); } - - slotLimit = currentLimit - 1; - rateLimitReductionsInWindow += 1; - return { canRetry: true, slotLimitBefore: currentLimit, slotLimitAfter: slotLimit }; }; + const rateLimitRetryDelayMs = (retryAttempt: number): number => + RATE_LIMIT_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, retryAttempt - 1); + const launch = (pending: QueuedSubagentPending): QueuedSubagentAttempt => { const readiness = createControlledPromise(); let ready = false; - const markReady = (): void => { + let launchSucceeded = false; + const markReadyOnly = (): void => { if (ready) return; ready = true; clearTimeout(readinessTimer); readiness.resolve(); }; - const readinessTimer = setTimeout(markReady, SUBAGENT_QUEUE_LAUNCH_DELAY_MS); + const markReady = (): void => { + if (!launchSucceeded && !rateLimitMode) { + initialSuccessfulLaunches += 1; + } + launchSucceeded = true; + markReadyOnly(); + if (!rateLimitMode) return; + rateLimitedLaunchDelayMs = RATE_LIMIT_RETRY_BASE_DELAY_MS; + nextRateLimitedLaunchAtMs = Date.now() + RATE_LIMIT_RETRY_BASE_DELAY_MS; + }; + const readinessTimer = setTimeout(markReadyOnly, SUBAGENT_QUEUE_LAUNCH_DELAY_MS); const outcome = this.runAttempt(tasks[pending.index]!, { ...options, totalTimedOut, @@ -191,16 +181,19 @@ export class SubagentLaunchQueue { get ready() { return ready; }, + get launchSucceeded() { + return launchSucceeded; + }, settled: false, }; void outcome.then( () => { attempt.settled = true; - markReady(); + markReadyOnly(); }, () => { attempt.settled = true; - markReady(); + markReadyOnly(); }, ); active.push(attempt); @@ -211,18 +204,22 @@ export class SubagentLaunchQueue { active.splice(active.indexOf(attempt), 1); const outcome = await attempt.outcome; if (isRateLimitedOutcome(outcome)) { - const reduction = reduceSlotsAfterRateLimit(); - const agentId = outcome.agentId ?? attempt.pending.agentId; - if (!reduction.canRetry) { - results[attempt.pending.index] = { - task: tasks[attempt.pending.index]!, - agentId, - status: 'failed', - error: RATE_LIMIT_RETRY_EXHAUSTED_MESSAGE, - }; - return true; + if (!rateLimitMode) { + slotLimit = Math.max(1, initialSuccessfulLaunches); } + rateLimitMode = true; + reduceSlotsAfterRateLimit(); + const agentId = outcome.agentId ?? attempt.pending.agentId; const retryAttempt = (attempt.pending.rateLimitAttempts ?? 0) + 1; + const now = Date.now(); + const retryDelayMs = rateLimitRetryDelayMs(retryAttempt); + if (nextRateLimitedLaunchAtMs <= now) { + nextRateLimitedLaunchAtMs = now + RATE_LIMIT_RETRY_BASE_DELAY_MS; + } + if (!attempt.launchSucceeded) { + rateLimitedLaunchDelayMs = Math.max(rateLimitedLaunchDelayMs * 2, retryDelayMs); + nextRateLimitedLaunchAtMs = now + rateLimitedLaunchDelayMs; + } if (agentId !== undefined) { this.events.onSuspended?.({ task: tasks[attempt.pending.index]!, @@ -235,8 +232,9 @@ export class SubagentLaunchQueue { index: attempt.pending.index, agentId, rateLimitAttempts: retryAttempt, + nextRetryAtMs: now + retryDelayMs, }); - return false; + return true; } results[attempt.pending.index] = outcome; return true; @@ -253,100 +251,102 @@ export class SubagentLaunchQueue { const nextSettled = (): Promise => Promise.race(active.map((attempt) => attempt.outcome.then(() => undefined))); - const nextReadiness = (): Promise => { + const nextReadiness = (): Promise | undefined => { const unready = active.filter((attempt) => !attempt.ready); - if (unready.length === 0) return Promise.resolve(); + if (unready.length === 0) return undefined; return Promise.race(unready.map((attempt) => attempt.readiness)); }; - const nextSettledAttempt = async (): Promise> => { - await nextSettled(); - return active.find((attempt) => attempt.settled)!; - }; - - const waitForRampBatch = async ( - batch: readonly QueuedSubagentAttempt[], - ): Promise => { - const batchReady = Promise.all(batch.map((attempt) => attempt.readiness)); - while (batch.some((attempt) => !attempt.ready)) { + const waitForInitialIncrement = async (): Promise => { + const delay = sleep(SUBAGENT_INITIAL_INCREMENT_DELAY_MS).then(() => 'delay' as const); + while (true) { + if (rateLimitMode) return; options.signal.throwIfAborted(); - await abortable(Promise.race([batchReady, nextSettled()]), options.signal); - if (!(await processSettledAttempts())) return false; - } - return processSettledAttempts(); - }; - - const launchQueuedUpToSlotLimit = async (): Promise => { - if (slotLimit === undefined || (!hasResults() && !hasRetriableQueued())) return 0; - let launched = 0; - while (queued.length > 0 && unreadyActiveCount() < slotLimit) { - const delay = sleep(SUBAGENT_QUEUE_LAUNCH_DELAY_MS).then(() => 'delay' as const); + const waits: Array> = [delay]; const settled = active.length === 0 ? undefined : nextSettled().then(() => 'settled' as const); - const waitResult = await abortable( - settled === undefined ? delay : Promise.race([delay, settled]), - options.signal, - ); - if (waitResult === 'settled') break; - if (active.some((attempt) => attempt.settled)) break; - if (unreadyActiveCount() < slotLimit) { - launch(queued.shift()!); - launched += 1; - } + const readiness = nextReadiness()?.then(() => 'readiness' as const); + if (settled !== undefined) waits.push(settled); + if (readiness !== undefined) waits.push(readiness); + const waitResult = await abortable(Promise.race(waits), options.signal); + if (waitResult === 'delay') return; + if (waitResult === 'settled') await processSettledAttempts(); } - return launched; }; - const launchRampBatch = (): Array> => - queued.splice(0, SUBAGENT_LAUNCH_BATCH_SIZE).map(launch); + const eligibleRateLimitedQueuedIndex = (): number => { + const now = Date.now(); + return queued.findIndex((pending) => (pending.nextRetryAtMs ?? 0) <= now); + }; + + const nextRateLimitedLaunchWakeAt = (): number | undefined => { + if (!rateLimitMode || queued.length === 0 || active.length >= slotLimit) return undefined; + const nextPendingAt = Math.min( + ...queued.map((pending) => pending.nextRetryAtMs ?? 0), + ); + return Math.max(nextRateLimitedLaunchAtMs, nextPendingAt); + }; + + const launchRateLimitedQueued = (): number => { + if (!rateLimitMode || queued.length === 0 || active.length >= slotLimit) return 0; + const now = Date.now(); + if (now < nextRateLimitedLaunchAtMs) return 0; + const index = eligibleRateLimitedQueuedIndex(); + if (index < 0) return 0; + launch(queued.splice(index, 1)[0]!); + nextRateLimitedLaunchAtMs = now + rateLimitedLaunchDelayMs; + return 1; + }; + + const launchInitialBatch = (): void => { + const count = Math.min(SUBAGENT_LAUNCH_BATCH_SIZE, queued.length); + for (let index = 0; index < count; index += 1) { + launch(queued.shift()!); + } + }; try { + launchInitialBatch(); while (queued.length > 0) { - if (slotLimit !== undefined) break; - const batch = launchRampBatch(); - if (queued.length === 0) break; - if (!(await waitForRampBatch(batch))) break; + if (rateLimitMode) break; + await waitForInitialIncrement(); + if (!rateLimitMode && queued.length > 0) launch(queued.shift()!); } while (active.length > 0 || queued.length > 0) { options.signal.throwIfAborted(); - if (active.length === 0) { - if (queued.length === 0) break; - if (!hasResults() && !hasRetriableQueued()) { - throw new Error( - 'Could not start any subagents because every launch attempt was rate limited.', - ); - } - await launchQueuedUpToSlotLimit(); - if (active.length > 0) continue; - failQueued('No running subagents remained to open queue slots after rate-limited launches.'); - break; - } - - const settled = active.find((attempt) => attempt.settled); - if (settled !== undefined) { - await processAttempt(settled); - await launchQueuedUpToSlotLimit(); - continue; - } + await processSettledAttempts(); - const launched = await launchQueuedUpToSlotLimit(); + const launched = launchRateLimitedQueued(); if (launched > 0) continue; - if ( - queued.length > 0 && - slotLimit !== undefined && - unreadyActiveCount() >= slotLimit && - active.some((attempt) => !attempt.ready) - ) { - await abortable(Promise.race([nextSettled(), nextReadiness()]), options.signal); + if (active.length === 0) { + if (queued.length === 0) break; + const wakeAt = nextRateLimitedLaunchWakeAt(); + if (wakeAt === undefined) { + failQueued('No running subagents remained to open queue slots after rate-limited launches.'); + break; + } + await abortable(sleep(Math.max(0, wakeAt - Date.now())), options.signal); continue; } - const attempt = await abortable(nextSettledAttempt(), options.signal); - await processAttempt(attempt); + const wakeAt = nextRateLimitedLaunchWakeAt(); + const waitForLaunch = + wakeAt === undefined + ? undefined + : sleep(Math.max(0, wakeAt - Date.now())).then(() => undefined); + const waitForReadiness = nextReadiness(); + await abortable( + Promise.race( + [nextSettled(), waitForLaunch, waitForReadiness].filter( + (wait): wait is Promise => wait !== undefined, + ), + ), + options.signal, + ); } return finish('Subagent stopped before it could finish.'); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 364369902..a237e33c2 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -16,9 +16,12 @@ import { collectGitContext } from '../../src/session/git-context'; import { SessionSubagentHost, type QueuedSubagentTask, - type SubagentHandle, } from '../../src/session/subagent-host'; -import { SubagentLaunchQueue } from '../../src/session/subagent-launch-queue'; +import { + SubagentLaunchQueue, + type QueuedSubagentAttemptOptions, + type QueuedSubagentAttemptOutcome, +} from '../../src/session/subagent-launch-queue'; import { abortError, userCancellationReason } from '../../src/utils/abort'; import { testAgent, type AgentTestContext } from '../agent/harness/agent'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; @@ -44,134 +47,84 @@ afterEach(async () => { }); describe('SessionSubagentHost', () => { - it('runQueued keeps launching batches after the readiness window elapses', async () => { + it('runQueued starts five subagents and then adds one more every 700ms', async () => { vi.useFakeTimers(); try { - const host = new SessionSubagentHost({} as Session, 'main'); - const launches: Array>> = []; - const spawn = vi.spyOn(host, 'spawn').mockImplementation((options) => { - const completion = createControlledPromise<{ result: string }>(); - launches.push(completion); - return Promise.resolve({ - agentId: `agent-${String(launches.length)}`, - profileName: options.profileName, - resumed: false, - completion, - } satisfies SubagentHandle); - }); - - const running = host.runQueued( - Array.from({ length: 41 }, (_, index) => queuedTask(index + 1)), + const { queue, attempts } = createRecordedLaunchQueue(); + const running = queue.run( + Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { signal }, ); await vi.advanceTimersByTimeAsync(0); - expect(spawn).toHaveBeenCalledTimes(10); + expect(attempts).toHaveLength(5); - await vi.advanceTimersByTimeAsync(499); - expect(spawn).toHaveBeenCalledTimes(10); + await vi.advanceTimersByTimeAsync(699); + expect(attempts).toHaveLength(5); await vi.advanceTimersByTimeAsync(1); - expect(spawn).toHaveBeenCalledTimes(20); + expect(attempts).toHaveLength(6); - await vi.advanceTimersByTimeAsync(500); - expect(spawn).toHaveBeenCalledTimes(30); + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(7); - await vi.advanceTimersByTimeAsync(500); - expect(spawn).toHaveBeenCalledTimes(40); + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(8); - await vi.advanceTimersByTimeAsync(500); - expect(spawn).toHaveBeenCalledTimes(41); + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(9); - launches.forEach((completion, index) => { - completion.resolve({ result: `result ${String(index + 1)}` }); + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(9); + + attempts.forEach((attempt, index) => { + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 1)}`, + status: 'completed', + result: `result ${String(index + 1)}`, + }); }); const results = await running; - expect(results).toHaveLength(41); + expect(results).toHaveLength(9); expect(results.every((result) => result.status === 'completed')).toBe(true); } finally { vi.useRealTimers(); } }); - it('runQueued retries the same subagent and decrements slots by one after a 429', async () => { + it('runQueued stops the initial ramp when a subagent is rate limited', async () => { vi.useFakeTimers(); try { const controller = new AbortController(); - const host = new SessionSubagentHost({} as Session, 'main'); - const launches: Array< - ReturnType> & { - readonly prompt: string; - readonly ready: () => void; - } - > = []; - const spawn = vi - .spyOn(host, 'spawn') - .mockImplementation((options) => { - const completion = Object.assign(createControlledPromise<{ result: string }>(), { - prompt: options.prompt, - ready: options.onFirstOutput ?? (() => {}), - }); - launches.push(completion); - return Promise.resolve({ - agentId: `agent-${String(launches.length)}`, - profileName: options.profileName, - resumed: false, - completion, - } satisfies SubagentHandle); - }); - const retry = vi - .spyOn(host, 'retry') - .mockImplementation((agentId, options) => { - const completion = Object.assign(createControlledPromise<{ result: string }>(), { - prompt: options.prompt, - ready: options.onFirstOutput ?? (() => {}), - }); - launches.push(completion); - return Promise.resolve({ - agentId, - profileName: 'coder', - resumed: true, - completion, - } satisfies SubagentHandle); - }); - - const running = host.runQueued( - Array.from({ length: 21 }, (_, index) => queuedTask(index + 1)), - { signal: controller.signal }, - ); + const { queue, attempts } = createRecordedLaunchQueue(); + const running = queue.run(Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { + signal: controller.signal, + }); void running.catch(() => {}); await vi.advanceTimersByTimeAsync(0); - expect(spawn).toHaveBeenCalledTimes(10); - - launches.slice(0, 10).forEach((launch) => { - launch.ready(); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); }); - await vi.advanceTimersByTimeAsync(0); - expect(spawn).toHaveBeenCalledTimes(20); - launches[14]!.reject(new Error(rateLimit429Message)); + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); await vi.advanceTimersByTimeAsync(0); - expect(spawn).toHaveBeenCalledTimes(20); - expect(retry).not.toHaveBeenCalled(); - launches[0]!.resolve({ result: 'opened slot 1' }); - await vi.advanceTimersByTimeAsync(1000); - expect(spawn).toHaveBeenCalledTimes(20); - expect(retry).toHaveBeenCalledTimes(1); - expect(retry).toHaveBeenLastCalledWith('agent-15', { - data: 15, - profileName: 'coder', - parentToolCallId: 'call_swarm', - prompt: 'Review item-15', - description: 'Review #15', - runInBackground: false, - signal: controller.signal, - onFirstOutput: expect.any(Function), - suppressRateLimitFailureEvent: true, + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(5); + + attempts[1]!.outcome.resolve({ + task: attempts[1]!.task, + agentId: 'agent-2', + status: 'completed', + result: 'completed 2', }); + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.retryAgentId).toBe('agent-1'); controller.abort(); await expect(running).rejects.toThrow(); @@ -180,75 +133,94 @@ describe('SessionSubagentHost', () => { } }); - it('runQueued puts rate-limited subagents at the front of the queue', async () => { + it('runQueued keeps processing completions while waiting for the next initial launch', async () => { vi.useFakeTimers(); try { - const controller = new AbortController(); - const host = new SessionSubagentHost({} as Session, 'main'); - const launches: Array< - ReturnType> & { - readonly ready: () => void; - } - > = []; - vi.spyOn(host, 'spawn').mockImplementation((options) => { - const completion = Object.assign(createControlledPromise<{ result: string }>(), { - ready: options.onFirstOutput ?? (() => {}), - }); - launches.push(completion); - return Promise.resolve({ - agentId: `agent-${String(launches.length)}`, - profileName: options.profileName, - resumed: false, - completion, - } satisfies SubagentHandle); + const { queue, attempts } = createRecordedLaunchQueue(); + const running = queue.run( + Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)), + { signal }, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', }); - const retry = vi.spyOn(host, 'retry').mockImplementation((agentId, options) => { - const completion = Object.assign(createControlledPromise<{ result: string }>(), { - ready: options.onFirstOutput ?? (() => {}), + + await vi.advanceTimersByTimeAsync(699); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + + attempts.slice(1).forEach((attempt, index) => { + attempt.outcome.resolve({ + task: attempt.task, + agentId: `agent-${String(index + 2)}`, + status: 'completed', + result: `completed ${String(index + 2)}`, }); - launches.push(completion); - return Promise.resolve({ - agentId, - profileName: 'coder', - resumed: true, - completion, - } satisfies SubagentHandle); }); + await expect(running).resolves.toHaveLength(6); + } finally { + vi.useRealTimers(); + } + }); - const running = host.runQueued( - Array.from({ length: 12 }, (_, index) => queuedTask(index + 1)), - { signal: controller.signal }, - ); + it('runQueued requeues 429s and relaunches one at a time after the retry delay', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const onSuspended = vi.fn(); + const { queue, attempts } = createRecordedLaunchQueue({ onSuspended }); + const running = queue.run(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { + signal: controller.signal, + }); void running.catch(() => {}); await vi.advanceTimersByTimeAsync(0); - expect(launches).toHaveLength(10); - launches.slice(0, 10).forEach((launch) => { - launch.ready(); - }); - await vi.advanceTimersByTimeAsync(0); - expect(launches).toHaveLength(12); + expect(attempts).toHaveLength(5); - launches[0]!.reject(new Error(rateLimit429Message)); - await vi.advanceTimersByTimeAsync(0); - launches[4]!.reject(new Error(rateLimit429Message)); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' }); await vi.advanceTimersByTimeAsync(0); - expect(retry).not.toHaveBeenCalled(); + expect(onSuspended).toHaveBeenCalledTimes(2); - launches[1]!.resolve({ result: 'opened retry slot' }); await vi.advanceTimersByTimeAsync(500); - expect(retry).toHaveBeenCalledTimes(1); - expect(retry).toHaveBeenLastCalledWith('agent-5', { - data: 5, - profileName: 'coder', - parentToolCallId: 'call_swarm', - prompt: 'Review item-5', - description: 'Review #5', - runInBackground: false, - signal: controller.signal, - onFirstOutput: expect.any(Function), - suppressRateLimitFailureEvent: true, + expect(attempts).toHaveLength(5); + + attempts[2]!.outcome.resolve({ + task: attempts[2]!.task, + agentId: 'agent-3', + status: 'completed', + result: 'completed 3', }); + await vi.advanceTimersByTimeAsync(2499); + expect(attempts).toHaveLength(5); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.retryAgentId).toBe('agent-2'); + + attempts[3]!.outcome.resolve({ + task: attempts[3]!.task, + agentId: 'agent-4', + status: 'completed', + result: 'completed 4', + }); + await vi.advanceTimersByTimeAsync(2999); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(7); + expect(attempts[6]!.retryAgentId).toBe('agent-1'); controller.abort(); await expect(running).rejects.toThrow(); @@ -257,61 +229,43 @@ describe('SessionSubagentHost', () => { } }); - it('runQueued caps 429 slot reductions at three per second', async () => { + it('runQueued initializes rate-limit slots from initially started subagents', async () => { vi.useFakeTimers(); try { const controller = new AbortController(); - const host = new SessionSubagentHost({} as Session, 'main'); - const launches: Array< - ReturnType> & { - readonly ready: () => void; - } - > = []; - vi.spyOn(host, 'spawn').mockImplementation((options) => { - const completion = Object.assign(createControlledPromise<{ result: string }>(), { - ready: options.onFirstOutput ?? (() => {}), - }); - launches.push(completion); - return Promise.resolve({ - agentId: `agent-${String(launches.length)}`, - profileName: options.profileName, - resumed: false, - completion, - } satisfies SubagentHandle); - }); - const retry = vi.spyOn(host, 'retry').mockImplementation((agentId, options) => { - const completion = Object.assign(createControlledPromise<{ result: string }>(), { - ready: options.onFirstOutput ?? (() => {}), - }); - launches.push(completion); - return Promise.resolve({ - agentId, - profileName: 'coder', - resumed: true, - completion, - } satisfies SubagentHandle); + const { queue, attempts } = createRecordedLaunchQueue(); + const running = queue.run(Array.from({ length: 12 }, (_, index) => queuedTask(index + 1)), { + signal: controller.signal, }); - - const running = host.runQueued( - Array.from({ length: 14 }, (_, index) => queuedTask(index + 1)), - { signal: controller.signal }, - ); void running.catch(() => {}); await vi.advanceTimersByTimeAsync(0); - expect(launches).toHaveLength(10); - launches.slice(0, 10).forEach((launch) => { - launch.ready(); + expect(attempts).toHaveLength(5); + attempts.slice(0, 5).forEach((attempt) => { + attempt.markReady(); }); - for (const launch of launches.slice(0, 4)) { - launch.reject(new Error(rateLimit429Message)); - await vi.advanceTimersByTimeAsync(0); + for (let count = 6; count <= 12; count += 1) { + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(count); + attempts[count - 1]!.markReady(); } - expect(retry).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(500); - expect(retry).toHaveBeenCalledTimes(1); + attempts.slice(0, 12).forEach((attempt) => { + attempt.markReady(); + }); + + for (let index = 0; index < 8; index += 1) { + attempts[index]!.outcome.resolve({ + type: 'rate_limited', + agentId: `agent-${String(index + 1)}`, + }); + } + await vi.advanceTimersByTimeAsync(0); + + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(13); + expect(attempts[12]!.retryAgentId).toBe('agent-8'); controller.abort(); await expect(running).rejects.toThrow(); @@ -320,43 +274,177 @@ describe('SessionSubagentHost', () => { } }); - it('runQueued reports an error when every initial launch hits 429', async () => { - const host = new SessionSubagentHost({} as Session, 'main'); - vi.spyOn(host, 'spawn').mockRejectedValue(new Error(rateLimit429Message)); + it('runQueued reduces slots at most once every two seconds and counts active attempts', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { queue, attempts } = createRecordedLaunchQueue(); + const running = queue.run(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { + signal: controller.signal, + }); + void running.catch(() => {}); - await expect( - host.runQueued( - Array.from({ length: 3 }, (_, index) => queuedTask(index + 1)), - { signal }, - ), - ).rejects.toThrow('Could not start any subagents'); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt) => { + attempt.markReady(); + }); + + for (let index = 0; index < 3; index += 1) { + attempts[index]!.outcome.resolve({ + type: 'rate_limited', + agentId: `agent-${String(index + 1)}`, + }); + await vi.advanceTimersByTimeAsync(0); + } + + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(6); + expect(attempts[5]!.retryAgentId).toBe('agent-3'); + + attempts[3]!.markReady(); + attempts[3]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-4' }); + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(7); + + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(7); + + attempts[4]!.outcome.resolve({ + task: attempts[4]!.task, + agentId: 'agent-5', + status: 'completed', + result: 'completed 5', + }); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(8); + + controller.abort(); + await expect(running).rejects.toThrow(); + } finally { + vi.useRealTimers(); + } }); - it('runQueued fails a 429 retry when only one retry slot remains', async () => { + it('runQueued keeps retrying 429s until the batch total timeout elapses', async () => { vi.useFakeTimers(); try { - let attempts = 0; - const queue = new SubagentLaunchQueue(async (_task, options) => { - attempts += 1; - options.markReady(); - return { type: 'rate_limited', agentId: 'agent-1' }; - }); - - const running = queue.run([queuedTask(1)], { signal }); - void running.catch(() => {}); + const { queue, attempts } = createRecordedLaunchQueue(); + const running = queue.run([queuedTask(1)], { signal, totalTimeoutMs: 10_000 }); await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(5000); + attempts[0]!.markReady(); + attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + await vi.advanceTimersByTimeAsync(3000); + expect(attempts).toHaveLength(2); + + attempts[1]!.markReady(); + attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + await vi.advanceTimersByTimeAsync(5999); + expect(attempts).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(3); + attempts[2]!.markReady(); + attempts[2]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); + await vi.advanceTimersByTimeAsync(1000); await expect(running).resolves.toEqual([ { task: queuedTask(1), - agentId: 'agent-1', status: 'failed', - error: 'Subagent failed after another 429 with only one retry slot remaining.', + error: 'Subagent batch total timeout after 10s.', }, ]); - expect(attempts).toBe(10); + + } finally { + vi.useRealTimers(); + } + }); + + it('runQueued resets retry launch spacing to three seconds after a launch succeeds', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const attempts: Array = []; + const queue = new SubagentLaunchQueue(( + task: QueuedSubagentTask, + options: QueuedSubagentAttemptOptions, + ) => { + const outcome = createControlledPromise>(); + if (attempts.length >= 7) { + setTimeout(options.markReady, 100); + } + attempts.push({ + task: task as unknown as QueuedSubagentTask, + retryAgentId: options.retryAgentId, + markReady: options.markReady, + outcome: outcome as unknown as QueuedAttemptRecord['outcome'], + }); + return outcome; + }); + + const running = queue.run( + Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), + { signal: controller.signal }, + ); + void running.catch(() => {}); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.slice(0, 5).forEach((attempt) => { + attempt.markReady(); + }); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(6); + + await vi.advanceTimersByTimeAsync(700); + expect(attempts).toHaveLength(7); + + attempts[5]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-6' }); + attempts[6]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-7' }); + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + attempts[1]!.outcome.resolve({ + task: attempts[1]!.task, + agentId: 'agent-2', + status: 'completed', + result: 'completed 2', + }); + await vi.advanceTimersByTimeAsync(11_999); + expect(attempts).toHaveLength(7); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(8); + expect(attempts[7]!.retryAgentId).toBe('agent-7'); + + await vi.advanceTimersByTimeAsync(99); + expect(attempts).toHaveLength(8); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(8); + + await vi.advanceTimersByTimeAsync(2999); + expect(attempts).toHaveLength(8); + + attempts[2]!.outcome.resolve({ + task: attempts[2]!.task, + agentId: 'agent-3', + status: 'completed', + result: 'completed 3', + }); + + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toHaveLength(9); + expect(attempts[8]!.retryAgentId).toBe('agent-6'); + + controller.abort(); + await expect(running).rejects.toThrow(); } finally { vi.useRealTimers(); } @@ -440,6 +528,101 @@ describe('SessionSubagentHost', () => { event: 'subagent.failed', }), ); + }, 10_000); + + it('runQueued treats wrapped provider too-many-requests errors as rate limits', async () => { + const parent = testAgent(); + parent.configure(); + parent.newEvents(); + + const summary = + 'Recovered after the provider asked us to slow down, retried the queued subagent with its existing context, and completed the delegated review with enough detail for the parent to continue. '.repeat( + 2, + ); + let generateCalls = 0; + const generate: GenerateFn = async ( + _provider, + _systemPrompt, + _tools, + _history, + callbacks, + ) => { + generateCalls += 1; + if (generateCalls === 1) { + throw new Error( + "[provider.api_error] We're receiving too many requests at the moment. Please wait a moment and try again.", + ); + } + await callbacks?.onMessagePart?.({ type: 'text', text: summary }); + return textResult(summary); + }; + const child = testAgent({ + generate, + initialConfig: { + providers: {}, + loopControl: { maxRetriesPerStep: 1 }, + }, + }); + child.configure(); + + const session = fakeSession(parent.agent, child.agent); + const host = new SessionSubagentHost(session, 'main'); + + await expect(host.runQueued([queuedTask(1)], { signal })).resolves.toMatchObject([ + { + agentId: 'agent-0', + status: 'completed', + result: summary.trim(), + }, + ]); + + expect(generateCalls).toBe(2); + expect(parent.allEvents).toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'subagent.suspended', + args: expect.objectContaining({ + subagentId: 'agent-0', + reason: 'Provider rate limit; subagent requeued for retry.', + }), + }), + ); + expect(parent.allEvents).not.toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'subagent.failed', + }), + ); + }, 10_000); + + it('runQueued suppresses raw live Aborted failures from queued attempts', async () => { + const parent = testAgent(); + parent.configure(); + parent.newEvents(); + + const controller = new AbortController(); + const child = testAgent(); + child.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall()); + const session = fakeSession(parent.agent, child.agent); + const host = new SessionSubagentHost(session, 'main'); + + const running = host.runQueued([queuedTask(1)], { signal: controller.signal }); + void running.catch(() => {}); + + await child.untilApprovalRequest(); + controller.abort(abortError()); + await expect(running).rejects.toThrow('Aborted'); + await child.untilTurnEnd(); + + expect(parent.allEvents).not.toContainEqual( + expect.objectContaining({ + type: '[rpc]', + event: 'subagent.failed', + args: expect.objectContaining({ + error: 'Aborted', + }), + }), + ); }); it('fires subagent lifecycle hooks around the child turn', async () => { @@ -1750,6 +1933,36 @@ async function flushPromises(count = 2): Promise { } } +type QueuedAttemptRecord = { + readonly task: QueuedSubagentTask; + readonly retryAgentId?: string; + readonly markReady: () => void; + readonly outcome: ReturnType>>; +}; + +function createRecordedLaunchQueue( + events?: ConstructorParameters[1], +): { + readonly queue: SubagentLaunchQueue; + readonly attempts: QueuedAttemptRecord[]; +} { + const attempts: QueuedAttemptRecord[] = []; + const queue = new SubagentLaunchQueue(( + task: QueuedSubagentTask, + options: QueuedSubagentAttemptOptions, + ) => { + const outcome = createControlledPromise>(); + attempts.push({ + task: task as unknown as QueuedSubagentTask, + retryAgentId: options.retryAgentId, + markReady: options.markReady, + outcome: outcome as unknown as QueuedAttemptRecord['outcome'], + }); + return outcome; + }, events); + return { queue, attempts }; +} + function queuedTask(index: number): QueuedSubagentTask { return { data: index, From f5841ae2ebc1b761b24bc4460f735c8315d16cb2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 4 Jun 2026 22:04:56 +0800 Subject: [PATCH 19/72] fix --- .../src/tui/components/chrome/moon-loader.ts | 8 +- .../messages/agent-swarm-progress.ts | 107 ++++++++++++- .../tui/controllers/session-event-handler.ts | 12 +- apps/kimi-code/src/tui/kimi-tui.ts | 34 +++- apps/kimi-code/test/tui/activity-pane.test.ts | 68 ++++++++ .../messages/agent-swarm-progress.test.ts | 148 ++++++++++++------ .../test/tui/kimi-tui-message-flow.test.ts | 47 ++++++ .../builtin/collaboration/agent-swarm.ts | 51 ++---- .../test/tools/builtin-current.test.ts | 73 ++++++++- 9 files changed, 453 insertions(+), 95 deletions(-) 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/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index 0944ae7c4..7db15cfe8 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -24,17 +24,18 @@ const FAILED_PLACEHOLDER_RED_FACTOR = 0.75; const FAILED_PLACEHOLDER_NON_RED_FACTOR = 0.25; const STATUS_BAR_CHAR = '━'; const PROMPTING_TEXT_TRAILING_GAP = 1; +const ACTIVITY_SPINNER_PLACEHOLDER = ' '; const ORCHESTRATING_LABEL = 'Orchestrating...'; const PROMPTING_LABEL = 'Prompting...'; const WORKING_LABEL = 'Working...'; +const COMPLETED_LABEL = 'Completed.'; const FAILED_LABEL = 'Failed.'; const CANCELLED_LABEL = 'Cancelled.'; const QUEUED_LABEL = 'Queued...'; const SUSPENDED_LABEL = 'Suspended...'; const TOTAL_STATUS_LABEL_WIDTH = Math.max( - visibleWidth(ORCHESTRATING_LABEL), - visibleWidth(PROMPTING_LABEL), visibleWidth(WORKING_LABEL), + visibleWidth(COMPLETED_LABEL), visibleWidth(FAILED_LABEL), visibleWidth(CANCELLED_LABEL), visibleWidth(SUSPENDED_LABEL), @@ -51,7 +52,7 @@ const STATUS_BAR_ORDER = [ type AgentSwarmPhase = AgentSwarmProgressEstimatorPhase; type StatusBarPhase = typeof STATUS_BAR_ORDER[number]; -type TotalStatus = 'working' | 'suspended' | 'failed' | 'cancelled'; +type TotalStatus = 'working' | 'completed' | 'suspended' | 'failed' | 'cancelled'; interface AgentSwarmMember { readonly id: string; @@ -114,7 +115,9 @@ export class AgentSwarmProgressComponent implements Component { private failed = false; private cancelled = false; private itemsStarted = false; + private toolCallActive = true; private promptTemplateText = ''; + private activitySpinnerText: (() => string) | undefined; private timer: ReturnType | undefined; constructor(options: AgentSwarmProgressOptions) { @@ -132,6 +135,23 @@ export class AgentSwarmProgressComponent implements Component { invalidate(): void {} + setActivitySpinnerText(provider: (() => string) | undefined): void { + if (!this.toolCallActive) { + this.activitySpinnerText = () => ACTIVITY_SPINNER_PLACEHOLDER; + return; + } + this.activitySpinnerText = provider; + } + + markToolCallEnded(): void { + this.toolCallActive = false; + this.activitySpinnerText = () => ACTIVITY_SPINNER_PLACEHOLDER; + } + + isToolCallActive(): boolean { + return this.toolCallActive; + } + isRequestStreaming(): boolean { return !this.inputComplete; } @@ -364,9 +384,11 @@ export class AgentSwarmProgressComponent implements Component { this.startAnimationIfNeeded(); } - applyResult(output: string): void { + applyResult(output: string): boolean { + const statuses = parseAgentSwarmResultStatuses(output); + if (statuses.length === 0) return false; const nowMs = Date.now(); - for (const entry of parseAgentSwarmResultStatuses(output)) { + for (const entry of statuses) { this.ensureMemberCount(entry.index); const member = this.members[entry.index - 1]; if (member === undefined) continue; @@ -395,12 +417,14 @@ export class AgentSwarmProgressComponent implements Component { member.phase = entry.status; } this.startAnimationIfNeeded(); + return true; } render(width: number): string[] { const innerWidth = Math.max(1, width); if (this.members.length === 0) { const lines = [ + '', this.renderHeader(innerWidth, undefined), '', this.renderStatusLine(innerWidth), @@ -419,6 +443,7 @@ export class AgentSwarmProgressComponent implements Component { })); const summary = summarizeSnapshots(snapshots); const lines = [ + '', this.renderHeader(innerWidth, summary), '', ...this.renderGrid(innerWidth, snapshots, nowMs), @@ -448,6 +473,16 @@ export class AgentSwarmProgressComponent implements Component { } private renderStatusLine(width: number): string { + const spinner = this.activitySpinnerText?.() ?? ''; + if (spinner.length > 0) { + const contentWidth = Math.max(0, width - visibleWidth(spinner)); + if (contentWidth <= 0) return truncateToWidth(spinner, width); + return truncateToWidth(`${spinner}${this.renderStatusLineContent(contentWidth)}`, width); + } + return this.renderStatusLineContent(width); + } + + private renderStatusLineContent(width: number): string { const status = totalStatus(this.members, { failed: this.failed, cancelled: this.cancelled, @@ -728,6 +763,43 @@ function parseAgentSwarmDescriptionIndex(description: string | undefined): numbe } function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] { + const xmlStatuses = parseAgentSwarmXmlResultStatuses(output); + if (xmlStatuses.length > 0) return xmlStatuses; + return parseAgentSwarmLegacyResultStatuses(output); +} + +function parseAgentSwarmXmlResultStatuses(output: string): AgentSwarmResultStatus[] { + const result: AgentSwarmResultStatus[] = []; + const tagPattern = /]*)>/g; + let match: RegExpExecArray | null; + while ((match = tagPattern.exec(output)) !== null) { + const attrs = match[1] ?? ''; + const closeIndex = output.indexOf('', tagPattern.lastIndex); + if (closeIndex < 0) break; + + const index = Number(xmlAttribute(attrs, 'index')); + const outcome = xmlAttribute(attrs, 'outcome'); + if (Number.isInteger(index) && index > 0 && (outcome === 'completed' || outcome === 'failed')) { + const body = output.slice(tagPattern.lastIndex, closeIndex); + result.push({ + index, + status: outcome, + completedText: outcome === 'completed' ? body : undefined, + failureText: outcome === 'failed' ? body : undefined, + }); + } + + tagPattern.lastIndex = closeIndex + ''.length; + } + return result; +} + +function xmlAttribute(attrs: string, name: string): string | undefined { + const match = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs); + return match?.[1]; +} + +function parseAgentSwarmLegacyResultStatuses(output: string): AgentSwarmResultStatus[] { const result: AgentSwarmResultStatus[] = []; const blocks = output.split(/\n(?=\[agent \d+\]\n)/); for (const block of blocks) { @@ -917,8 +989,10 @@ function totalStatus( members: readonly AgentSwarmMember[], force: { readonly failed: boolean; readonly cancelled: boolean }, ): TotalStatus { - if (force.failed) return 'failed'; if (force.cancelled && members.length === 0) return 'cancelled'; + if (force.failed && members.length === 0) return 'failed'; + const hasCompleted = members.some((member) => member.phase === 'completed'); + const hasFailed = members.some((member) => member.phase === 'failed'); const hasCancelled = members.some((member) => member.phase === 'cancelled'); const hasSuspended = members.some((member) => member.phase === 'suspended'); const hasRunning = members.some((member) => member.phase === 'running'); @@ -930,6 +1004,12 @@ function totalStatus( member.phase === 'running' ) ); + if (!hasActive && members.length > 0) { + if (hasCompleted) return 'completed'; + if (hasFailed || force.failed) return 'failed'; + if (hasCancelled || force.cancelled) return 'cancelled'; + } + if (force.failed) return 'failed'; if (hasSuspended && !hasRunning) return 'suspended'; return (force.cancelled || hasCancelled) && !hasActive ? 'cancelled' : 'working'; } @@ -938,6 +1018,8 @@ function totalStatusLabel(status: TotalStatus): string { switch (status) { case 'working': return WORKING_LABEL; + case 'completed': + return COMPLETED_LABEL; case 'suspended': return SUSPENDED_LABEL; case 'failed': @@ -951,6 +1033,8 @@ function totalStatusColor(status: TotalStatus, colors: ColorPalette): string { switch (status) { case 'working': return colors.success; + case 'completed': + return colors.success; case 'suspended': return colors.warning; case 'failed': @@ -1071,6 +1155,9 @@ function normalizeFailureText(text: string | undefined): string | 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; @@ -1079,6 +1166,14 @@ function nestedAgentSwarmFailureText(text: string): string | 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(); } 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 4f1b997c6..609e4c9ae 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -96,6 +96,7 @@ export interface SessionEventHost { showError(msg: string): void; showStatus(msg: string, color?: string): void; showNotice(title: string, detail?: string): void; + updateActivityPane(): void; appendTranscriptEntry(entry: TranscriptEntry): void; updateTerminalTitle(): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; @@ -135,6 +136,7 @@ export class SessionEventHandler { progress.dispose(); } this.agentSwarmProgress.clear(); + this.host.updateActivityPane(); } startSubscription(): void { @@ -482,6 +484,7 @@ export class SessionEventHandler { children.splice(index, 1); this.host.state.transcriptContainer.invalidate(); } + this.host.updateActivityPane(); } private isAnthropicSessionActive(): boolean { @@ -632,6 +635,7 @@ export class SessionEventHandler { this.agentSwarmProgress.set(toolCallId, progress); this.host.streamingUI.finalizeLiveTextBuffers('tool'); this.host.state.transcriptContainer.addChild(progress); + this.host.updateActivityPane(); this.host.state.ui.requestRender(); return progress; } @@ -661,13 +665,19 @@ export class SessionEventHandler { if (progress.isRequestStreaming()) { this.removeAgentSwarmProgress(event.toolCallId, progress); } else { + progress.markToolCallEnded(); progress.markActiveCancelled(); } } else if (event.isError === true) { - progress.markSwarmFailed(resultData.output); + progress.markToolCallEnded(); + if (!progress.applyResult(resultData.output)) { + progress.markSwarmFailed(resultData.output); + } } else { + progress.markToolCallEnded(); progress.applyResult(resultData.output); } + this.host.updateActivityPane(); this.host.state.ui.requestRender(); } if (matchedCall !== undefined && matchedCall.name === 'TodoList' && !event.isError) { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 41d042bfc..287cf701d 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1486,24 +1486,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', @@ -1514,12 +1522,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', @@ -1530,6 +1540,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', @@ -1541,6 +1553,7 @@ export class KimiTUI { case 'idle': case 'session': { this.stopActivitySpinner(); + this.syncAgentSwarmActivitySpinner(undefined); break; } } @@ -1654,6 +1667,23 @@ export class KimiTUI { ); } + private shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode: EffectiveActivityPaneMode): boolean { + return ( + Array.from(this.sessionEventHandler.agentSwarmProgress.values()).some((progress) => + progress.isToolCallActive() + ) && + (effectiveMode === 'waiting' || effectiveMode === 'tool') + ); + } + + private syncAgentSwarmActivitySpinner(spinner: MoonLoader | undefined): void { + for (const progress of this.sessionEventHandler.agentSwarmProgress.values()) { + progress.setActivitySpinnerText( + spinner === undefined ? undefined : () => spinner.renderInline(), + ); + } + } + private syncTerminalProgress(active: boolean): void { if (this.state.terminalState.progressActive === active) return; this.state.terminal.setProgress(active); diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index 7b657d7c1..fe9885bba 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; +import { AgentSwarmProgressComponent } from '#/tui/components/messages/agent-swarm-progress'; +import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; interface ActivityDriver { state: TUIState; + sessionEventHandler: SessionEventHandler; updateActivityPane(): void; } +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + function makeStartupInput(): KimiTUIStartupInput { return { cliOptions: { @@ -111,4 +118,65 @@ 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 = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: state.theme.colors, + }); + progress.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + progress.markInputComplete(); + progress.markStarted('agent-1'); + driver.sessionEventHandler.agentSwarmProgress.set('call_swarm', progress); + 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(); + progress.dispose(); + driver.sessionEventHandler.agentSwarmProgress.clear(); + } 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 = new AgentSwarmProgressComponent({ + description: 'Review changed files', + colors: state.theme.colors, + }); + progress.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); + progress.markInputComplete(); + progress.markStarted('agent-1'); + progress.markToolCallEnded(); + driver.sessionEventHandler.agentSwarmProgress.set('call_swarm', progress); + 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(); + progress.dispose(); + driver.sessionEventHandler.agentSwarmProgress.clear(); + } finally { + vi.useRealTimers(); + } + }); }); 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 index 9226cf4b5..dd0ca7cd3 100644 --- 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 @@ -52,6 +52,20 @@ describe('AgentSwarmProgressComponent', () => { expect(output).not.toContain('agents=2'); }); + 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', @@ -92,7 +106,7 @@ describe('AgentSwarmProgressComponent', () => { output = strip(component.render(100).join('\n')); expect(output).toContain('001 ['); expect(output).toContain('✓'); - expect(output).not.toContain('Completed'); + expect(output).toContain('Completed.'); expect(output).toContain('002 ['); expect(output).toContain('Failed'); }); @@ -109,7 +123,7 @@ describe('AgentSwarmProgressComponent', () => { const output = strip(component.render(100).join('\n')); expect(output).toContain('✓ Reviewed imports and found no regressions'); - expect(output).not.toContain('Completed'); + expect(output).toContain('Completed.'); }); it('renders failure details from live subagent failures', () => { @@ -164,20 +178,10 @@ describe('AgentSwarmProgressComponent', () => { items: ['src/a.ts'], }); component.applyResult([ - 'agent_swarm: failed', - 'description: Review changed files', - 'items: 1', - 'completed: 0', - 'failed: 1', - '', - '[agent 1]', - 'agent_id: agent-1', - 'item: "src/a.ts"', - 'actual_subagent_type: coder', - 'status: failed', - 'description: Review changed files #1 (coder)', - '', - 'subagent error: Agent timed out after 30s.', + '', + 'failed: 1', + 'Agent timed out after 30s.', + '', ].join('\n')); const output = strip(component.render(100).join('\n')); @@ -197,20 +201,9 @@ describe('AgentSwarmProgressComponent', () => { items: ['src/a.ts'], }); component.applyResult([ - 'agent_swarm: failed', - 'description: Review changed files', - 'items: 1', - 'completed: 0', - 'failed: 1', - '', - '[agent 1]', - 'agent_id: agent-1', - 'item: "src/a.ts"', - 'actual_subagent_type: coder', - 'status: failed', - 'description: Review changed files #1 (coder)', - '', - 'subagent error: agent_swarm: failed', + '', + 'failed: 1', + 'agent_swarm: failed', 'description: Nested review', 'items: 1', 'completed: 0', @@ -219,7 +212,8 @@ describe('AgentSwarmProgressComponent', () => { '[agent 1]', 'status: failed', '', - 'subagent error: [provider.rate_limit] 429 request reached user+model max RPM.', + 'subagent error: [provider.rate_limit] 429 request reached user+model max RPM.', + '', ].join('\n')); const output = strip(component.render(120).join('\n')); @@ -240,27 +234,43 @@ describe('AgentSwarmProgressComponent', () => { items: ['src/a.ts'], }); component.applyResult([ - 'agent_swarm: completed', - 'description: Review changed files', - 'items: 1', - 'completed: 1', - 'failed: 0', - '', - '[agent 1]', - 'agent_id: agent-1', - 'item: "src/a.ts"', - 'actual_subagent_type: coder', - 'status: completed', - 'description: Review changed files #1 (coder)', - '', - '[summary]', - 'Reviewed src/a.ts and confirmed imports are stable.', + '', + '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).not.toContain('Completed'); + 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', () => { @@ -279,7 +289,7 @@ describe('AgentSwarmProgressComponent', () => { const output = strip(component.render(100).join('\n')); expect(output).toContain('✓ Imports look stable'); - expect(output).not.toContain('Completed'); + expect(output).toContain('Completed.'); }); it('shows latest assistant text after the progress bar with ellipsis truncation', () => { @@ -336,6 +346,48 @@ describe('AgentSwarmProgressComponent', () => { expect(promptTextIndex).toBe(progressBarIndex); }); + 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('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('reserves one trailing cell for prompting streaming text', () => { const prompting = new AgentSwarmProgressComponent({ description: '', 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 4aeb654e2..a8612ccb0 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 @@ -2416,6 +2416,53 @@ command = "vim" expect(transcript).not.toContain('Completed'); }); + 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(); diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index db541d906..55d47b60d 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -25,10 +25,12 @@ export const AgentSwarmToolInputSchema = z timeout: z .number() .int() - .min(30) + .min(60) .max(3600) .optional() - .describe('Timeout in seconds for each subagent.'), + .describe( + 'Timeout in seconds for each subagent. Set a generous value so every child agent has enough time to complete its full assigned task.', + ), subagent_type: z .string() .trim() @@ -67,8 +69,6 @@ interface AgentSwarmSpec { interface SwarmRunResult { readonly spec: AgentSwarmSpec; readonly agentId?: string; - readonly profileName: string; - readonly description: string; readonly status: 'completed' | 'failed'; readonly result?: string; readonly error?: string; @@ -105,7 +105,6 @@ export class AgentSwarmTool implements BuiltinTool { const result = await this.runSwarm(args, specs, context.signal, context.toolCallId); return { output: result, - isError: swarmResultHasFailures(result) ? true : undefined, }; } catch (error) { return { @@ -136,7 +135,7 @@ export class AgentSwarmTool implements BuiltinTool { signal, timeoutMs: args.timeout === undefined ? undefined : args.timeout * 1000, }); - return renderSwarmResults(args, results.map(toSwarmRunResult)); + return renderSwarmResults(results.map(toSwarmRunResult)); } } @@ -167,45 +166,31 @@ function childDescription(swarmDescription: string, index: number, profileName: return `${swarmDescription} #${String(index)} (${profileName})`; } -function renderSwarmResults( - args: AgentSwarmToolInput, - results: readonly SwarmRunResult[], -): string { +function renderSwarmResults(results: readonly SwarmRunResult[]): string { const completed = results.filter((result) => result.status === 'completed').length; const failed = results.length - completed; const lines = [ - `agent_swarm: ${failed > 0 ? 'failed' : 'completed'}`, - `description: ${args.description}`, - `subagent_type: ${args.subagent_type ?? DEFAULT_SUBAGENT_TYPE}`, - `placeholder: ${PROMPT_TEMPLATE_PLACEHOLDER}`, - `items: ${String(results.length)}`, - `completed: ${String(completed)}`, - `failed: ${String(failed)}`, + '', + `${renderSwarmSummary(completed, failed)}`, ]; for (const result of results) { + const agentId = result.agentId === undefined ? '' : ` agent_id="${result.agentId}"`; + const body = result.status === 'completed' ? (result.result ?? '') : (result.error ?? 'unknown error'); lines.push( - '', - `[agent ${String(result.spec.index)}]`, - ...(result.agentId === undefined ? [] : [`agent_id: ${result.agentId}`]), - `item: ${JSON.stringify(result.spec.item)}`, - `actual_subagent_type: ${result.profileName}`, - `status: ${result.status}`, - `description: ${result.description}`, - '', + `${body}`, ); - if (result.status === 'completed') { - lines.push('[summary]', result.result ?? ''); - } else { - lines.push(`subagent error: ${result.error ?? 'unknown error'}`); - } } + lines.push(''); return lines.join('\n'); } -function swarmResultHasFailures(result: string): boolean { - return result.startsWith('agent_swarm: failed\n'); +function renderSwarmSummary(completed: number, failed: number): string { + const parts: string[] = []; + if (completed > 0) parts.push(`completed: ${String(completed)}`); + if (failed > 0) parts.push(`failed: ${String(failed)}`); + return parts.join(', '); } function toSwarmRunResult( @@ -214,8 +199,6 @@ function toSwarmRunResult( return { spec: result.task.data, agentId: result.agentId, - profileName: result.task.profileName, - description: result.task.description, status: result.status, result: result.result, error: result.error, diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 4c6423348..2f7be3233 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -339,7 +339,13 @@ describe('current builtin collaboration tools', () => { ).toBe(true); expect(tool.parameters).toMatchObject({ type: 'object', - properties: { subagent_type: { type: 'string' }, total_timeout: { type: 'integer' } }, + properties: { + subagent_type: { type: 'string' }, + timeout: { + type: 'integer', + description: expect.stringContaining('enough time to complete'), + }, + }, }); const result = await executeTool(tool, context(input, 'call_swarm')); @@ -370,8 +376,69 @@ describe('current builtin collaboration tools', () => { totalTimeoutMs: undefined, }, ); - expect(result.output).toContain('subagent_type: explore'); - expect(result.output).toContain('explore result'); + expect(result.output).toBe([ + '', + 'completed: 2', + 'explore result a', + 'explore result b', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + + it('AgentSwarm reports failed subagents inside the XML result without failing the tool', async () => { + const host = mockSubagentHost({ + runQueued: vi.fn().mockResolvedValue([ + { + task: { + data: { index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (coder)', + runInBackground: false, + }, + agentId: 'agent-coder-1', + status: 'completed', + result: 'imports are stable', + }, + { + task: { + data: { index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (coder)', + runInBackground: false, + }, + agentId: 'agent-coder-2', + status: 'failed', + error: 'Agent timed out after 30s.', + }, + ]), + }); + const tool = new AgentSwarmTool(host); + + const result = await executeTool( + tool, + context( + { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + 'call_swarm', + ), + ); + + expect(result.output).toBe([ + '', + 'completed: 1, failed: 1', + 'imports are stable', + 'Agent timed out after 30s.', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); }); it('Skill exposes parameters and reports unknown skills as tool errors', async () => { From 99adec613c74f1c7a74e5e6ed8695de170c9df64 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 12:18:16 +0800 Subject: [PATCH 20/72] fix --- .../agent-swarm-progress-estimator.ts | 57 ++- .../messages/agent-swarm-progress.ts | 79 +++- .../messages/agent-swarm-progress.test.ts | 226 +++++++++- plans/repro-agent-1440-kimi.ts | 415 ++++++++++++++++++ 4 files changed, 739 insertions(+), 38 deletions(-) create mode 100644 plans/repro-agent-1440-kimi.ts 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 index 9fb4b0ea5..95020789c 100644 --- 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 @@ -46,11 +46,13 @@ export interface AgentSwarmProgressEstimate { interface MemberProgressState { startedAtMs?: number; + pausedAtMs?: number; + pausedDurationMs: number; terminalAtMs?: number; terminalKind?: 'completed' | 'failed' | 'cancelled'; rawTicks: number; readonly seenToolCallIds: Set; - toolCallTimesMs: number[]; + toolCallActiveTimesMs: number[]; displayTicks: number; lastEstimateAtMs?: number; lastTargetTicks?: number; @@ -106,7 +108,7 @@ export class AgentSwarmProgressEstimator { markStarted(memberKey: string, nowMs: number): void { const state = this.getOrCreateMember(memberKey); - state.startedAtMs ??= nowMs; + this.startWork(state, nowMs); if (state.rawTicks === 0) { state.rawTicks = 1; state.displayTicks = Math.max(state.displayTicks, 1); @@ -115,18 +117,26 @@ export class AgentSwarmProgressEstimator { 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); - state.startedAtMs ??= input.nowMs; + this.startWork(state, input.nowMs); if (state.seenToolCallIds.has(input.toolCallId)) { return { accepted: false, rawTicks: state.rawTicks }; } state.seenToolCallIds.add(input.toolCallId); - state.toolCallTimesMs.push(input.nowMs); + state.toolCallActiveTimesMs.push(this.activeElapsedMs(state, input.nowMs)); state.rawTicks += 1; state.displayTicks = Math.max(state.displayTicks + 1, state.rawTicks); delete state.terminalAtMs; @@ -242,19 +252,43 @@ export class AgentSwarmProgressEstimator { 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 existing = this.members.get(memberKey); if (existing !== undefined) return existing; const state: MemberProgressState = { + pausedDurationMs: 0, rawTicks: 0, seenToolCallIds: new Set(), - toolCallTimesMs: [], + toolCallActiveTimesMs: [], displayTicks: 0, }; this.members.set(memberKey, state); @@ -280,7 +314,7 @@ export class AgentSwarmProgressEstimator { if (state.terminalKind !== 'completed') continue; if (state.startedAtMs === undefined || state.terminalAtMs === undefined) continue; if (state.rawTicks <= 0) continue; - const totalMs = state.terminalAtMs - state.startedAtMs; + const totalMs = this.activeElapsedMs(state, state.terminalAtMs); if (totalMs <= 0) continue; samples.push({ totalMs, rawTicks: state.rawTicks }); } @@ -293,8 +327,8 @@ export class AgentSwarmProgressEstimator { nowMs: number, completedConfidence: number, ): number { - const elapsedMs = Math.max(0, nowMs - (state.startedAtMs ?? nowMs)); - const localRatePerMs = this.estimateLocalRatePerMs(state, elapsedMs, nowMs); + 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, @@ -334,12 +368,11 @@ export class AgentSwarmProgressEstimator { private estimateLocalRatePerMs( state: MemberProgressState, elapsedMs: number, - nowMs: number, ): number { - if (elapsedMs <= 0 || state.toolCallTimesMs.length === 0) return 0; + if (elapsedMs <= 0 || state.toolCallActiveTimesMs.length === 0) return 0; let decayedToolCalls = 0; - for (const timeMs of state.toolCallTimesMs) { - decayedToolCalls += Math.exp(-(nowMs - timeMs) / this.rateWindowMs); + 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; 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 index 7db15cfe8..d58f377d7 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -136,16 +136,13 @@ export class AgentSwarmProgressComponent implements Component { invalidate(): void {} setActivitySpinnerText(provider: (() => string) | undefined): void { - if (!this.toolCallActive) { - this.activitySpinnerText = () => ACTIVITY_SPINNER_PLACEHOLDER; - return; - } + if (!this.toolCallActive) return; this.activitySpinnerText = provider; } markToolCallEnded(): void { this.toolCallActive = false; - this.activitySpinnerText = () => ACTIVITY_SPINNER_PLACEHOLDER; + this.activitySpinnerText = undefined; } isToolCallActive(): boolean { @@ -299,6 +296,7 @@ export class AgentSwarmProgressComponent implements Component { this.findMemberForSubagent(input.agentId, input.description); if (member === undefined || member.phase === 'completed' || member.phase === 'cancelled') return; member.agentId = input.agentId; + this.progressEstimator.markQueued(member.id, Date.now()); member.phase = 'queued'; delete member.suspendedReason; delete member.completedAtMs; @@ -429,7 +427,6 @@ export class AgentSwarmProgressComponent implements Component { '', this.renderStatusLine(innerWidth), '', - chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), ]; return lines.map((line) => truncateToWidth(line, innerWidth)); } @@ -450,7 +447,6 @@ export class AgentSwarmProgressComponent implements Component { '', this.renderStatusLine(innerWidth), '', - chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), ]; this.startAnimationIfNeeded(); return lines.map((line) => truncateToWidth(line, innerWidth)); @@ -473,20 +469,25 @@ export class AgentSwarmProgressComponent implements Component { } private renderStatusLine(width: number): string { - const spinner = this.activitySpinnerText?.() ?? ''; - if (spinner.length > 0) { - const contentWidth = Math.max(0, width - visibleWidth(spinner)); - if (contentWidth <= 0) return truncateToWidth(spinner, width); - return truncateToWidth(`${spinner}${this.renderStatusLineContent(contentWidth)}`, width); - } - return this.renderStatusLineContent(width); - } - - private renderStatusLineContent(width: number): string { const status = totalStatus(this.members, { failed: this.failed, cancelled: this.cancelled, }); + 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) { @@ -499,7 +500,7 @@ export class AgentSwarmProgressComponent implements Component { private renderProgressStatusLine(width: number, status: TotalStatus): string { const label = renderTotalStatusLabel( totalStatusLabel(status), - totalStatusColor(status, this.colors), + totalStatusLabelColor(status, this.members, this.colors), ); if (this.members.length === 0) return truncateToWidth(label, width); const barWidth = Math.max(0, width - visibleWidth(label) - 2); @@ -513,15 +514,15 @@ export class AgentSwarmProgressComponent implements Component { private renderOrchestratingStatusLine(width: number): string { if (this.itemsStarted) { return truncateToWidth( - renderTotalStatusLabel(ORCHESTRATING_LABEL, this.colors.textMuted), + renderInlineStatusLabel(ORCHESTRATING_LABEL, this.colors.primary), width, ); } const promptTemplate = collapseWhitespace(this.promptTemplateText); - const label = renderTotalStatusLabel( + const label = renderInlineStatusLabel( promptTemplate.length > 0 ? PROMPTING_LABEL : ORCHESTRATING_LABEL, - this.colors.textMuted, + this.colors.primary, ); if (promptTemplate.length === 0) return truncateToWidth(label, width); @@ -589,7 +590,7 @@ export class AgentSwarmProgressComponent implements Component { capacityTicks: barWidth * BRAILLE_LEVELS.length, nowMs, }); - const id = chalk.hex(this.colors.textDim)(member.id); + const id = chalk.hex(this.colors.primary)(member.id); const bar = brailleBar( estimate.displayTicks, snapshot.phase, @@ -938,6 +939,25 @@ function renderTotalStatusLabel(label: string, color: string): string { return ` ${padAnsi(chalk.hex(color)(label), TOTAL_STATUS_LABEL_WIDTH)}`; } +function renderInlineStatusLabel(label: string, color: string): string { + return ` ${chalk.hex(color)(label)}`; +} + +function activityPrefixForTotalStatus(status: TotalStatus, colors: ColorPalette): string { + const color = totalStatusColor(status, colors); + switch (status) { + case 'completed': + return ` ${chalk.hex(color)(SUCCESS_MARK.trimEnd())}`; + case 'failed': + return ` ${chalk.hex(color)(FAILURE_MARK.trimEnd())}`; + case 'cancelled': + return ` ${chalk.hex(color)('⊘')}`; + case 'working': + case 'suspended': + return ACTIVITY_SPINNER_PLACEHOLDER; + } +} + function statusBarCounts(members: readonly AgentSwarmMember[]): StatusBarCount[] { const counts = new Map(); for (const member of members) { @@ -1044,6 +1064,17 @@ function totalStatusColor(status: TotalStatus, colors: ColorPalette): string { } } +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); @@ -1099,7 +1130,7 @@ function renderPendingCell( width: number, colors: ColorPalette, ): string { - const id = chalk.hex(colors.textDim)(member.id); + const id = chalk.hex(colors.primary)(member.id); const prefix = `${id} `; const itemText = collapseWhitespace(member.itemText); const label = itemText.length > 0 ? itemText : QUEUED_LABEL; @@ -1112,7 +1143,7 @@ function renderQueuedCell( width: number, colors: ColorPalette, ): string { - const id = chalk.hex(colors.textDim)(member.id); + 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); 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 index dd0ca7cd3..9b72951b6 100644 --- 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 @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; import { AgentSwarmProgressComponent, @@ -15,6 +16,16 @@ 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; + } +} + afterEach(() => { vi.useRealTimers(); }); @@ -34,6 +45,46 @@ describe('AgentSwarmProgressComponent', () => { 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('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', @@ -52,6 +103,28 @@ describe('AgentSwarmProgressComponent', () => { 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', @@ -312,7 +385,7 @@ describe('AgentSwarmProgressComponent', () => { expect(output).toContain('…'); }); - it('keeps total status labels fixed before bars and streaming text', () => { + it('uses natural status label width for prompting text', () => { const prompting = new AgentSwarmProgressComponent({ description: '', colors: darkColors, @@ -343,7 +416,7 @@ describe('AgentSwarmProgressComponent', () => { const progressBarIndex = workingLine?.indexOf('━') ?? -1; expect(promptTextIndex).toBeGreaterThan(0); expect(progressBarIndex).toBeGreaterThan(0); - expect(promptTextIndex).toBe(progressBarIndex); + expect(promptTextIndex).toBe(visibleWidth(' Prompting... ')); }); it('renders the activity spinner before the total status line', () => { @@ -365,6 +438,33 @@ describe('AgentSwarmProgressComponent', () => { 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', @@ -388,6 +488,71 @@ describe('AgentSwarmProgressComponent', () => { 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.markActiveCancelled(); + cancelled.markToolCallEnded(); + + const cancelledRawLine = cancelled.render(80).join('\n') + .split('\n') + .find((line) => strip(line).startsWith(' ⊘ Cancelled.')); + const cancelledLine = cancelledRawLine === undefined ? undefined : strip(cancelledRawLine); + expect(cancelledLine).toBeDefined(); + expect(cancelledLine?.startsWith(' ⊘ Cancelled.')).toBe(true); + expect(cancelledRawLine).toContain(chalk.hex(darkColors.warning)('⊘')); + } finally { + chalk.level = previousChalkLevel; + } + }); + it('reserves one trailing cell for prompting streaming text', () => { const prompting = new AgentSwarmProgressComponent({ description: '', @@ -574,6 +739,63 @@ describe('AgentSwarmProgressEstimator', () => { 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, diff --git a/plans/repro-agent-1440-kimi.ts b/plans/repro-agent-1440-kimi.ts new file mode 100644 index 000000000..c1bab7d01 --- /dev/null +++ b/plans/repro-agent-1440-kimi.ts @@ -0,0 +1,415 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +import { + createProvider, + type ChatProvider, + type ContentPart, + type Message, + type StreamedMessagePart, + type Tool, + type ToolCall, +} from '../packages/kosong/src/index.ts'; +import { + createKimiDefaultHeaders, + KIMI_CODE_FLOW_CONFIG, + KIMI_CODE_PROVIDER_NAME, + KimiOAuthToolkit, + kimiCodeBaseUrl, + resolveKimiCodeOAuthRef, +} from '../packages/oauth/src/index.ts'; + +const DEFAULT_WIRE = + '/Users/moonshot/.kimi-code/sessions/wd_lug-2026-annual-audit_be08e3cd25e2/session_0069f26b-bd7c-498f-aa2a-340362199ef2/agents/agent-1440/wire.jsonl'; +const DEFAULT_TARGET_STEP_UUID = 'eb9c6131-61a8-4012-97c5-f48a9c2e19e9'; +const DEFAULT_MAX_COMPLETION_TOKENS = 32_000; + +type JsonRecord = Record; + +interface ProjectedContext { + systemPrompt: string; + messages: Message[]; + config: { + modelAlias?: string; + thinkingLevel?: string; + cwd?: string; + }; + stoppedAtLine: number; +} + +interface AssistantMessage extends Message { + content: ContentPart[]; + toolCalls: ToolCall[]; +} + +function parseArgs(): { + wirePath: string; + targetStepUuid: string; + dropEmptyAssistants: boolean; + maxCompletionTokens: number; +} { + const args = process.argv.slice(2); + let wirePath = DEFAULT_WIRE; + let targetStepUuid = DEFAULT_TARGET_STEP_UUID; + let dropEmptyAssistants = false; + let maxCompletionTokens = DEFAULT_MAX_COMPLETION_TOKENS; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--wire') { + wirePath = requiredValue(args, ++i, '--wire'); + } else if (arg === '--target-step') { + targetStepUuid = requiredValue(args, ++i, '--target-step'); + } else if (arg === '--drop-empty-assistants') { + dropEmptyAssistants = true; + } else if (arg === '--max-completion-tokens') { + maxCompletionTokens = Number(requiredValue(args, ++i, '--max-completion-tokens')); + if (!Number.isInteger(maxCompletionTokens) || maxCompletionTokens <= 0) { + throw new Error('--max-completion-tokens must be a positive integer'); + } + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return { wirePath, targetStepUuid, dropEmptyAssistants, maxCompletionTokens }; +} + +function requiredValue(args: string[], index: number, flag: string): string { + const value = args[index]; + if (value === undefined || value.startsWith('--')) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +async function projectWire(input: { + wirePath: string; + targetStepUuid: string; + dropEmptyAssistants: boolean; +}): Promise { + const text = await readFile(input.wirePath, 'utf8'); + const records = text.split(/\r?\n/).filter(Boolean).map((line, index) => ({ + lineNo: index + 1, + record: JSON.parse(line) as JsonRecord, + })); + + const blobsDir = join(dirname(input.wirePath), 'blobs'); + const messages: Message[] = []; + const openSteps = new Map(); + const config: ProjectedContext['config'] = {}; + let systemPrompt = ''; + let stoppedAtLine = records.length; + + for (const { lineNo, record } of records) { + if ( + record.type === 'context.append_loop_event' && + record.event?.type === 'step.begin' && + record.event.uuid === input.targetStepUuid + ) { + stoppedAtLine = lineNo; + break; + } + + if (record.type === 'config.update') { + if (typeof record.systemPrompt === 'string') systemPrompt = record.systemPrompt; + if (typeof record.modelAlias === 'string') config.modelAlias = record.modelAlias; + if (typeof record.thinkingLevel === 'string') config.thinkingLevel = record.thinkingLevel; + if (typeof record.cwd === 'string') config.cwd = record.cwd; + continue; + } + + if (record.type === 'context.append_message') { + const message = cloneMessage(record.message); + await rehydrateParts(message.content, blobsDir); + messages.push(message); + continue; + } + + if (record.type !== 'context.append_loop_event') continue; + const event = record.event; + + if (event.type === 'step.begin') { + const message: AssistantMessage = { + role: 'assistant', + content: [], + toolCalls: [], + }; + messages.push(message); + openSteps.set(event.uuid, message); + continue; + } + + if (event.type === 'content.part') { + const message = openSteps.get(event.stepUuid); + if (message !== undefined) { + const part = structuredClone(event.part) as ContentPart; + await rehydrateParts([part], blobsDir); + message.content.push(part); + } + continue; + } + + if (event.type === 'tool.call') { + const message = openSteps.get(event.stepUuid); + if (message !== undefined) { + message.toolCalls.push({ + type: 'function', + id: event.toolCallId, + name: event.name, + arguments: typeof event.args === 'string' ? event.args : JSON.stringify(event.args ?? {}), + }); + } + continue; + } + + if (event.type === 'step.end') { + openSteps.delete(event.uuid); + continue; + } + + if (event.type === 'tool.result') { + const output = event.result?.output; + const content: ContentPart[] = + typeof output === 'string' + ? [{ type: 'text', text: output }] + : structuredClone(output as ContentPart[]); + await rehydrateParts(content, blobsDir); + messages.push({ + role: 'tool', + content, + toolCalls: [], + toolCallId: event.toolCallId, + }); + } + } + + return { + systemPrompt, + messages: input.dropEmptyAssistants + ? messages.filter((message) => { + if (message.role !== 'assistant') return true; + return message.content.length > 0 || message.toolCalls.length > 0; + }) + : messages, + config, + stoppedAtLine, + }; +} + +function cloneMessage(raw: any): Message { + return { + role: raw.role, + name: raw.name, + content: structuredClone(raw.content ?? []) as ContentPart[], + toolCalls: structuredClone(raw.toolCalls ?? []) as ToolCall[], + toolCallId: raw.toolCallId, + partial: raw.partial, + }; +} + +async function rehydrateParts(parts: ContentPart[], blobsDir: string): Promise { + for (const part of parts) { + for (const key of ['imageUrl', 'audioUrl', 'videoUrl'] as const) { + const media = (part as any)[key]; + if (typeof media?.url !== 'string') continue; + const url = media.url as string; + if (!url.startsWith('blobref:')) continue; + media.url = await blobRefToDataUrl(url, blobsDir); + } + } +} + +async function blobRefToDataUrl(url: string, blobsDir: string): Promise { + const rest = url.slice('blobref:'.length); + const semi = rest.indexOf(';'); + if (semi === -1) return url; + const mimeType = rest.slice(0, semi); + const hash = rest.slice(semi + 1); + if (!/^[0-9a-f]{64}$/i.test(hash)) return url; + const bytes = await readFile(join(blobsDir, hash)); + return `data:${mimeType};base64,${bytes.toString('base64')}`; +} + +function summarizeContext(context: ProjectedContext): Record { + let textChars = 0; + let thinkChars = 0; + let imageParts = 0; + let toolCalls = 0; + const roles: Record = {}; + + for (const message of context.messages) { + roles[message.role] = (roles[message.role] ?? 0) + 1; + toolCalls += message.toolCalls.length; + for (const part of message.content) { + if (part.type === 'text') textChars += part.text.length; + if (part.type === 'think') thinkChars += part.think.length; + if (part.type === 'image_url') imageParts += 1; + } + } + + return { + stoppedAtLine: context.stoppedAtLine, + config: context.config, + messageCount: context.messages.length, + roles, + textChars, + thinkChars, + imageParts, + toolCalls, + systemPromptChars: context.systemPrompt.length, + }; +} + +function makeTools(): Tool[] { + return [ + tool('Bash', 'Run a shell command in the current working directory.', { + command: { type: 'string' }, + description: { type: 'string' }, + }, ['command']), + tool('Read', 'Read a UTF-8 text file.', { path: { type: 'string' } }, ['path']), + tool('ReadMediaFile', 'Read an image or video file and return multimodal content.', { + path: { type: 'string' }, + }, ['path']), + tool('Glob', 'Find files by glob pattern.', { pattern: { type: 'string' } }, ['pattern']), + tool('Grep', 'Search file contents.', { + pattern: { type: 'string' }, + path: { type: 'string' }, + }, ['pattern']), + tool('Write', 'Create or overwrite a file.', { + path: { type: 'string' }, + content: { type: 'string' }, + }, ['path', 'content']), + tool('Edit', 'Edit a file by replacing text.', { + path: { type: 'string' }, + old_string: { type: 'string' }, + new_string: { type: 'string' }, + }, ['path', 'old_string', 'new_string']), + tool('WebSearch', 'Search the web.', { query: { type: 'string' } }, ['query']), + tool('FetchURL', 'Fetch a URL.', { url: { type: 'string' } }, ['url']), + ]; +} + +function tool( + name: string, + description: string, + properties: Record, + required: string[], +): Tool { + return { + name, + description, + parameters: { + type: 'object', + properties, + required, + additionalProperties: true, + }, + }; +} + +async function createKimiProvider(maxCompletionTokens: number): Promise<{ + provider: ChatProvider; + auth: { apiKey?: string; headers?: Record }; +}> { + const homeDir = join(homedir(), '.kimi-code'); + const baseUrl = kimiCodeBaseUrl(); + const oauthRef = resolveKimiCodeOAuthRef({ + oauthHost: KIMI_CODE_FLOW_CONFIG.oauthHost, + baseUrl, + }); + const identity = { + userAgentProduct: 'kimi-code-cli', + version: '0.9.0', + }; + const toolkit = new KimiOAuthToolkit({ homeDir, identity }); + const apiKey = await toolkit.ensureFresh(KIMI_CODE_PROVIDER_NAME, { oauthRef }); + let provider = createProvider({ + type: 'kimi', + model: 'kimi-for-coding', + baseUrl, + defaultHeaders: createKimiDefaultHeaders({ + homeDir, + ...identity, + }), + }).withThinking('high'); + provider = provider.withMaxCompletionTokens?.(maxCompletionTokens) ?? provider; + return { provider, auth: { apiKey } }; +} + +async function collect( + provider: ChatProvider, + context: ProjectedContext, + tools: Tool[], + auth: { apiKey?: string; headers?: Record }, +): Promise { + const stream = await provider.generate(context.systemPrompt, tools, context.messages, { auth }); + let textChars = 0; + let thinkChars = 0; + let toolCalls = 0; + const samples: string[] = []; + const partCounts: Record = {}; + + for await (const part of stream) { + partCounts[part.type] = (partCounts[part.type] ?? 0) + 1; + if (part.type === 'text') { + textChars += part.text.length; + if (samples.length < 3) samples.push(`text:${part.text.slice(0, 240)}`); + } else if (part.type === 'think') { + thinkChars += part.think.length; + if (samples.length < 3) samples.push(`think:${part.think.slice(0, 240)}`); + } else if (part.type === 'function') { + toolCalls += 1; + if (samples.length < 3) samples.push(`tool:${part.name}(${part.id})`); + } else { + if (samples.length < 3) samples.push(`${part.type}:${JSON.stringify(part).slice(0, 240)}`); + } + } + + const outcome = + thinkChars > 0 && textChars === 0 && toolCalls === 0 + ? 'think-only' + : textChars > 0 || toolCalls > 0 + ? 'normal-output' + : 'empty'; + + console.log(JSON.stringify({ + outcome, + streamId: stream.id, + finishReason: stream.finishReason, + rawFinishReason: stream.rawFinishReason, + usage: stream.usage, + partCounts, + textChars, + thinkChars, + toolCalls, + samples, + }, null, 2)); +} + +async function main(): Promise { + const args = parseArgs(); + const context = await projectWire(args); + console.log(JSON.stringify({ + script: basename(import.meta.url), + context: summarizeContext(context), + options: { + targetStepUuid: args.targetStepUuid, + dropEmptyAssistants: args.dropEmptyAssistants, + maxCompletionTokens: args.maxCompletionTokens, + }, + }, null, 2)); + + const { provider, auth } = await createKimiProvider(args.maxCompletionTokens); + await collect(provider, context, makeTools(), auth); +} + +main().catch((error: unknown) => { + console.error(JSON.stringify({ + errorName: error instanceof Error ? error.name : undefined, + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack?.split('\n').slice(0, 8) : undefined, + }, null, 2)); + process.exitCode = 1; +}); From b418a04a4efff254a6512901270314f0a6905d34 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 12:37:47 +0800 Subject: [PATCH 21/72] fix --- .../messages/agent-swarm-progress.ts | 61 ++++++++++----- .../messages/agent-swarm-progress.test.ts | 77 ++++++++++++++----- 2 files changed, 98 insertions(+), 40 deletions(-) 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 index d58f377d7..b32352d74 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -25,19 +25,21 @@ const FAILED_PLACEHOLDER_NON_RED_FACTOR = 0.25; const STATUS_BAR_CHAR = '━'; const PROMPTING_TEXT_TRAILING_GAP = 1; const ACTIVITY_SPINNER_PLACEHOLDER = ' '; +const AGENT_SWARM_LEFT_INDENT = ' '; +const AGENT_SWARM_RIGHT_GAP = 1; const ORCHESTRATING_LABEL = 'Orchestrating...'; const PROMPTING_LABEL = 'Prompting...'; -const WORKING_LABEL = 'Working...'; +const WORKING_LABEL = ' Working...'; const COMPLETED_LABEL = 'Completed.'; const FAILED_LABEL = 'Failed.'; -const CANCELLED_LABEL = 'Cancelled.'; +const ABORTED_LABEL = 'Aborted.'; const QUEUED_LABEL = 'Queued...'; const SUSPENDED_LABEL = 'Suspended...'; const TOTAL_STATUS_LABEL_WIDTH = Math.max( visibleWidth(WORKING_LABEL), visibleWidth(COMPLETED_LABEL), visibleWidth(FAILED_LABEL), - visibleWidth(CANCELLED_LABEL), + visibleWidth(ABORTED_LABEL), visibleWidth(SUSPENDED_LABEL), ); @@ -52,7 +54,7 @@ const STATUS_BAR_ORDER = [ type AgentSwarmPhase = AgentSwarmProgressEstimatorPhase; type StatusBarPhase = typeof STATUS_BAR_ORDER[number]; -type TotalStatus = 'working' | 'completed' | 'suspended' | 'failed' | 'cancelled'; +type TotalStatus = 'working' | 'completed' | 'suspended' | 'failed' | 'aborted'; interface AgentSwarmMember { readonly id: string; @@ -102,7 +104,7 @@ const PHASE_LABELS: Record = { running: 'Running', completed: 'Completed', failed: 'Failed', - cancelled: 'Cancelled.', + cancelled: ABORTED_LABEL, }; export class AgentSwarmProgressComponent implements Component { @@ -113,7 +115,7 @@ export class AgentSwarmProgressComponent implements Component { private readonly requestRender: (() => void) | undefined; private inputComplete = false; private failed = false; - private cancelled = false; + private aborted = false; private itemsStarted = false; private toolCallActive = true; private promptTemplateText = ''; @@ -325,7 +327,7 @@ export class AgentSwarmProgressComponent implements Component { markSwarmFailed(failureText?: string): void { this.failed = true; - this.cancelled = false; + this.aborted = false; const normalizedFailureText = normalizeFailureText(failureText); const nowMs = Date.now(); for (const member of this.members) { @@ -350,7 +352,6 @@ export class AgentSwarmProgressComponent implements Component { markCancelled(agentId: string): void { const member = this.findMemberByAgentId(agentId); if (member === undefined) return; - this.cancelled = true; this.progressEstimator.markCancelled(member.id, Date.now()); member.phase = 'cancelled'; delete member.completedAtMs; @@ -361,7 +362,7 @@ export class AgentSwarmProgressComponent implements Component { } markActiveCancelled(): void { - this.cancelled = true; + this.aborted = true; const nowMs = Date.now(); for (const member of this.members) { if ( @@ -385,6 +386,7 @@ export class AgentSwarmProgressComponent implements Component { 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); @@ -419,7 +421,11 @@ export class AgentSwarmProgressComponent implements Component { } render(width: number): string[] { - const innerWidth = Math.max(1, width); + 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 = [ '', @@ -428,7 +434,7 @@ export class AgentSwarmProgressComponent implements Component { this.renderStatusLine(innerWidth), '', ]; - return lines.map((line) => truncateToWidth(line, innerWidth)); + return this.indentLines(lines, outerWidth); } const nowMs = Date.now(); @@ -449,7 +455,20 @@ export class AgentSwarmProgressComponent implements Component { '', ]; this.startAnimationIfNeeded(); - return lines.map((line) => truncateToWidth(line, innerWidth)); + 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 { @@ -471,7 +490,7 @@ export class AgentSwarmProgressComponent implements Component { private renderStatusLine(width: number): string { const status = totalStatus(this.members, { failed: this.failed, - cancelled: this.cancelled, + aborted: this.aborted, }); const prefix = this.renderActivityPrefix(status); if (prefix.length > 0) { @@ -950,7 +969,7 @@ function activityPrefixForTotalStatus(status: TotalStatus, colors: ColorPalette) return ` ${chalk.hex(color)(SUCCESS_MARK.trimEnd())}`; case 'failed': return ` ${chalk.hex(color)(FAILURE_MARK.trimEnd())}`; - case 'cancelled': + case 'aborted': return ` ${chalk.hex(color)('⊘')}`; case 'working': case 'suspended': @@ -1007,9 +1026,9 @@ function statusBarColor(phase: StatusBarPhase, colors: ColorPalette): string { function totalStatus( members: readonly AgentSwarmMember[], - force: { readonly failed: boolean; readonly cancelled: boolean }, + force: { readonly failed: boolean; readonly aborted: boolean }, ): TotalStatus { - if (force.cancelled && members.length === 0) return 'cancelled'; + if (force.aborted) return 'aborted'; if (force.failed && members.length === 0) return 'failed'; const hasCompleted = members.some((member) => member.phase === 'completed'); const hasFailed = members.some((member) => member.phase === 'failed'); @@ -1025,13 +1044,13 @@ function totalStatus( ) ); if (!hasActive && members.length > 0) { + if (hasCancelled) return 'aborted'; if (hasCompleted) return 'completed'; if (hasFailed || force.failed) return 'failed'; - if (hasCancelled || force.cancelled) return 'cancelled'; } if (force.failed) return 'failed'; if (hasSuspended && !hasRunning) return 'suspended'; - return (force.cancelled || hasCancelled) && !hasActive ? 'cancelled' : 'working'; + return hasCancelled && !hasActive ? 'aborted' : 'working'; } function totalStatusLabel(status: TotalStatus): string { @@ -1044,8 +1063,8 @@ function totalStatusLabel(status: TotalStatus): string { return SUSPENDED_LABEL; case 'failed': return FAILED_LABEL; - case 'cancelled': - return CANCELLED_LABEL; + case 'aborted': + return ABORTED_LABEL; } } @@ -1059,7 +1078,7 @@ function totalStatusColor(status: TotalStatus, colors: ColorPalette): string { return colors.warning; case 'failed': return colors.error; - case 'cancelled': + case 'aborted': return colors.warning; } } 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 index 9b72951b6..0e4f1f139 100644 --- 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 @@ -53,11 +53,28 @@ describe('AgentSwarmProgressComponent', () => { const lines = strip(component.render(100).join('\n')).split('\n'); - expect(lines.at(-1)).toBe(''); + 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); + + expect(rendered.every((line) => visibleWidth(line) <= 79)).toBe(true); + expect(rendered.some((line) => line.includes('Agent swarm'))).toBe(true); + expect(rendered.some((line) => line.includes('Working...'))).toBe(true); + }); + it('renders orchestrating and prompting labels in primary blue', () => { withAnsiColor(() => { const orchestrating = new AgentSwarmProgressComponent({ @@ -113,14 +130,14 @@ describe('AgentSwarmProgressComponent', () => { 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...')); + .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 [')); + .find((line) => strip(line).startsWith(' 001 [')); expect(activeLine).toContain(chalk.hex(darkColors.primary)('001')); }); }); @@ -135,7 +152,7 @@ describe('AgentSwarmProgressComponent', () => { const lines = strip(component.render(100).join('\n')).split('\n'); - expect(lines[0]).toBe(''); + expect(lines[0]).toBe(' '); expect(lines[1]).toContain('Agent swarm'); }); @@ -149,7 +166,7 @@ describe('AgentSwarmProgressComponent', () => { 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(94).join('\n')).split('\n'); + const lines = strip(component.render(96).join('\n')).split('\n'); const queuedLine = lines.find((line) => line.includes('001 Queued...')); expect(queuedLine).toBeDefined(); @@ -416,7 +433,7 @@ describe('AgentSwarmProgressComponent', () => { const progressBarIndex = workingLine?.indexOf('━') ?? -1; expect(promptTextIndex).toBeGreaterThan(0); expect(progressBarIndex).toBeGreaterThan(0); - expect(promptTextIndex).toBe(visibleWidth(' Prompting... ')); + expect(promptTextIndex).toBe(visibleWidth(' Prompting... ')); }); it('renders the activity spinner before the total status line', () => { @@ -435,7 +452,7 @@ describe('AgentSwarmProgressComponent', () => { .find((line) => line.includes('Working...')); expect(statusLine).toBeDefined(); - expect(statusLine?.startsWith('🌗 Working...')).toBe(true); + expect(statusLine?.startsWith(' 🌗 Working...')).toBe(true); }); it('renders working label blue until a subagent completes, then green', () => { @@ -454,14 +471,14 @@ describe('AgentSwarmProgressComponent', () => { const initialRawLine = component.render(80).join('\n') .split('\n') .find((line) => strip(line).includes('Working...')); - expect(initialRawLine).toContain(chalk.hex(darkColors.primary)('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...')); + expect(partialRawLine).toContain(chalk.hex(darkColors.success)(' Working...')); }); }); @@ -483,7 +500,7 @@ describe('AgentSwarmProgressComponent', () => { .find((line) => line.includes('Working...')); expect(statusLine).toBeDefined(); - expect(statusLine?.startsWith(' Working...')).toBe(true); + expect(statusLine?.startsWith(' Working...')).toBe(true); expect(statusLine).not.toContain('🌗'); expect(statusLine).not.toContain('🌘'); }); @@ -506,10 +523,10 @@ describe('AgentSwarmProgressComponent', () => { const completedRawLine = completed.render(80).join('\n') .split('\n') - .find((line) => strip(line).startsWith(' ✓ Completed.')); + .find((line) => strip(line).startsWith(' ✓ Completed.')); const completedLine = completedRawLine === undefined ? undefined : strip(completedRawLine); expect(completedLine).toBeDefined(); - expect(completedLine?.startsWith(' ✓ Completed.')).toBe(true); + expect(completedLine?.startsWith(' ✓ Completed.')).toBe(true); expect(completedRawLine).toContain(chalk.hex(darkColors.success)('✓')); expect(completedLine).not.toContain('🌗'); expect(completedLine).not.toContain('🌘'); @@ -525,10 +542,10 @@ describe('AgentSwarmProgressComponent', () => { const failedRawLine = failed.render(80).join('\n') .split('\n') - .find((line) => strip(line).startsWith(' ✗ Failed.')); + .find((line) => strip(line).startsWith(' ✗ Failed.')); const failedLine = failedRawLine === undefined ? undefined : strip(failedRawLine); expect(failedLine).toBeDefined(); - expect(failedLine?.startsWith(' ✗ Failed.')).toBe(true); + expect(failedLine?.startsWith(' ✗ Failed.')).toBe(true); expect(failedRawLine).toContain(chalk.hex(darkColors.error)('✗')); const cancelled = new AgentSwarmProgressComponent({ @@ -538,16 +555,38 @@ describe('AgentSwarmProgressComponent', () => { cancelled.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); cancelled.markInputComplete(); cancelled.markStarted('agent-1'); - cancelled.markActiveCancelled(); + cancelled.markCancelled('agent-1'); cancelled.markToolCallEnded(); - const cancelledRawLine = cancelled.render(80).join('\n') + const cancelledOutput = cancelled.render(80).join('\n'); + expect(strip(cancelledOutput)).not.toContain('Cancelled.'); + + const cancelledRawLine = cancelledOutput .split('\n') - .find((line) => strip(line).startsWith(' ⊘ Cancelled.')); + .find((line) => strip(line).startsWith(' ⊘ Aborted.')); const cancelledLine = cancelledRawLine === undefined ? undefined : strip(cancelledRawLine); expect(cancelledLine).toBeDefined(); - expect(cancelledLine?.startsWith(' ⊘ Cancelled.')).toBe(true); + 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; } @@ -567,7 +606,7 @@ describe('AgentSwarmProgressComponent', () => { .find((line) => line.includes('Prompting...')); expect(promptLine).toBeDefined(); - expect(visibleWidth(promptLine ?? '')).toBe(49); + expect(visibleWidth(promptLine ?? '')).toBe(48); }); it('renders boosted fractional progress ticks without leaking undefined cells', () => { From 765f108f9abcdedd29bea3ae97d852c8a705112a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 13:33:28 +0800 Subject: [PATCH 22/72] fix --- .../messages/agent-swarm-progress.ts | 23 +++++++++--------- .../messages/agent-swarm-progress.test.ts | 18 +++++++++----- .../agent-core/src/agent/context/index.ts | 13 ++++++++++ ...arm-mode-reminder.md => enter-reminder.md} | 0 .../src/agent/swarm/exit-reminder.md | 5 ++++ packages/agent-core/src/agent/swarm/index.ts | 24 +++++++++---------- packages/agent-core/src/agent/turn/index.ts | 3 --- 7 files changed, 53 insertions(+), 33 deletions(-) rename packages/agent-core/src/agent/swarm/{swarm-mode-reminder.md => enter-reminder.md} (100%) create mode 100644 packages/agent-core/src/agent/swarm/exit-reminder.md 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 index b32352d74..4c226626e 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -23,25 +23,20 @@ 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 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_GRID_RIGHT_GAP = 1; const ORCHESTRATING_LABEL = 'Orchestrating...'; const PROMPTING_LABEL = 'Prompting...'; -const WORKING_LABEL = ' Working...'; +const WORKING_LABEL = 'Working...'; const COMPLETED_LABEL = 'Completed.'; const FAILED_LABEL = 'Failed.'; const ABORTED_LABEL = 'Aborted.'; const QUEUED_LABEL = 'Queued...'; const SUSPENDED_LABEL = 'Suspended...'; -const TOTAL_STATUS_LABEL_WIDTH = Math.max( - visibleWidth(WORKING_LABEL), - visibleWidth(COMPLETED_LABEL), - visibleWidth(FAILED_LABEL), - visibleWidth(ABORTED_LABEL), - visibleWidth(SUSPENDED_LABEL), -); const STATUS_BAR_ORDER = [ 'completed', @@ -449,7 +444,11 @@ export class AgentSwarmProgressComponent implements Component { '', this.renderHeader(innerWidth, summary), '', - ...this.renderGrid(innerWidth, snapshots, nowMs), + ...this.renderGrid( + Math.max(1, innerWidth - AGENT_SWARM_GRID_RIGHT_GAP), + snapshots, + nowMs, + ), '', this.renderStatusLine(innerWidth), '', @@ -522,10 +521,10 @@ export class AgentSwarmProgressComponent implements Component { totalStatusLabelColor(status, this.members, this.colors), ); if (this.members.length === 0) return truncateToWidth(label, width); - const barWidth = Math.max(0, width - visibleWidth(label) - 2); + const barWidth = Math.max(0, width - visibleWidth(label) - TOTAL_STATUS_BAR_GAP); if (barWidth <= 0) return truncateToWidth(label, width); return truncateToWidth( - `${label} ${renderStatusPipBar(this.members, barWidth, this.colors)} `, + `${label}${' '.repeat(TOTAL_STATUS_BAR_GAP)}${renderStatusPipBar(this.members, barWidth, this.colors)}`, width, ); } @@ -955,7 +954,7 @@ function renderStatusPipBar( } function renderTotalStatusLabel(label: string, color: string): string { - return ` ${padAnsi(chalk.hex(color)(label), TOTAL_STATUS_LABEL_WIDTH)}`; + return ` ${chalk.hex(color)(label)}`; } function renderInlineStatusLabel(label: string, color: string): string { 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 index 0e4f1f139..72ddadbed 100644 --- 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 @@ -69,10 +69,15 @@ describe('AgentSwarmProgressComponent', () => { 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(rendered.some((line) => line.includes('Working...'))).toBe(true); + expect(statusLine).toBeDefined(); + expect(statusLine?.match(/ *$/)?.[0].length).toBe(0); + expect(gridLine).toBeDefined(); + expect(visibleWidth(gridLine ?? '')).toBeLessThan(visibleWidth(statusLine ?? '')); }); it('renders orchestrating and prompting labels in primary blue', () => { @@ -166,7 +171,7 @@ describe('AgentSwarmProgressComponent', () => { 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(96).join('\n')).split('\n'); + const lines = strip(component.render(97).join('\n')).split('\n'); const queuedLine = lines.find((line) => line.includes('001 Queued...')); expect(queuedLine).toBeDefined(); @@ -434,6 +439,7 @@ describe('AgentSwarmProgressComponent', () => { 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', () => { @@ -452,7 +458,7 @@ describe('AgentSwarmProgressComponent', () => { .find((line) => line.includes('Working...')); expect(statusLine).toBeDefined(); - expect(statusLine?.startsWith(' 🌗 Working...')).toBe(true); + expect(statusLine?.startsWith(' 🌗 Working...')).toBe(true); }); it('renders working label blue until a subagent completes, then green', () => { @@ -471,14 +477,14 @@ describe('AgentSwarmProgressComponent', () => { const initialRawLine = component.render(80).join('\n') .split('\n') .find((line) => strip(line).includes('Working...')); - expect(initialRawLine).toContain(chalk.hex(darkColors.primary)(' 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...')); + expect(partialRawLine).toContain(chalk.hex(darkColors.success)('Working...')); }); }); @@ -500,7 +506,7 @@ describe('AgentSwarmProgressComponent', () => { .find((line) => line.includes('Working...')); expect(statusLine).toBeDefined(); - expect(statusLine?.startsWith(' Working...')).toBe(true); + expect(statusLine?.startsWith(' Working...')).toBe(true); expect(statusLine).not.toContain('🌗'); expect(statusLine).not.toContain('🌘'); }); diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index f9dff23b4..4c16b98a4 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -58,6 +58,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/swarm/swarm-mode-reminder.md b/packages/agent-core/src/agent/swarm/enter-reminder.md similarity index 100% rename from packages/agent-core/src/agent/swarm/swarm-mode-reminder.md rename to packages/agent-core/src/agent/swarm/enter-reminder.md 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 index 88ed0b5e6..1d048844b 100644 --- a/packages/agent-core/src/agent/swarm/index.ts +++ b/packages/agent-core/src/agent/swarm/index.ts @@ -1,9 +1,8 @@ import type { Agent } from '..'; import type { ContentPart } from '@moonshot-ai/kosong'; -import SWARM_MODE_REMINDER from './swarm-mode-reminder.md'; - -const SWARM_MODE_EXIT_REMINDER = 'Swarm mode has ended.'; +import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md'; +import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md'; export class SwarmMode { protected active = false; @@ -13,7 +12,7 @@ export class SwarmMode { run(input: readonly ContentPart[]): void { this.agent.records.logRecord({ type: 'swarm_mode.enter' }); this.active = true; - this.agent.context.appendSystemReminder(SWARM_MODE_REMINDER, { + this.agent.context.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { kind: 'injection', variant: 'swarm_mode', }); @@ -21,8 +20,7 @@ export class SwarmMode { if (this.agent.records.restoring) { this.agent.turn.restorePrompt(); } else { - const turnId = this.agent.turn.prompt(input); - if (turnId === null) this.exit(); + this.agent.turn.prompt(input); } } @@ -30,14 +28,16 @@ export class SwarmMode { if (!this.active) return; this.agent.records.logRecord({ type: 'swarm_mode.exit' }); this.active = false; - if (this.agent.records.restoring) { + this.agent.emitStatusUpdated(); + if (this.agent.context.popMatchedMessage((origin) => origin?.kind === 'injection' && origin.variant === 'swarm_mode')) { return; } - this.agent.context.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { - kind: 'injection', - variant: 'swarm_mode_exit', - }); - this.agent.emitStatusUpdated(); + if (!this.agent.records.restoring) { + this.agent.context.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { + kind: 'injection', + variant: 'swarm_mode_exit', + }); + } } get isActive(): boolean { diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 86ae211a6..58e8b7d10 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -520,9 +520,6 @@ export class TurnFlow { this.agent.usage.endTurn(); } this.agent.emitEvent(ended); - if (this.agent.swarmMode.isActive) { - this.agent.swarmMode.exit(); - } if (standalone && this.currentId === turnId) { this.activeTurn = null; } From 75e00b89adae1163dfbb81116556333120f5412d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 13:52:57 +0800 Subject: [PATCH 23/72] update --- apps/kimi-code/src/tui/commands/dispatch.ts | 3 +- apps/kimi-code/src/tui/commands/registry.ts | 4 +- apps/kimi-code/src/tui/commands/swarm.ts | 47 ++++++++-- apps/kimi-code/src/tui/kimi-tui.ts | 25 +----- .../kimi-code/src/tui/utils/message-replay.ts | 1 + .../test/tui/commands/registry.test.ts | 8 ++ .../kimi-code/test/tui/commands/swarm.test.ts | 80 +++++++++++++---- .../kimi-code/test/tui/message-replay.test.ts | 1 + docs/en/reference/slash-commands.md | 3 +- docs/zh/reference/slash-commands.md | 3 +- packages/agent-core/src/agent/index.ts | 14 ++- .../agent-core/src/agent/records/index.ts | 6 ++ packages/agent-core/src/agent/swarm/index.ts | 17 ++-- packages/agent-core/src/agent/turn/index.ts | 3 + packages/agent-core/src/rpc/core-api.ts | 4 +- packages/agent-core/src/rpc/core-impl.ts | 14 ++- packages/agent-core/src/rpc/resumed.ts | 1 + packages/agent-core/src/session/rpc.ts | 12 ++- packages/agent-core/test/agent/turn.test.ts | 88 +++++++++++++++++++ packages/node-sdk/src/rpc.ts | 25 +++++- packages/node-sdk/src/session.ts | 11 +++ packages/node-sdk/src/types.ts | 1 + 22 files changed, 298 insertions(+), 73 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index d274cf0e2..aaff10b12 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -139,7 +139,6 @@ export interface SlashCommandHost { createNewSession(): Promise; showSessionPicker(): Promise; sendNormalUserInput(text: string): void; - sendSwarmUserInput(text: string): void; sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; readonly skillCommandMap: Map; @@ -304,7 +303,7 @@ async function handleBuiltInSlashCommand( await handlePlanCommand(host, args); return; case 'swarm': - handleSwarmCommand(host, args); + await handleSwarmCommand(host, args); return; case 'compact': await handleCompactCommand(host, args); diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 30cf58ed1..bf1b0b3e7 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -70,8 +70,10 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: 'swarm', aliases: [], - description: 'Run one task in swarm mode', + description: 'Toggle swarm mode or run one task in swarm mode', priority: 100, + availability: (args) => + ['on', 'off'].includes(args.trim().toLowerCase()) ? 'always' : 'idle-only', }, { name: 'model', diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index 5a336475a..d346bf17a 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -8,19 +8,26 @@ import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi import { formatErrorMessage } from '../utils/event-payload'; import type { SlashCommandHost } from './dispatch'; -export function handleSwarmCommand(host: SlashCommandHost, args: string): void { +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); + return; + } + if (host.state.appState.model.trim().length === 0) { host.showError(LLM_NOT_SET_MESSAGE); return; } - const prompt = args.trim(); if (prompt.length === 0) { - host.showError('Usage: /swarm '); + host.showError('Usage: /swarm '); return; } @@ -29,7 +36,7 @@ export function handleSwarmCommand(host: SlashCommandHost, args: string): void { return; } - host.sendSwarmUserInput(prompt); + await startSwarmTask(host, prompt); } function showSwarmStartPermissionPrompt(host: SlashCommandHost, prompt: string): void { @@ -62,7 +69,7 @@ async function startSwarmWithPermission( if (choice === 'auto' || choice === 'yolo') { if (!(await setPermissionForSwarm(host, choice))) return; } - host.sendSwarmUserInput(prompt); + await startSwarmTask(host, prompt); } async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMode): Promise { @@ -75,3 +82,33 @@ async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMod host.setAppState({ permissionMode: mode }); return true; } + +async function startSwarmTask(host: SlashCommandHost, prompt: string): Promise { + if (!(await setSwarmMode(host, true))) return; + host.sendNormalUserInput(prompt); +} + +async function applySwarmMode(host: SlashCommandHost, enabled: boolean): Promise { + if (!(await setSwarmMode(host, enabled))) return; + host.showStatus(`Swarm mode ${enabled ? 'enabled' : 'disabled'}.`); +} + +async function setSwarmMode(host: SlashCommandHost, enabled: boolean): Promise { + try { + await host.requireSession().setSwarmMode(enabled); + } catch (error) { + host.showError( + `Failed to ${enabled ? 'enable' : 'disable'} swarm mode: ${formatErrorMessage(error)}`, + ); + return false; + } + host.setAppState({ swarmMode: enabled }); + return true; +} + +function swarmModeSubcommand(input: string): boolean | undefined { + const command = input.toLowerCase(); + if (command === 'on') return true; + if (command === 'off') return false; + return undefined; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index ece975ba5..80e576fa1 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -718,30 +718,6 @@ export class KimiTUI { this.state.ui.requestRender(); } - sendSwarmUserInput(text: string): void { - if (this.state.appState.model.trim().length === 0) { - this.showError(LLM_NOT_SET_MESSAGE); - return; - } - const session = this.session; - if (session === undefined) { - this.showError(LLM_NOT_SET_MESSAGE); - return; - } - this.appendTranscriptEntry({ - id: nextTranscriptId(), - kind: 'user', - turnId: undefined, - renderMode: 'plain', - content: text, - }); - this.beginSessionRequest(); - void session.swarm(text).catch((error: unknown) => { - const message = formatErrorMessage(error); - this.failSessionRequest(`Failed to send swarm prompt: ${message}`); - }); - } - private validateMediaCapabilities( extraction: ReturnType, ): boolean { @@ -1058,6 +1034,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, diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 5d2657957..1c516c288 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 { expect(resolveSlashCommandAvailability(plan!, 'clear')).toBe('idle-only'); }); + it('keeps swarm toggles always available while swarm tasks are idle-only', () => { + const swarm = findBuiltInSlashCommand('swarm'); + expect(swarm).toBeDefined(); + expect(resolveSlashCommandAvailability(swarm!, 'on')).toBe('always'); + expect(resolveSlashCommandAvailability(swarm!, 'off')).toBe('always'); + expect(resolveSlashCommandAvailability(swarm!, 'Ship feature X')).toBe('idle-only'); + }); + it('defaults commands without explicit availability to idle-only', () => { const command: KimiSlashCommand = { name: 'example', diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 86a09bc9e..0ffa375df 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -21,6 +21,7 @@ function makeHost( ) { const session = { setPermission: vi.fn(async () => {}), + setSwarmMode: vi.fn(async () => {}), }; const hasSession = overrides.hasSession ?? true; const host = { @@ -39,7 +40,7 @@ function makeHost( mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), restoreInputText: vi.fn(), - sendSwarmUserInput: vi.fn(), + sendNormalUserInput: vi.fn(), } as unknown as SlashCommandHost; return { host, session }; } @@ -55,24 +56,48 @@ function mountedPicker(host: SlashCommandHost): TestPicker { } describe('handleSwarmCommand', () => { - it('sends the swarm prompt directly outside Manual mode', () => { + it('sends the swarm prompt as a normal prompt after enabling swarm mode', async () => { const { host, session } = makeHost({ permissionMode: 'auto' }); - handleSwarmCommand(host, 'Ship feature X'); + await handleSwarmCommand(host, 'Ship feature X'); expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setSwarmMode).toHaveBeenCalledWith(true); expect(host.mountEditorReplacement).not.toHaveBeenCalled(); - expect(host.sendSwarmUserInput).toHaveBeenCalledWith('Ship feature X'); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); - it('asks before starting a swarm task in Manual mode', () => { + it('turns swarm mode on without sending a prompt', async () => { + const { host, session } = makeHost({ model: '' }); + + await handleSwarmCommand(host, 'on'); + + expect(session.setSwarmMode).toHaveBeenCalledWith(true); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.showStatus).toHaveBeenCalledWith('Swarm mode enabled.'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns swarm mode off without sending a prompt', async () => { + const { host, session } = makeHost({ model: '' }); + + await handleSwarmCommand(host, 'off'); + + expect(session.setSwarmMode).toHaveBeenCalledWith(false); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); + expect(host.showStatus).toHaveBeenCalledWith('Swarm mode disabled.'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('asks before starting a swarm task in Manual mode', async () => { const { host, session } = makeHost({ permissionMode: 'manual' }); - handleSwarmCommand(host, 'Ship feature X'); + await handleSwarmCommand(host, 'Ship feature X'); expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); expect(session.setPermission).not.toHaveBeenCalled(); - expect(host.sendSwarmUserInput).not.toHaveBeenCalled(); + expect(session.setSwarmMode).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).toContain('Return to the input box with your swarm command'); @@ -81,63 +106,69 @@ describe('handleSwarmCommand', () => { it('defaults to Auto when confirming a Manual-mode swarm start', async () => { const { host, session } = makeHost({ permissionMode: 'manual' }); - handleSwarmCommand(host, 'Ship feature X'); + await handleSwarmCommand(host, 'Ship feature X'); mountedPicker(host).handleInput(ENTER); await vi.waitFor(() => { - expect(host.sendSwarmUserInput).toHaveBeenCalledWith('Ship feature X'); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); }); it('can start a Manual-mode swarm task without changing permission', async () => { const { host, session } = makeHost({ permissionMode: 'manual' }); - handleSwarmCommand(host, 'Ship feature X'); + await handleSwarmCommand(host, 'Ship feature X'); const picker = mountedPicker(host); picker.handleInput(DOWN); picker.handleInput(DOWN); picker.handleInput(ENTER); await vi.waitFor(() => { - expect(host.sendSwarmUserInput).toHaveBeenCalledWith('Ship feature X'); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setSwarmMode).toHaveBeenCalledWith(true); }); it('can switch to YOLO when starting a Manual-mode swarm task', async () => { const { host, session } = makeHost({ permissionMode: 'manual' }); - handleSwarmCommand(host, 'Ship feature X'); + await handleSwarmCommand(host, 'Ship feature X'); const picker = mountedPicker(host); picker.handleInput(DOWN); picker.handleInput(ENTER); await vi.waitFor(() => { - expect(host.sendSwarmUserInput).toHaveBeenCalledWith('Ship feature X'); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); }); - it('returns the command to the input box when a Manual-mode swarm start is cancelled', () => { + it('returns the command to the input box when a Manual-mode swarm start is cancelled', async () => { const { host, session } = makeHost({ permissionMode: 'manual' }); - handleSwarmCommand(host, 'Ship feature X'); + 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(host.sendSwarmUserInput).not.toHaveBeenCalled(); + expect(session.setSwarmMode).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')); - handleSwarmCommand(host, 'Ship feature X'); + await handleSwarmCommand(host, 'Ship feature X'); mountedPicker(host).handleInput(ENTER); await vi.waitFor(() => { @@ -145,6 +176,19 @@ describe('handleSwarmCommand', () => { expect.stringContaining('Failed to set permission mode'), ); }); - expect(host.sendSwarmUserInput).not.toHaveBeenCalled(); + expect(session.setSwarmMode).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(host.sendNormalUserInput).not.toHaveBeenCalled(); }); }); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index ee8f57849..0b5894bf4 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -108,6 +108,7 @@ function baseAgentState( replay, permission: { mode: 'manual', rules: [] }, plan: null, + swarmMode: false, usage: {}, tools: [], toolStore: {}, diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 640640051..8f0a513d6 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -46,7 +46,8 @@ Some commands are only available in the idle state. Executing these commands whi | `/auto [on\|off]` | — | Toggle auto permission mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. When enabled, tool approvals are handled automatically and the agent will not ask questions. | Yes | | `/plan [on\|off]` | — | Toggle Plan mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. Toggling alone does not create an empty plan file. | Yes | | `/plan clear` | — | Clear the current plan. | No | -| `/swarm ` | — | Run one task in swarm mode. In `manual` permission mode, Kimi Code asks whether to switch to `auto` before starting. | 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 [status\|pause\|resume\|cancel\|replace \|]` | — | Start or manage an autonomous goal. This command is experimental; enable it from `/experiments`, `[experimental].goal_command`, or `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1`. | See below | ::: warning diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 6403067c7..957658c32 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -44,7 +44,8 @@ | `/auto [on\|off]` | — | 切换 auto 权限模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。开启后工具审批自动处理,Agent 不会向用户提问。 | 是 | | `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。单纯切换不会创建空计划文件。 | 是 | | `/plan clear` | — | 清除当前 plan 方案。 | 否 | -| `/swarm ` | — | 以 swarm 模式运行一个任务。若当前是 `manual` 权限模式,启动前会提示是否切换到 `auto`。 | 否 | +| `/swarm on\|off` | — | 开启或关闭 swarm mode,但不发送提示词。 | 是 | +| `/swarm ` | — | 先开启 swarm mode,再把 `` 作为普通提示词发送。如果该轮次正常完成,swarm mode 会自动关闭。若当前是 `manual` 权限模式,启动前会提示是否切换到 `auto`。 | 否 | | `/goal [status\|pause\|resume\|cancel\|replace \|]` | — | 开始或管理一个自主 goal。该命令仍是实验功能,可通过 `/experiments`、`[experimental].goal_command` 或 `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1` 启用。 | 见下文 | ::: warning 注意 diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index adc008077..72f4438bd 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -366,8 +366,14 @@ export class Agent { this.planMode.cancel(payload.id); }, clearPlan: () => this.planMode.clear(), - runSwarm: (payload) => { - this.swarmMode.run(payload.input); + enterSwarm: () => { + this.swarmMode.enter(); + }, + exitSwarm: () => { + this.swarmMode.exit(); + }, + getSwarmMode: () => { + return this.swarmMode.isActive; }, beginCompaction: (payload) => { this.fullCompaction.begin({ source: 'manual', instruction: payload.instruction }); @@ -424,7 +430,7 @@ export class Agent { }; } - emitStatusUpdated(): void { + emitStatusUpdated(options: { readonly swarmMode?: boolean } = {}): void { if (this.records.restoring) return; if (!this.config.hasModel) return; @@ -444,7 +450,7 @@ export class Agent { maxContextTokens, contextUsage, planMode: this.planMode.isActive, - swarmMode: this.swarmMode.isActive, + swarmMode: options.swarmMode ?? (this.swarmMode.isActive ? true : undefined), permission: this.permission.mode, usage, }); diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 6960a362b..380cb3471 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -68,6 +68,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(); + 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/swarm/index.ts b/packages/agent-core/src/agent/swarm/index.ts index 1d048844b..99e56ce64 100644 --- a/packages/agent-core/src/agent/swarm/index.ts +++ b/packages/agent-core/src/agent/swarm/index.ts @@ -1,5 +1,4 @@ import type { Agent } from '..'; -import type { ContentPart } from '@moonshot-ai/kosong'; import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md'; import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md'; @@ -9,26 +8,26 @@ export class SwarmMode { constructor(protected readonly agent: Agent) {} - run(input: readonly ContentPart[]): void { + enter(): void { + if (this.active) return; this.agent.records.logRecord({ type: 'swarm_mode.enter' }); this.active = true; this.agent.context.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { kind: 'injection', variant: 'swarm_mode', }); - this.agent.emitStatusUpdated(); - if (this.agent.records.restoring) { - this.agent.turn.restorePrompt(); - } else { - this.agent.turn.prompt(input); - } + this.agent.emitStatusUpdated({ swarmMode: true }); + } + + restoreEnter(): void { + this.active = true; } exit(): void { if (!this.active) return; this.agent.records.logRecord({ type: 'swarm_mode.exit' }); this.active = false; - this.agent.emitStatusUpdated(); + this.agent.emitStatusUpdated({ swarmMode: false }); if (this.agent.context.popMatchedMessage((origin) => origin?.kind === 'injection' && origin.variant === 'swarm_mode')) { return; } diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 58e8b7d10..5979b5e7e 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -523,6 +523,9 @@ export class TurnFlow { if (standalone && this.currentId === turnId) { this.activeTurn = null; } + if (ended.reason === 'completed') { + this.agent.swarmMode.exit(); + } if (errorEvent !== undefined) { this.agent.emitEvent(errorEvent); } diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index c15220bd7..a2f0d7f67 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -315,7 +315,9 @@ export interface AgentAPI { enterPlan: (payload: EmptyPayload) => void; cancelPlan: (payload: CancelPlanPayload) => void; clearPlan: (payload: EmptyPayload) => void; - runSwarm: (payload: PromptPayload) => void; + enterSwarm: (payload: EmptyPayload) => 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 914681579..b5e6b7d4f 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -523,8 +523,16 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).clearPlan(payload); } - runSwarm({ sessionId, ...payload }: SessionAgentPayload) { - return this.sessionApi(sessionId).runSwarm(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) { @@ -947,6 +955,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, @@ -955,6 +964,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/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/rpc.ts b/packages/agent-core/src/session/rpc.ts index e1754c0c4..d8e1d2086 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -198,8 +198,16 @@ export class SessionAPIImpl implements PromisableMethods { return (await this.getAgent(agentId)).clearPlan(payload); } - async runSwarm({ agentId, ...payload }: AgentScopedPayload) { - return (await this.getAgent(agentId)).runSwarm(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) { diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 004715e56..987741a04 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -248,6 +248,63 @@ describe('Agent turn flow', () => { await ctx.expectResumeMatches(); }); + it('exits swarm mode after a turn completes normally', async () => { + const ctx = testAgent(); + ctx.configure(); + ctx.mockNextResponse({ type: 'text', text: 'swarm done' }); + + await ctx.rpc.enterSwarm({}); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run a swarm task' }] }); + await ctx.untilTurnEnd(); + + const turnEndedIndex = eventIndex(ctx, '[rpc]', 'turn.ended'); + const swarmExitIndex = eventIndex(ctx, '[wire]', 'swarm_mode.exit'); + const inactiveStatusIndex = ctx.allEvents.findIndex((entry, index) => { + return ( + index > turnEndedIndex && + entry.type === '[rpc]' && + entry.event === 'agent.status.updated' && + (entry.args as { readonly swarmMode?: boolean }).swarmMode === false + ); + }); + + expect(ctx.agent.swarmMode.isActive).toBe(false); + expect(swarmExitIndex).toBeGreaterThan(turnEndedIndex); + expect(inactiveStatusIndex).toBeGreaterThan(turnEndedIndex); + expect(ctx.agent.context.history.at(-1)?.origin).toEqual({ + kind: 'injection', + variant: 'swarm_mode_exit', + }); + await ctx.expectResumeMatches(); + }); + + it('keeps swarm mode active when the swarm turn fails', async () => { + const ctx = testAgent(); + ctx.configure(); + + await ctx.rpc.enterSwarm({}); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fail a swarm task' }] }); + await ctx.untilTurnEnd(); + + expect(ctx.agent.swarmMode.isActive).toBe(true); + expect(eventIndex(ctx, '[wire]', 'swarm_mode.exit')).toBe(-1); + }); + + it('keeps swarm mode active when the user cancels the swarm turn', async () => { + const ctx = testAgent({ generate: abortableGenerate }); + ctx.configure(); + + const stepStarted = ctx.once('turn.step.started'); + await ctx.rpc.enterSwarm({}); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Cancel a swarm task' }] }); + await stepStarted; + await ctx.rpc.cancel({ turnId: 0 }); + await ctx.untilTurnEnd(); + + expect(ctx.agent.swarmMode.isActive).toBe(true); + expect(eventIndex(ctx, '[wire]', 'swarm_mode.exit')).toBe(-1); + }); + it('emits a friendly model.not_configured error when no model is configured', async () => { const ctx = testAgent(); @@ -1400,6 +1457,37 @@ describe('Agent turn flow', () => { }); }); +const abortableGenerate: GenerateFn = async ( + _chat, + _systemPrompt, + _tools, + _history, + _callbacks, + options, +) => { + await new Promise((_resolve, reject) => { + const rejectAbort = () => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + reject(error); + }; + if (options?.signal?.aborted === true) { + rejectAbort(); + return; + } + options?.signal?.addEventListener('abort', rejectAbort, { once: true }); + }); + throw new Error('abortableGenerate unexpectedly completed'); +}; + +function eventIndex( + ctx: Pick, 'allEvents'>, + type: string, + event: string, +): number { + return ctx.allEvents.findIndex((entry) => entry.type === type && entry.event === event); +} + function bashCall(): ToolCall { return bashCallWithId('call_bash', 'printf should-not-run'); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index f6bf96f3e..2cad40b82 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -80,6 +80,10 @@ export interface SetSessionPlanModeRpcInput extends SessionIdRpcInput { readonly enabled: boolean; } +export interface SetSessionSwarmModeRpcInput extends SessionIdRpcInput { + readonly enabled: boolean; +} + export interface ActivateSkillRpcInput extends SessionIdRpcInput { readonly name: string; readonly args?: string | undefined; @@ -260,15 +264,25 @@ export abstract class SDKRpcClientBase { }); } - async swarm(input: SessionPromptRpcInput): Promise { + async setSwarmMode(input: SetSessionSwarmModeRpcInput): Promise { const rpc = await this.getRpc(); - return rpc.runSwarm({ + if (!input.enabled) { + return rpc.exitSwarm({ + sessionId: input.sessionId, + agentId: this.interactiveAgentId, + }); + } + return rpc.enterSwarm({ sessionId: input.sessionId, agentId: this.interactiveAgentId, - input: input.input, }); } + async swarm(input: SessionPromptRpcInput): Promise { + await this.setSwarmMode({ sessionId: input.sessionId, enabled: true }); + return this.prompt(input); + } + async getPlan(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); return rpc.getPlan({ @@ -346,6 +360,10 @@ export abstract class SDKRpcClientBase { sessionId: input.sessionId, agentId, }); + const swarmMode = await rpc.getSwarmMode({ + sessionId: input.sessionId, + agentId, + }); const usage = await rpc.getUsage({ sessionId: input.sessionId, agentId, @@ -360,6 +378,7 @@ export abstract class SDKRpcClientBase { thinkingLevel: config.thinkingLevel, permission: permission.mode, planMode: plan !== null, + swarmMode, contextTokens, maxContextTokens, contextUsage, diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index eeee5e605..cd28acab4 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -173,6 +173,17 @@ export class Session { await this.rpc.setPlanMode({ sessionId: this.id, enabled }); } + async setSwarmMode(enabled: boolean): Promise { + this.ensureOpen(); + if (typeof enabled !== 'boolean') { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + 'Session swarm mode must be a boolean', + ); + } + await this.rpc.setSwarmMode({ sessionId: this.id, enabled }); + } + async getPlan(): Promise { this.ensureOpen(); return this.rpc.getPlan({ sessionId: this.id }); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 1ef8a3277..0d23d7f92 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -167,6 +167,7 @@ export interface SessionStatus { readonly thinkingLevel: string; readonly permission: PermissionMode; readonly planMode: boolean; + readonly swarmMode?: boolean | undefined; readonly contextTokens: number; readonly maxContextTokens: number; readonly contextUsage: number; From 108f63554aebe9f4fb1b80c7a72dfdca2a17195b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 14:00:01 +0800 Subject: [PATCH 24/72] fix --- docs/en/reference/slash-commands.md | 12 ++++++------ docs/en/reference/tools.md | 4 ++-- docs/zh/reference/slash-commands.md | 10 +++++----- docs/zh/reference/tools.md | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 8f0a513d6..4688cb886 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -42,19 +42,19 @@ Some commands are only available in the idle state. Executing these commands whi | Command | Alias | Description | Always available | | --- | --- | --- | --- | -| `/yolo [on\|off]` | `/yes` | Toggle YOLO mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. When enabled, ordinary tool call approvals are skipped; the Plan mode exit approval is not skipped. | Yes | -| `/auto [on\|off]` | — | Toggle auto permission mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. When enabled, tool approvals are handled automatically and the agent will not ask questions. | Yes | -| `/plan [on\|off]` | — | Toggle Plan mode. Without arguments, flip the current state; pass `on`/`off` explicitly to force the corresponding state. Toggling alone does not create an empty plan file. | Yes | -| `/plan clear` | — | Clear the current plan. | No | +| `/yolo [on\|off]` | `/yes` | Toggle YOLO mode. Without arguments, flips the current state; explicitly passing `on`/`off` forces the setting. When enabled, skips approval for regular tool calls; Plan mode exit approval is not affected | Yes | +| `/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 [status\|pause\|resume\|cancel\|replace \|]` | — | Start or manage an autonomous goal. This command is experimental; enable it from `/experiments`, `[experimental].goal_command`, or `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1`. | See below | +| `/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 `/yolo` skips approval for regular tool calls. Please make sure you understand the potential risks before enabling it. Plan mode exit approval is not bypassed by `/yolo`; `Bash` inside Plan mode is still subject to the regular `/yolo` allow rules. ::: -## Autonomous goals +## Autonomous Goal (Experimental) ::: info `/goal` is an experimental command. Enable it from `/experiments`, or write it in `~/.kimi-code/config.toml`: diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index e612d3d73..4a7dcd6e5 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -84,7 +84,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | Tool | Default Approval | Description | | --- | --- | --- | -| `Agent` | Auto-allow | Spawn a subagent to execute a subtask | +| `Agent` | Auto-allow | Spawn a sub-Agent to execute a subtask | | `AgentSwarm` | Auto-allow | Launch multiple subagents from a prompt template | | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | @@ -93,7 +93,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill **`AgentSwarm`** launches multiple subagents from a shared `prompt_template` and an `items` array. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one subagent. Pass `subagent_type` to choose the profile used by every subagent in the swarm, or omit it to use `coder`. The tool accepts 2 to 50 items, 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. -**`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. +**`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/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 957658c32..d574cdfb6 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -40,13 +40,13 @@ | 命令 | 别名 | 说明 | 随时可用 | | --- | --- | --- | --- | -| `/yolo [on\|off]` | `/yes` | 切换 YOLO 模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。开启后跳过普通工具调用审批;Plan 模式的退出审批不会被跳过。 | 是 | -| `/auto [on\|off]` | — | 切换 auto 权限模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。开启后工具审批自动处理,Agent 不会向用户提问。 | 是 | -| `/plan [on\|off]` | — | 切换 Plan 模式。不带参数时按当前状态翻转;显式传 `on`/`off` 时强制设为对应状态。单纯切换不会创建空计划文件。 | 是 | -| `/plan clear` | — | 清除当前 plan 方案。 | 否 | +| `/yolo [on\|off]` | `/yes` | 切换 YOLO 模式。不带参数时翻转;显式传 `on`/`off` 时强制设置。开启后跳过普通工具调用审批;Plan 模式的退出审批不受影响 | 是 | +| `/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 [status\|pause\|resume\|cancel\|replace \|]` | — | 开始或管理一个自主 goal。该命令仍是实验功能,可通过 `/experiments`、`[experimental].goal_command` 或 `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1` 启用。 | 见下文 | +| `/goal [...]` | — | 开始或管理目标模式(实验功能;可通过 `/experiments`、`[experimental].goal_command` 或 `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1` 启用) | 见下文 | ::: warning 注意 `/yolo` 会跳过普通工具调用的审批确认,使用前请确保了解可能的风险。Plan 模式的退出审批不会被 `/yolo` 跳过;Plan 模式下的 `Bash` 也按 `/yolo` 的普通放行规则处理。 diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 4d2db8d06..3af657d5a 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -93,7 +93,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 **`AgentSwarm`** 从共享的 `prompt_template` 和 `items` 数组启动多个子 Agent。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有子 Agent 使用的 profile;省略时默认使用 `coder`。本工具接受 2 到 50 个 item,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。 -**`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统会自动附加「其他」选项。`background` 为 true 时会启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 +**`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)。 From 263c968db71f06bd76e6ca77223ed1b5f848f4d6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 14:02:50 +0800 Subject: [PATCH 25/72] fix --- .changeset/agent-swarm-progress-ui.md | 5 ----- .changeset/template-agent-swarm.md | 3 ++- 2 files changed, 2 insertions(+), 6 deletions(-) delete mode 100644 .changeset/agent-swarm-progress-ui.md diff --git a/.changeset/agent-swarm-progress-ui.md b/.changeset/agent-swarm-progress-ui.md deleted file mode 100644 index a040e23ee..000000000 --- a/.changeset/agent-swarm-progress-ui.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Show live AgentSwarm progress in the TUI above the input box. diff --git a/.changeset/template-agent-swarm.md b/.changeset/template-agent-swarm.md index d1fba16de..cb9c53b8d 100644 --- a/.changeset/template-agent-swarm.md +++ b/.changeset/template-agent-swarm.md @@ -1,6 +1,7 @@ --- "@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor "@moonshot-ai/kimi-code": minor --- -Add template-based AgentSwarm launches with shared subagent type selection. +Add template-based AgentSwarm launches with live TUI progress. From 206183e3b1c4f734672e7d76a1c9640f1e9e272f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 14:35:54 +0800 Subject: [PATCH 26/72] fix --- packages/agent-core/src/agent/index.ts | 2 +- .../agent-core/src/agent/records/index.ts | 2 +- .../agent-core/src/agent/records/types.ts | 4 +- packages/agent-core/src/agent/swarm/index.ts | 34 +++++---- packages/agent-core/src/agent/tool/index.ts | 3 +- .../builtin/collaboration/agent-swarm.ts | 7 +- .../agent-core/test/agent/harness/agent.ts | 1 + packages/agent-core/test/agent/turn.test.ts | 74 +++++++++++++++++++ .../test/tools/builtin-current.test.ts | 13 +++- 9 files changed, 119 insertions(+), 21 deletions(-) diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 72f4438bd..9c5562012 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -367,7 +367,7 @@ export class Agent { }, clearPlan: () => this.planMode.clear(), enterSwarm: () => { - this.swarmMode.enter(); + this.swarmMode.enter('explicit'); }, exitSwarm: () => { this.swarmMode.exit(); diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 380cb3471..046c2588f 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -69,7 +69,7 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { agent.planMode.exit(input.id); return; case 'swarm_mode.enter': - agent.swarmMode.restoreEnter(); + agent.swarmMode.restoreEnter(input.trigger); return; case 'swarm_mode.exit': agent.swarmMode.exit(); diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index e92c8079a..35c83be53 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -47,7 +47,9 @@ export interface AgentRecordEvents { id?: string; }; - 'swarm_mode.enter': {}; + 'swarm_mode.enter': { + trigger: 'explicit' | 'implicit'; + }; 'swarm_mode.exit': {}; 'tools.register_user_tool': UserToolRegistration; diff --git a/packages/agent-core/src/agent/swarm/index.ts b/packages/agent-core/src/agent/swarm/index.ts index 99e56ce64..40a134ecd 100644 --- a/packages/agent-core/src/agent/swarm/index.ts +++ b/packages/agent-core/src/agent/swarm/index.ts @@ -3,31 +3,37 @@ import type { Agent } from '..'; import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md'; import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md'; +type SwarmModeTrigger = 'explicit' | 'implicit'; + export class SwarmMode { - protected active = false; + protected active: SwarmModeTrigger | null = null; constructor(protected readonly agent: Agent) {} - enter(): void { - if (this.active) return; - this.agent.records.logRecord({ type: 'swarm_mode.enter' }); - this.active = true; - this.agent.context.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { - kind: 'injection', - variant: 'swarm_mode', - }); + enter(trigger: SwarmModeTrigger): void { + if (this.active !== null) return; + this.agent.records.logRecord({ type: 'swarm_mode.enter', trigger }); + this.active = trigger; + if (trigger === 'explicit') { + this.agent.context.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { + kind: 'injection', + variant: 'swarm_mode', + }); + } this.agent.emitStatusUpdated({ swarmMode: true }); } - restoreEnter(): void { - this.active = true; + restoreEnter(trigger: SwarmModeTrigger): void { + this.active = trigger; } exit(): void { - if (!this.active) return; + if (this.active === null) return; this.agent.records.logRecord({ type: 'swarm_mode.exit' }); - this.active = false; + const trigger = this.active; + this.active = null; this.agent.emitStatusUpdated({ swarmMode: false }); + if (trigger !== 'explicit') return; if (this.agent.context.popMatchedMessage((origin) => origin?.kind === 'injection' && origin.variant === 'swarm_mode')) { return; } @@ -40,6 +46,6 @@ export class SwarmMode { } get isActive(): boolean { - return this.active; + return this.active !== null; } } diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index a8a982248..7146627c3 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -415,7 +415,8 @@ export class ToolManager { log: this.agent.log, }, ), - this.agent.subagentHost && new b.AgentSwarmTool(this.agent.subagentHost), + this.agent.subagentHost && + 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/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 55d47b60d..d8f20b373 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import type { SwarmMode } from '../../../agent/swarm'; import type { BuiltinTool } from '../../../agent/tool'; import type { QueuedSubagentRunResult, @@ -79,7 +80,10 @@ export class AgentSwarmTool implements BuiltinTool { readonly description = AGENT_SWARM_DESCRIPTION; readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); - constructor(private readonly subagentHost: SessionSubagentHost) {} + constructor( + private readonly subagentHost: SessionSubagentHost, + private readonly swarmMode: SwarmMode, + ) {} resolveExecution(args: AgentSwarmToolInput): ToolExecution { return { @@ -101,6 +105,7 @@ export class AgentSwarmTool implements BuiltinTool { context: ExecutableToolContext, ): Promise { try { + this.swarmMode.enter('implicit'); const specs = createAgentSwarmSpecs(args); const result = await this.runSwarm(args, specs, context.signal, context.toolCallId); return { diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 6adb39ff8..623e47127 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -745,6 +745,7 @@ export class AgentTestContext { generate: failOnResumeGenerate, compactionStrategy: this.options.compactionStrategy, microCompaction: this.options.microCompaction, + subagentHost: this.options.subagentHost, persistence: new InMemoryAgentRecordPersistence( withMetadata(this.recordHistory.map(cloneRecord)), ), diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 3420ed631..05262e788 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -18,6 +18,11 @@ import { describe, expect, it, vi } from 'vitest'; import { HookEngine } from '../../src/session/hooks'; import type { AgentOptions } from '../../src/agent'; import type { Logger, LogPayload } from '../../src/logging'; +import type { + QueuedSubagentRunResult, + QueuedSubagentTask, + SessionSubagentHost, +} from '../../src/session/subagent-host'; import { estimateTokens, estimateTokensForMessages, @@ -303,6 +308,55 @@ describe('Agent turn flow', () => { expect(ctx.agent.swarmMode.isActive).toBe(true); expect(eventIndex(ctx, '[wire]', 'swarm_mode.exit')).toBe(-1); + }); + + it('enters silent swarm mode when the agent calls AgentSwarm', async () => { + const runQueued = vi.fn(async ( + tasks: readonly QueuedSubagentTask[], + ): Promise>> => { + return tasks.map((task, index) => ({ + task, + agentId: `agent-${String(index + 1)}`, + status: 'completed' as const, + result: `result ${String(index + 1)}`, + })); + }); + const subagentHost = mockSubagentHost({ + runQueued: runQueued as unknown as SessionSubagentHost['runQueued'], + }); + const ctx = testAgent({ subagentHost }); + ctx.configure({ tools: ['AgentSwarm'] }); + + ctx.mockNextResponse( + { type: 'text', text: 'I will launch a swarm.' }, + agentSwarmCall(), + ); + ctx.mockNextResponse({ type: 'text', text: 'Swarm results reviewed.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Use AgentSwarm' }] }); + await ctx.untilTurnEnd(); + + const enterEvent = ctx.allEvents.find( + (entry) => entry.type === '[wire]' && entry.event === 'swarm_mode.enter', + ); + const reminderOrigins = ctx.agent.context.history + .map((message) => message.origin) + .filter((origin) => origin?.kind === 'injection'); + + expect(runQueued).toHaveBeenCalledTimes(1); + expect(enterEvent?.args).toMatchObject({ trigger: 'implicit' }); + expect(ctx.agent.swarmMode.isActive).toBe(false); + expect(eventIndex(ctx, '[wire]', 'swarm_mode.exit')).toBeGreaterThan( + eventIndex(ctx, '[rpc]', 'turn.ended'), + ); + expect(reminderOrigins).not.toContainEqual({ kind: 'injection', variant: 'swarm_mode' }); + expect(reminderOrigins).not.toContainEqual({ + kind: 'injection', + variant: 'swarm_mode_exit', + }); + await ctx.expectResumeMatches(); + }); + it('includes provider finish reason details on empty response failures', async () => { const generate: GenerateFn = async () => { throw new APIEmptyResponseError( @@ -1554,6 +1608,26 @@ function bashCallWithId(id: string, command: string): ToolCall { }; } +function agentSwarmCall(): ToolCall { + return { + type: 'function', + id: 'call_swarm', + name: 'AgentSwarm', + arguments: JSON.stringify({ + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }), + }; +} + +function mockSubagentHost>( + host: T, +): T & SessionSubagentHost { + return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T & + SessionSubagentHost; +} + interface ApiErrorTelemetryCase { readonly name: string; readonly createError: () => Error; diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 033aff904..053865bdd 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -11,6 +11,7 @@ import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; import type { Agent } from '../../src/agent'; +import type { SwarmMode } from '../../src/agent/swarm'; import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; import { SkillRegistry } from '../../src/skill'; @@ -68,6 +69,10 @@ function mockSubagentHost>( SessionSubagentHost; } +function mockSwarmMode(): SwarmMode { + return { enter: vi.fn() } as unknown as SwarmMode; +} + function processWithOutput(stdout: string, exitCode = 0): KaosProcess { return { stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, @@ -324,7 +329,8 @@ describe('current builtin collaboration tools', () => { }, ]), }); - const tool = new AgentSwarmTool(host); + const swarmMode = mockSwarmMode(); + const tool = new AgentSwarmTool(host, swarmMode); const input = { description: 'Review files', prompt_template: 'Review {{item}}', @@ -352,6 +358,7 @@ describe('current builtin collaboration tools', () => { const result = await executeTool(tool, context(input, 'call_swarm')); + expect(swarmMode.enter).toHaveBeenCalledWith('implicit'); expect(host.runQueued).toHaveBeenCalledTimes(1); expect(host.runQueued).toHaveBeenCalledWith( [ @@ -419,7 +426,8 @@ describe('current builtin collaboration tools', () => { }, ]), }); - const tool = new AgentSwarmTool(host); + const swarmMode = mockSwarmMode(); + const tool = new AgentSwarmTool(host, swarmMode); const result = await executeTool( tool, @@ -440,6 +448,7 @@ describe('current builtin collaboration tools', () => { 'Agent timed out after 30s.', '', ].join('\n')); + expect(swarmMode.enter).toHaveBeenCalledWith('implicit'); expect(result.isError).toBeUndefined(); }); From 65454ebae1c78de036cc86502ab1903988de62d0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 15:00:03 +0800 Subject: [PATCH 27/72] fix --- apps/kimi-code/src/tui/commands/dispatch.ts | 1 + apps/kimi-code/src/tui/commands/swarm.ts | 13 +- .../messages/agent-swarm-progress.ts | 179 ++++++++++++++++-- .../tui/controllers/session-event-handler.ts | 17 +- apps/kimi-code/src/tui/kimi-tui.ts | 8 + .../kimi-code/test/tui/commands/swarm.test.ts | 50 ++++- .../messages/agent-swarm-progress.test.ts | 93 +++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 101 ++++++++++ .../agent-core/src/agent/records/types.ts | 3 +- packages/agent-core/src/agent/swarm/index.ts | 2 +- 10 files changed, 427 insertions(+), 40 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index aaff10b12..d0062137f 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -128,6 +128,7 @@ export interface SlashCommandHost { showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; showProgressSpinner(label: string): LoginProgressSpinnerHandle; clearAgentSwarmProgress(): void; + renderSwarmModeMarker(active: boolean): void; // Theme applyTheme(theme: Theme, resolved?: ResolvedTheme): void; diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index d346bf17a..2a158ca83 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -84,13 +84,22 @@ async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMod } async function startSwarmTask(host: SlashCommandHost, prompt: string): Promise { - if (!(await setSwarmMode(host, true))) return; + if (!host.state.appState.swarmMode && !(await setSwarmMode(host, true))) return; + host.renderSwarmModeMarker(true); host.sendNormalUserInput(prompt); } async function applySwarmMode(host: SlashCommandHost, enabled: boolean): 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 (!(await setSwarmMode(host, enabled))) return; - host.showStatus(`Swarm mode ${enabled ? 'enabled' : 'disabled'}.`); + host.renderSwarmModeMarker(enabled); } async function setSwarmMode(host: SlashCommandHost, enabled: boolean): Promise { 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 index 4c226626e..d3f7b034e 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -29,6 +29,7 @@ const ACTIVITY_SPINNER_PLACEHOLDER = ' '; const AGENT_SWARM_LEFT_INDENT = ' '; const AGENT_SWARM_RIGHT_GAP = 1; const AGENT_SWARM_GRID_RIGHT_GAP = 1; +const AGENT_SWARM_NON_GRID_LINES = 6; const ORCHESTRATING_LABEL = 'Orchestrating...'; const PROMPTING_LABEL = 'Prompting...'; const WORKING_LABEL = 'Working...'; @@ -86,10 +87,25 @@ interface AgentSwarmSummary { 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; +} + export interface AgentSwarmProgressOptions { readonly description: string; readonly colors: ColorPalette; readonly requestRender?: () => void; + readonly availableGridHeight?: () => number | undefined; } const PHASE_LABELS: Record = { @@ -108,6 +124,7 @@ export class AgentSwarmProgressComponent implements Component { 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; @@ -121,6 +138,7 @@ export class AgentSwarmProgressComponent implements Component { this.description = options.description; this.colors = options.colors; this.requestRender = options.requestRender; + this.availableGridHeight = options.availableGridHeight; this.members = []; } @@ -446,6 +464,7 @@ export class AgentSwarmProgressComponent implements Component { '', ...this.renderGrid( Math.max(1, innerWidth - AGENT_SWARM_GRID_RIGHT_GAP), + this.availableGridHeight?.(), snapshots, nowMs, ), @@ -557,16 +576,17 @@ export class AgentSwarmProgressComponent implements Component { private renderGrid( width: number, + height: number | undefined, snapshots: readonly AgentSwarmSnapshot[], nowMs: number, ): string[] { - const columns = columnsForWidth(width, this.members.length); - const gapWidth = visibleWidth(CELL_GAP); - const cellWidth = Math.max( - 1, - Math.floor((width - gapWidth * Math.max(0, columns - 1)) / columns), - ); - const rows = Math.ceil(this.members.length / columns); + 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 lines: string[] = []; for (let row = 0; row < rows; row += 1) { @@ -576,7 +596,7 @@ export class AgentSwarmProgressComponent implements Component { const member = this.members[index]; const snapshot = snapshots[index]; if (member === undefined || snapshot === undefined) continue; - cells.push(padAnsi(this.renderCell(member, snapshot, cellWidth, nowMs), cellWidth)); + cells.push(padAnsi(this.renderCell(member, snapshot, layout, nowMs), layout.cellWidth)); } lines.push(cells.join(CELL_GAP)); } @@ -586,9 +606,13 @@ export class AgentSwarmProgressComponent implements Component { private renderCell( member: AgentSwarmMember, snapshot: AgentSwarmSnapshot, - width: number, + layout: AgentSwarmGridLayout, nowMs: number, ): string { + const width = layout.cellWidth; + if (!layout.renderText) { + return this.renderCompactCell(member, snapshot, layout.barCells, nowMs); + } if (snapshot.phase === 'pending') { return renderPendingCell(member, width, this.colors); } @@ -596,23 +620,17 @@ export class AgentSwarmProgressComponent implements Component { return renderQueuedCell(member, width, this.colors); } - const fixedWidth = member.id.length + 1 + 2 + 1 + MIN_LABEL_WIDTH; - const availableForBar = width - fixedWidth - 2; - const barWidth = - availableForBar >= BRAILLE_BAR_MIN_WIDTH - ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) - : Math.max(1, availableForBar); const estimate = this.progressEstimator.estimate({ memberKey: member.id, phase: snapshot.phase, - capacityTicks: barWidth * BRAILLE_LEVELS.length, + capacityTicks: layout.barCells * BRAILLE_LEVELS.length, nowMs, }); const id = chalk.hex(this.colors.primary)(member.id); const bar = brailleBar( estimate.displayTicks, snapshot.phase, - barWidth, + layout.barCells, this.colors, snapshot.phaseElapsedMs, ); @@ -622,6 +640,30 @@ export class AgentSwarmProgressComponent implements Component { 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, + ); + return `${id} ${bar}`; + } + private findMemberForSubagent( agentId: string, description: string | undefined, @@ -848,13 +890,110 @@ function parseAgentSwarmFailureText(block: string): string | undefined { return normalizeFailureText(match[1]); } -function columnsForWidth(width: number, count: number): number { - if (count <= 1) return 1; +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, + }; + } + + const textColumns = columnsForCellWidth(width, count, MIN_CELL_WIDTH); + const textRows = rowsForColumns(count, textColumns); + const textCellWidth = gridCellWidth(width, textColumns); + if (textRows <= height) { + return { + renderText: true, + barCells: barCellsForTextCellWidth(textCellWidth, idWidth), + columns: textColumns, + rows: textRows, + cellWidth: textCellWidth, + }; + } + + const compactMaxCellWidth = compactCellWidth(idWidth, BRAILLE_BAR_MAX_WIDTH); + const compactMaxColumns = columnsForCellWidth(width, count, compactMaxCellWidth); + const compactMaxRows = rowsForColumns(count, compactMaxColumns); + if (compactMaxRows <= height) { + return { + renderText: false, + barCells: BRAILLE_BAR_MAX_WIDTH, + columns: compactMaxColumns, + rows: compactMaxRows, + cellWidth: compactMaxCellWidth, + }; + } + + const targetColumns = height <= 0 ? count : Math.min(count, Math.ceil(count / height)); + const targetCellWidth = gridCellWidth(width, targetColumns); + const compressedBarCells = Math.max(1, targetCellWidth - compactFixedWidth(idWidth)); + const compressedCellWidth = compactCellWidth(idWidth, compressedBarCells); + const compressedColumns = columnsForCellWidth(width, count, compressedCellWidth); + return { + renderText: false, + barCells: compressedBarCells, + columns: compressedColumns, + rows: rowsForColumns(count, compressedColumns), + cellWidth: compressedCellWidth, + }; +} + +export function agentSwarmGridHeightForTerminalRows(rows: number | undefined): number | undefined { + if (rows === undefined || !Number.isFinite(rows)) return undefined; + return Math.max(0, Math.floor(rows) - 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): number { + if (count <= 1) return count <= 0 ? 0 : 1; const gapWidth = visibleWidth(CELL_GAP); - const columns = Math.floor((width + gapWidth) / (MIN_CELL_WIDTH + gapWidth)); + 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): number { + if (columns <= 0) return 0; + const gapWidth = visibleWidth(CELL_GAP); + return Math.max( + 1, + Math.floor((width - gapWidth * Math.max(0, columns - 1)) / columns), + ); +} + +function barCellsForTextCellWidth(cellWidth: number, idWidth: number): number { + const fixedWidth = idWidth + 1 + 2 + 1 + MIN_LABEL_WIDTH; + const availableForBar = cellWidth - fixedWidth - 2; + return availableForBar >= BRAILLE_BAR_MIN_WIDTH + ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) + : Math.max(1, availableForBar); +} + +function compactCellWidth(idWidth: number, barCells: number): number { + return compactFixedWidth(idWidth) + Math.max(1, barCells); +} + +function compactFixedWidth(idWidth: number): number { + return idWidth + 1 + 2; +} + function summarizeSnapshots(snapshots: readonly AgentSwarmSnapshot[]): AgentSwarmSummary { let completed = 0; let failed = 0; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 99d7a9378..e091c6af7 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -40,9 +40,9 @@ import { MoonLoader } from '../components/chrome/moon-loader'; import { AgentSwarmProgressComponent, agentSwarmDescriptionFromArgs, + agentSwarmGridHeightForTerminalRows, } from '../components/messages/agent-swarm-progress'; import { buildGoalMarker } from '../components/messages/goal-markers'; -import { SwarmModeMarkerComponent } from '../components/messages/swarm-markers'; import { StatusMessageComponent } from '../components/messages/status-message'; import { MAIN_AGENT_ID, @@ -647,6 +647,9 @@ export class SessionEventHandler { const progress = new AgentSwarmProgressComponent({ description: agentSwarmDescriptionFromArgs(args), colors: this.host.state.theme.colors, + availableGridHeight: () => agentSwarmGridHeightForTerminalRows( + this.host.state.ui.terminal.rows, + ), requestRender: () => { this.host.state.ui.requestRender(); }, @@ -716,7 +719,6 @@ export class SessionEventHandler { private handleStatusUpdate(event: AgentStatusUpdatedEvent): void { const patch: Partial = {}; - const previousSwarmMode = this.host.state.appState.swarmMode; if (event.contextUsage !== undefined) patch.contextUsage = event.contextUsage; if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; @@ -727,9 +729,6 @@ export class SessionEventHandler { } if (event.model !== undefined) patch.model = event.model; if (Object.keys(patch).length > 0) this.host.setAppState(patch); - if (event.swarmMode !== undefined && event.swarmMode !== previousSwarmMode) { - this.renderSwarmModeMarker(event.swarmMode); - } } private handleGoalUpdated(event: GoalUpdatedEvent): void { @@ -772,14 +771,6 @@ export class SessionEventHandler { } } - private renderSwarmModeMarker(active: boolean): void { - const { state } = this.host; - state.transcriptContainer.addChild( - new SwarmModeMarkerComponent(active, state.theme.colors), - ); - state.ui.requestRender(); - } - private scheduleQueuedGoalPromotion(): void { if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; if (this.queuedGoalPromotionTimer !== undefined) return; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 80e576fa1..eb46b0bff 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -76,6 +76,7 @@ import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; +import { SwarmModeMarkerComponent } from './components/messages/swarm-markers'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; import { UserMessageComponent } from './components/messages/user-message'; @@ -987,6 +988,13 @@ export class KimiTUI { this.sessionEventHandler.clearAgentSwarmProgress(); } + renderSwarmModeMarker(active: boolean): void { + this.state.transcriptContainer.addChild( + new SwarmModeMarkerComponent(active, this.state.theme.colors), + ); + this.state.ui.requestRender(); + } + // ========================================================================= // Session Runtime // ========================================================================= diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 0ffa375df..8000084a4 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -17,6 +17,7 @@ function makeHost( model?: string; hasSession?: boolean; permissionMode?: 'manual' | 'auto' | 'yolo'; + swarmMode?: boolean; } = {}, ) { const session = { @@ -29,6 +30,7 @@ function makeHost( appState: { model: overrides.model ?? 'kimi-model', permissionMode: overrides.permissionMode ?? 'auto', + swarmMode: overrides.swarmMode ?? false, }, theme: { colors: getColorPalette('dark') }, }, @@ -37,6 +39,7 @@ function makeHost( setAppState: vi.fn((patch: Record) => Object.assign(host.state.appState, patch)), showError: vi.fn(), showStatus: vi.fn(), + renderSwarmModeMarker: vi.fn(), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), restoreInputText: vi.fn(), @@ -63,10 +66,21 @@ describe('handleSwarmCommand', () => { expect(session.setPermission).not.toHaveBeenCalled(); expect(session.setSwarmMode).toHaveBeenCalledWith(true); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); 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.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + it('turns swarm mode on without sending a prompt', async () => { const { host, session } = makeHost({ model: '' }); @@ -74,18 +88,44 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledWith(true); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); - expect(host.showStatus).toHaveBeenCalledWith('Swarm mode enabled.'); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + 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(host.renderSwarmModeMarker).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: '' }); + const { host, session } = makeHost({ model: '', swarmMode: true }); await handleSwarmCommand(host, 'off'); expect(session.setSwarmMode).toHaveBeenCalledWith(false); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); - expect(host.showStatus).toHaveBeenCalledWith('Swarm mode disabled.'); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(false); + 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(host.renderSwarmModeMarker).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Swarm mode is already off.'); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); @@ -116,6 +156,7 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledWith(true); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); }); it('can start a Manual-mode swarm task without changing permission', async () => { @@ -132,6 +173,7 @@ describe('handleSwarmCommand', () => { }); expect(session.setPermission).not.toHaveBeenCalled(); expect(session.setSwarmMode).toHaveBeenCalledWith(true); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); }); it('can switch to YOLO when starting a Manual-mode swarm task', async () => { @@ -149,6 +191,7 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledWith(true); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); }); it('returns the command to the input box when a Manual-mode swarm start is cancelled', async () => { @@ -189,6 +232,7 @@ describe('handleSwarmCommand', () => { expect(host.showError).toHaveBeenCalledWith( expect.stringContaining('Failed to enable swarm mode'), ); + expect(host.renderSwarmModeMarker).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); }); 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 index 72ddadbed..fe31af0cc 100644 --- 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 @@ -5,9 +5,11 @@ 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'; @@ -30,6 +32,70 @@ 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, + }); + }); + + it('drops text and recomputes columns when compact bars fit', () => { + expect(calculateAgentSwarmGridLayout({ + width: 100, + height: 4, + count: 20, + })).toEqual({ + renderText: false, + barCells: 8, + columns: 6, + rows: 4, + cellWidth: 14, + }); + }); + + it('compresses bar cells from the target row count when compact max bars still overflow', () => { + expect(calculateAgentSwarmGridLayout({ + width: 100, + height: 4, + count: 40, + })).toEqual({ + renderText: false, + barCells: 2, + columns: 10, + rows: 4, + cellWidth: 8, + }); + }); + + it('keeps at least one bar cell when no rows are available', () => { + expect(calculateAgentSwarmGridLayout({ + width: 20, + height: 0, + count: 4, + })).toEqual({ + renderText: false, + barCells: 1, + columns: 2, + rows: 2, + cellWidth: 7, + }); + }); + + it('derives the grid height left inside the AgentSwarm block', () => { + expect(agentSwarmGridHeightForTerminalRows(undefined)).toBeUndefined(); + expect(agentSwarmGridHeightForTerminalRows(10)).toBe(4); + expect(agentSwarmGridHeightForTerminalRows(4)).toBe(0); + }); +}); + describe('AgentSwarmProgressComponent', () => { it('renders an orchestrating panel before subagents spawn', () => { const component = new AgentSwarmProgressComponent({ @@ -179,6 +245,33 @@ describe('AgentSwarmProgressComponent', () => { 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: () => 4, + }); + + for (let index = 1; index <= 20; index += 1) { + component.registerSubagent({ + agentId: `agent-${String(index)}`, + description: `Review changed files #${String(index)} (coder)`, + }); + } + component.markInputComplete(); + for (let index = 1; index <= 20; 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(4); + expect(gridLines[0]).toContain('001 ['); + expect(gridLines[0]).toContain('006 ['); + expect(gridLines.join('\n')).not.toContain('Running'); + }); + it('advances from queued when a subagent tool call starts and marks terminal states', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', 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 43199b207..e9cc2a46b 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 @@ -134,6 +134,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 () => []), @@ -2068,6 +2069,106 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); }); + it('renders swarm mode markers only from /swarm commands', async () => { + const { driver, session } = 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'); + + const callsBeforeAlreadyOn = session.setSwarmMode.mock.calls.length; + driver.handleUserInput('/swarm on'); + + expect(session.setSwarmMode).toHaveBeenCalledTimes(callsBeforeAlreadyOn); + let transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm activated')).toBe(0); + expect(countOccurrences(transcript, 'Swarm mode is already on.')).toBe(1); + + 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'); + + driver.handleUserInput('/swarm on'); + + await vi.waitFor(() => { + expect(session.setSwarmMode).toHaveBeenCalledWith(true); + }); + await vi.waitFor(() => { + expect(countOccurrences(stripSgr(renderTranscript(driver)), 'Swarm activated')).toBe(1); + }); + + transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm activated')).toBe(1); + + 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'); + + const callsBeforeAlreadyOff = session.setSwarmMode.mock.calls.length; + driver.handleUserInput('/swarm off'); + + expect(session.setSwarmMode).toHaveBeenCalledTimes(callsBeforeAlreadyOff); + transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm deactivated')).toBe(0); + expect(countOccurrences(transcript, 'Swarm mode is already off.')).toBe(1); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + swarmMode: true, + } as Event, + vi.fn(), + ); + + driver.handleUserInput('/swarm off'); + + await vi.waitFor(() => { + expect(session.setSwarmMode).toHaveBeenCalledWith(false); + }); + await vi.waitFor(() => { + expect(countOccurrences(stripSgr(renderTranscript(driver)), 'Swarm deactivated')).toBe(1); + }); + + transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Swarm activated')).toBe(1); + expect(countOccurrences(transcript, 'Swarm deactivated')).toBe(1); + expect(countOccurrences(transcript, 'Swarm mode is already on.')).toBe(1); + expect(countOccurrences(transcript, 'Swarm mode is already off.')).toBe(1); + expect(transcript).not.toContain('Swarm mode enabled.'); + expect(transcript).not.toContain('Swarm mode disabled.'); + }); + it('queues Ctrl-S input instead of steering while /init is running', async () => { let resolveInit: (() => void) | undefined; const session = makeSession({ diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index 35c83be53..bedc118e3 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'; export interface AgentRecordEvents { metadata: { @@ -48,7 +49,7 @@ export interface AgentRecordEvents { }; 'swarm_mode.enter': { - trigger: 'explicit' | 'implicit'; + trigger: SwarmModeTrigger; }; 'swarm_mode.exit': {}; diff --git a/packages/agent-core/src/agent/swarm/index.ts b/packages/agent-core/src/agent/swarm/index.ts index 40a134ecd..b374fab89 100644 --- a/packages/agent-core/src/agent/swarm/index.ts +++ b/packages/agent-core/src/agent/swarm/index.ts @@ -3,7 +3,7 @@ import type { Agent } from '..'; import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md'; import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md'; -type SwarmModeTrigger = 'explicit' | 'implicit'; +export type SwarmModeTrigger = 'explicit' | 'implicit'; export class SwarmMode { protected active: SwarmModeTrigger | null = null; From ef7521abbc84f762f9a38658b3a9dfe80452e8fb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 15:26:06 +0800 Subject: [PATCH 28/72] fix --- apps/kimi-code/src/tui/commands/registry.ts | 3 +- apps/kimi-code/src/tui/commands/swarm.ts | 12 +++++-- .../test/tui/commands/registry.test.ts | 6 ++-- .../test/tui/commands/resolve.test.ts | 20 +++++++++++ .../kimi-code/test/tui/commands/swarm.test.ts | 36 +++++++++++++++++-- .../src/agent/swarm/enter-reminder.md | 2 +- .../builtin/collaboration/agent-swarm.md | 2 ++ .../builtin/collaboration/agent-swarm.ts | 9 +++++ .../test/prompt-placeholders.test.ts | 6 ++++ .../test/tools/builtin-current.test.ts | 27 +++++++++++++- 10 files changed, 110 insertions(+), 13 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index bf1b0b3e7..d5008f2e1 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -72,8 +72,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Toggle swarm mode or run one task in swarm mode', priority: 100, - availability: (args) => - ['on', 'off'].includes(args.trim().toLowerCase()) ? 'always' : 'idle-only', + availability: 'idle-only', }, { name: 'model', diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index 2a158ca83..ab90c09b9 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -32,6 +32,7 @@ export async function handleSwarmCommand(host: SlashCommandHost, args: string): } if (host.state.appState.permissionMode === 'manual') { + if (!(await activateSwarmForTask(host))) return; showSwarmStartPermissionPrompt(host, prompt); return; } @@ -69,7 +70,7 @@ async function startSwarmWithPermission( if (choice === 'auto' || choice === 'yolo') { if (!(await setPermissionForSwarm(host, choice))) return; } - await startSwarmTask(host, prompt); + host.sendNormalUserInput(prompt); } async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMode): Promise { @@ -84,11 +85,16 @@ async function setPermissionForSwarm(host: SlashCommandHost, mode: PermissionMod } async function startSwarmTask(host: SlashCommandHost, prompt: string): Promise { - if (!host.state.appState.swarmMode && !(await setSwarmMode(host, true))) return; - host.renderSwarmModeMarker(true); + if (!(await activateSwarmForTask(host))) return; host.sendNormalUserInput(prompt); } +async function activateSwarmForTask(host: SlashCommandHost): Promise { + if (!host.state.appState.swarmMode && !(await setSwarmMode(host, true))) return false; + host.renderSwarmModeMarker(true); + return true; +} + async function applySwarmMode(host: SlashCommandHost, enabled: boolean): Promise { if (enabled && host.state.appState.swarmMode) { host.showStatus('Swarm mode is already on.'); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 47be272db..046214dcd 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -47,11 +47,11 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(plan!, 'clear')).toBe('idle-only'); }); - it('keeps swarm toggles always available while swarm tasks are idle-only', () => { + it('keeps swarm mode changes and swarm tasks idle-only', () => { const swarm = findBuiltInSlashCommand('swarm'); expect(swarm).toBeDefined(); - expect(resolveSlashCommandAvailability(swarm!, 'on')).toBe('always'); - expect(resolveSlashCommandAvailability(swarm!, 'off')).toBe('always'); + expect(resolveSlashCommandAvailability(swarm!, 'on')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(swarm!, 'off')).toBe('idle-only'); expect(resolveSlashCommandAvailability(swarm!, 'Ship feature X')).toBe('idle-only'); }); diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index fb54f7d9d..7f4929aeb 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -89,6 +89,16 @@ 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', () => { @@ -112,6 +122,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', () => { diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 8000084a4..6f5a74ca9 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -134,9 +134,17 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, 'Ship feature X'); + expect(session.setSwarmMode).toHaveBeenCalledWith(true); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + const markerOrder = (host.renderSwarmModeMarker as ReturnType).mock + .invocationCallOrder[0]; + const promptOrder = (host.mountEditorReplacement as ReturnType).mock + .invocationCallOrder[0]; + expect(markerOrder).toBeDefined(); + expect(promptOrder).toBeDefined(); + expect(markerOrder!).toBeLessThan(promptOrder!); expect(session.setPermission).not.toHaveBeenCalled(); - expect(session.setSwarmMode).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'); @@ -154,9 +162,11 @@ describe('handleSwarmCommand', () => { }); expect(session.setPermission).toHaveBeenCalledWith('auto'); expect(session.setSwarmMode).toHaveBeenCalledWith(true); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expect(host.renderSwarmModeMarker).toHaveBeenCalledTimes(1); }); it('can start a Manual-mode swarm task without changing permission', async () => { @@ -173,7 +183,9 @@ describe('handleSwarmCommand', () => { }); expect(session.setPermission).not.toHaveBeenCalled(); expect(session.setSwarmMode).toHaveBeenCalledWith(true); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expect(host.renderSwarmModeMarker).toHaveBeenCalledTimes(1); }); it('can switch to YOLO when starting a Manual-mode swarm task', async () => { @@ -189,9 +201,11 @@ describe('handleSwarmCommand', () => { }); expect(session.setPermission).toHaveBeenCalledWith('yolo'); expect(session.setSwarmMode).toHaveBeenCalledWith(true); + expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expect(host.renderSwarmModeMarker).toHaveBeenCalledTimes(1); }); it('returns the command to the input box when a Manual-mode swarm start is cancelled', async () => { @@ -203,7 +217,8 @@ describe('handleSwarmCommand', () => { 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(session.setSwarmMode).toHaveBeenCalledWith(true); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); @@ -219,7 +234,22 @@ describe('handleSwarmCommand', () => { expect.stringContaining('Failed to set permission mode'), ); }); - expect(session.setSwarmMode).not.toHaveBeenCalled(); + expect(session.setSwarmMode).toHaveBeenCalledWith(true); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not show the Manual-mode prompt when enabling swarm mode fails', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + session.setSwarmMode.mockRejectedValueOnce(new Error('denied')); + + await handleSwarmCommand(host, 'Ship feature X'); + + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to enable swarm mode'), + ); + expect(host.renderSwarmModeMarker).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); diff --git a/packages/agent-core/src/agent/swarm/enter-reminder.md b/packages/agent-core/src/agent/swarm/enter-reminder.md index 64355265f..033750637 100644 --- a/packages/agent-core/src/agent/swarm/enter-reminder.md +++ b/packages/agent-core/src/agent/swarm/enter-reminder.md @@ -18,4 +18,4 @@ You do not need to use TodoList to record this workflow. - 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 limit, do not try to conserve the number of agents. 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 when that improves coverage or cross-checking. +- 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/tools/builtin/collaboration/agent-swarm.md b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md index 41f342966..729f92dfc 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md @@ -1,3 +1,5 @@ Launch multiple subagents from one prompt template and a list of item values. 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 subagents with those two concrete prompts. + +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 index d8f20b373..d716623f3 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -15,6 +15,7 @@ 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({ @@ -53,6 +54,9 @@ export const AgentSwarmToolInputSchema = z items: z .array(z.string().trim().min(1)) .min(2) + .max(MAX_AGENT_SWARM_SUBAGENTS, { + message: `AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`, + }) .describe( `Values used to fill ${PROMPT_TEMPLATE_PLACEHOLDER}. Each item launches one subagent.`, ), @@ -148,6 +152,11 @@ function createAgentSwarmSpecs(args: AgentSwarmToolInput): AgentSwarmSpec[] { if (!args.prompt_template.includes(PROMPT_TEMPLATE_PLACEHOLDER)) { throw new Error(`AgentSwarm prompt_template must include ${PROMPT_TEMPLATE_PLACEHOLDER}.`); } + if (args.items.length > MAX_AGENT_SWARM_SUBAGENTS) { + throw new Error( + `AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`, + ); + } const seenPrompts = new Map(); return args.items.map((item, index) => { diff --git a/packages/agent-core/test/prompt-placeholders.test.ts b/packages/agent-core/test/prompt-placeholders.test.ts index eee21b852..4068979b5 100644 --- a/packages/agent-core/test/prompt-placeholders.test.ts +++ b/packages/agent-core/test/prompt-placeholders.test.ts @@ -30,6 +30,11 @@ const TEMPLATED = new Set([ 'tools/builtin/collaboration/skill-tool.md', ]); +const STATIC_PLACEHOLDER_PROTOCOL_FILES = new Set([ + 'agent/swarm/enter-reminder.md', + 'tools/builtin/collaboration/agent-swarm.md', +]); + const mdFiles = globSync('**/*.md', { cwd: SRC }) .map((file) => file.split('\\').join('/')) .filter((file) => !file.endsWith('README.md')); @@ -42,6 +47,7 @@ describe('prompt placeholders', () => { it('static .md files contain no unrendered template syntax', () => { for (const file of mdFiles) { if (TEMPLATED.has(file)) continue; + if (STATIC_PLACEHOLDER_PROTOCOL_FILES.has(file)) continue; const content = readFileSync(join(SRC, file), 'utf-8'); expect( /\{\{|\{%|\$\{/.test(content), diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 053865bdd..827cbfc11 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -342,9 +342,15 @@ describe('current builtin collaboration tools', () => { expect( AgentSwarmToolInputSchema.safeParse({ ...input, - items: Array.from({ length: 31 }, (_, index) => `src/${String(index + 1)}.ts`), + items: Array.from({ length: 128 }, (_, index) => `src/${String(index + 1)}.ts`), }).success, ).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + ...input, + items: Array.from({ length: 129 }, (_, index) => `src/${String(index + 1)}.ts`), + }).success, + ).toBe(false); expect(tool.parameters).toMatchObject({ type: 'object', properties: { @@ -395,6 +401,25 @@ describe('current builtin collaboration tools', () => { expect(result.isError).toBeUndefined(); }); + it('AgentSwarm rejects more than 128 subagents at execution time', async () => { + const host = mockSubagentHost({ runQueued: vi.fn() }); + const swarmMode = mockSwarmMode(); + const tool = new AgentSwarmTool(host, swarmMode); + + const result = await executeTool( + tool, + context({ + description: 'Review files', + prompt_template: 'Review {{item}}', + items: Array.from({ length: 129 }, (_, index) => `src/${String(index + 1)}.ts`), + }), + ); + + expect(result.output).toBe('AgentSwarm supports at most 128 subagents.'); + expect(result.isError).toBe(true); + expect(host.runQueued).not.toHaveBeenCalled(); + }); + it('AgentSwarm reports failed subagents inside the XML result without failing the tool', async () => { const host = mockSubagentHost({ runQueued: vi.fn().mockResolvedValue([ From 96a081a08cc4bb24c0857e412a172bf43613c9a2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 16:32:56 +0800 Subject: [PATCH 29/72] fix --- .../messages/agent-swarm-progress.ts | 165 ++++-- .../tui/controllers/session-event-handler.ts | 36 +- apps/kimi-code/src/tui/swarm-demo.ts | 534 ++++++------------ .../messages/agent-swarm-progress.test.ts | 151 ++++- 4 files changed, 474 insertions(+), 412 deletions(-) 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 index d3f7b034e..e91b84c3a 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -8,10 +8,10 @@ import { import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; -const MIN_CELL_WIDTH = 30; +const TEXT_CELL_PREFERRED_WIDTH = 30; const CELL_GAP = ' '; const FRAME_INTERVAL_MS = 80; -const BRAILLE_BAR_MIN_WIDTH = 5; +const TEXT_BRAILLE_BAR_MIN_WIDTH = 6; const BRAILLE_BAR_MAX_WIDTH = 8; const BRAILLE_EMPTY = '⣀'; const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; @@ -23,12 +23,12 @@ 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_GRID_RIGHT_GAP = 1; const AGENT_SWARM_NON_GRID_LINES = 6; const ORCHESTRATING_LABEL = 'Orchestrating...'; const PROMPTING_LABEL = 'Prompting...'; @@ -99,6 +99,8 @@ export interface AgentSwarmGridLayout { readonly columns: number; readonly rows: number; readonly cellWidth: number; + readonly columnGap: number; + readonly leftPadding: number; } export interface AgentSwarmProgressOptions { @@ -463,7 +465,7 @@ export class AgentSwarmProgressComponent implements Component { this.renderHeader(innerWidth, summary), '', ...this.renderGrid( - Math.max(1, innerWidth - AGENT_SWARM_GRID_RIGHT_GAP), + innerWidth, this.availableGridHeight?.(), snapshots, nowMs, @@ -587,6 +589,8 @@ export class AgentSwarmProgressComponent implements Component { }); 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) { @@ -598,7 +602,7 @@ export class AgentSwarmProgressComponent implements Component { if (member === undefined || snapshot === undefined) continue; cells.push(padAnsi(this.renderCell(member, snapshot, layout, nowMs), layout.cellWidth)); } - lines.push(cells.join(CELL_GAP)); + lines.push(leftPadding + cells.join(cellGap)); } return lines; } @@ -610,12 +614,12 @@ export class AgentSwarmProgressComponent implements Component { nowMs: number, ): string { const width = layout.cellWidth; - if (!layout.renderText) { - return this.renderCompactCell(member, snapshot, layout.barCells, nowMs); - } if (snapshot.phase === 'pending') { return renderPendingCell(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); } @@ -661,7 +665,7 @@ export class AgentSwarmProgressComponent implements Component { this.colors, snapshot.phaseElapsedMs, ); - return `${id} ${bar}`; + return `${id} ${bar}${compactTerminalMark(snapshot.phase, this.colors)}`; } private findMemberForSubagent( @@ -905,61 +909,88 @@ export function calculateAgentSwarmGridLayout( columns: 0, rows: 0, cellWidth: 0, + columnGap: 0, + leftPadding: 0, }; } - const textColumns = columnsForCellWidth(width, count, MIN_CELL_WIDTH); + 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); - if (textRows <= height) { + const textCellWidth = gridCellWidth(width, textColumns, textGapWidth); + if (textRows <= height && textCellWidth >= minTextCellWidth(idWidth)) { return { renderText: true, barCells: barCellsForTextCellWidth(textCellWidth, idWidth), columns: textColumns, rows: textRows, cellWidth: textCellWidth, + columnGap: textGapWidth, + leftPadding: 0, }; } - - const compactMaxCellWidth = compactCellWidth(idWidth, BRAILLE_BAR_MAX_WIDTH); - const compactMaxColumns = columnsForCellWidth(width, count, compactMaxCellWidth); - const compactMaxRows = rowsForColumns(count, compactMaxColumns); - if (compactMaxRows <= height) { + 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 { - renderText: false, - barCells: BRAILLE_BAR_MAX_WIDTH, - columns: compactMaxColumns, - rows: compactMaxRows, - cellWidth: compactMaxCellWidth, + renderText: true, + barCells: barCellsForTextCellWidth(targetTextCellWidth, idWidth), + columns: targetTextColumns, + rows: targetTextRows, + cellWidth: targetTextCellWidth, + columnGap: textGapWidth, + leftPadding: 0, }; } - const targetColumns = height <= 0 ? count : Math.min(count, Math.ceil(count / height)); - const targetCellWidth = gridCellWidth(width, targetColumns); - const compressedBarCells = Math.max(1, targetCellWidth - compactFixedWidth(idWidth)); - const compressedCellWidth = compactCellWidth(idWidth, compressedBarCells); - const compressedColumns = columnsForCellWidth(width, count, compressedCellWidth); + 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: compressedBarCells, - columns: compressedColumns, - rows: rowsForColumns(count, compressedColumns), - cellWidth: compressedCellWidth, + barCells: compactBarCells, + columns: compactColumns, + rows: rowsForColumns(count, compactColumns), + cellWidth: compactActualCellWidth, + columnGap: compactGapWidth, + leftPadding: 0, }; } -export function agentSwarmGridHeightForTerminalRows(rows: number | undefined): number | undefined { +export function agentSwarmGridHeightForTerminalRows( + rows: number | undefined, + followingRows = 0, +): number | undefined { if (rows === undefined || !Number.isFinite(rows)) return undefined; - return Math.max(0, Math.floor(rows) - AGENT_SWARM_NON_GRID_LINES); + 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): number { +function columnsForCellWidth( + width: number, + count: number, + cellWidth: number, + gapWidth: number, +): number { if (count <= 1) return count <= 0 ? 0 : 1; - const gapWidth = visibleWidth(CELL_GAP); const columns = Math.floor((width + gapWidth) / (Math.max(1, cellWidth) + gapWidth)); return Math.max(1, Math.min(count, columns)); } @@ -969,31 +1000,58 @@ function rowsForColumns(count: number, columns: number): number { return Math.ceil(count / Math.max(1, columns)); } -function gridCellWidth(width: number, columns: number): number { +function gridCellWidth(width: number, columns: number, gapWidth: number): number { if (columns <= 0) return 0; - const gapWidth = visibleWidth(CELL_GAP); 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 - 2; - return availableForBar >= BRAILLE_BAR_MIN_WIDTH + const availableForBar = cellWidth - fixedWidth; + return availableForBar >= TEXT_BRAILLE_BAR_MIN_WIDTH ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) - : Math.max(1, 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) - compactTerminalMarkWidth(), + ); } function compactCellWidth(idWidth: number, barCells: number): number { - return compactFixedWidth(idWidth) + Math.max(1, barCells); + return compactFixedWidth(idWidth) + Math.max(1, barCells) + compactTerminalMarkWidth(); } function compactFixedWidth(idWidth: number): number { return idWidth + 1 + 2; } +function compactTerminalMarkWidth(): number { + return 1; +} + function summarizeSnapshots(snapshots: readonly AgentSwarmSnapshot[]): AgentSwarmSummary { let completed = 0; let failed = 0; @@ -1108,7 +1166,7 @@ function activityPrefixForTotalStatus(status: TotalStatus, colors: ColorPalette) case 'failed': return ` ${chalk.hex(color)(FAILURE_MARK.trimEnd())}`; case 'aborted': - return ` ${chalk.hex(color)('⊘')}`; + return ` ${chalk.hex(color)(CANCELLED_MARK.trimEnd())}`; case 'working': case 'suspended': return ACTIVITY_SPINNER_PLACEHOLDER; @@ -1269,6 +1327,13 @@ function renderCellLabel( if (snapshot.phase === 'completed') { return renderCompletedCellLabel(member.completedText ?? latestLine, width, colors); } + if (snapshot.phase === 'cancelled') { + return truncateWithColor( + `${CANCELLED_MARK}${PHASE_LABELS[snapshot.phase]}`, + width, + colors.warning, + ); + } return truncateWithColor(PHASE_LABELS[snapshot.phase], width, phaseColor(snapshot.phase, colors)); } @@ -1282,6 +1347,22 @@ function renderCompletedCellLabel( return truncateWithColor(label, width, colors.success); } +function compactTerminalMark(phase: AgentSwarmPhase, colors: ColorPalette): string { + switch (phase) { + case 'completed': + return chalk.hex(colors.success)(SUCCESS_MARK.trimEnd()); + case 'failed': + return chalk.hex(colors.error)(FAILURE_MARK.trimEnd()); + case 'cancelled': + return chalk.hex(colors.warning)(CANCELLED_MARK.trimEnd()); + case 'pending': + case 'queued': + case 'running': + case 'suspended': + return ''; + } +} + function renderPendingCell( member: AgentSwarmMember, width: number, 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 e091c6af7..68824419d 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -49,6 +49,7 @@ import { OAUTH_LOGIN_REQUIRED_CODE, OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, } from '../constant/kimi-tui'; +import { CHROME_GUTTER } from '../constant/rendering'; import { argsRecord, formatErrorPayload, @@ -115,6 +116,18 @@ export interface SessionEventHost { readonly tasksBrowserController: TasksBrowserController; } +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 SessionEventHandler { constructor(private readonly host: SessionEventHost) {} @@ -644,12 +657,11 @@ export class SessionEventHandler { return existing; } - const progress = new AgentSwarmProgressComponent({ + let progress: AgentSwarmProgressComponent; + progress = new AgentSwarmProgressComponent({ description: agentSwarmDescriptionFromArgs(args), colors: this.host.state.theme.colors, - availableGridHeight: () => agentSwarmGridHeightForTerminalRows( - this.host.state.ui.terminal.rows, - ), + availableGridHeight: () => this.agentSwarmGridHeight(progress), requestRender: () => { this.host.state.ui.requestRender(); }, @@ -663,6 +675,22 @@ export class SessionEventHandler { return progress; } + private agentSwarmGridHeight(progress: AgentSwarmProgressComponent): 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 transcriptWidth = Math.max(1, width - CHROME_GUTTER * 2); + const rowsAfterSwarm = + renderedRowsAfterChild(state.transcriptContainer.children, progress, transcriptWidth) + + renderedRowsAfterChild(state.ui.children, state.transcriptContainer, width); + return agentSwarmGridHeightForTerminalRows(terminalRows, rowsAfterSwarm); + } + private handleToolProgress(event: ToolProgressEvent): void { if (event.update.kind !== 'status') return; const text = event.update.text; diff --git a/apps/kimi-code/src/tui/swarm-demo.ts b/apps/kimi-code/src/tui/swarm-demo.ts index 20d8c6c53..b7766cfb8 100644 --- a/apps/kimi-code/src/tui/swarm-demo.ts +++ b/apps/kimi-code/src/tui/swarm-demo.ts @@ -3,16 +3,17 @@ import { Key, matchesKey, ProcessTerminal, - truncateToWidth, TUI, - visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import { CHROME_GUTTER } from './constant/rendering'; +import { + AgentSwarmProgressComponent, + agentSwarmGridHeightForTerminalRows, +} from './components/messages/agent-swarm-progress'; import { GutterContainer } from './components/chrome/gutter-container'; import { loadTuiConfig, TuiConfigParseError } from './config'; +import { CHROME_GUTTER } from './constant/rendering'; import { createKimiTUIThemeBundle } from './theme/bundle'; import type { ColorPalette } from './theme/colors'; import { detectTerminalTheme } from './theme/detect'; @@ -21,20 +22,11 @@ import { printableChar } from './utils/printable-key'; const DEFAULT_SWARM_COUNT = 32; const MAX_SWARM_COUNT = 256; const FRAME_INTERVAL_MS = 80; -const MIN_CELL_WIDTH = 30; -const CELL_GAP = ' '; -const BRAILLE_BAR_MIN_WIDTH = 8; -const BRAILLE_BAR_MAX_WIDTH = 24; -const BRAILLE_EMPTY = '⣀'; -const BRAILLE_SPAWNING_RIGHT = '⣷'; -const BRAILLE_SPAWNING_LEFT = '⣾'; -const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; -const BRAILLE_LEVELS = ['⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'] as const; -const NOMINAL_FULL_BAR_TICKS = BRAILLE_LEVELS.length * BRAILLE_BAR_MAX_WIDTH; -const PHASE_LABEL_WIDTH = 'Completed'.length; -const COMPLETE_FILL_MS = 360; -const LONG_SPAWNING_WAIT_MS = 30_000; -const FAILURE_COUNT = 2; +const INPUT_COMPLETE_MS = 500; +const TOOL_TICK_INTERVAL_MS = 520; +const LONG_RUNNING_FINISH_MS = 45_000; +const FAILED_COUNT = 2; +const CANCELLED_COUNT = 1; export interface SwarmDemoRunOptions { readonly count?: string; @@ -43,40 +35,35 @@ export interface SwarmDemoRunOptions { interface SwarmDemoComponentOptions { readonly count: number; readonly colors: ColorPalette; + readonly terminalRows: () => number | undefined; readonly requestRender: () => void; readonly onExit: () => void; } -type SwarmPhase = 'spawning' | 'working' | 'completed' | 'failed'; +type DemoTaskPhase = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; +type DemoTaskTerminal = 'completed' | 'failed' | 'cancelled'; -interface SwarmTask { +interface DemoTask { readonly index: number; - readonly id: string; - readonly waitMs: number; - readonly offsetMs: number; - readonly shouldFail: boolean; - readonly terminalTicks: number; - readonly tickTimesMs: readonly number[]; -} - -interface SwarmSnapshot { - readonly phase: SwarmPhase; - readonly ticks: number; - readonly phaseElapsedMs: number; + readonly agentId: string; + readonly description: string; + readonly itemText: string; + readonly spawnAtMs: number; + readonly startAtMs: number; + readonly finishAtMs: number; + readonly terminal: DemoTaskTerminal; + phase: DemoTaskPhase; + toolTickCount: number; + modelLineCount: number; } -interface SwarmSummary { - readonly active: number; - readonly completed: number; - readonly failed: number; -} - -const PHASE_LABELS: Record = { - spawning: 'Spawning', - working: 'Working', - completed: 'Completed', - failed: 'Failed', -}; +const MODEL_LINES = [ + 'Reading relevant files', + 'Checking edge cases', + 'Comparing nearby patterns', + 'Validating behavior', + 'Writing concise findings', +] as const; export async function runSwarmDemo(options: SwarmDemoRunOptions = {}): Promise { const count = resolveSwarmCount(options.count); @@ -92,6 +79,7 @@ export async function runSwarmDemo(options: SwarmDemoRunOptions = {}): Promise ui.terminal.rows, requestRender: () => { ui.requestRender(); }, @@ -174,36 +162,55 @@ async function loadSwarmDemoColors(): Promise { export class SwarmDemoComponent extends Container implements Focusable { focused = false; - private readonly tasks: readonly SwarmTask[]; - private readonly colors: ColorPalette; + private readonly tasks: DemoTask[]; + private readonly progress: AgentSwarmProgressComponent; private readonly requestRender: () => void; private readonly onExit: () => void; + private inputComplete = false; private startedAt = Date.now(); - private frame = 0; private timer: ReturnType | undefined; constructor(options: SwarmDemoComponentOptions) { super(); - this.colors = options.colors; this.requestRender = options.requestRender; this.onExit = options.onExit; - this.tasks = createSwarmTasks(options.count); + this.tasks = createDemoTasks(options.count); + this.progress = new AgentSwarmProgressComponent({ + description: 'Demo AgentSwarm progress', + colors: options.colors, + availableGridHeight: () => agentSwarmGridHeightForTerminalRows(options.terminalRows()), + requestRender: options.requestRender, + }); + this.progress.updateArgs({ + description: 'Demo AgentSwarm progress', + prompt_template: 'Inspect {{item}} and report the most relevant finding.', + items: this.tasks.map((task) => task.itemText), + }); } start(): void { - this.dispose(); + this.disposeTimer(); this.startedAt = Date.now(); + this.inputComplete = false; + for (const task of this.tasks) { + task.phase = 'pending'; + task.toolTickCount = 0; + task.modelLineCount = 0; + } + this.syncProgress(); this.timer = setInterval(() => { - this.frame += 1; + this.syncProgress(); this.requestRender(); }, FRAME_INTERVAL_MS); } dispose(): void { - if (this.timer !== undefined) { - clearInterval(this.timer); - this.timer = undefined; - } + this.disposeTimer(); + this.progress.dispose(); + } + + override invalidate(): void { + this.progress.invalidate(); } handleInput(data: string): void { @@ -220,329 +227,148 @@ export class SwarmDemoComponent extends Container implements Focusable { } override render(width: number): string[] { - const innerWidth = Math.max(1, width); - const elapsedMs = Date.now() - this.startedAt; - const snapshots = this.tasks.map((task) => snapshotTask(task, elapsedMs)); - const summary = summarizeSnapshots(snapshots); - const lines: string[] = [ - this.renderHeader(innerWidth, summary), - chalk.hex(this.colors.textMuted)(' q / Esc / Ctrl-C exit'), - chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), - '', - ...this.renderGrid(innerWidth, snapshots), - '', - chalk.hex(this.colors.primary)('─'.repeat(innerWidth)), - ]; - return lines.map((line) => truncateToWidth(line, innerWidth)); + this.syncProgress(); + return this.progress.render(width); } - private renderHeader(width: number, summary: SwarmSummary): string { - const title = chalk.hex(this.colors.primary).bold(' Kimi swarm demo'); - const count = chalk.hex(this.colors.textMuted)(` swarms=${String(this.tasks.length)}`); - const activeLabel = chalk.hex(this.colors.accent)(` running=${String(summary.active)}`); - const doneLabel = chalk.hex(this.colors.success)(` complete=${String(summary.completed)}`); - const failedLabel = chalk.hex(this.colors.error)(` failed=${String(summary.failed)}`); - return truncateToWidth(title + count + activeLabel + doneLabel + failedLabel, width); + private disposeTimer(): void { + if (this.timer === undefined) return; + clearInterval(this.timer); + this.timer = undefined; } - private renderGrid(width: number, snapshots: readonly SwarmSnapshot[]): string[] { - const columns = columnsForWidth(width, this.tasks.length); - const gapWidth = visibleWidth(CELL_GAP); - const cellWidth = Math.max( - 1, - Math.floor((width - gapWidth * Math.max(0, columns - 1)) / columns), - ); - const rows = Math.ceil(this.tasks.length / columns); - 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 task = this.tasks[index]; - const snapshot = snapshots[index]; - if (task === undefined || snapshot === undefined) continue; - cells.push(padAnsi(this.renderCell(task, snapshot, cellWidth), cellWidth)); + private syncProgress(): void { + const elapsedMs = Date.now() - this.startedAt; + if (!this.inputComplete && elapsedMs >= INPUT_COMPLETE_MS) { + this.progress.markInputComplete(); + this.inputComplete = true; + for (const task of this.tasks) { + if (task.phase === 'pending') task.phase = 'queued'; } - lines.push(cells.join(CELL_GAP)); } - return lines; - } - private renderCell(task: SwarmTask, snapshot: SwarmSnapshot, width: number): string { - const status = PHASE_LABELS[snapshot.phase]; - const fixedWidth = task.id.length + 2 + PHASE_LABEL_WIDTH + 1; - const availableForBar = width - fixedWidth - 2; - const barWidth = - availableForBar >= BRAILLE_BAR_MIN_WIDTH - ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) - : Math.max(1, availableForBar); - const id = chalk.hex(this.colors.textDim)(`${task.id}:`); - return [ - id, - stylePhase(status.padStart(PHASE_LABEL_WIDTH), snapshot.phase, this.colors), - brailleBar( - snapshot.ticks, - snapshot.phase, - barWidth, - this.colors, - this.frame, - task.index, - snapshot.phaseElapsedMs, - ), - ].join(' '); + for (const task of this.tasks) { + this.syncTask(task, elapsedMs); + } } -} -function createSwarmTasks(count: number): readonly SwarmTask[] { - const failureIndexes = chooseFailureIndexes(count); - return Array.from({ length: count }, (_, index) => { - const shouldFail = failureIndexes.has(index); - let terminalTicks: number; - if (shouldFail) { - terminalTicks = 8 + Math.floor(Math.random() * 9); - } else if (Math.random() < 0.8) { - terminalTicks = Math.floor(NOMINAL_FULL_BAR_TICKS * (0.35 + Math.random() * 0.45)); - } else { - terminalTicks = - NOMINAL_FULL_BAR_TICKS + 10 + Math.floor(Math.random() * NOMINAL_FULL_BAR_TICKS); + private syncTask(task: DemoTask, elapsedMs: number): void { + if (task.phase === 'pending' && elapsedMs >= task.spawnAtMs) { + this.progress.registerSubagent({ + agentId: task.agentId, + description: task.description, + }); + task.phase = 'queued'; } - let waitMs = 250 + Math.floor(Math.random() * 1_100); - if (index === 0) waitMs = LONG_SPAWNING_WAIT_MS; - else if (shouldFail) waitMs = 120 + Math.floor(Math.random() * 360); - - return { - index, - id: `swarm-${String(index + 1).padStart(3, '0')}`, - waitMs, - offsetMs: index === 0 ? 0 : Math.floor(Math.random() * (shouldFail ? 250 : 900)), - shouldFail, - terminalTicks, - tickTimesMs: createTickTimes(terminalTicks, shouldFail), - }; - }); -} - -function snapshotTask(task: SwarmTask, elapsedMs: number): SwarmSnapshot { - const elapsed = elapsedMs + task.offsetMs; - if (elapsed < task.waitMs) { - return { phase: 'spawning', ticks: 0, phaseElapsedMs: elapsed }; - } - - const workingElapsed = elapsed - task.waitMs; - const ticks = ticksForElapsed(task.tickTimesMs, workingElapsed); - if (ticks >= task.terminalTicks) { - const terminalAtMs = task.tickTimesMs[task.terminalTicks - 1] ?? 0; - return { - phase: task.shouldFail ? 'failed' : 'completed', - ticks: task.terminalTicks, - phaseElapsedMs: Math.max(0, workingElapsed - terminalAtMs), - }; - } - return { phase: 'working', ticks, phaseElapsedMs: workingElapsed }; -} + if (task.phase === 'queued' && elapsedMs >= task.startAtMs) { + this.progress.markStarted(task.agentId); + task.phase = 'running'; + } -function summarizeSnapshots(snapshots: readonly SwarmSnapshot[]): SwarmSummary { - let completed = 0; - let failed = 0; - for (const snapshot of snapshots) { - if (snapshot.phase === 'completed') completed += 1; - if (snapshot.phase === 'failed') failed += 1; - } - return { - active: snapshots.length - completed - failed, - completed, - failed, - }; -} + if (task.phase === 'running') { + const runningElapsedMs = Math.max(0, elapsedMs - task.startAtMs); + const targetToolTicks = Math.floor(runningElapsedMs / TOOL_TICK_INTERVAL_MS); + while (task.toolTickCount < targetToolTicks) { + task.toolTickCount += 1; + this.progress.recordToolCall({ + agentId: task.agentId, + toolCallId: `${task.agentId}-tool-${String(task.toolTickCount)}`, + }); + } -function columnsForWidth(width: number, count: number): number { - if (count <= 1) return 1; - const gapWidth = visibleWidth(CELL_GAP); - const columns = Math.floor((width + gapWidth) / (MIN_CELL_WIDTH + gapWidth)); - return Math.max(1, Math.min(count, columns)); -} + const targetModelLines = Math.floor(runningElapsedMs / (TOOL_TICK_INTERVAL_MS * 2)); + while (task.modelLineCount < targetModelLines) { + task.modelLineCount += 1; + const line = MODEL_LINES[(task.modelLineCount + task.index) % MODEL_LINES.length]; + this.progress.appendModelDelta({ + agentId: task.agentId, + delta: `${line}: ${task.itemText}\n`, + }); + } -function brailleBar( - ticks: number, - phase: SwarmPhase, - width: number, - colors: ColorPalette, - frame: number, - taskIndex: number, - phaseElapsedMs: number, -): string { - const innerWidth = Math.max(1, width); - switch (phase) { - case 'spawning': - return bracketBar(spawningBrailleBar(innerWidth, frame, taskIndex, colors), colors); - case 'working': - return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.success, colors), colors); - case 'completed': - return bracketBar( - accumulatedBrailleBar( - completedDisplayTicks(ticks, innerWidth, phaseElapsedMs), - innerWidth, - colors.success, - colors, - ), - colors, - ); - case 'failed': - return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.error, colors), colors); + if (elapsedMs >= task.finishAtMs) { + this.finishTask(task); + } + } } -} - -function bracketBar(content: string, colors: ColorPalette): string { - const bracket = chalk.hex(colors.textMuted); - return bracket('[') + content + bracket(']'); -} -function stylePhase(label: string, phase: SwarmPhase, colors: ColorPalette): string { - switch (phase) { - case 'spawning': - return chalk.hex(colors.textDim)(label); - case 'working': - return chalk.hex(colors.primary)(label); - case 'completed': - return chalk.hex(colors.success)(label); - case 'failed': - return chalk.hex(colors.error)(label); + private finishTask(task: DemoTask): void { + switch (task.terminal) { + case 'completed': + this.progress.markCompleted(task.agentId, `Completed ${task.itemText}`); + task.phase = 'completed'; + return; + case 'failed': + this.progress.markFailed(task.agentId, `Failed while checking ${task.itemText}`); + task.phase = 'failed'; + return; + case 'cancelled': + this.progress.markCancelled(task.agentId); + task.phase = 'cancelled'; + return; + } } } -function padAnsi(text: string, width: number): string { - const truncated = truncateToWidth(text, width); - return truncated + ' '.repeat(Math.max(0, width - visibleWidth(truncated))); +function createDemoTasks(count: number): DemoTask[] { + const failed = chooseTerminalIndexes(count, FAILED_COUNT, 0.42); + const cancelled = chooseTerminalIndexes(count, CANCELLED_COUNT, 0.68, failed); + + return Array.from({ length: count }, (_item, index) => { + const agentNumber = String(index + 1).padStart(3, '0'); + const spawnAtMs = 120 + (index % 16) * 70 + Math.floor(index / 16) * 35; + const startAtMs = spawnAtMs + 350 + (index % 5) * 130; + const terminal = failed.has(index) + ? 'failed' + : cancelled.has(index) + ? 'cancelled' + : 'completed'; + const finishAtMs = index === 0 + ? LONG_RUNNING_FINISH_MS + : startAtMs + 2_200 + (index % 9) * 360 + Math.floor(index / 9) * 80; + return { + index, + agentId: `demo-agent-${agentNumber}`, + description: `Demo AgentSwarm #${String(index + 1)} (coder)`, + itemText: demoItemText(index), + spawnAtMs, + startAtMs, + finishAtMs, + terminal, + phase: 'pending', + toolTickCount: 0, + modelLineCount: 0, + }; + }); } -function chooseFailureIndexes(count: number): ReadonlySet { - const target = Math.min(FAILURE_COUNT, count); - const candidates = - count > target - ? Array.from({ length: count - 1 }, (_, index) => index + 1) - : Array.from({ length: count }, (_, index) => index); +function chooseTerminalIndexes( + count: number, + targetCount: number, + offsetRatio: number, + exclude: ReadonlySet = new Set(), +): ReadonlySet { const indexes = new Set(); - while (indexes.size < target) { - indexes.add(candidates[Math.floor(Math.random() * candidates.length)]!); - } - return indexes; -} - -function createTickTimes(ticks: number, fastFailure: boolean): readonly number[] { - const times: number[] = []; - let elapsed = 0; - for (let i = 0; i < ticks; i += 1) { - elapsed += fastFailure ? randomFailureTickIntervalMs() : randomTickIntervalMs(); - times.push(elapsed); - } - return times; -} - -function randomFailureTickIntervalMs(): number { - return 50 + Math.floor(Math.random() * 120); -} + if (count <= 1 || targetCount <= 0) return indexes; -function randomTickIntervalMs(): number { - const roll = Math.random(); - if (roll < 0.5) return 30 + Math.floor(Math.random() * 140); - if (roll < 0.8) return 170 + Math.floor(Math.random() * 480); - if (roll < 0.95) return 650 + Math.floor(Math.random() * 1_150); - return 1_800 + Math.floor(Math.random() * 3_200); -} - -function ticksForElapsed(tickTimesMs: readonly number[], elapsedMs: number): number { - let low = 0; - let high = tickTimesMs.length; - while (low < high) { - const mid = Math.floor((low + high) / 2); - if ((tickTimesMs[mid] ?? 0) <= elapsedMs) { - low = mid + 1; - } else { - high = mid; - } - } - return low; -} - -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 spawningBrailleBar( - width: number, - frame: number, - taskIndex: number, - colors: ColorPalette, -): string { - if (width <= 1) { - return chalk.hex(colors.textMuted)(BRAILLE_SPAWNING_RIGHT); - } - let out = ''; - const maxPosition = width - 1; - const period = maxPosition * 2; - const position = (frame + taskIndex) % period; - const movingRight = position <= maxPosition; - const cursorCell = movingRight ? position : period - position; - const cursorChar = movingRight ? BRAILLE_SPAWNING_RIGHT : BRAILLE_SPAWNING_LEFT; - for (let i = 0; i < width; i += 1) { - out += chalk.hex(i === cursorCell ? colors.textMuted : colors.textDim)( - i === cursorCell ? cursorChar : BRAILLE_EMPTY, - ); + let cursor = Math.max(1, Math.min(count - 1, Math.floor(count * offsetRatio))); + while (indexes.size < targetCount && indexes.size + exclude.size < count - 1) { + if (cursor !== 0 && !exclude.has(cursor)) indexes.add(cursor); + cursor = cursor + 3 >= count ? 1 + ((cursor + 3) % count) : cursor + 3; } - return out; + return indexes; } -function accumulatedBrailleBar( - ticks: number, - width: number, - filledColor: string, - colors: ColorPalette, -): string { - const dotsPerCell = BRAILLE_LEVELS.length; - const cycleSize = width * dotsPerCell; - const safeTicks = Math.max(0, 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 ? colors.textDim : filledColor, - ); - } - flush(); - return out; +function demoItemText(index: number): string { + const files = [ + 'apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts', + 'apps/kimi-code/src/tui/controllers/session-event-handler.ts', + 'apps/kimi-code/src/tui/components/messages/tool-call.ts', + 'packages/agent-core/src/tools/builtin/current.ts', + 'packages/node-sdk/src/session.ts', + 'docs/en/release-notes/changelog.md', + ] as const; + const file = files[index % files.length]; + return `${file}#${String(index + 1)}`; } 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 index fe31af0cc..984739318 100644 --- 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 @@ -44,34 +44,72 @@ describe('calculateAgentSwarmGridLayout', () => { 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: 4, - count: 20, + 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: 14, + cellWidth: 21, + columnGap: 2, + leftPadding: 0, }); }); - it('compresses bar cells from the target row count when compact max bars still overflow', () => { + 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: 2, + barCells: 1, columns: 10, rows: 4, cellWidth: 8, + columnGap: 2, + leftPadding: 0, }); }); @@ -82,16 +120,39 @@ describe('calculateAgentSwarmGridLayout', () => { count: 4, })).toEqual({ renderText: false, - barCells: 1, + barCells: 2, columns: 2, rows: 2, - cellWidth: 7, + 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); }); }); @@ -143,7 +204,7 @@ describe('AgentSwarmProgressComponent', () => { expect(statusLine).toBeDefined(); expect(statusLine?.match(/ *$/)?.[0].length).toBe(0); expect(gridLine).toBeDefined(); - expect(visibleWidth(gridLine ?? '')).toBeLessThan(visibleWidth(statusLine ?? '')); + expect(visibleWidth(gridLine ?? '')).toBeLessThanOrEqual(79); }); it('renders orchestrating and prompting labels in primary blue', () => { @@ -249,29 +310,95 @@ describe('AgentSwarmProgressComponent', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', colors: darkColors, - availableGridHeight: () => 4, + availableGridHeight: () => 5, }); - for (let index = 1; index <= 20; index += 1) { + 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 <= 20; index += 1) { + 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(4); + 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 an aborted subagent label with the aborted mark', () => { + 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.markCancelled('agent-1'); + + const output = strip(component.render(100).join('\n')); + + expect(output).toContain('001 ['); + expect(output).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', From ef4a584cd23b18dfce343c3ae0e1a52f5304545e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 16:57:50 +0800 Subject: [PATCH 30/72] fix --- .../agent-core/src/session/subagent-host.ts | 38 +++-- .../src/session/subagent-launch-queue.ts | 1 + .../builtin/collaboration/agent-swarm.md | 6 +- .../builtin/collaboration/agent-swarm.ts | 151 ++++++++++++++---- .../test/session/subagent-host.test.ts | 51 ++++++ .../test/tools/builtin-current.test.ts | 136 +++++++++++++++- 6 files changed, 330 insertions(+), 53 deletions(-) diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 26bf9a218..d9e70a518 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -326,22 +326,28 @@ export class SessionSubagentHost { let handle: SubagentHandle | undefined; try { runSignal.throwIfAborted(); - handle = - options.retryAgentId === undefined - ? await this.spawn({ - ...task, - signal: runSignal, - onStarted: options.markReady, - onFirstOutput: options.markReady, - suppressRateLimitFailureEvent: true, - }) - : await this.retry(options.retryAgentId, { - ...task, - signal: runSignal, - onStarted: options.markReady, - onFirstOutput: options.markReady, - suppressRateLimitFailureEvent: true, - }); + const runOptions = { + parentToolCallId: task.parentToolCallId, + parentToolCallUuid: task.parentToolCallUuid, + prompt: task.prompt, + description: task.description, + runInBackground: task.runInBackground, + origin: task.origin, + signal: runSignal, + onStarted: options.markReady, + onFirstOutput: options.markReady, + suppressRateLimitFailureEvent: true, + }; + if (options.retryAgentId !== undefined) { + handle = await this.retry(options.retryAgentId, runOptions); + } else if (task.resumeAgentId !== undefined) { + handle = await this.resume(task.resumeAgentId, runOptions); + } else { + handle = await this.spawn({ + profileName: task.profileName, + ...runOptions, + }); + } const completion = await handle.completion; return { task, diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index ccb5179f0..30ed307af 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -20,6 +20,7 @@ export type QueuedSubagentTask = { readonly description: string; readonly runInBackground: boolean; readonly origin?: PromptOrigin; + readonly resumeAgentId?: string; }; export type QueuedSubagentRunOptions = { diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md index 729f92dfc..856e1fffa 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md @@ -1,5 +1,7 @@ -Launch multiple subagents from one prompt template and a list of item values. +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 subagents with those two concrete prompts. +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. 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 index d716623f3..3489e48ee 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -48,29 +48,70 @@ export const AgentSwarmToolInputSchema = z .refine((value) => value.includes(PROMPT_TEMPLATE_PLACEHOLDER), { message: `prompt_template must include the ${PROMPT_TEMPLATE_PLACEHOLDER} placeholder.`, }) + .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)) - .min(2) - .max(MAX_AGENT_SWARM_SUBAGENTS, { - message: `AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`, - }) + .max(MAX_AGENT_SWARM_SUBAGENTS) + .optional() .describe( - `Values used to fill ${PROMPT_TEMPLATE_PLACEHOLDER}. Each item launches one subagent.`, + `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(); + .strict() + .superRefine((args, ctx) => { + const itemCount = args.items?.length ?? 0; + const resumeCount = Object.keys(args.resume_agent_ids ?? {}).length; + const totalCount = itemCount + resumeCount; + if (itemCount > 0 && args.prompt_template === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['prompt_template'], + message: 'prompt_template is required when items are provided.', + }); + } + if (totalCount < 2) { + ctx.addIssue({ + code: 'custom', + path: ['items'], + message: 'AgentSwarm requires at least 2 total subagents.', + }); + } + if (totalCount > MAX_AGENT_SWARM_SUBAGENTS) { + ctx.addIssue({ + code: 'custom', + path: ['items'], + message: `AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`, + }); + } + }); export type AgentSwarmToolInput = z.infer; -interface AgentSwarmSpec { +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 prompt: string; +} + +type AgentSwarmSpec = AgentSwarmSpawnSpec | AgentSwarmResumeSpec; + interface SwarmRunResult { readonly spec: AgentSwarmSpec; readonly agentId?: string; @@ -129,15 +170,21 @@ export class AgentSwarmTool implements BuiltinTool { signal: AbortSignal, toolCallId: string, ): Promise { - const profileName = args.subagent_type ?? DEFAULT_SUBAGENT_TYPE; + const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; const tasks = specs.map((spec): QueuedSubagentTask => { + const resumeAgentId = spec.kind === 'resume' ? spec.agentId : undefined; return { data: spec, - profileName, + profileName: spec.kind === 'resume' ? 'subagent' : profileName, parentToolCallId: toolCallId, prompt: spec.prompt, - description: childDescription(args.description, spec.index, profileName), + description: childDescription( + args.description, + spec.index, + spec.kind === 'resume' ? 'resume' : profileName, + ), runInBackground: false, + resumeAgentId, }; }); const results = await this.subagentHost.runQueued(tasks, { @@ -149,31 +196,72 @@ export class AgentSwarmTool implements BuiltinTool { } function createAgentSwarmSpecs(args: AgentSwarmToolInput): AgentSwarmSpec[] { - if (!args.prompt_template.includes(PROMPT_TEMPLATE_PLACEHOLDER)) { - throw new Error(`AgentSwarm prompt_template must include ${PROMPT_TEMPLATE_PLACEHOLDER}.`); + const resumeEntries = Object.entries(args.resume_agent_ids ?? {}).map(([agentId, prompt]) => { + return { + agentId: agentId.trim(), + prompt: prompt.trim(), + }; + }); + const items = (args.items ?? []).map((item) => item.trim()); + const totalCount = resumeEntries.length + items.length; + if (totalCount < 2) { + throw new Error('AgentSwarm requires at least 2 total subagents.'); } - if (args.items.length > MAX_AGENT_SWARM_SUBAGENTS) { + if (totalCount > MAX_AGENT_SWARM_SUBAGENTS) { throw new Error( `AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`, ); } + const invalidResume = resumeEntries.find( + (entry) => entry.agentId.length === 0 || entry.prompt.length === 0, + ); + if (invalidResume !== undefined) { + throw new Error('AgentSwarm resume_agent_ids must map non-empty agent ids to non-empty prompts.'); + } + const invalidItem = items.find((item) => item.length === 0); + if (invalidItem !== undefined) { + throw new Error('AgentSwarm items must be non-empty strings.'); + } + const promptTemplate = normalizeOptionalString(args.prompt_template); + if (items.length > 0 && promptTemplate === undefined) { + throw new Error('AgentSwarm prompt_template is required when items are provided.'); + } + if (promptTemplate !== undefined && !promptTemplate.includes(PROMPT_TEMPLATE_PLACEHOLDER)) { + throw new Error(`AgentSwarm prompt_template must include ${PROMPT_TEMPLATE_PLACEHOLDER}.`); + } const seenPrompts = new Map(); - return args.items.map((item, index) => { - const prompt = args.prompt_template.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.`, - ); + const specs: AgentSwarmSpec[] = []; + for (const entry of resumeEntries) { + specs.push({ + kind: 'resume', + index: specs.length + 1, + agentId: entry.agentId, + prompt: entry.prompt, + }); + } + if (items.length > 0) { + if (promptTemplate === undefined) { + throw new Error('AgentSwarm prompt_template is required when items are provided.'); } - seenPrompts.set(prompt, index + 1); - return { - index: index + 1, - item, - prompt, - }; - }); + items.forEach((item, index) => { + const prompt = promptTemplate.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 childDescription(swarmDescription: string, index: number, profileName: string): string { @@ -190,9 +278,10 @@ function renderSwarmResults(results: readonly SwarmRunResult[]): string { for (const result of results) { const agentId = result.agentId === undefined ? '' : ` agent_id="${result.agentId}"`; + const mode = result.spec.kind === 'resume' ? ' mode="resume"' : ''; const body = result.status === 'completed' ? (result.result ?? '') : (result.error ?? 'unknown error'); lines.push( - `${body}`, + `${body}`, ); } @@ -200,6 +289,12 @@ function renderSwarmResults(results: readonly SwarmRunResult[]): string { 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): string { const parts: string[] = []; if (completed > 0) parts.push(`completed: ${String(completed)}`); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index a237e33c2..24359a132 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1420,6 +1420,57 @@ describe('SessionSubagentHost', () => { ); }); + it('runQueued resumes tasks that carry an existing agent id', async () => { + const parent = testAgent(); + parent.configure(); + + const child = testAgent({ type: 'sub' }); + child.configure(); + child.agent.useProfile( + profile({ name: 'coder', tools: [], systemPrompt: 'coder prompt' }), + ); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier swarm context' }]); + const summary = + 'Resumed the queued swarm subagent from its prior context, completed the missing work, and returned a detailed enough handoff for the parent to proceed without starting over. '.repeat( + 2, + ); + child.mockNextResponse({ type: 'text', text: summary }); + + const session = fakeSession(parent.agent, child.agent, { + 'agent-0': { + homedir: '/tmp/kimi-session/agents/agent-0', + type: 'sub', + parentAgentId: 'main', + }, + }); + const host = new SessionSubagentHost(session, 'main'); + + await expect( + host.runQueued( + [ + { + ...queuedTask(1), + prompt: 'Continue the previous swarm task', + resumeAgentId: 'agent-0', + }, + ], + { signal }, + ), + ).resolves.toMatchObject([ + { + agentId: 'agent-0', + status: 'completed', + result: summary.trim(), + }, + ]); + + expect(session.createAgent).not.toHaveBeenCalled(); + expect(userTextMessages(child.llmCalls[0]?.history ?? [])).toEqual([ + 'Earlier swarm context', + 'Continue the previous swarm task', + ]); + }); + it('retries a rate-limited child turn without appending the original prompt again', async () => { const parent = testAgent(); parent.configure(); diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index d32ddc1fb..b449ae657 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -13,7 +13,11 @@ import { describe, expect, it, vi } from 'vitest'; import type { Agent } from '../../src/agent'; import type { SwarmMode } from '../../src/agent/swarm'; import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; -import type { SessionSubagentHost } from '../../src/session/subagent-host'; +import type { + QueuedSubagentRunResult, + QueuedSubagentTask, + SessionSubagentHost, +} from '../../src/session/subagent-host'; import { SkillRegistry } from '../../src/skill'; import { TaskListInputSchema } from '../../src/tools/background/task-list'; import { TaskOutputInputSchema } from '../../src/tools/background/task-output'; @@ -305,7 +309,7 @@ describe('current builtin collaboration tools', () => { runQueued: vi.fn().mockResolvedValue([ { task: { - data: { index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + data: { kind: 'spawn', index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, profileName: 'explore', parentToolCallId: 'call_swarm', prompt: 'Review src/a.ts', @@ -318,7 +322,7 @@ describe('current builtin collaboration tools', () => { }, { task: { - data: { index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + data: { kind: 'spawn', index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, profileName: 'explore', parentToolCallId: 'call_swarm', prompt: 'Review src/b.ts', @@ -363,6 +367,9 @@ describe('current builtin collaboration tools', () => { }, }, }); + expect(Object.keys(tool.parameters['properties'] as Record).at(-1)).toBe( + 'resume_agent_ids', + ); const result = await executeTool(tool, context(input, 'call_swarm')); @@ -371,20 +378,22 @@ describe('current builtin collaboration tools', () => { expect(host.runQueued).toHaveBeenCalledWith( [ { - data: { index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + data: { kind: 'spawn', index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, profileName: 'explore', parentToolCallId: 'call_swarm', prompt: 'Review src/a.ts', description: 'Review files #1 (explore)', runInBackground: false, + resumeAgentId: undefined, }, { - data: { index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + data: { kind: 'spawn', index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, profileName: 'explore', parentToolCallId: 'call_swarm', prompt: 'Review src/b.ts', description: 'Review files #2 (explore)', runInBackground: false, + resumeAgentId: undefined, }, ], { @@ -422,12 +431,125 @@ describe('current builtin collaboration tools', () => { expect(host.runQueued).not.toHaveBeenCalled(); }); + it('AgentSwarm resumes mapped agents before spawning item subagents', async () => { + const runQueued = vi.fn( + async ( + tasks: readonly QueuedSubagentTask[], + ): Promise>> => { + return tasks.map((task, index) => ({ + task, + agentId: task.resumeAgentId ?? `agent-new-${String(index + 1)}`, + status: 'completed' as const, + result: `result ${String(index + 1)}`, + })); + }, + ); + const host = mockSubagentHost({ + runQueued: runQueued as unknown as SessionSubagentHost['runQueued'], + }); + const swarmMode = mockSwarmMode(); + const tool = new AgentSwarmTool(host, swarmMode); + const input = { + description: 'Finish review', + subagent_type: 'explore', + prompt_template: 'Review {{item}}', + items: ['src/new.ts'], + resume_agent_ids: { + 'agent-old-1': 'Continue previous review A', + 'agent-old-2': 'Continue previous review B', + }, + }; + + expect(AgentSwarmToolInputSchema.safeParse(input).success).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + description: 'Resume two agents', + resume_agent_ids: { + 'agent-old-1': 'Continue previous review A', + 'agent-old-2': 'Continue previous review B', + }, + }).success, + ).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ + description: 'Resume one agent', + resume_agent_ids: { + 'agent-old-1': 'Continue previous review A', + }, + }).success, + ).toBe(false); + + const result = await executeTool(tool, context(input, 'call_swarm')); + + expect(host.runQueued).toHaveBeenCalledTimes(1); + expect(host.runQueued).toHaveBeenCalledWith( + [ + { + data: { + kind: 'resume', + index: 1, + agentId: 'agent-old-1', + prompt: 'Continue previous review A', + }, + profileName: 'subagent', + parentToolCallId: 'call_swarm', + prompt: 'Continue previous review A', + description: 'Finish review #1 (resume)', + runInBackground: false, + resumeAgentId: 'agent-old-1', + }, + { + data: { + kind: 'resume', + index: 2, + agentId: 'agent-old-2', + prompt: 'Continue previous review B', + }, + profileName: 'subagent', + parentToolCallId: 'call_swarm', + prompt: 'Continue previous review B', + description: 'Finish review #2 (resume)', + runInBackground: false, + resumeAgentId: 'agent-old-2', + }, + { + data: { + kind: 'spawn', + index: 3, + item: 'src/new.ts', + prompt: 'Review src/new.ts', + }, + profileName: 'explore', + parentToolCallId: 'call_swarm', + prompt: 'Review src/new.ts', + description: 'Finish review #3 (explore)', + runInBackground: false, + resumeAgentId: undefined, + }, + ], + { + signal, + timeoutMs: undefined, + totalTimeoutMs: undefined, + }, + ); + expect(result.output).toBe([ + '', + 'completed: 3', + 'result 1', + 'result 2', + 'result 3', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + it('AgentSwarm reports failed subagents inside the XML result without failing the tool', async () => { const host = mockSubagentHost({ runQueued: vi.fn().mockResolvedValue([ { task: { - data: { index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + data: { kind: 'spawn', index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, profileName: 'coder', parentToolCallId: 'call_swarm', prompt: 'Review src/a.ts', @@ -440,7 +562,7 @@ describe('current builtin collaboration tools', () => { }, { task: { - data: { index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + data: { kind: 'spawn', index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, profileName: 'coder', parentToolCallId: 'call_swarm', prompt: 'Review src/b.ts', From 61c3a034082994f80f3886255d13239f4ae3a9fc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 19:08:49 +0800 Subject: [PATCH 31/72] fix --- apps/kimi-code/src/tui/commands/registry.ts | 11 +++ apps/kimi-code/src/tui/commands/swarm.ts | 8 +- .../messages/agent-swarm-progress.ts | 60 ++++++++++++ .../src/tui/components/messages/tool-call.ts | 49 +++++++++- .../tui/controllers/session-event-handler.ts | 14 +-- .../test/tui/commands/registry.test.ts | 17 ++++ .../kimi-code/test/tui/commands/swarm.test.ts | 26 ++++++ .../tui/components/messages/tool-call.test.ts | 55 +++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 82 +++++++++++++++++ .../kimi-code/test/tui/message-replay.test.ts | 40 ++++++++ .../agent-core/src/session/subagent-host.ts | 8 +- .../src/session/subagent-launch-queue.ts | 54 ++++++++++- .../builtin/collaboration/agent-swarm.ts | 15 ++- .../test/session/subagent-host.test.ts | 91 +++++++++++++++++++ .../test/tools/builtin-current.test.ts | 71 +++++++++++++++ 15 files changed, 578 insertions(+), 23 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index d5008f2e1..f28b09afd 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', @@ -72,6 +82,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Toggle swarm mode or run one task in swarm mode', priority: 100, + completeArgs: swarmArgumentCompletions, availability: 'idle-only', }, { diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index ab90c09b9..a521e560d 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -21,13 +21,13 @@ export async function handleSwarmCommand(host: SlashCommandHost, args: string): return; } - if (host.state.appState.model.trim().length === 0) { - host.showError(LLM_NOT_SET_MESSAGE); + if (prompt.length === 0) { + await applySwarmMode(host, !host.state.appState.swarmMode); return; } - if (prompt.length === 0) { - host.showError('Usage: /swarm '); + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); return; } 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 index e91b84c3a..00ee0995e 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -80,6 +80,13 @@ interface AgentSwarmResultStatus { 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; @@ -827,6 +834,23 @@ function parseAgentSwarmDescriptionIndex(description: string | undefined): numbe return Number.isInteger(index) && index > 0 ? index : undefined; } +export function agentSwarmResultSummaryFromOutput(output: string): AgentSwarmResultSummary { + const statuses = parseAgentSwarmResultStatuses(output); + const aborted = countAgentSwarmAbortedResultStatuses(output); + let completed = 0; + let failed = 0; + for (const status of statuses) { + if (status.status === 'completed') completed += 1; + if (status.status === 'failed') failed += 1; + } + return { + completed, + failed, + aborted, + parsed: completed + failed + aborted > 0, + }; +} + function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] { const xmlStatuses = parseAgentSwarmXmlResultStatuses(output); if (xmlStatuses.length > 0) return xmlStatuses; @@ -859,6 +883,36 @@ function parseAgentSwarmXmlResultStatuses(output: string): AgentSwarmResultStatu return result; } +function countAgentSwarmAbortedResultStatuses(output: string): number { + const xmlAborted = countAgentSwarmXmlAbortedResultStatuses(output); + if (xmlAborted > 0) return xmlAborted; + return countAgentSwarmLegacyAbortedResultStatuses(output); +} + +function countAgentSwarmXmlAbortedResultStatuses(output: string): number { + let count = 0; + const tagPattern = /]*)>/g; + let match: RegExpExecArray | null; + while ((match = tagPattern.exec(output)) !== null) { + const attrs = match[1] ?? ''; + const closeIndex = output.indexOf('', tagPattern.lastIndex); + if (closeIndex < 0) break; + + const index = Number(xmlAttribute(attrs, 'index')); + const outcome = xmlAttribute(attrs, 'outcome'); + if ( + Number.isInteger(index) && + index > 0 && + (outcome === 'aborted' || outcome === 'cancelled') + ) { + count += 1; + } + + tagPattern.lastIndex = closeIndex + ''.length; + } + return count; +} + function xmlAttribute(attrs: string, name: string): string | undefined { const match = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs); return match?.[1]; @@ -881,6 +935,12 @@ function parseAgentSwarmLegacyResultStatuses(output: string): AgentSwarmResultSt return result; } +function countAgentSwarmLegacyAbortedResultStatuses(output: string): number { + return output.split(/\n(?=\[agent \d+\]\n)/).filter((block) => + /^status: (aborted|cancelled)$/m.test(block) + ).length; +} + function parseAgentSwarmCompletedText(block: string): string | undefined { const marker = '\n[summary]\n'; const markerIndex = block.indexOf(marker); 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 e9843c0da..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,6 +36,7 @@ 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'; @@ -1785,12 +1787,15 @@ 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; } @@ -1850,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/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 68824419d..679fc0dfe 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -49,7 +49,6 @@ import { OAUTH_LOGIN_REQUIRED_CODE, OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, } from '../constant/kimi-tui'; -import { CHROME_GUTTER } from '../constant/rendering'; import { argsRecord, formatErrorPayload, @@ -661,7 +660,7 @@ export class SessionEventHandler { progress = new AgentSwarmProgressComponent({ description: agentSwarmDescriptionFromArgs(args), colors: this.host.state.theme.colors, - availableGridHeight: () => this.agentSwarmGridHeight(progress), + availableGridHeight: () => this.agentSwarmGridHeight(), requestRender: () => { this.host.state.ui.requestRender(); }, @@ -675,7 +674,7 @@ export class SessionEventHandler { return progress; } - private agentSwarmGridHeight(progress: AgentSwarmProgressComponent): number | undefined { + private agentSwarmGridHeight(): number | undefined { const { state } = this.host; const terminalRows = state.ui.terminal.rows; const terminalColumns = state.ui.terminal.columns; @@ -684,10 +683,11 @@ export class SessionEventHandler { } const width = Math.floor(terminalColumns); - const transcriptWidth = Math.max(1, width - CHROME_GUTTER * 2); - const rowsAfterSwarm = - renderedRowsAfterChild(state.transcriptContainer.children, progress, transcriptWidth) + - renderedRowsAfterChild(state.ui.children, state.transcriptContainer, width); + const rowsAfterSwarm = renderedRowsAfterChild( + state.ui.children, + state.transcriptContainer, + width, + ); return agentSwarmGridHeightForTerminalRows(terminalRows, rowsAfterSwarm); } diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 046214dcd..45ca391e5 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'; @@ -55,6 +56,22 @@ describe('built-in slash command registry', () => { 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/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 6f5a74ca9..6937e54ad 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -93,6 +93,19 @@ describe('handleSwarmCommand', () => { 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); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + 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 }); @@ -117,6 +130,19 @@ describe('handleSwarmCommand', () => { 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); + expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); + expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(false); + 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 }); 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..f513b10c4 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( { 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 e9cc2a46b..6655cbe9c 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,7 @@ 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 { 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'; @@ -282,6 +283,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; } @@ -2552,6 +2560,80 @@ command = "vim" expect(transcript).not.toContain('Completed'); }); + 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.sessionEventHandler as unknown as { + agentSwarmProgress: Map; + } + ).agentSwarmProgress.get('call_swarm'); + 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(); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 0b5894bf4..dfc6b6350 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; @@ -321,6 +325,42 @@ 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('hydrates todo and background snapshot state from resumed main agent', async () => { const driver = await replayIntoDriver([], { toolStore: { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index d9e70a518..af1170d59 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -348,6 +348,7 @@ export class SessionSubagentHost { ...runOptions, }); } + options.markAgentId(handle.agentId); const completion = await handle.completion; return { task, @@ -364,11 +365,15 @@ export class SessionSubagentHost { throw error; } let message: string; + let status: QueuedSubagentRunResult['status'] = 'failed'; + let state: QueuedSubagentRunResult['state']; if (subagentDeadline?.timedOut() === true && options.timeoutMs !== undefined) { message = `Subagent timed out after ${formatTimeoutMs(options.timeoutMs)}.`; } else if (options.totalTimedOut() && options.totalTimeoutMs !== undefined) { message = totalTimeoutMessage(options.totalTimeoutMs); } else if (isUserCancellation(runSignal.reason)) { + status = 'aborted'; + state = 'started'; message = 'The user manually interrupted this subagent batch.'; } else if (isAbortError(error)) { message = 'The subagent was stopped before it finished.'; @@ -378,7 +383,8 @@ export class SessionSubagentHost { return { task, agentId: handle.agentId, - status: 'failed', + status, + state, error: message, }; } finally { diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index 30ed307af..2dd5d6112 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -2,7 +2,7 @@ import { createControlledPromise, sleep } from '@antfu/utils'; import type { TokenUsage } from '@moonshot-ai/kosong'; import type { PromptOrigin } from '../agent/context'; -import { abortable, createDeadlineAbortSignal } from '../utils/abort'; +import { abortable, createDeadlineAbortSignal, isUserCancellation } from '../utils/abort'; const SUBAGENT_LAUNCH_BATCH_SIZE = 5; const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; @@ -32,7 +32,8 @@ export type QueuedSubagentRunOptions = { export type QueuedSubagentRunResult = { readonly task: QueuedSubagentTask; readonly agentId?: string; - readonly status: 'completed' | 'failed'; + readonly status: 'completed' | 'failed' | 'aborted'; + readonly state?: 'started' | 'not_started'; readonly result?: string; readonly usage?: TokenUsage; readonly error?: string; @@ -65,6 +66,7 @@ type QueuedSubagentAttempt = { readonly pending: QueuedSubagentPending; readonly outcome: Promise>; readonly readiness: Promise; + readonly agentId?: string; readonly ready: boolean; readonly launchSucceeded: boolean; settled: boolean; @@ -72,6 +74,7 @@ type QueuedSubagentAttempt = { export type QueuedSubagentAttemptOptions = QueuedSubagentRunOptions & { readonly totalTimedOut: () => boolean; + readonly markAgentId: (agentId: string) => void; readonly markReady: () => void; readonly retryAgentId?: string; }; @@ -123,6 +126,32 @@ export class SubagentLaunchQueue { (result, index) => result ?? { task: tasks[index]!, status: 'failed', error: fallback }, ); + const finishInterrupted = (): Array> => { + const activeAgentIds = new Map(); + for (const attempt of active) { + activeAgentIds.set(attempt.pending.index, attempt.agentId ?? attempt.pending.agentId); + } + const queuedAgentIds = new Map(); + for (const pending of queued) { + if (pending.agentId !== undefined) queuedAgentIds.set(pending.index, pending.agentId); + } + + return results.map((result, index) => { + if (result !== undefined) return result; + const task = tasks[index]!; + const wasStarted = activeAgentIds.has(index) || queuedAgentIds.has(index); + return { + task, + agentId: activeAgentIds.get(index) ?? queuedAgentIds.get(index) ?? task.resumeAgentId, + status: 'aborted', + state: wasStarted ? 'started' : 'not_started', + error: wasStarted + ? 'The user manually interrupted this subagent batch before this subagent finished.' + : 'The user manually interrupted this subagent batch before this subagent was started.', + }; + }); + }; + const requeueRateLimited = (pending: QueuedSubagentPending): void => { if (results[pending.index] !== undefined) return; queued.unshift(pending); @@ -150,6 +179,7 @@ export class SubagentLaunchQueue { const launch = (pending: QueuedSubagentPending): QueuedSubagentAttempt => { const readiness = createControlledPromise(); + let agentId = pending.agentId; let ready = false; let launchSucceeded = false; const markReadyOnly = (): void => { @@ -172,6 +202,9 @@ export class SubagentLaunchQueue { const outcome = this.runAttempt(tasks[pending.index]!, { ...options, totalTimedOut, + markAgentId: (id) => { + agentId = id; + }, markReady, retryAgentId: pending.agentId, }); @@ -179,6 +212,9 @@ export class SubagentLaunchQueue { pending, outcome, readiness, + get agentId() { + return agentId; + }, get ready() { return ready; }, @@ -352,8 +388,18 @@ export class SubagentLaunchQueue { return finish('Subagent stopped before it could finish.'); } catch (error) { - if (!totalTimedOut()) throw error; - return finish(totalTimeoutMessage(options.totalTimeoutMs)); + if (totalTimedOut()) return finish(totalTimeoutMessage(options.totalTimeoutMs)); + if (isUserCancellation(options.signal.reason)) { + try { + await processSettledAttempts(); + } catch { + // A child may observe the same user abort before it returns a handle. + // Keep the parent tool result structured so the next turn has a + // balanced, inspectable swarm summary instead of a bare abort error. + } + return finishInterrupted(); + } + throw error; } finally { totalDeadline?.clear(); } diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 3489e48ee..81f47378d 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -115,7 +115,8 @@ type AgentSwarmSpec = AgentSwarmSpawnSpec | AgentSwarmResumeSpec; interface SwarmRunResult { readonly spec: AgentSwarmSpec; readonly agentId?: string; - readonly status: 'completed' | 'failed'; + readonly status: 'completed' | 'failed' | 'aborted'; + readonly state?: 'started' | 'not_started'; readonly result?: string; readonly error?: string; } @@ -270,18 +271,20 @@ function childDescription(swarmDescription: string, index: number, profileName: function renderSwarmResults(results: readonly SwarmRunResult[]): string { const completed = results.filter((result) => result.status === 'completed').length; - const failed = results.length - completed; + const failed = results.filter((result) => result.status === 'failed').length; + const aborted = results.filter((result) => result.status === 'aborted').length; const lines = [ '', - `${renderSwarmSummary(completed, failed)}`, + `${renderSwarmSummary(completed, failed, aborted)}`, ]; for (const result of results) { const agentId = result.agentId === undefined ? '' : ` agent_id="${result.agentId}"`; const mode = result.spec.kind === 'resume' ? ' mode="resume"' : ''; + const state = result.state === undefined ? '' : ` state="${result.state}"`; const body = result.status === 'completed' ? (result.result ?? '') : (result.error ?? 'unknown error'); lines.push( - `${body}`, + `${body}`, ); } @@ -295,10 +298,11 @@ function normalizeOptionalString(value: string | undefined): string | undefined return trimmed.length > 0 ? trimmed : undefined; } -function renderSwarmSummary(completed: number, failed: number): string { +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(', '); } @@ -309,6 +313,7 @@ function toSwarmRunResult( spec: result.task.data, agentId: result.agentId, status: result.status, + state: result.state, result: result.result, error: result.error, }; diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 24359a132..18fd0ec7f 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -133,6 +133,94 @@ describe('SessionSubagentHost', () => { } }); + it('runQueued returns completed, started, and not-started results on user interrupt', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const { queue, attempts } = createRecordedLaunchQueue(); + const running = queue.run(Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)), { + signal: controller.signal, + }); + + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toHaveLength(5); + attempts.forEach((attempt, index) => { + attempt.markAgentId(`agent-${String(index + 1)}`); + }); + + attempts[0]!.outcome.resolve({ + task: attempts[0]!.task, + agentId: 'agent-1', + status: 'completed', + result: 'completed 1', + }); + await vi.advanceTimersByTimeAsync(0); + + controller.abort(userCancellationReason()); + const results = await running; + + expect(results.map((result) => ({ + data: result.task.data, + agentId: result.agentId, + status: result.status, + state: result.state, + result: result.result, + error: result.error, + }))).toEqual([ + { + data: 1, + agentId: 'agent-1', + status: 'completed', + state: undefined, + result: 'completed 1', + error: undefined, + }, + { + data: 2, + agentId: 'agent-2', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 3, + agentId: 'agent-3', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 4, + agentId: 'agent-4', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 5, + agentId: 'agent-5', + status: 'aborted', + state: 'started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + data: 6, + agentId: undefined, + status: 'aborted', + state: 'not_started', + result: undefined, + error: 'The user manually interrupted this subagent batch before this subagent was started.', + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + it('runQueued keeps processing completions while waiting for the next initial launch', async () => { vi.useFakeTimers(); try { @@ -378,6 +466,7 @@ describe('SessionSubagentHost', () => { attempts.push({ task: task as unknown as QueuedSubagentTask, retryAgentId: options.retryAgentId, + markAgentId: options.markAgentId, markReady: options.markReady, outcome: outcome as unknown as QueuedAttemptRecord['outcome'], }); @@ -1987,6 +2076,7 @@ async function flushPromises(count = 2): Promise { type QueuedAttemptRecord = { readonly task: QueuedSubagentTask; readonly retryAgentId?: string; + readonly markAgentId: (agentId: string) => void; readonly markReady: () => void; readonly outcome: ReturnType>>; }; @@ -2006,6 +2096,7 @@ function createRecordedLaunchQueue( attempts.push({ task: task as unknown as QueuedSubagentTask, retryAgentId: options.retryAgentId, + markAgentId: options.markAgentId, markReady: options.markReady, outcome: outcome as unknown as QueuedAttemptRecord['outcome'], }); diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index b449ae657..6985d7cc1 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -601,6 +601,77 @@ describe('current builtin collaboration tools', () => { expect(result.isError).toBeUndefined(); }); + it('AgentSwarm reports partial aborted subagents inside the XML result', async () => { + const host = mockSubagentHost({ + runQueued: vi.fn().mockResolvedValue([ + { + task: { + data: { kind: 'spawn', index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (coder)', + runInBackground: false, + }, + agentId: 'agent-coder-1', + status: 'completed', + result: 'imports are stable', + }, + { + task: { + data: { kind: 'spawn', index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (coder)', + runInBackground: false, + }, + agentId: 'agent-coder-2', + status: 'aborted', + state: 'started', + error: 'The user manually interrupted this subagent batch before this subagent finished.', + }, + { + task: { + data: { kind: 'spawn', index: 3, item: 'src/c.ts', prompt: 'Review src/c.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/c.ts', + description: 'Review files #3 (coder)', + runInBackground: false, + }, + status: 'aborted', + state: 'not_started', + error: 'The user manually interrupted this subagent batch before this subagent was started.', + }, + ]), + }); + const swarmMode = mockSwarmMode(); + const tool = new AgentSwarmTool(host, swarmMode); + + const result = await executeTool( + tool, + context( + { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + }, + 'call_swarm', + ), + ); + + expect(result.output).toBe([ + '', + 'completed: 1, aborted: 2', + 'imports are stable', + 'The user manually interrupted this subagent batch before this subagent finished.', + 'The user manually interrupted this subagent batch before this subagent was started.', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + it('Skill exposes parameters and reports unknown skills as tool errors', async () => { const tool = new SkillTool({ skills: { From 7122b52a1d84535989ad24dc4757dfa9bb37935b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 19:35:03 +0800 Subject: [PATCH 32/72] fix --- .../messages/agent-swarm-progress.ts | 66 +++++++++++++++++-- .../src/tui/utils/export-markdown.ts | 13 +--- .../messages/agent-swarm-progress.test.ts | 39 +++++++++++ .../builtin/collaboration/agent-swarm.md | 2 +- .../builtin/collaboration/agent-swarm.ts | 9 +++ .../test/tools/builtin-current.test.ts | 56 ++++++++++++++++ 6 files changed, 165 insertions(+), 20 deletions(-) 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 index 00ee0995e..bbaf3cd71 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -38,6 +38,7 @@ const FAILED_LABEL = 'Failed.'; const ABORTED_LABEL = 'Aborted.'; const QUEUED_LABEL = 'Queued...'; const SUSPENDED_LABEL = 'Suspended...'; +const RESUMED_ITEM_LABEL = '(resumed)'; const STATUS_BAR_ORDER = [ 'completed', @@ -185,17 +186,24 @@ export class AgentSwarmProgressComponent implements Component { if (description.length > 0 || this.description.length === 0) { this.description = description; } + const fullResumeItems = agentSwarmResumeItemsFromArgs(args); + const partialResumeItems = + options.streamingArguments === undefined + ? [] + : agentSwarmPartialResumeItemsFromArguments(options.streamingArguments); const fullItems = agentSwarmItemsFromArgs(args); const partialItems = options.streamingArguments === undefined ? [] : agentSwarmPartialItemsFromArguments(options.streamingArguments); + const fullRows = [...fullResumeItems, ...fullItems]; + const partialRows = [...partialResumeItems, ...partialItems]; if ( - fullItems.length > 0 || - partialItems.length > 0 || + fullRows.length > 0 || + partialRows.length > 0 || ( options.streamingArguments !== undefined && - agentSwarmItemsStartedFromArguments(options.streamingArguments) + agentSwarmWorkItemsStartedFromArguments(options.streamingArguments) ) ) { this.itemsStarted = true; @@ -211,9 +219,9 @@ export class AgentSwarmProgressComponent implements Component { this.promptTemplateText = promptTemplate; } - const itemCount = Math.max(fullItems.length, partialItems.length); + const itemCount = Math.max(fullRows.length, partialRows.length); if (itemCount > 0) this.ensureMemberCount(itemCount); - this.updateItemTexts(fullItems, partialItems); + this.updateItemTexts(fullRows, partialRows); } markInputComplete(): void { @@ -782,12 +790,24 @@ export function agentSwarmItemsFromArgs(args: Record): string[] 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 agentSwarmItemsStartedFromArguments(argumentsText: string): boolean { - return /"items"\s*:/.test(argumentsText); +function agentSwarmWorkItemsStartedFromArguments(argumentsText: string): boolean { + return /"items"\s*:/.test(argumentsText) || /"resume_agent_ids"\s*:/.test(argumentsText); } export function agentSwarmPartialItemsFromArguments(argumentsText: string): string[] { @@ -810,6 +830,15 @@ export function agentSwarmPartialItemsFromArguments(argumentsText: string): stri 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 : ''; @@ -1522,6 +1551,29 @@ function latestNonEmptyLine(text: string): string { 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, diff --git a/apps/kimi-code/src/tui/utils/export-markdown.ts b/apps/kimi-code/src/tui/utils/export-markdown.ts index 9531efa10..6157bdf62 100644 --- a/apps/kimi-code/src/tui/utils/export-markdown.ts +++ b/apps/kimi-code/src/tui/utils/export-markdown.ts @@ -92,18 +92,7 @@ function formatToolResultMd(msg: ContextMessage, toolName: string, hint: string) ); } -const INTERNAL_ORIGINS = new Set([ - 'injection', - 'system_trigger', - 'compaction_summary', - 'hook_result', - // Cron fires are stored as user-role records carrying a `` - // XML envelope meant only for the model. Replay and the TUI projector - // already hide them; the markdown exporter must do the same or the raw - // protocol XML leaks into the user-facing export. - 'cron_job', - 'cron_missed', -]); +const INTERNAL_ORIGINS = new Set([]); export function isInternalMessage(msg: ContextMessage): boolean { const origin = msg.origin; 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 index 984739318..1578298f3 100644 --- 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 @@ -906,6 +906,28 @@ describe('AgentSwarmProgressComponent', () => { 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'), @@ -933,6 +955,23 @@ describe('AgentSwarmProgressComponent', () => { 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', diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md index 856e1fffa..38efbc69e 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.md @@ -2,6 +2,6 @@ Launch multiple subagents from one prompt template, existing agent resumes, or b 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. Do not duplicate resumed work in `items`. +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 index 81f47378d..56a172015 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -273,11 +273,20 @@ 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"' : ''; diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 6985d7cc1..54e0e5b85 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -593,6 +593,7 @@ describe('current builtin collaboration tools', () => { expect(result.output).toBe([ '', 'completed: 1, failed: 1', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', 'imports are stable', 'Agent timed out after 30s.', '', @@ -601,6 +602,60 @@ describe('current builtin collaboration tools', () => { expect(result.isError).toBeUndefined(); }); + it('AgentSwarm omits resume hint when incomplete subagents have no agent ids', async () => { + const host = mockSubagentHost({ + runQueued: vi.fn().mockResolvedValue([ + { + task: { + data: { kind: 'spawn', index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/a.ts', + description: 'Review files #1 (coder)', + runInBackground: false, + }, + status: 'failed', + error: 'Agent did not start.', + }, + { + task: { + data: { kind: 'spawn', index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' }, + profileName: 'coder', + parentToolCallId: 'call_swarm', + prompt: 'Review src/b.ts', + description: 'Review files #2 (coder)', + runInBackground: false, + }, + status: 'failed', + error: 'Agent also did not start.', + }, + ]), + }); + const swarmMode = mockSwarmMode(); + const tool = new AgentSwarmTool(host, swarmMode); + + const result = await executeTool( + tool, + context( + { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + 'call_swarm', + ), + ); + + expect(result.output).toBe([ + '', + 'failed: 2', + 'Agent did not start.', + 'Agent also did not start.', + '', + ].join('\n')); + expect(result.isError).toBeUndefined(); + }); + it('AgentSwarm reports partial aborted subagents inside the XML result', async () => { const host = mockSubagentHost({ runQueued: vi.fn().mockResolvedValue([ @@ -664,6 +719,7 @@ describe('current builtin collaboration tools', () => { expect(result.output).toBe([ '', 'completed: 1, aborted: 2', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', 'imports are stable', 'The user manually interrupted this subagent batch before this subagent finished.', 'The user manually interrupted this subagent batch before this subagent was started.', From e175ddf3942b8896c80c0cdaafe6648b4073ca4d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 19:45:54 +0800 Subject: [PATCH 33/72] swarmItem --- packages/agent-core/src/session/index.ts | 3 ++ .../agent-core/src/session/subagent-host.ts | 12 ++++- .../src/session/subagent-launch-queue.ts | 1 + .../builtin/collaboration/agent-swarm.ts | 22 ++++++++- .../test/session/subagent-host.test.ts | 42 +++++++++++++++++ .../test/tools/builtin-current.test.ts | 45 +++++++++++++------ 6 files changed, 108 insertions(+), 17 deletions(-) diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index ef3e83fc9..218a885c9 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(); } diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index af1170d59..b1435b1b1 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -75,6 +75,7 @@ type RunSubagentOptions = { readonly parentToolCallUuid?: string; readonly prompt: string; readonly description: string; + readonly swarmItem?: string; readonly runInBackground: boolean; readonly origin?: PromptOrigin; readonly signal: AbortSignal; @@ -129,7 +130,7 @@ export class SessionSubagentHost { 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); @@ -314,6 +315,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 async runQueuedTaskAttempt( task: QueuedSubagentTask, options: QueuedSubagentAttemptOptions, @@ -331,6 +340,7 @@ export class SessionSubagentHost { parentToolCallUuid: task.parentToolCallUuid, prompt: task.prompt, description: task.description, + swarmItem: task.swarmItem, runInBackground: task.runInBackground, origin: task.origin, signal: runSignal, diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index 2dd5d6112..f41b2889f 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -18,6 +18,7 @@ export type QueuedSubagentTask = { readonly parentToolCallUuid?: string; readonly prompt: string; readonly description: string; + readonly swarmItem?: string; readonly runInBackground: boolean; readonly origin?: PromptOrigin; readonly resumeAgentId?: string; diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 56a172015..885d69963 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -107,6 +107,7 @@ interface AgentSwarmResumeSpec { readonly kind: 'resume'; readonly index: number; readonly agentId: string; + readonly item?: string; readonly prompt: string; } @@ -172,7 +173,14 @@ export class AgentSwarmTool implements BuiltinTool { toolCallId: string, ): Promise { const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; - const tasks = specs.map((spec): QueuedSubagentTask => { + const specsWithPersistedItems = specs.map((spec): AgentSwarmSpec => { + if (spec.kind === 'spawn') return spec; + return { + ...spec, + item: this.subagentHost.getSwarmItem(spec.agentId), + }; + }); + const tasks = specsWithPersistedItems.map((spec): QueuedSubagentTask => { const resumeAgentId = spec.kind === 'resume' ? spec.agentId : undefined; return { data: spec, @@ -184,6 +192,7 @@ export class AgentSwarmTool implements BuiltinTool { spec.index, spec.kind === 'resume' ? 'resume' : profileName, ), + swarmItem: spec.item, runInBackground: false, resumeAgentId, }; @@ -290,10 +299,11 @@ function renderSwarmResults(results: readonly SwarmRunResult[]): string { 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}`, + `${body}`, ); } @@ -315,6 +325,14 @@ function renderSwarmSummary(completed: number, failed: number, aborted = 0): str return parts.join(', '); } +function escapeXmlAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + function toSwarmRunResult( result: QueuedSubagentRunResult, ): SwarmRunResult { diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 18fd0ec7f..9ec9d032f 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1560,6 +1560,47 @@ describe('SessionSubagentHost', () => { ]); }); + it('runQueued persists swarm item metadata for spawned tasks', async () => { + const parent = testAgent(); + parent.configure(); + + const child = testAgent({ type: 'sub' }); + child.configure(); + const summary = + 'Completed the queued swarm item and returned a detailed technical handoff so the parent can map the result back to the original swarm input. '.repeat( + 2, + ); + child.mockNextResponse({ type: 'text', text: summary }); + + const metadataAgents: Session['metadata']['agents'] = {}; + const session = fakeSession(parent.agent, child.agent, metadataAgents); + const host = new SessionSubagentHost(session, 'main'); + + await expect( + host.runQueued([{ ...queuedTask(1), swarmItem: 'src/a.ts' }], { signal }), + ).resolves.toMatchObject([ + { + agentId: 'agent-0', + status: 'completed', + result: summary.trim(), + }, + ]); + + expect(session.createAgent).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + parentAgentId: 'main', + swarmItem: 'src/a.ts', + }), + ); + expect(metadataAgents['agent-0']).toMatchObject({ + type: 'sub', + parentAgentId: 'main', + swarmItem: 'src/a.ts', + }); + expect(host.getSwarmItem('agent-0')).toBe('src/a.ts'); + }); + it('retries a rate-limited child turn without appending the original prompt again', async () => { const parent = testAgent(); parent.configure(); @@ -1997,6 +2038,7 @@ function fakeSession( homedir: '/tmp/kimi-session/agents/agent-0', type: config.type ?? 'main', parentAgentId, + swarmItem: options.swarmItem, }; } if (options.profile !== undefined) { diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 54e0e5b85..94c6a6af1 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -69,8 +69,13 @@ function context(args: Input, toolCallId = 'call_1') { function mockSubagentHost>( host: T, ): T & SessionSubagentHost { - return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T & - SessionSubagentHost; + return { + spawn: vi.fn(), + resume: vi.fn(), + runQueued: vi.fn(), + getSwarmItem: vi.fn(), + ...host, + } as unknown as T & SessionSubagentHost; } function mockSwarmMode(): SwarmMode { @@ -383,6 +388,7 @@ describe('current builtin collaboration tools', () => { parentToolCallId: 'call_swarm', prompt: 'Review src/a.ts', description: 'Review files #1 (explore)', + swarmItem: 'src/a.ts', runInBackground: false, resumeAgentId: undefined, }, @@ -392,6 +398,7 @@ describe('current builtin collaboration tools', () => { parentToolCallId: 'call_swarm', prompt: 'Review src/b.ts', description: 'Review files #2 (explore)', + swarmItem: 'src/b.ts', runInBackground: false, resumeAgentId: undefined, }, @@ -405,8 +412,8 @@ describe('current builtin collaboration tools', () => { expect(result.output).toBe([ '', 'completed: 2', - 'explore result a', - 'explore result b', + 'explore result a', + 'explore result b', '', ].join('\n')); expect(result.isError).toBeUndefined(); @@ -444,7 +451,12 @@ describe('current builtin collaboration tools', () => { })); }, ); + const persistedItems: Record = { + 'agent-old-1': 'src/old-a.ts', + 'agent-old-2': 'src/old-b.ts', + }; const host = mockSubagentHost({ + getSwarmItem: vi.fn((agentId: string) => persistedItems[agentId]), runQueued: runQueued as unknown as SessionSubagentHost['runQueued'], }); const swarmMode = mockSwarmMode(); @@ -489,12 +501,14 @@ describe('current builtin collaboration tools', () => { kind: 'resume', index: 1, agentId: 'agent-old-1', + item: 'src/old-a.ts', prompt: 'Continue previous review A', }, profileName: 'subagent', parentToolCallId: 'call_swarm', prompt: 'Continue previous review A', description: 'Finish review #1 (resume)', + swarmItem: 'src/old-a.ts', runInBackground: false, resumeAgentId: 'agent-old-1', }, @@ -503,12 +517,14 @@ describe('current builtin collaboration tools', () => { kind: 'resume', index: 2, agentId: 'agent-old-2', + item: 'src/old-b.ts', prompt: 'Continue previous review B', }, profileName: 'subagent', parentToolCallId: 'call_swarm', prompt: 'Continue previous review B', description: 'Finish review #2 (resume)', + swarmItem: 'src/old-b.ts', runInBackground: false, resumeAgentId: 'agent-old-2', }, @@ -523,6 +539,7 @@ describe('current builtin collaboration tools', () => { parentToolCallId: 'call_swarm', prompt: 'Review src/new.ts', description: 'Finish review #3 (explore)', + swarmItem: 'src/new.ts', runInBackground: false, resumeAgentId: undefined, }, @@ -536,9 +553,9 @@ describe('current builtin collaboration tools', () => { expect(result.output).toBe([ '', 'completed: 3', - 'result 1', - 'result 2', - 'result 3', + 'result 1', + 'result 2', + 'result 3', '', ].join('\n')); expect(result.isError).toBeUndefined(); @@ -594,8 +611,8 @@ describe('current builtin collaboration tools', () => { '', 'completed: 1, failed: 1', 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', - 'imports are stable', - 'Agent timed out after 30s.', + 'imports are stable', + 'Agent timed out after 30s.', '', ].join('\n')); expect(swarmMode.enter).toHaveBeenCalledWith('implicit'); @@ -649,8 +666,8 @@ describe('current builtin collaboration tools', () => { expect(result.output).toBe([ '', 'failed: 2', - 'Agent did not start.', - 'Agent also did not start.', + 'Agent did not start.', + 'Agent also did not start.', '', ].join('\n')); expect(result.isError).toBeUndefined(); @@ -720,9 +737,9 @@ describe('current builtin collaboration tools', () => { '', 'completed: 1, aborted: 2', 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', - 'imports are stable', - 'The user manually interrupted this subagent batch before this subagent finished.', - 'The user manually interrupted this subagent batch before this subagent was started.', + 'imports are stable', + 'The user manually interrupted this subagent batch before this subagent finished.', + 'The user manually interrupted this subagent batch before this subagent was started.', '', ].join('\n')); expect(result.isError).toBeUndefined(); From 71d85df25788a8da311c0d747c8d04f6c189baa4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 20:10:52 +0800 Subject: [PATCH 34/72] fix --- .../messages/agent-swarm-progress.ts | 2 +- .../messages/agent-swarm-progress.test.ts | 8 +++---- .../test/tui/kimi-tui-message-flow.test.ts | 10 ++++---- .../builtin/collaboration/agent-swarm.ts | 2 +- .../test/tools/builtin-current.test.ts | 24 +++++++++---------- 5 files changed, 23 insertions(+), 23 deletions(-) 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 index bbaf3cd71..c41db593d 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -509,7 +509,7 @@ export class AgentSwarmProgressComponent implements Component { private renderHeader(width: number, _summary: AgentSwarmSummary | undefined): string { if (width <= 3) return chalk.hex(this.colors.primary)('─'.repeat(width)); - const title = chalk.hex(this.colors.primary).bold('Agent swarm'); + const title = chalk.hex(this.colors.primary).bold('Agent Swarm'); const description = this.description.length > 0 ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) 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 index 1578298f3..584aa8840 100644 --- 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 @@ -166,7 +166,7 @@ describe('AgentSwarmProgressComponent', () => { const output = strip(component.render(100).join('\n')); - expect(output).toContain('Agent swarm'); + expect(output).toContain('Agent Swarm'); expect(output).toContain('Review changed files'); expect(output).toContain('Orchestrating...'); expect(output).not.toContain('01'); @@ -200,7 +200,7 @@ describe('AgentSwarmProgressComponent', () => { 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(rendered.some((line) => line.includes('Agent Swarm'))).toBe(true); expect(statusLine).toBeDefined(); expect(statusLine?.match(/ *$/)?.[0].length).toBe(0); expect(gridLine).toBeDefined(); @@ -285,7 +285,7 @@ describe('AgentSwarmProgressComponent', () => { const lines = strip(component.render(100).join('\n')).split('\n'); expect(lines[0]).toBe(' '); - expect(lines[1]).toContain('Agent swarm'); + expect(lines[1]).toContain('Agent Swarm'); }); it('fits three queued columns with the narrower gap and minimum cell width', () => { @@ -900,7 +900,7 @@ describe('AgentSwarmProgressComponent', () => { }); const output = strip(component.render(100).join('\n')); - expect(output).toContain('Agent swarm'); + 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'); 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 6655cbe9c..8fb40e572 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 @@ -936,7 +936,7 @@ command = "vim" let transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('launch swarm'); - expect(transcript).toContain('Agent swarm'); + expect(transcript).toContain('Agent Swarm'); expect(transcript).toContain('Review changed files'); driver.state.appState.streamingPhase = 'idle'; @@ -948,7 +948,7 @@ command = "vim" transcript = stripSgr(renderTranscript(driver)); expect(transcript).not.toContain('launch swarm'); - expect(transcript).not.toContain('Agent swarm'); + expect(transcript).not.toContain('Agent Swarm'); expect(transcript).not.toContain('Review changed files'); }); @@ -2535,7 +2535,7 @@ command = "vim" expect(driver.state.ui.requestRender).toHaveBeenCalled(); transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Agent swarm'); + expect(transcript).toContain('Agent Swarm'); expect(transcript).toContain('Review changed files'); expect(transcript).toContain('001 ['); expect(transcript).toContain('Reviewing src/a.ts'); @@ -2699,7 +2699,7 @@ command = "vim" ); let transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Agent swarm'); + expect(transcript).toContain('Agent Swarm'); expect(transcript).toContain('Orchestrating...'); expect(transcript).not.toContain('01'); @@ -2716,7 +2716,7 @@ command = "vim" ); transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Agent swarm'); + expect(transcript).toContain('Agent Swarm'); expect(transcript).toContain('Review changed files'); expect(transcript).toContain('001 src/a.ts'); expect(transcript).toContain('002 src/b'); diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 885d69963..a516923be 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -303,7 +303,7 @@ function renderSwarmResults(results: readonly SwarmRunResult[]): string { const state = result.state === undefined ? '' : ` state="${result.state}"`; const body = result.status === 'completed' ? (result.result ?? '') : (result.error ?? 'unknown error'); lines.push( - `${body}`, + `${body}`, ); } diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 94c6a6af1..90dae6130 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -412,8 +412,8 @@ describe('current builtin collaboration tools', () => { expect(result.output).toBe([ '', 'completed: 2', - 'explore result a', - 'explore result b', + 'explore result a', + 'explore result b', '', ].join('\n')); expect(result.isError).toBeUndefined(); @@ -553,9 +553,9 @@ describe('current builtin collaboration tools', () => { expect(result.output).toBe([ '', 'completed: 3', - 'result 1', - 'result 2', - 'result 3', + 'result 1', + 'result 2', + 'result 3', '', ].join('\n')); expect(result.isError).toBeUndefined(); @@ -611,8 +611,8 @@ describe('current builtin collaboration tools', () => { '', 'completed: 1, failed: 1', 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', - 'imports are stable', - 'Agent timed out after 30s.', + 'imports are stable', + 'Agent timed out after 30s.', '', ].join('\n')); expect(swarmMode.enter).toHaveBeenCalledWith('implicit'); @@ -666,8 +666,8 @@ describe('current builtin collaboration tools', () => { expect(result.output).toBe([ '', 'failed: 2', - 'Agent did not start.', - 'Agent also did not start.', + 'Agent did not start.', + 'Agent also did not start.', '', ].join('\n')); expect(result.isError).toBeUndefined(); @@ -737,9 +737,9 @@ describe('current builtin collaboration tools', () => { '', 'completed: 1, aborted: 2', 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', - 'imports are stable', - 'The user manually interrupted this subagent batch before this subagent finished.', - 'The user manually interrupted this subagent batch before this subagent was started.', + 'imports are stable', + 'The user manually interrupted this subagent batch before this subagent finished.', + 'The user manually interrupted this subagent batch before this subagent was started.', '', ].join('\n')); expect(result.isError).toBeUndefined(); From 43c708f5c6f4808e8e20aa41458a2fa5b7726122 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 20:18:11 +0800 Subject: [PATCH 35/72] fix: restore export markdown internal filtering --- apps/kimi-code/src/tui/utils/export-markdown.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/utils/export-markdown.ts b/apps/kimi-code/src/tui/utils/export-markdown.ts index 6157bdf62..9531efa10 100644 --- a/apps/kimi-code/src/tui/utils/export-markdown.ts +++ b/apps/kimi-code/src/tui/utils/export-markdown.ts @@ -92,7 +92,18 @@ function formatToolResultMd(msg: ContextMessage, toolName: string, hint: string) ); } -const INTERNAL_ORIGINS = new Set([]); +const INTERNAL_ORIGINS = new Set([ + 'injection', + 'system_trigger', + 'compaction_summary', + 'hook_result', + // Cron fires are stored as user-role records carrying a `` + // XML envelope meant only for the model. Replay and the TUI projector + // already hide them; the markdown exporter must do the same or the raw + // protocol XML leaks into the user-facing export. + 'cron_job', + 'cron_missed', +]); export function isInternalMessage(msg: ContextMessage): boolean { const origin = msg.origin; From fe4aa167ddacb66a4411fc7f08903ee476691af4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 20:55:54 +0800 Subject: [PATCH 36/72] upd --- .../agent-swarm-progress-estimator.ts | 13 +- .../messages/agent-swarm-progress.ts | 548 +++++++----------- .../src/tui/utils/refresh-providers.ts | 245 +++----- .../messages/agent-swarm-progress.test.ts | 4 +- .../agent-core/src/session/subagent-host.ts | 181 ++---- .../src/session/subagent-launch-queue.ts | 54 +- .../builtin/collaboration/agent-swarm.ts | 56 +- 7 files changed, 400 insertions(+), 701 deletions(-) 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 index 95020789c..64343f9d0 100644 --- 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 @@ -238,12 +238,9 @@ export class AgentSwarmProgressEstimator { } hasPendingCatchup(): boolean { - for (const state of this.members.values()) { - if (state.lastTargetTicks !== undefined && state.lastTargetTicks > state.displayTicks + 0.1) { - return true; - } - } - return false; + return Array.from(this.members.values()).some( + (state) => state.lastTargetTicks !== undefined && state.lastTargetTicks > state.displayTicks + 0.1, + ); } private markTerminal( @@ -282,9 +279,7 @@ export class AgentSwarmProgressEstimator { } private getOrCreateMember(memberKey: string): MemberProgressState { - const existing = this.members.get(memberKey); - if (existing !== undefined) return existing; - const state: MemberProgressState = { + const state = this.members.get(memberKey) ?? { pausedDurationMs: 0, rawTicks: 0, seenToolCallIds: new Set(), 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 index c41db593d..9708e3de4 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -52,6 +52,12 @@ const STATUS_BAR_ORDER = [ type AgentSwarmPhase = AgentSwarmProgressEstimatorPhase; type StatusBarPhase = typeof STATUS_BAR_ORDER[number]; type TotalStatus = 'working' | 'completed' | 'suspended' | 'failed' | 'aborted'; +type ClearableMemberKey = + | 'completedAtMs' + | 'completedText' + | 'failedAtMs' + | 'failureText' + | 'suspendedReason'; interface AgentSwarmMember { readonly id: string; @@ -251,10 +257,7 @@ export class AgentSwarmProgressComponent implements Component { const nowMs = Date.now(); this.progressEstimator.markStarted(member.id, nowMs); member.ticks = Math.max(member.ticks, 1); - if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { - member.phase = 'running'; - } - delete member.suspendedReason; + this.promoteToRunning(member, nowMs); this.startAnimationIfNeeded(); } @@ -271,10 +274,7 @@ export class AgentSwarmProgressComponent implements Component { }); if (!result.accepted) return; member.ticks = result.rawTicks; - if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { - member.phase = 'running'; - } - delete member.suspendedReason; + this.promoteToRunning(member); this.startAnimationIfNeeded(); } @@ -287,19 +287,7 @@ export class AgentSwarmProgressComponent implements Component { member.latestModelText = `${member.latestModelText}${input.delta}`.slice( -MAX_LATEST_MODEL_CHARS, ); - if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { - this.progressEstimator.markStarted(member.id, Date.now()); - member.ticks = Math.max(member.ticks, 1); - member.phase = 'running'; - } - delete member.suspendedReason; - } - - appendAssistantDelta(input: { - readonly agentId: string; - readonly delta: string; - }): void { - this.appendModelDelta(input); + this.promoteToRunning(member, Date.now(), true); } markCompleted(agentId: string, completedText?: string): void { @@ -312,9 +300,7 @@ export class AgentSwarmProgressComponent implements Component { } const normalizedCompletedText = normalizeFinalOutputText(completedText); if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; - delete member.failedAtMs; - delete member.failureText; - delete member.suspendedReason; + clearMemberState(member, 'failedAtMs', 'failureText', 'suspendedReason'); member.phase = 'completed'; this.startAnimationIfNeeded(); } @@ -330,11 +316,7 @@ export class AgentSwarmProgressComponent implements Component { member.agentId = input.agentId; this.progressEstimator.markQueued(member.id, Date.now()); member.phase = 'queued'; - delete member.suspendedReason; - delete member.completedAtMs; - delete member.completedText; - delete member.failedAtMs; - delete member.failureText; + clearMemberState(member, 'suspendedReason', 'completedAtMs', 'completedText', 'failedAtMs', 'failureText'); this.startAnimationIfNeeded(); } @@ -349,9 +331,7 @@ export class AgentSwarmProgressComponent implements Component { const normalizedFailureText = normalizeFailureText(failureText); if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; member.phase = 'failed'; - delete member.completedAtMs; - delete member.completedText; - delete member.suspendedReason; + clearMemberState(member, 'completedAtMs', 'completedText', 'suspendedReason'); this.startAnimationIfNeeded(); } @@ -361,20 +341,12 @@ export class AgentSwarmProgressComponent implements Component { const normalizedFailureText = normalizeFailureText(failureText); const nowMs = Date.now(); for (const member of this.members) { - if ( - member.phase === 'completed' || - member.phase === 'failed' || - member.phase === 'cancelled' - ) { - continue; - } + if (isTerminalPhase(member.phase)) continue; this.progressEstimator.markFailed(member.id, nowMs); member.failedAtMs = nowMs; if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; member.phase = 'failed'; - delete member.completedAtMs; - delete member.completedText; - delete member.suspendedReason; + clearMemberState(member, 'completedAtMs', 'completedText', 'suspendedReason'); } this.startAnimationIfNeeded(); } @@ -384,31 +356,17 @@ export class AgentSwarmProgressComponent implements Component { if (member === undefined) return; this.progressEstimator.markCancelled(member.id, Date.now()); member.phase = 'cancelled'; - delete member.completedAtMs; - delete member.completedText; - delete member.failedAtMs; - delete member.failureText; - delete member.suspendedReason; + clearMemberState(member, 'completedAtMs', 'completedText', 'failedAtMs', 'failureText', 'suspendedReason'); } markActiveCancelled(): void { this.aborted = true; const nowMs = Date.now(); for (const member of this.members) { - if ( - member.phase === 'completed' || - member.phase === 'failed' || - member.phase === 'cancelled' - ) { - continue; - } + if (isTerminalPhase(member.phase)) continue; this.progressEstimator.markCancelled(member.id, nowMs); member.phase = 'cancelled'; - delete member.completedAtMs; - delete member.completedText; - delete member.failedAtMs; - delete member.failureText; - delete member.suspendedReason; + clearMemberState(member, 'completedAtMs', 'completedText', 'failedAtMs', 'failureText', 'suspendedReason'); } this.startAnimationIfNeeded(); } @@ -422,28 +380,23 @@ export class AgentSwarmProgressComponent implements Component { this.ensureMemberCount(entry.index); const member = this.members[entry.index - 1]; if (member === undefined) continue; - if (entry.status === 'completed' && member.phase !== 'completed') { - this.progressEstimator.markCompleted(member.id, nowMs); - member.completedAtMs = nowMs; - } if (entry.status === 'completed') { + if (member.phase !== 'completed') { + this.progressEstimator.markCompleted(member.id, nowMs); + member.completedAtMs = nowMs; + } const normalizedCompletedText = normalizeFinalOutputText(entry.completedText); if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; - } - if (entry.status === 'completed') delete member.failedAtMs; - if (entry.status === 'completed') delete member.failureText; - if (entry.status === 'completed') delete member.suspendedReason; - if (entry.status === 'failed' && member.phase !== 'failed') { - this.progressEstimator.markFailed(member.id, nowMs); - member.failedAtMs = nowMs; - } - if (entry.status === 'failed') { + clearMemberState(member, 'failedAtMs', 'failureText', 'suspendedReason'); + } else if (entry.status === 'failed') { + if (member.phase !== 'failed') { + this.progressEstimator.markFailed(member.id, nowMs); + member.failedAtMs = nowMs; + } const normalizedFailureText = normalizeFailureText(entry.failureText); if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; + clearMemberState(member, 'completedAtMs', 'completedText', 'suspendedReason'); } - if (entry.status === 'failed') delete member.completedAtMs; - if (entry.status === 'failed') delete member.completedText; - if (entry.status === 'failed') delete member.suspendedReason; member.phase = entry.status; } this.startAnimationIfNeeded(); @@ -552,7 +505,7 @@ export class AgentSwarmProgressComponent implements Component { } private renderProgressStatusLine(width: number, status: TotalStatus): string { - const label = renderTotalStatusLabel( + const label = renderStatusLabel( totalStatusLabel(status), totalStatusLabelColor(status, this.members, this.colors), ); @@ -568,13 +521,13 @@ export class AgentSwarmProgressComponent implements Component { private renderOrchestratingStatusLine(width: number): string { if (this.itemsStarted) { return truncateToWidth( - renderInlineStatusLabel(ORCHESTRATING_LABEL, this.colors.primary), + renderStatusLabel(ORCHESTRATING_LABEL, this.colors.primary), width, ); } const promptTemplate = collapseWhitespace(this.promptTemplateText); - const label = renderInlineStatusLabel( + const label = renderStatusLabel( promptTemplate.length > 0 ? PROMPTING_LABEL : ORCHESTRATING_LABEL, this.colors.primary, ); @@ -763,6 +716,15 @@ export class AgentSwarmProgressComponent implements Component { ) ); } + + 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; + } } function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[] { @@ -775,6 +737,14 @@ function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[ })); } +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 @@ -886,60 +856,51 @@ function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] return parseAgentSwarmLegacyResultStatuses(output); } -function parseAgentSwarmXmlResultStatuses(output: string): AgentSwarmResultStatus[] { - const result: AgentSwarmResultStatus[] = []; +function forEachSubagentTag( + output: string, + callback: (attrs: string, body: string) => T | undefined, +): T[] { + const result: T[] = []; const tagPattern = /]*)>/g; let match: RegExpExecArray | null; while ((match = tagPattern.exec(output)) !== null) { const attrs = match[1] ?? ''; const closeIndex = output.indexOf('', tagPattern.lastIndex); if (closeIndex < 0) break; - - const index = Number(xmlAttribute(attrs, 'index')); - const outcome = xmlAttribute(attrs, 'outcome'); - if (Number.isInteger(index) && index > 0 && (outcome === 'completed' || outcome === 'failed')) { - const body = output.slice(tagPattern.lastIndex, closeIndex); - result.push({ - index, - status: outcome, - completedText: outcome === 'completed' ? body : undefined, - failureText: outcome === 'failed' ? body : undefined, - }); - } - + const body = output.slice(tagPattern.lastIndex, closeIndex); + const value = callback(attrs, body); + if (value !== undefined) result.push(value); tagPattern.lastIndex = closeIndex + ''.length; } return result; } -function countAgentSwarmAbortedResultStatuses(output: string): number { - const xmlAborted = countAgentSwarmXmlAbortedResultStatuses(output); - if (xmlAborted > 0) return xmlAborted; - return countAgentSwarmLegacyAbortedResultStatuses(output); -} - -function countAgentSwarmXmlAbortedResultStatuses(output: string): number { - let count = 0; - const tagPattern = /]*)>/g; - let match: RegExpExecArray | null; - while ((match = tagPattern.exec(output)) !== null) { - const attrs = match[1] ?? ''; - const closeIndex = output.indexOf('', tagPattern.lastIndex); - if (closeIndex < 0) break; - +function parseAgentSwarmXmlResultStatuses(output: string): AgentSwarmResultStatus[] { + return forEachSubagentTag(output, (attrs, body) => { const index = Number(xmlAttribute(attrs, 'index')); const outcome = xmlAttribute(attrs, 'outcome'); - if ( - Number.isInteger(index) && - index > 0 && - (outcome === 'aborted' || outcome === 'cancelled') - ) { - count += 1; + if (!Number.isInteger(index) || index <= 0 || (outcome !== 'completed' && outcome !== 'failed')) { + return undefined; } + return { + index, + status: outcome, + completedText: outcome === 'completed' ? body : undefined, + failureText: outcome === 'failed' ? body : undefined, + }; + }); +} - tagPattern.lastIndex = closeIndex + ''.length; - } - return count; +function countAgentSwarmAbortedResultStatuses(output: string): number { + const xmlAborted = forEachSubagentTag(output, (attrs) => { + const index = Number(xmlAttribute(attrs, 'index')); + const outcome = xmlAttribute(attrs, 'outcome'); + return Number.isInteger(index) && index > 0 && (outcome === 'aborted' || outcome === 'cancelled') + ? true + : undefined; + }).length; + if (xmlAborted > 0) return xmlAborted; + return countAgentSwarmLegacyAbortedResultStatuses(output); } function xmlAttribute(attrs: string, name: string): string | undefined { @@ -947,26 +908,33 @@ function xmlAttribute(attrs: string, name: string): string | undefined { return match?.[1]; } -function parseAgentSwarmLegacyResultStatuses(output: string): AgentSwarmResultStatus[] { - const result: AgentSwarmResultStatus[] = []; - const blocks = output.split(/\n(?=\[agent \d+\]\n)/); - for (const block of blocks) { +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)$/m.exec(block); - if (indexMatch === null || statusMatch === null) continue; - result.push({ - index: Number(indexMatch[1]), + if (statusMatch === null) return undefined; + return { + index, status: statusMatch[1] as 'completed' | 'failed', completedText: parseAgentSwarmCompletedText(block), failureText: parseAgentSwarmFailureText(block), - }); - } - return result; + }; + }); } function countAgentSwarmLegacyAbortedResultStatuses(output: string): number { - return output.split(/\n(?=\[agent \d+\]\n)/).filter((block) => - /^status: (aborted|cancelled)$/m.test(block) + return forEachAgentBlock(output, (block) => + /^status: (aborted|cancelled)$/m.test(block) ? true : undefined, ).length; } @@ -983,6 +951,24 @@ function parseAgentSwarmFailureText(block: string): string | 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 { @@ -1005,42 +991,17 @@ export function calculateAgentSwarmGridLayout( const textGapWidth = visibleWidth(CELL_GAP); const compactGapWidth = textGapWidth; - const textColumns = columnsForCellWidth( - width, - count, - TEXT_CELL_PREFERRED_WIDTH, - 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 { - renderText: true, - barCells: barCellsForTextCellWidth(textCellWidth, idWidth), - columns: textColumns, - rows: textRows, - cellWidth: textCellWidth, - columnGap: textGapWidth, - leftPadding: 0, - }; + 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 { - renderText: true, - barCells: barCellsForTextCellWidth(targetTextCellWidth, idWidth), - columns: targetTextColumns, - rows: targetTextRows, - cellWidth: targetTextCellWidth, - columnGap: textGapWidth, - leftPadding: 0, - }; + if (height > 0 && targetTextRows <= height && targetTextCellWidth >= minTextCellWidth(idWidth)) { + return textGridLayout(targetTextColumns, targetTextRows, targetTextCellWidth, textGapWidth, idWidth); } const compactColumns = compactColumnsForLayout(width, count, height, idWidth, compactGapWidth); @@ -1166,30 +1127,17 @@ function brailleBar( phaseElapsedMs: number, ): string { const innerWidth = Math.max(1, width); - switch (phase) { - case 'pending': - return ''; - case 'queued': - return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.textDim, colors), colors); - case 'suspended': - return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.warning, colors), colors); - case 'running': - return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.success, colors), colors); - case 'completed': - return bracketBar( - accumulatedBrailleBar( - completedDisplayTicks(ticks, innerWidth, phaseElapsedMs), - innerWidth, - colors.success, - colors, - ), - colors, - ); - case 'failed': - return bracketBar(failedBrailleBar(ticks, innerWidth, phaseElapsedMs, colors), colors); - case 'cancelled': - return bracketBar(accumulatedBrailleBar(ticks, innerWidth, colors.warning, colors), colors); - } + if (phase === 'pending') return ''; + if (phase === 'failed') return bracketBar(failedBrailleBar(ticks, innerWidth, phaseElapsedMs, colors), colors); + const displayTicks = phase === 'completed' ? completedDisplayTicks(ticks, innerWidth, phaseElapsedMs) : ticks; + const colorMap: Record, string> = { + queued: colors.textDim, + suspended: colors.warning, + running: colors.success, + completed: colors.success, + cancelled: colors.warning, + }; + return bracketBar(accumulatedBrailleBar(displayTicks, innerWidth, colorMap[phase], colors), colors); } function bracketBar(content: string, colors: ColorPalette): string { @@ -1198,21 +1146,16 @@ function bracketBar(content: string, colors: ColorPalette): string { } function phaseColor(phase: AgentSwarmPhase, colors: ColorPalette): string { - switch (phase) { - case 'pending': - case 'queued': - return colors.textDim; - case 'suspended': - return colors.warning; - case 'running': - return colors.textDim; - case 'completed': - return colors.success; - case 'failed': - return colors.error; - case 'cancelled': - return colors.warning; - } + const map: Record = { + pending: colors.textDim, + queued: colors.textDim, + suspended: colors.warning, + running: colors.textDim, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; } interface StatusBarCount { @@ -1239,27 +1182,22 @@ function renderStatusPipBar( }).join(''); } -function renderTotalStatusLabel(label: string, color: string): string { - return ` ${chalk.hex(color)(label)}`; -} - -function renderInlineStatusLabel(label: string, color: string): string { +function renderStatusLabel(label: string, color: string): string { return ` ${chalk.hex(color)(label)}`; } function activityPrefixForTotalStatus(status: TotalStatus, colors: ColorPalette): string { - const color = totalStatusColor(status, colors); - switch (status) { - case 'completed': - return ` ${chalk.hex(color)(SUCCESS_MARK.trimEnd())}`; - case 'failed': - return ` ${chalk.hex(color)(FAILURE_MARK.trimEnd())}`; - case 'aborted': - return ` ${chalk.hex(color)(CANCELLED_MARK.trimEnd())}`; - case 'working': - case 'suspended': - return ACTIVITY_SPINNER_PLACEHOLDER; - } + 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[] { @@ -1275,38 +1213,28 @@ function statusBarCounts(members: readonly AgentSwarmMember[]): StatusBarCount[] } function statusBarPhase(phase: AgentSwarmPhase): StatusBarPhase { - switch (phase) { - case 'pending': - case 'queued': - return 'queued'; - case 'suspended': - return 'suspended'; - case 'running': - return 'working'; - case 'completed': - return 'completed'; - case 'failed': - return 'failed'; - case 'cancelled': - return 'cancelled'; - } + 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 { - switch (phase) { - case 'queued': - return colors.textMuted; - case 'working': - return colors.primary; - case 'suspended': - return colors.warning; - case 'completed': - return colors.success; - case 'failed': - return colors.error; - case 'cancelled': - return colors.warning; - } + const map: Record = { + queued: colors.textMuted, + working: colors.primary, + suspended: colors.warning, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; } function totalStatus( @@ -1314,58 +1242,38 @@ function totalStatus( force: { readonly failed: boolean; readonly aborted: boolean }, ): TotalStatus { if (force.aborted) return 'aborted'; - if (force.failed && members.length === 0) return 'failed'; - const hasCompleted = members.some((member) => member.phase === 'completed'); - const hasFailed = members.some((member) => member.phase === 'failed'); - const hasCancelled = members.some((member) => member.phase === 'cancelled'); - const hasSuspended = members.some((member) => member.phase === 'suspended'); - const hasRunning = members.some((member) => member.phase === 'running'); - const hasActive = members.some((member) => - ( - member.phase === 'pending' || - member.phase === 'queued' || - member.phase === 'suspended' || - member.phase === 'running' - ) - ); + 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 (hasCancelled) return 'aborted'; - if (hasCompleted) return 'completed'; - if (hasFailed || force.failed) return 'failed'; + if (phases.has('cancelled')) return 'aborted'; + if (phases.has('completed')) return 'completed'; + return 'failed'; } if (force.failed) return 'failed'; - if (hasSuspended && !hasRunning) return 'suspended'; - return hasCancelled && !hasActive ? 'aborted' : 'working'; + if (phases.has('suspended') && !phases.has('running')) return 'suspended'; + return 'working'; } function totalStatusLabel(status: TotalStatus): string { - switch (status) { - case 'working': - return WORKING_LABEL; - case 'completed': - return COMPLETED_LABEL; - case 'suspended': - return SUSPENDED_LABEL; - case 'failed': - return FAILED_LABEL; - case 'aborted': - return ABORTED_LABEL; - } + 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 { - switch (status) { - case 'working': - return colors.success; - case 'completed': - return colors.success; - case 'suspended': - return colors.warning; - case 'failed': - return colors.error; - case 'aborted': - return colors.warning; - } + const map: Record = { + working: colors.success, + completed: colors.success, + suspended: colors.warning, + failed: colors.error, + aborted: colors.warning, + }; + return map[status]; } function totalStatusLabelColor( @@ -1437,19 +1345,16 @@ function renderCompletedCellLabel( } function compactTerminalMark(phase: AgentSwarmPhase, colors: ColorPalette): string { - switch (phase) { - case 'completed': - return chalk.hex(colors.success)(SUCCESS_MARK.trimEnd()); - case 'failed': - return chalk.hex(colors.error)(FAILURE_MARK.trimEnd()); - case 'cancelled': - return chalk.hex(colors.warning)(CANCELLED_MARK.trimEnd()); - case 'pending': - case 'queued': - case 'running': - case 'suspended': - return ''; - } + const map: Record = { + completed: chalk.hex(colors.success)(SUCCESS_MARK.trimEnd()), + failed: chalk.hex(colors.error)(FAILURE_MARK.trimEnd()), + cancelled: chalk.hex(colors.warning)(CANCELLED_MARK.trimEnd()), + pending: '', + queued: '', + running: '', + suspended: '', + }; + return map[phase]; } function renderPendingCell( @@ -1590,31 +1495,15 @@ function parsePartialJsonString( const escaped = text[i + 1]; if (escaped === undefined) return { value, closed: false, nextIndex: i }; switch (escaped) { - case 'n': - value += '\n'; - i += 1; - break; - case 't': - value += '\t'; - i += 1; - break; - case 'r': - value += '\r'; - i += 1; - break; - case 'b': - value += '\b'; - i += 1; - break; - case 'f': - value += '\f'; - i += 1; - break; + 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; - i += 1; break; case 'u': { const hex = text.slice(i + 2, i + 6); @@ -1622,13 +1511,13 @@ function parsePartialJsonString( const code = Number.parseInt(hex, 16); if (Number.isNaN(code)) return { value, closed: false, nextIndex: i }; value += String.fromCodePoint(code); - i += 5; + i += 4; break; } default: value += escaped; - i += 1; } + i += 1; } return { value, closed: false, nextIndex: text.length }; } @@ -1667,15 +1556,14 @@ function failedBrailleBar( function darkenRedHexColor(hex: string): string { const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); if (match === null) return hex; - const [, red = '00', green = '00', blue = '00'] = match; - const darken = (channel: string, factor: number): string => { - const value = Math.max(0, Math.min(255, Math.round(Number.parseInt(channel, 16) * factor))); - return value.toString(16).padStart(2, '0'); - }; - return `#${darken(red, FAILED_PLACEHOLDER_RED_FACTOR)}${darken( - green, + 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]!, FAILED_PLACEHOLDER_RED_FACTOR)}${darken( + match[2]!, FAILED_PLACEHOLDER_NON_RED_FACTOR, - )}${darken(blue, FAILED_PLACEHOLDER_NON_RED_FACTOR)}`; + )}${darken(match[3]!, FAILED_PLACEHOLDER_NON_RED_FACTOR)}`; } function accumulatedBrailleBar( diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index ab186f562..bfa89b510 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -46,8 +46,7 @@ function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySourc if (candidate['kind'] !== 'apiJson') return undefined; const url = candidate['url']; const apiKey = candidate['apiKey']; - if (typeof url !== 'string' || url.length === 0) return undefined; - if (typeof apiKey !== 'string') return undefined; + if (typeof url !== 'string' || url.length === 0 || typeof apiKey !== 'string') return undefined; return { kind: 'apiJson', url, apiKey }; } @@ -66,38 +65,10 @@ function collectModelIdsForAliases(config: KimiConfig, aliasKeys: ReadonlySet { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId) keys.add(alias); - } - return keys; -} - -function generatedProviderAliasKeys( - config: KimiConfig, - providerId: string, - aliasPrefix: string, -): Set { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId && alias.startsWith(aliasPrefix)) { - keys.add(alias); - } - } - return keys; -} - function computeChanges(oldIds: Set, newIds: Set): { added: number; removed: number } { - let added = 0; - for (const id of newIds) { - if (!oldIds.has(id)) added++; - } - let removed = 0; - for (const id of oldIds) { - if (!newIds.has(id)) removed++; - } - return { added, removed }; + const count = (ids: Set, other: Set): number => + Array.from(ids).filter((id) => !other.has(id)).length; + return { added: count(newIds, oldIds), removed: count(oldIds, newIds) }; } interface ProviderModelSnapshot { @@ -149,47 +120,40 @@ function providerRefreshAliasKeys( providerId: string, aliasPrefix: string, ): Set { - const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); - for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); + const keys = new Set(); + for (const [alias, model] of Object.entries(config.models ?? {})) { + if (model.provider === providerId && alias.startsWith(aliasPrefix)) keys.add(alias); + } + for (const [alias, model] of Object.entries(nextConfig.models ?? {})) { + if (model.provider === providerId) keys.add(alias); + } return keys; } -function preserveUserProviderAliases( +function preserveAndRestoreUserAliases( config: KimiConfig, + next: KimiConfig, providerId: string, refreshedAliasKeys: ReadonlySet, -): Record { +): void { const preserved: Record = {}; for (const [alias, model] of Object.entries(config.models ?? {})) { if (model.provider !== providerId || refreshedAliasKeys.has(alias)) continue; preserved[alias] = structuredClone(model); } - return preserved; -} - -function restoreProviderAliases(config: KimiConfig, aliases: Record): void { - if (Object.keys(aliases).length === 0) return; - config.models = { - ...config.models, - ...aliases, - }; + if (Object.keys(preserved).length === 0) return; + next.models = { ...next.models, ...preserved }; } -function restoreDefaultSelection( +function restoreAndClampDefaults( config: KimiConfig, defaultModel: string | undefined, defaultThinking: boolean | undefined, ): void { - if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; - config.defaultModel = defaultModel; - config.defaultThinking = defaultThinking; -} - -// `apply*` may leave `defaultModel` pointing at an alias that no longer exists -// (e.g. the previously-selected model was dropped from the registry). The host's -// `setConfig` deep-merge cannot clear a key, so the matching `removeProvider` -// call handles disk cleanup while this drops the dangling reference in memory. -function clampDanglingDefault(config: KimiConfig): void { + if (defaultModel !== undefined && config.models?.[defaultModel] !== undefined) { + config.defaultModel = defaultModel; + config.defaultThinking = defaultThinking; + } if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { config.defaultModel = undefined; config.defaultThinking = undefined; @@ -205,14 +169,46 @@ function pickDefaultModel(config: KimiConfig, providerId: string, models: Array< const alias = config.models?.[existingDefault]; if (alias !== undefined && alias.provider === providerId) { const stillAvailable = models.find((m) => m.id === alias.model); - if (stillAvailable !== undefined) { - return stillAvailable.id; - } + if (stillAvailable !== undefined) return stillAvailable.id; } } return firstModel.id; } +async function refreshSingleProvider( + host: RefreshProviderHost, + config: KimiConfig, + providerId: string, + providerName: string, + aliasPrefix: string, + apply: (next: KimiConfig) => void, +): Promise<{ config: KimiConfig; changed?: ProviderChange }> { + const next = structuredClone(config); + apply(next); + const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, aliasPrefix); + preserveAndRestoreUserAliases(config, next, providerId, refreshedAliasKeys); + restoreAndClampDefaults(next, config.defaultModel, config.defaultThinking); + + if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { + return { config }; + } + const { added, removed } = computeChanges( + collectModelIdsForAliases(config, refreshedAliasKeys), + collectModelIdsForAliases(next, refreshedAliasKeys), + ); + await host.removeProvider(providerId); + const newConfig = await host.setConfig({ + providers: next.providers, + models: next.models, + defaultModel: next.defaultModel, + defaultThinking: next.defaultThinking, + }); + return { + config: newConfig, + changed: { providerId, providerName, added, removed }, + }; +} + export async function refreshAllProviderModels(host: RefreshProviderHost): Promise { const changed: ProviderChange[] = []; const unchanged: string[] = []; @@ -235,53 +231,26 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi configuredOAuthRef: managedProvider.oauth, }); const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); - const models = await fetchManagedKimiCodeModels({ - accessToken, - baseUrl: auth.baseUrl, - }); + const models = await fetchManagedKimiCodeModels({ accessToken, baseUrl: auth.baseUrl }); if (models.length > 0) { - const next = structuredClone(config); - applyManagedKimiCodeConfig(asManaged(next), { - models, - baseUrl: auth.baseUrl, - oauthKey: auth.oauthRef.key, - oauthHost: auth.oauthRef.oauthHost, - preserveDefaultModel: true, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( + const result = await refreshSingleProvider( + host, config, - next, KIMI_CODE_PROVIDER_NAME, + 'Kimi Code', `${KIMI_CODE_PLATFORM_ID}/`, + (next) => + applyManagedKimiCodeConfig(asManaged(next), { + models, + baseUrl: auth.baseUrl, + oauthKey: auth.oauthRef.key, + oauthHost: auth.oauthRef.oauthHost, + preserveDefaultModel: true, + }), ); - restoreProviderAliases( - next, - preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), - ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - - if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(KIMI_CODE_PROVIDER_NAME); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(KIMI_CODE_PROVIDER_NAME); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId: KIMI_CODE_PROVIDER_NAME, - providerName: 'Kimi Code', - added, - removed, - }); - } + config = result.config; + if (result.changed !== undefined) changed.push(result.changed); + else unchanged.push(KIMI_CODE_PROVIDER_NAME); } } catch (error) { failed.push({ @@ -312,45 +281,24 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi const selectedModelId = pickDefaultModel(config, providerId, models); const selectedModel = models.find((m) => m.id === selectedModelId); if (selectedModel === undefined) continue; - const next = structuredClone(config); - applyOpenPlatformConfig(asManaged(next), { - platform, - models, - selectedModel, - thinking: false, - apiKey, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( + const result = await refreshSingleProvider( + host, config, - next, providerId, + platform.name, `${providerId}/`, + (next) => + applyOpenPlatformConfig(asManaged(next), { + platform, + models, + selectedModel, + thinking: false, + apiKey, + }), ); - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - - if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { - unchanged.push(providerId); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(providerId); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId, - providerName: platform.name, - added, - removed, - }); - } + config = result.config; + if (result.changed !== undefined) changed.push(result.changed); + else unchanged.push(providerId); } catch (error) { failed.push({ provider: providerId, @@ -384,12 +332,7 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi // from the same source do not overwrite each other's aliases, and so the // config we compare is exactly the config we persist. const next = structuredClone(config); - const changedProviders: Array<{ - readonly providerId: string; - readonly providerName: string; - readonly added: number; - readonly removed: number; - }> = []; + const changedProviders: ProviderChange[] = []; for (const providerId of providerIds) { const entry = entries[providerId]; @@ -397,7 +340,7 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi applyCustomRegistryProvider(asManaged(next), entry, source); const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`); - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); + preserveAndRestoreUserAliases(config, next, providerId, refreshedAliasKeys); if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { unchanged.push(providerId); @@ -406,18 +349,12 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi collectModelIdsForAliases(config, refreshedAliasKeys), collectModelIdsForAliases(next, refreshedAliasKeys), ); - changedProviders.push({ - providerId, - providerName: entry.name || providerId, - added, - removed, - }); + changedProviders.push({ providerId, providerName: entry.name || providerId, added, removed }); } } if (changedProviders.length > 0) { - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); + restoreAndClampDefaults(next, config.defaultModel, config.defaultThinking); for (const { providerId } of changedProviders) { await host.removeProvider(providerId); } @@ -427,9 +364,7 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi defaultModel: next.defaultModel, defaultThinking: next.defaultThinking, }); - for (const change of changedProviders) { - changed.push(change); - } + for (const change of changedProviders) changed.push(change); } } catch (error) { for (const providerId of providerIds) { 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 index 584aa8840..451dbe0a7 100644 --- 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 @@ -595,7 +595,7 @@ describe('AgentSwarmProgressComponent', () => { }); component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); - component.appendAssistantDelta({ + component.appendModelDelta({ agentId: 'agent-1', delta: 'Reviewing src/a.ts\nImports look stable', }); @@ -616,7 +616,7 @@ describe('AgentSwarmProgressComponent', () => { component.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); component.markInputComplete(); component.recordToolCall({ agentId: 'agent-1', toolCallId: 'call-read' }); - component.appendAssistantDelta({ + component.appendModelDelta({ agentId: 'agent-1', delta: 'Reviewing src/a.ts and checking imports for regressions in detail', }); diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index b1435b1b1..63f3550b9 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -164,62 +164,18 @@ export class SessionSubagentHost { } async resume(agentId: string, options: RunSubagentOptions): Promise { - options.signal.throwIfAborted(); - - const parent = await this.session.ensureAgentResumed(this.ownerAgentId); - const metadata = this.session.metadata.agents[agentId]; - if (metadata?.type !== 'sub') { - throw new Error(`Agent instance "${agentId}" is not a subagent`); - } - if (metadata.parentAgentId !== this.ownerAgentId) { - throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`); - } - 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`, - ); - } - - const profileName = child.config.profileName ?? 'subagent'; - - const controller = new AbortController(); - const unlinkAbortSignal = linkAbortSignal(options.signal, controller); - this.activeChildren.set(agentId, { - controller, - runInBackground: options.runInBackground, - }); - - 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); - }); - - return { - agentId, - profileName, - resumed: true, - completion, - }; + return this.resumeOrRetry(agentId, options, 'resume'); } async retry(agentId: string, options: RunSubagentOptions): Promise { + return this.resumeOrRetry(agentId, options, 'retry'); + } + + private async resumeOrRetry( + agentId: string, + options: RunSubagentOptions, + operation: 'resume' | 'retry', + ): Promise { options.signal.throwIfAborted(); const parent = await this.session.ensureAgentResumed(this.ownerAgentId); @@ -233,7 +189,7 @@ 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 retried concurrently`, + `Agent instance "${agentId}" is already running and cannot be ${operation}d concurrently`, ); } @@ -246,20 +202,27 @@ export class SessionSubagentHost { runInBackground: options.runInBackground, }); - const completion = this.runChildRetry(parent, agentId, child, profileName, { - ...options, - signal: controller.signal, - }).finally(() => { + const runPromise = + operation === 'resume' + ? this.runChild( + parent, + agentId, + child, + profileName, + { ...options, signal: controller.signal }, + () => { + child.config.update({ modelAlias: parent.config.modelAlias }); + return Promise.resolve(); + }, + ) + : this.runChildRetry(parent, agentId, child, profileName, { ...options, signal: controller.signal }); + + const completion = runPromise.finally(() => { unlinkAbortSignal(); this.activeChildren.delete(agentId); }); - return { - agentId, - profileName, - resumed: true, - completion, - }; + return { agentId, profileName, resumed: true, completion }; } async runQueued( @@ -374,29 +337,19 @@ export class SessionSubagentHost { if (handle === undefined) { throw error; } - let message: string; - let status: QueuedSubagentRunResult['status'] = 'failed'; - let state: QueuedSubagentRunResult['state']; - if (subagentDeadline?.timedOut() === true && options.timeoutMs !== undefined) { - message = `Subagent timed out after ${formatTimeoutMs(options.timeoutMs)}.`; - } else if (options.totalTimedOut() && options.totalTimeoutMs !== undefined) { - message = totalTimeoutMessage(options.totalTimeoutMs); - } else if (isUserCancellation(runSignal.reason)) { - status = 'aborted'; - state = 'started'; - message = 'The user manually interrupted this subagent batch.'; - } else if (isAbortError(error)) { - message = 'The subagent was stopped before it finished.'; - } else { - message = error instanceof Error ? error.message : String(error); - } - return { - task, - agentId: handle.agentId, - status, - state, - error: message, - }; + const status: QueuedSubagentRunResult['status'] = isUserCancellation(runSignal.reason) ? 'aborted' : 'failed'; + const state = status === 'aborted' ? 'started' : undefined; + const message = + subagentDeadline?.timedOut() === true && options.timeoutMs !== undefined + ? `Subagent timed out after ${formatTimeoutMs(options.timeoutMs)}.` + : options.totalTimedOut() && options.totalTimeoutMs !== undefined + ? totalTimeoutMessage(options.totalTimeoutMs) + : status === 'aborted' + ? 'The user manually interrupted this subagent batch.' + : isAbortError(error) + ? 'The subagent was stopped before it finished.' + : error instanceof Error ? error.message : String(error); + return { task, agentId: handle.agentId, status, state, error: message }; } finally { subagentDeadline?.clear(); } @@ -423,8 +376,7 @@ export class SessionSubagentHost { ): Promise { if (emitSpawnedEvent) this.emitSubagentSpawned(parent, childId, profileName, options); const unwatchFirstOutput = this.watchFirstOutput(child, options.onFirstOutput); - - try { + return this.runChildWithErrorHandling(parent, childId, options, unwatchFirstOutput, async () => { await prepareChild(); options.signal.throwIfAborted(); await this.triggerSubagentStart(parent, profileName, options.prompt, options.signal); @@ -441,21 +393,8 @@ export class SessionSubagentHost { this.emitSubagentStarted(parent, childId, profileName, options); options.onStarted?.(); child.turn.prompt([{ type: 'text', text: childPrompt }], origin); - return await this.waitForChildCompletion(parent, childId, child, profileName, options, origin); - } catch (error) { - if (!shouldSuppressQueuedAttemptFailureEvent(options, error)) { - const message = error instanceof Error ? error.message : String(error); - parent.emitEvent({ - type: 'subagent.failed', - subagentId: childId, - parentToolCallId: options.parentToolCallId, - error: message, - }); - } - throw error; - } finally { - unwatchFirstOutput?.(); - } + return this.waitForChildCompletion(parent, childId, child, profileName, options, origin); + }); } private async runChildRetry( @@ -466,8 +405,7 @@ export class SessionSubagentHost { options: RunSubagentOptions, ): Promise { const unwatchFirstOutput = this.watchFirstOutput(child, options.onFirstOutput); - - try { + return this.runChildWithErrorHandling(parent, childId, options, unwatchFirstOutput, async () => { options.signal.throwIfAborted(); child.config.update({ modelAlias: parent.config.modelAlias }); const origin: PromptOrigin = options.origin ?? { kind: 'system_trigger', name: 'subagent' }; @@ -476,7 +414,19 @@ export class SessionSubagentHost { if (child.turn.retry(origin) === null) { throw new Error(`Agent instance "${childId}" could not start a retry turn`); } - return await this.waitForChildCompletion(parent, childId, child, profileName, options, origin); + return this.waitForChildCompletion(parent, childId, child, profileName, options, origin); + }); + } + + private async runChildWithErrorHandling( + parent: Agent, + childId: string, + options: RunSubagentOptions, + unwatchFirstOutput: (() => void) | undefined, + run: () => Promise, + ): Promise { + try { + return await run(); } catch (error) { if (!shouldSuppressQueuedAttemptFailureEvent(options, error)) { const message = error instanceof Error ? error.message : String(error); @@ -685,21 +635,12 @@ function isFirstOutputEvent(event: AgentEvent): boolean { function isRateLimit429Error(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); if (hasRateLimitStatus(error)) return true; - if (message.includes(RATE_LIMIT_429_MESSAGE)) return true; - if (message.includes(RATE_LIMIT_429_BODY)) return true; - if (message.includes('provider.rate_limit')) return true; + if ([RATE_LIMIT_429_MESSAGE, RATE_LIMIT_429_BODY, 'provider.rate_limit'].some((part) => message.includes(part))) return true; const normalized = message.toLowerCase(); - if (normalized.includes('too many requests')) return true; - if (normalized.includes('max rpm')) return true; - if (normalized.includes('max tpm')) return true; - if (normalized.includes('requests per minute')) return true; - if (normalized.includes('tokens per minute')) return true; + const loosePatterns = ['too many requests', 'max rpm', 'max tpm', 'requests per minute', 'tokens per minute']; + if (loosePatterns.some((pattern) => normalized.includes(pattern))) return true; if (!/\b429\b/.test(normalized)) return false; - if (normalized.includes('apistatuserror')) return true; - if (normalized.includes('rate limit')) return true; - if (normalized.includes('rate_limit')) return true; - if (normalized.includes('rate-limited')) return true; - return false; + return ['apistatuserror', 'rate limit', 'rate_limit', 'rate-limited'].some((pattern) => normalized.includes(pattern)); } function shouldSuppressQueuedAttemptFailureEvent( diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index f41b2889f..a1d93b03d 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -123,9 +123,7 @@ export class SubagentLaunchQueue { let initialSuccessfulLaunches = 0; const finish = (fallback: string): Array> => - results.map( - (result, index) => result ?? { task: tasks[index]!, status: 'failed', error: fallback }, - ); + results.map((result, index) => result ?? { task: tasks[index]!, status: 'failed', error: fallback }); const finishInterrupted = (): Array> => { const activeAgentIds = new Map(); @@ -213,27 +211,16 @@ export class SubagentLaunchQueue { pending, outcome, readiness, - get agentId() { - return agentId; - }, - get ready() { - return ready; - }, - get launchSucceeded() { - return launchSucceeded; - }, + get agentId() { return agentId; }, + get ready() { return ready; }, + get launchSucceeded() { return launchSucceeded; }, settled: false, }; - void outcome.then( - () => { - attempt.settled = true; - markReadyOnly(); - }, - () => { - attempt.settled = true; - markReadyOnly(); - }, - ); + const settle = (): void => { + attempt.settled = true; + markReadyOnly(); + }; + void outcome.then(settle, settle); active.push(attempt); return attempt; }; @@ -279,11 +266,11 @@ export class SubagentLaunchQueue { }; const processSettledAttempts = async (): Promise => { - for (let attempt = active.find((item) => item.settled); attempt !== undefined; ) { + while (true) { + const attempt = active.find((item) => item.settled); + if (attempt === undefined) return true; if (!(await processAttempt(attempt))) return false; - attempt = active.find((item) => item.settled); } - return true; }; const nextSettled = (): Promise => @@ -301,12 +288,9 @@ export class SubagentLaunchQueue { if (rateLimitMode) return; options.signal.throwIfAborted(); const waits: Array> = [delay]; - const settled = - active.length === 0 - ? undefined - : nextSettled().then(() => 'settled' as const); + const settled = nextSettled().then(() => 'settled' as const); const readiness = nextReadiness()?.then(() => 'readiness' as const); - if (settled !== undefined) waits.push(settled); + waits.push(settled); if (readiness !== undefined) waits.push(readiness); const waitResult = await abortable(Promise.race(waits), options.signal); if (waitResult === 'delay') return; @@ -339,9 +323,8 @@ export class SubagentLaunchQueue { }; const launchInitialBatch = (): void => { - const count = Math.min(SUBAGENT_LAUNCH_BATCH_SIZE, queued.length); - for (let index = 0; index < count; index += 1) { - launch(queued.shift()!); + for (const pending of queued.splice(0, Math.min(SUBAGENT_LAUNCH_BATCH_SIZE, queued.length))) { + launch(pending); } }; @@ -361,7 +344,6 @@ export class SubagentLaunchQueue { if (launched > 0) continue; if (active.length === 0) { - if (queued.length === 0) break; const wakeAt = nextRateLimitedLaunchWakeAt(); if (wakeAt === undefined) { failQueued('No running subagents remained to open queue slots after rate-limited launches.'); @@ -394,9 +376,7 @@ export class SubagentLaunchQueue { try { await processSettledAttempts(); } catch { - // A child may observe the same user abort before it returns a handle. - // Keep the parent tool result structured so the next turn has a - // balanced, inspectable swarm summary instead of a bare abort error. + // Children may observe the same user abort before returning handles. } return finishInterrupted(); } diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index a516923be..5a74b56f6 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -160,7 +160,7 @@ export class AgentSwarmTool implements BuiltinTool { }; } catch (error) { return { - output: errorMessage(error), + output: error instanceof Error ? error.message : String(error), isError: true, }; } @@ -201,43 +201,22 @@ export class AgentSwarmTool implements BuiltinTool { signal, timeoutMs: args.timeout === undefined ? undefined : args.timeout * 1000, }); - return renderSwarmResults(results.map(toSwarmRunResult)); + return renderSwarmResults(results.map(({ task, ...result }) => ({ spec: task.data, ...result }))); } } function createAgentSwarmSpecs(args: AgentSwarmToolInput): AgentSwarmSpec[] { - const resumeEntries = Object.entries(args.resume_agent_ids ?? {}).map(([agentId, prompt]) => { - return { - agentId: agentId.trim(), - prompt: prompt.trim(), - }; - }); + 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 totalCount = resumeEntries.length + items.length; if (totalCount < 2) { throw new Error('AgentSwarm requires at least 2 total subagents.'); } if (totalCount > MAX_AGENT_SWARM_SUBAGENTS) { - throw new Error( - `AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`, - ); - } - const invalidResume = resumeEntries.find( - (entry) => entry.agentId.length === 0 || entry.prompt.length === 0, - ); - if (invalidResume !== undefined) { - throw new Error('AgentSwarm resume_agent_ids must map non-empty agent ids to non-empty prompts.'); - } - const invalidItem = items.find((item) => item.length === 0); - if (invalidItem !== undefined) { - throw new Error('AgentSwarm items must be non-empty strings.'); - } - const promptTemplate = normalizeOptionalString(args.prompt_template); - if (items.length > 0 && promptTemplate === undefined) { - throw new Error('AgentSwarm prompt_template is required when items are provided.'); - } - if (promptTemplate !== undefined && !promptTemplate.includes(PROMPT_TEMPLATE_PLACEHOLDER)) { - throw new Error(`AgentSwarm prompt_template must include ${PROMPT_TEMPLATE_PLACEHOLDER}.`); + throw new Error(`AgentSwarm supports at most ${String(MAX_AGENT_SWARM_SUBAGENTS)} subagents.`); } const seenPrompts = new Map(); @@ -251,9 +230,7 @@ function createAgentSwarmSpecs(args: AgentSwarmToolInput): AgentSwarmSpec[] { }); } if (items.length > 0) { - if (promptTemplate === undefined) { - throw new Error('AgentSwarm prompt_template is required when items are provided.'); - } + const promptTemplate = normalizeOptionalString(args.prompt_template)!; items.forEach((item, index) => { const prompt = promptTemplate.split(PROMPT_TEMPLATE_PLACEHOLDER).join(item); const previousIndex = seenPrompts.get(prompt); @@ -332,20 +309,3 @@ function escapeXmlAttribute(value: string): string { .replaceAll('<', '<') .replaceAll('>', '>'); } - -function toSwarmRunResult( - result: QueuedSubagentRunResult, -): SwarmRunResult { - return { - spec: result.task.data, - agentId: result.agentId, - status: result.status, - state: result.state, - result: result.result, - error: result.error, - }; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} From 2c471c8dc5b3e70796e354b738e4f208de446940 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 11:33:28 +0800 Subject: [PATCH 37/72] fix --- .../messages/agent-swarm-progress.ts | 70 ++++++++++--------- .../messages/agent-swarm-progress.test.ts | 28 ++++++++ .../kimi-code/test/tui/message-replay.test.ts | 39 +++++++++++ 3 files changed, 105 insertions(+), 32 deletions(-) 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 index 9708e3de4..920fff485 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -82,7 +82,7 @@ interface AgentSwarmSnapshot { interface AgentSwarmResultStatus { readonly index: number; - readonly status: 'completed' | 'failed'; + readonly status: 'completed' | 'failed' | 'cancelled'; readonly completedText?: string; readonly failureText?: string; } @@ -396,6 +396,16 @@ export class AgentSwarmProgressComponent implements Component { const normalizedFailureText = normalizeFailureText(entry.failureText); if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; clearMemberState(member, 'completedAtMs', 'completedText', 'suspendedReason'); + } else { + this.progressEstimator.markCancelled(member.id, nowMs); + clearMemberState( + member, + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'suspendedReason', + ); } member.phase = entry.status; } @@ -835,18 +845,19 @@ function parseAgentSwarmDescriptionIndex(description: string | undefined): numbe export function agentSwarmResultSummaryFromOutput(output: string): AgentSwarmResultSummary { const statuses = parseAgentSwarmResultStatuses(output); - const aborted = countAgentSwarmAbortedResultStatuses(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: completed + failed + aborted > 0, + parsed: statuses.length > 0, }; } @@ -858,17 +869,19 @@ function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] function forEachSubagentTag( output: string, - callback: (attrs: string, body: string) => T | undefined, + 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); - const value = callback(attrs, body); + index += 1; + const value = callback(attrs, body, index); if (value !== undefined) result.push(value); tagPattern.lastIndex = closeIndex + ''.length; } @@ -876,39 +889,37 @@ function forEachSubagentTag( } function parseAgentSwarmXmlResultStatuses(output: string): AgentSwarmResultStatus[] { - return forEachSubagentTag(output, (attrs, body) => { - const index = Number(xmlAttribute(attrs, 'index')); + 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 (!Number.isInteger(index) || index <= 0 || (outcome !== 'completed' && outcome !== 'failed')) { + if ( + outcome !== 'completed' && + outcome !== 'failed' && + outcome !== 'aborted' && + outcome !== 'cancelled' + ) { return undefined; } return { index, - status: outcome, + status: outcome === 'aborted' || outcome === 'cancelled' ? 'cancelled' : outcome, completedText: outcome === 'completed' ? body : undefined, failureText: outcome === 'failed' ? body : undefined, }; }); } -function countAgentSwarmAbortedResultStatuses(output: string): number { - const xmlAborted = forEachSubagentTag(output, (attrs) => { - const index = Number(xmlAttribute(attrs, 'index')); - const outcome = xmlAttribute(attrs, 'outcome'); - return Number.isInteger(index) && index > 0 && (outcome === 'aborted' || outcome === 'cancelled') - ? true - : undefined; - }).length; - if (xmlAborted > 0) return xmlAborted; - return countAgentSwarmLegacyAbortedResultStatuses(output); -} - 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[] { +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); @@ -921,23 +932,18 @@ function forEachAgentBlock(output: string, callback: (block: string, index: n function parseAgentSwarmLegacyResultStatuses(output: string): AgentSwarmResultStatus[] { return forEachAgentBlock(output, (block, index) => { - const statusMatch = /^status: (completed|failed)$/m.exec(block); + 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: statusMatch[1] as 'completed' | 'failed', - completedText: parseAgentSwarmCompletedText(block), - failureText: parseAgentSwarmFailureText(block), + status: status === 'aborted' || status === 'cancelled' ? 'cancelled' : status, + completedText: status === 'completed' ? parseAgentSwarmCompletedText(block) : undefined, + failureText: status === 'failed' ? parseAgentSwarmFailureText(block) : undefined, }; }); } -function countAgentSwarmLegacyAbortedResultStatuses(output: string): number { - return forEachAgentBlock(output, (block) => - /^status: (aborted|cancelled)$/m.test(block) ? true : undefined, - ).length; -} - function parseAgentSwarmCompletedText(block: string): string | undefined { const marker = '\n[summary]\n'; const markerIndex = block.indexOf(marker); 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 index 451dbe0a7..8e2d05d83 100644 --- 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 @@ -505,6 +505,34 @@ describe('AgentSwarmProgressComponent', () => { 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('⊘ Aborted.'); + expect(output).not.toContain('Completed.'); + }); + it('strips nested AgentSwarm prefixes from failure details', () => { const component = new AgentSwarmProgressComponent({ description: 'Review changed files', diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index dfc6b6350..b97daff64 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -361,6 +361,45 @@ describe('KimiTUI resume message replay', () => { 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: { From e7a07aa32e9825e843f1d05fd2a3bfc47062cb37 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 11:45:08 +0800 Subject: [PATCH 38/72] wip --- .../agent-core/src/agent/context/index.ts | 1 + .../agent-core/src/agent/context/types.ts | 8 ++- packages/agent-core/src/agent/index.ts | 13 +---- packages/agent-core/src/agent/turn/index.ts | 58 +++---------------- .../agent-core/src/session/subagent-host.ts | 3 +- 5 files changed, 18 insertions(+), 65 deletions(-) diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 4c16b98a4..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], 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 3d3b245d8..c048527ce 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -128,7 +128,6 @@ export class Agent { readonly replayBuilder: ReplayBuilder; private lastLlmConfigLogSignature?: string; - private readonly eventListeners = new Set<(event: AgentEvent) => void>(); constructor(options: AgentOptions) { this.type = options.type ?? 'main'; @@ -411,18 +410,10 @@ export class Agent { emitEvent(event: AgentEvent): void { if (this.records.restoring) return; - for (const listener of this.eventListeners) listener(event); void this.rpc?.emitEvent?.(event); } - onEvent(listener: (event: AgentEvent) => void): () => void { - this.eventListeners.add(listener); - return () => { - this.eventListeners.delete(listener); - }; - } - - emitStatusUpdated(options: { readonly swarmMode?: boolean } = {}): void { + emitStatusUpdated(): void { if (this.records.restoring) return; if (!this.config.hasModel) return; @@ -442,7 +433,7 @@ export class Agent { maxContextTokens, contextUsage, planMode: this.planMode.isActive, - swarmMode: options.swarmMode ?? (this.swarmMode.isActive ? true : undefined), + swarmMode: this.swarmMode.isActive, permission: this.permission.mode, usage, }); diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 5979b5e7e..f011b6946 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -65,7 +65,6 @@ const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; /** Origin tag for the synthetic "continue" prompt that drives each goal turn. */ const GOAL_CONTINUATION_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'goal_continuation' }; -const TURN_RETRY_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'turn_retry' }; const GOAL_RATE_LIMIT_PAUSE_REASON = 'Paused after provider rate limit'; /** @@ -138,6 +137,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({ @@ -161,31 +164,6 @@ export class TurnFlow { return turnId; } - retry(origin: PromptOrigin = TURN_RETRY_ORIGIN): number | null { - this.agent.records.logRecord({ - type: 'turn.prompt', - input: [], - origin, - }); - if (this.activeTurn) { - this.agent.emitEvent({ - type: 'error', - ...makeErrorPayload( - 'turn.agent_busy', - `Cannot retry the latest turn while another turn (ID ${this.turnId}) is active`, - { details: { turnId: this.turnId } }, - ), - }); - return null; - } - - const turnId = this.allocateTurnId(); - const controller = new AbortController(); - const promise = this.retryWorker(turnId, origin, controller.signal); - this.activeTurn = { controller, promise }; - return turnId; - } - /** Allocates the next monotonic turn id. */ private allocateTurnId(): number { this.turnId += 1; @@ -325,24 +303,6 @@ export class TurnFlow { } } - private async retryWorker( - turnId: number, - origin: PromptOrigin, - signal: AbortSignal, - ): Promise { - const ownsActiveTurn = (): boolean => - this.activeTurn !== null && - this.activeTurn !== 'resuming' && - this.activeTurn.controller.signal === signal; - try { - return await this.runOneTurn(turnId, null, origin, signal, true); - } finally { - if (ownsActiveTurn()) { - this.activeTurn = null; - } - } - } - /** * Drives an active goal as a sequence of ordinary turns — the autonomous * equivalent of the user repeatedly typing "continue". Each iteration runs one @@ -439,7 +399,7 @@ export class TurnFlow { */ private async runOneTurn( turnId: number, - input: readonly ContentPart[] | null, + input: readonly ContentPart[], origin: PromptOrigin, signal: AbortSignal, standalone: boolean, @@ -454,9 +414,7 @@ export class TurnFlow { this.agent.fullCompaction.resetForTurn(); this.agent.usage.beginTurn(); this.agent.emitEvent({ type: 'turn.started', turnId, origin }); - if (input !== null) { - this.agent.context.appendUserMessage(input, origin); - } + this.agent.context.appendUserMessage(input, origin); const startedAt = Date.now(); let ended: TurnEndedEvent; @@ -466,9 +424,7 @@ export class TurnFlow { // sits just past the turn.ended boundary that consumers watch for. let errorEvent: AgentEvent | undefined; try { - const promptHookEnded = input !== null - ? await this.applyUserPromptHook(turnId, input, origin, signal) - : undefined; + const promptHookEnded = await this.applyUserPromptHook(turnId, input, origin, signal); if (promptHookEnded !== undefined) { ended = promptHookEnded.event; blockedByUserPromptHook = promptHookEnded.blocked; diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 63f3550b9..99b8a5629 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -408,10 +408,9 @@ export class SessionSubagentHost { return this.runChildWithErrorHandling(parent, childId, options, unwatchFirstOutput, async () => { options.signal.throwIfAborted(); child.config.update({ modelAlias: parent.config.modelAlias }); - const origin: PromptOrigin = options.origin ?? { kind: 'system_trigger', name: 'subagent' }; this.emitSubagentStarted(parent, childId, profileName, options); options.onStarted?.(); - if (child.turn.retry(origin) === null) { + if (child.turn.retry('agent-host') === null) { throw new Error(`Agent instance "${childId}" could not start a retry turn`); } return this.waitForChildCompletion(parent, childId, child, profileName, options, origin); From d72e2793143587637cfd88980750a799d2629562 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 12:18:01 +0800 Subject: [PATCH 39/72] wip --- .../agent-core/src/session/subagent-host.ts | 8 +-- .../src/session/subagent-launch-queue.ts | 51 ++++--------------- .../test/session/subagent-host.test.ts | 18 +++---- 3 files changed, 23 insertions(+), 54 deletions(-) diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 99b8a5629..3012ed3e9 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -70,12 +70,11 @@ IMPORTANT: - If you do not know the answer, say so directly. `; -type RunSubagentOptions = { +export interface RunSubagentOptions { readonly parentToolCallId: string; readonly parentToolCallUuid?: string; readonly prompt: string; readonly description: string; - readonly swarmItem?: string; readonly runInBackground: boolean; readonly origin?: PromptOrigin; readonly signal: AbortSignal; @@ -84,8 +83,9 @@ type RunSubagentOptions = { readonly suppressRateLimitFailureEvent?: boolean; }; -type SpawnSubagentOptions = RunSubagentOptions & { +export interface SpawnSubagentOptions extends RunSubagentOptions { readonly profileName: string; + readonly swarmItem?: string; }; type SubagentCompletion = { @@ -229,7 +229,7 @@ export class SessionSubagentHost { tasks: readonly QueuedSubagentTask[], options: QueuedSubagentRunOptions, ): Promise>> { - return this.launchQueue.run(tasks, options); + return this.launchQueue.enqueue(tasks, options); } async startBtw(): Promise { diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index a1d93b03d..2f4cf6f3b 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -3,6 +3,7 @@ import type { TokenUsage } from '@moonshot-ai/kosong'; import type { PromptOrigin } from '../agent/context'; import { abortable, createDeadlineAbortSignal, isUserCancellation } from '../utils/abort'; +import type { RunSubagentOptions, SpawnSubagentOptions, SubagentHandle } from '.'; const SUBAGENT_LAUNCH_BATCH_SIZE = 5; const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; @@ -22,12 +23,8 @@ export type QueuedSubagentTask = { readonly runInBackground: boolean; readonly origin?: PromptOrigin; readonly resumeAgentId?: string; -}; - -export type QueuedSubagentRunOptions = { - readonly signal: AbortSignal; - readonly timeoutMs?: number; - readonly totalTimeoutMs?: number; + readonly timeout?: number; + readonly signal?: AbortSignal; }; export type QueuedSubagentRunResult = { @@ -73,43 +70,16 @@ type QueuedSubagentAttempt = { settled: boolean; }; -export type QueuedSubagentAttemptOptions = QueuedSubagentRunOptions & { - readonly totalTimedOut: () => boolean; - readonly markAgentId: (agentId: string) => void; - readonly markReady: () => void; - readonly retryAgentId?: string; -}; - -type RunQueuedSubagentAttempt = ( - task: QueuedSubagentTask, - options: QueuedSubagentAttemptOptions, -) => Promise>; - -type SubagentLaunchQueueEvents = { - readonly onSuspended?: (event: QueuedSubagentSuspended) => void; -}; +export interface SubagentLauncher { + spawn(options: SpawnSubagentOptions): Promise; + resume(agentId: string, options: RunSubagentOptions): Promise; + retry(agentId: string): Promise; +} export class SubagentLaunchQueue { - constructor( - private readonly runAttempt: RunQueuedSubagentAttempt, - private readonly events: SubagentLaunchQueueEvents = {}, - ) {} - - async run( - tasks: readonly QueuedSubagentTask[], - runOptions: QueuedSubagentRunOptions, - ): Promise>> { - const totalDeadline = - runOptions.totalTimeoutMs === undefined - ? undefined - : createDeadlineAbortSignal(runOptions.signal, runOptions.totalTimeoutMs); - const options: QueuedSubagentRunOptions = { - signal: totalDeadline?.signal ?? runOptions.signal, - timeoutMs: runOptions.timeoutMs, - totalTimeoutMs: runOptions.totalTimeoutMs, - }; - const totalTimedOut = (): boolean => totalDeadline?.timedOut() === true; + constructor(private launcher: SubagentLauncher) { } + enqueue(tasks: readonly QueuedSubagentTask[]): Array>> { const queued = tasks.map((_, index): QueuedSubagentPending => ({ index })); const active: Array> = []; const results: Array | undefined> = Array.from({ @@ -337,7 +307,6 @@ export class SubagentLaunchQueue { } while (active.length > 0 || queued.length > 0) { - options.signal.throwIfAborted(); await processSettledAttempts(); const launched = launchRateLimitedQueued(); diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 9ec9d032f..d77c83974 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -51,7 +51,7 @@ describe('SessionSubagentHost', () => { vi.useFakeTimers(); try { const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.run( + const running = queue.enqueue( Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { signal }, ); @@ -99,7 +99,7 @@ describe('SessionSubagentHost', () => { try { const controller = new AbortController(); const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.run(Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { + const running = queue.enqueue(Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); void running.catch(() => {}); @@ -138,7 +138,7 @@ describe('SessionSubagentHost', () => { try { const controller = new AbortController(); const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.run(Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)), { + const running = queue.enqueue(Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); @@ -225,7 +225,7 @@ describe('SessionSubagentHost', () => { vi.useFakeTimers(); try { const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.run( + const running = queue.enqueue( Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)), { signal }, ); @@ -265,7 +265,7 @@ describe('SessionSubagentHost', () => { const controller = new AbortController(); const onSuspended = vi.fn(); const { queue, attempts } = createRecordedLaunchQueue({ onSuspended }); - const running = queue.run(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { + const running = queue.enqueue(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); void running.catch(() => {}); @@ -322,7 +322,7 @@ describe('SessionSubagentHost', () => { try { const controller = new AbortController(); const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.run(Array.from({ length: 12 }, (_, index) => queuedTask(index + 1)), { + const running = queue.enqueue(Array.from({ length: 12 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); void running.catch(() => {}); @@ -367,7 +367,7 @@ describe('SessionSubagentHost', () => { try { const controller = new AbortController(); const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.run(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { + const running = queue.enqueue(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); void running.catch(() => {}); @@ -418,7 +418,7 @@ describe('SessionSubagentHost', () => { vi.useFakeTimers(); try { const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.run([queuedTask(1)], { signal, totalTimeoutMs: 10_000 }); + const running = queue.enqueue([queuedTask(1)], { signal, totalTimeoutMs: 10_000 }); await vi.advanceTimersByTimeAsync(0); attempts[0]!.markReady(); @@ -473,7 +473,7 @@ describe('SessionSubagentHost', () => { return outcome; }); - const running = queue.run( + const running = queue.enqueue( Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal }, ); From 82d3831909fcb068f70afd919d7aa0496e2e276d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 12:22:05 +0800 Subject: [PATCH 40/72] remove swarm demo --- apps/kimi-code/src/cli/commands.ts | 9 - apps/kimi-code/src/main.ts | 14 - apps/kimi-code/src/tui/swarm-demo.ts | 374 ------------------------ apps/kimi-code/test/cli/options.test.ts | 26 -- 4 files changed, 423 deletions(-) delete mode 100644 apps/kimi-code/src/tui/swarm-demo.ts diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 8d5c87c56..faf1e1da8 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -13,7 +13,6 @@ export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; export type UpgradeCommandHandler = () => void | Promise; -export type SwarmDemoCommandHandler = (count?: string) => void; export function createProgram( version: string, @@ -21,7 +20,6 @@ export function createProgram( onMigrate: MigrateCommandHandler, onPluginNodeRunner: PluginNodeRunnerHandler = () => {}, onUpgrade: UpgradeCommandHandler = () => {}, - onSwarmDemo: SwarmDemoCommandHandler = () => {}, ): Command { const program = new Command(CLI_COMMAND_NAME) .description('The Starting Point for Next-Gen Agents') @@ -83,13 +81,6 @@ export function createProgram( registerLoginCommand(program); registerDoctorCommand(program); registerMigrateCommand(program, onMigrate); - program - .command('swarm-demo') - .description('Run an animated demo of the swarm progress UI.') - .argument('[count]', 'Number of swarms to render. Defaults to 32.') - .action((count: string | undefined) => { - onSwarmDemo(count); - }); program .command('upgrade') .description('Upgrade Kimi Code to the latest version.') diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 4efe3c309..e94472590 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -37,7 +37,6 @@ import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/a import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; -import { runSwarmDemo } from './tui/swarm-demo'; export async function handleMainCommand(opts: CLIOptions, version: string): Promise { let validated: ReturnType; @@ -103,11 +102,6 @@ export async function handleUpgradeCommand(version: string): Promise { process.exit(exitCode); } -export async function handleSwarmDemoCommand(count: string | undefined): Promise { - const exitCode = await runSwarmDemo({ count }); - process.exit(exitCode); -} - /** A neutral CLIOptions value — `kimi migrate` never opens a chat session. */ const MIGRATE_CLI_OPTIONS: CLIOptions = { session: undefined, @@ -180,14 +174,6 @@ export function main(): void { process.exit(1); }); }, - (count) => { - void handleSwarmDemoCommand(count).catch(async (error: unknown) => { - await logStartupFailure('run swarm demo', error); - process.stderr.write(formatStartupError(error, { operation: 'run swarm demo' })); - process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); - process.exit(1); - }); - }, ); program.parse(process.argv); diff --git a/apps/kimi-code/src/tui/swarm-demo.ts b/apps/kimi-code/src/tui/swarm-demo.ts deleted file mode 100644 index b7766cfb8..000000000 --- a/apps/kimi-code/src/tui/swarm-demo.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { - Container, - Key, - matchesKey, - ProcessTerminal, - TUI, - type Focusable, -} from '@earendil-works/pi-tui'; - -import { - AgentSwarmProgressComponent, - agentSwarmGridHeightForTerminalRows, -} from './components/messages/agent-swarm-progress'; -import { GutterContainer } from './components/chrome/gutter-container'; -import { loadTuiConfig, TuiConfigParseError } from './config'; -import { CHROME_GUTTER } from './constant/rendering'; -import { createKimiTUIThemeBundle } from './theme/bundle'; -import type { ColorPalette } from './theme/colors'; -import { detectTerminalTheme } from './theme/detect'; -import { printableChar } from './utils/printable-key'; - -const DEFAULT_SWARM_COUNT = 32; -const MAX_SWARM_COUNT = 256; -const FRAME_INTERVAL_MS = 80; -const INPUT_COMPLETE_MS = 500; -const TOOL_TICK_INTERVAL_MS = 520; -const LONG_RUNNING_FINISH_MS = 45_000; -const FAILED_COUNT = 2; -const CANCELLED_COUNT = 1; - -export interface SwarmDemoRunOptions { - readonly count?: string; -} - -interface SwarmDemoComponentOptions { - readonly count: number; - readonly colors: ColorPalette; - readonly terminalRows: () => number | undefined; - readonly requestRender: () => void; - readonly onExit: () => void; -} - -type DemoTaskPhase = 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; -type DemoTaskTerminal = 'completed' | 'failed' | 'cancelled'; - -interface DemoTask { - readonly index: number; - readonly agentId: string; - readonly description: string; - readonly itemText: string; - readonly spawnAtMs: number; - readonly startAtMs: number; - readonly finishAtMs: number; - readonly terminal: DemoTaskTerminal; - phase: DemoTaskPhase; - toolTickCount: number; - modelLineCount: number; -} - -const MODEL_LINES = [ - 'Reading relevant files', - 'Checking edge cases', - 'Comparing nearby patterns', - 'Validating behavior', - 'Writing concise findings', -] as const; - -export async function runSwarmDemo(options: SwarmDemoRunOptions = {}): Promise { - const count = resolveSwarmCount(options.count); - const colors = await loadSwarmDemoColors(); - const terminal = new ProcessTerminal(); - const ui = new TUI(terminal); - let stopped = false; - let resolveExit: (code: number) => void = () => {}; - const done = new Promise((resolve) => { - resolveExit = resolve; - }); - - const component = new SwarmDemoComponent({ - count, - colors, - terminalRows: () => ui.terminal.rows, - requestRender: () => { - ui.requestRender(); - }, - onExit: () => { - void stop(0); - }, - }); - - const root = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - root.addChild(component); - ui.addChild(root); - ui.setFocus(component); - - const cleanupHandlers: Array<() => void> = []; - const addSignalHandler = (signal: NodeJS.Signals, code: number): void => { - const handler = (): void => { - void stop(code); - }; - process.prependListener(signal, handler); - cleanupHandlers.push(() => { - process.off(signal, handler); - }); - }; - addSignalHandler('SIGTERM', 143); - if (process.platform !== 'win32') addSignalHandler('SIGHUP', 129); - - async function stop(code: number): Promise { - if (stopped) return; - stopped = true; - for (const cleanup of cleanupHandlers) cleanup(); - cleanupHandlers.length = 0; - component.dispose(); - terminal.setProgress(false); - await terminal.drainInput().catch(() => {}); - ui.stop(); - resolveExit(code); - } - - try { - terminal.setTitle('Kimi swarm demo'); - terminal.setProgress(true); - ui.start(); - component.start(); - ui.requestRender(true); - } catch (error) { - component.dispose(); - for (const cleanup of cleanupHandlers) cleanup(); - cleanupHandlers.length = 0; - terminal.setProgress(false); - ui.stop(); - throw error; - } - - return done; -} - -export function resolveSwarmCount(raw: string | undefined): number { - if (raw === undefined || raw.trim().length === 0) return DEFAULT_SWARM_COUNT; - const count = Number(raw); - if (!Number.isInteger(count) || count < 1 || count > MAX_SWARM_COUNT) { - throw new Error( - `Invalid swarm count "${raw}". Use an integer from 1 to ${String(MAX_SWARM_COUNT)}.`, - ); - } - return count; -} - -async function loadSwarmDemoColors(): Promise { - try { - const config = await loadTuiConfig(); - const resolvedTheme = config.theme === 'auto' ? await detectTerminalTheme() : config.theme; - return createKimiTUIThemeBundle(config.theme, resolvedTheme).colors; - } catch (error) { - if (!(error instanceof TuiConfigParseError)) throw error; - const resolvedTheme = - error.fallback.theme === 'auto' ? await detectTerminalTheme() : error.fallback.theme; - return createKimiTUIThemeBundle(error.fallback.theme, resolvedTheme).colors; - } -} - -export class SwarmDemoComponent extends Container implements Focusable { - focused = false; - private readonly tasks: DemoTask[]; - private readonly progress: AgentSwarmProgressComponent; - private readonly requestRender: () => void; - private readonly onExit: () => void; - private inputComplete = false; - private startedAt = Date.now(); - private timer: ReturnType | undefined; - - constructor(options: SwarmDemoComponentOptions) { - super(); - this.requestRender = options.requestRender; - this.onExit = options.onExit; - this.tasks = createDemoTasks(options.count); - this.progress = new AgentSwarmProgressComponent({ - description: 'Demo AgentSwarm progress', - colors: options.colors, - availableGridHeight: () => agentSwarmGridHeightForTerminalRows(options.terminalRows()), - requestRender: options.requestRender, - }); - this.progress.updateArgs({ - description: 'Demo AgentSwarm progress', - prompt_template: 'Inspect {{item}} and report the most relevant finding.', - items: this.tasks.map((task) => task.itemText), - }); - } - - start(): void { - this.disposeTimer(); - this.startedAt = Date.now(); - this.inputComplete = false; - for (const task of this.tasks) { - task.phase = 'pending'; - task.toolTickCount = 0; - task.modelLineCount = 0; - } - this.syncProgress(); - this.timer = setInterval(() => { - this.syncProgress(); - this.requestRender(); - }, FRAME_INTERVAL_MS); - } - - dispose(): void { - this.disposeTimer(); - this.progress.dispose(); - } - - override invalidate(): void { - this.progress.invalidate(); - } - - handleInput(data: string): void { - const printable = printableChar(data); - if ( - matchesKey(data, Key.escape) || - matchesKey(data, Key.ctrl('c')) || - matchesKey(data, Key.ctrl('d')) || - printable === 'q' || - printable === 'Q' - ) { - this.onExit(); - } - } - - override render(width: number): string[] { - this.syncProgress(); - return this.progress.render(width); - } - - private disposeTimer(): void { - if (this.timer === undefined) return; - clearInterval(this.timer); - this.timer = undefined; - } - - private syncProgress(): void { - const elapsedMs = Date.now() - this.startedAt; - if (!this.inputComplete && elapsedMs >= INPUT_COMPLETE_MS) { - this.progress.markInputComplete(); - this.inputComplete = true; - for (const task of this.tasks) { - if (task.phase === 'pending') task.phase = 'queued'; - } - } - - for (const task of this.tasks) { - this.syncTask(task, elapsedMs); - } - } - - private syncTask(task: DemoTask, elapsedMs: number): void { - if (task.phase === 'pending' && elapsedMs >= task.spawnAtMs) { - this.progress.registerSubagent({ - agentId: task.agentId, - description: task.description, - }); - task.phase = 'queued'; - } - - if (task.phase === 'queued' && elapsedMs >= task.startAtMs) { - this.progress.markStarted(task.agentId); - task.phase = 'running'; - } - - if (task.phase === 'running') { - const runningElapsedMs = Math.max(0, elapsedMs - task.startAtMs); - const targetToolTicks = Math.floor(runningElapsedMs / TOOL_TICK_INTERVAL_MS); - while (task.toolTickCount < targetToolTicks) { - task.toolTickCount += 1; - this.progress.recordToolCall({ - agentId: task.agentId, - toolCallId: `${task.agentId}-tool-${String(task.toolTickCount)}`, - }); - } - - const targetModelLines = Math.floor(runningElapsedMs / (TOOL_TICK_INTERVAL_MS * 2)); - while (task.modelLineCount < targetModelLines) { - task.modelLineCount += 1; - const line = MODEL_LINES[(task.modelLineCount + task.index) % MODEL_LINES.length]; - this.progress.appendModelDelta({ - agentId: task.agentId, - delta: `${line}: ${task.itemText}\n`, - }); - } - - if (elapsedMs >= task.finishAtMs) { - this.finishTask(task); - } - } - } - - private finishTask(task: DemoTask): void { - switch (task.terminal) { - case 'completed': - this.progress.markCompleted(task.agentId, `Completed ${task.itemText}`); - task.phase = 'completed'; - return; - case 'failed': - this.progress.markFailed(task.agentId, `Failed while checking ${task.itemText}`); - task.phase = 'failed'; - return; - case 'cancelled': - this.progress.markCancelled(task.agentId); - task.phase = 'cancelled'; - return; - } - } -} - -function createDemoTasks(count: number): DemoTask[] { - const failed = chooseTerminalIndexes(count, FAILED_COUNT, 0.42); - const cancelled = chooseTerminalIndexes(count, CANCELLED_COUNT, 0.68, failed); - - return Array.from({ length: count }, (_item, index) => { - const agentNumber = String(index + 1).padStart(3, '0'); - const spawnAtMs = 120 + (index % 16) * 70 + Math.floor(index / 16) * 35; - const startAtMs = spawnAtMs + 350 + (index % 5) * 130; - const terminal = failed.has(index) - ? 'failed' - : cancelled.has(index) - ? 'cancelled' - : 'completed'; - const finishAtMs = index === 0 - ? LONG_RUNNING_FINISH_MS - : startAtMs + 2_200 + (index % 9) * 360 + Math.floor(index / 9) * 80; - return { - index, - agentId: `demo-agent-${agentNumber}`, - description: `Demo AgentSwarm #${String(index + 1)} (coder)`, - itemText: demoItemText(index), - spawnAtMs, - startAtMs, - finishAtMs, - terminal, - phase: 'pending', - toolTickCount: 0, - modelLineCount: 0, - }; - }); -} - -function chooseTerminalIndexes( - count: number, - targetCount: number, - offsetRatio: number, - exclude: ReadonlySet = new Set(), -): ReadonlySet { - const indexes = new Set(); - if (count <= 1 || targetCount <= 0) return indexes; - - let cursor = Math.max(1, Math.min(count - 1, Math.floor(count * offsetRatio))); - while (indexes.size < targetCount && indexes.size + exclude.size < count - 1) { - if (cursor !== 0 && !exclude.has(cursor)) indexes.add(cursor); - cursor = cursor + 3 >= count ? 1 + ((cursor + 3) % count) : cursor + 3; - } - return indexes; -} - -function demoItemText(index: number): string { - const files = [ - 'apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts', - 'apps/kimi-code/src/tui/controllers/session-event-handler.ts', - 'apps/kimi-code/src/tui/components/messages/tool-call.ts', - 'packages/agent-core/src/tools/builtin/current.ts', - 'packages/node-sdk/src/session.ts', - 'docs/en/release-notes/changelog.md', - ] as const; - const file = files[index % files.length]; - return `${file}#${String(index + 1)}`; -} diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 732f05e8b..90fb53ecf 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -280,31 +280,6 @@ describe('CLI options parsing', () => { expect(upgradeCalls).toBe(1); }); - it('routes swarm-demo with the optional count argument', () => { - const swarmDemoCounts: Array = []; - const program = createProgram( - '0.0.0', - () => { - throw new Error('main action should not run'); - }, - () => {}, - () => {}, - () => {}, - (count) => { - swarmDemoCounts.push(count); - }, - ); - program.exitOverride(); - program.configureOutput({ - writeOut: () => {}, - writeErr: () => {}, - }); - - program.parse(['node', 'kimi', 'swarm-demo', '48']); - - expect(swarmDemoCounts).toEqual(['48']); - }); - it('registers the visible sub-commands', () => { const program = createProgram('0.0.0', () => {}, () => {}); const commandNames: string[] = program.commands @@ -317,7 +292,6 @@ describe('CLI options parsing', () => { 'login', 'doctor', 'migrate', - 'swarm-demo', 'upgrade', ]); }); From 615256d11e78b2be26b7a10c29721508a6ed41b2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 12:23:51 +0800 Subject: [PATCH 41/72] fix --- packages/agent-core/src/session/subagent-launch-queue.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index 2f4cf6f3b..4614a19c2 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -79,7 +79,11 @@ export interface SubagentLauncher { export class SubagentLaunchQueue { constructor(private launcher: SubagentLauncher) { } - enqueue(tasks: readonly QueuedSubagentTask[]): Array>> { + enqueue(task: QueuedSubagentTask): Promise> { + // TODO + } + + old_code() { const queued = tasks.map((_, index): QueuedSubagentPending => ({ index })); const active: Array> = []; const results: Array | undefined> = Array.from({ From bc82e594ba7354678ad076cf58228079b29be429 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 12:36:47 +0800 Subject: [PATCH 42/72] wip --- .../src/session/subagent-launch-queue.ts | 345 +----------------- 1 file changed, 7 insertions(+), 338 deletions(-) diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts index 4614a19c2..d15c2c630 100644 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ b/packages/agent-core/src/session/subagent-launch-queue.ts @@ -1,16 +1,7 @@ -import { createControlledPromise, sleep } from '@antfu/utils'; import type { TokenUsage } from '@moonshot-ai/kosong'; -import type { PromptOrigin } from '../agent/context'; -import { abortable, createDeadlineAbortSignal, isUserCancellation } from '../utils/abort'; import type { RunSubagentOptions, SpawnSubagentOptions, SubagentHandle } from '.'; - -const SUBAGENT_LAUNCH_BATCH_SIZE = 5; -const SUBAGENT_QUEUE_LAUNCH_DELAY_MS = 500; -const SUBAGENT_INITIAL_INCREMENT_DELAY_MS = 700; -const RATE_LIMIT_SLOT_REDUCTION_WINDOW_MS = 2000; -const RATE_LIMIT_RETRY_BASE_DELAY_MS = 3000; -const RATE_LIMIT_SUSPENDED_REASON = 'Provider rate limit; subagent requeued for retry.'; +import type { PromptOrigin } from '../agent/context'; export type QueuedSubagentTask = { readonly data: T; @@ -37,38 +28,6 @@ export type QueuedSubagentRunResult = { readonly error?: string; }; -export type QueuedSubagentRateLimitOutcome = { - readonly type: 'rate_limited'; - readonly agentId?: string; -}; - -export type QueuedSubagentSuspended = { - readonly task: QueuedSubagentTask; - readonly agentId: string; - readonly reason: string; - readonly retryAttempt: number; -}; - -export type QueuedSubagentAttemptOutcome = - | QueuedSubagentRateLimitOutcome - | QueuedSubagentRunResult; - -type QueuedSubagentPending = { - readonly index: number; - readonly agentId?: string; - readonly rateLimitAttempts?: number; - readonly nextRetryAtMs?: number; -}; - -type QueuedSubagentAttempt = { - readonly pending: QueuedSubagentPending; - readonly outcome: Promise>; - readonly readiness: Promise; - readonly agentId?: string; - readonly ready: boolean; - readonly launchSucceeded: boolean; - settled: boolean; -}; export interface SubagentLauncher { spawn(options: SpawnSubagentOptions): Promise; @@ -76,302 +35,12 @@ export interface SubagentLauncher { retry(agentId: string): Promise; } -export class SubagentLaunchQueue { - constructor(private launcher: SubagentLauncher) { } +export class SubagentLaunchQueue { + constructor( + private launcher: SubagentLauncher, + private tasks: QueuedSubagentTask[] + ) { } - enqueue(task: QueuedSubagentTask): Promise> { - // TODO + run(): Promise>> { } - - old_code() { - const queued = tasks.map((_, index): QueuedSubagentPending => ({ index })); - const active: Array> = []; - const results: Array | undefined> = Array.from({ - length: tasks.length, - }); - let slotLimit = SUBAGENT_LAUNCH_BATCH_SIZE; - let rateLimitMode = false; - let rateLimitReductionWindowStartMs: number | undefined; - let nextRateLimitedLaunchAtMs = 0; - let rateLimitedLaunchDelayMs = RATE_LIMIT_RETRY_BASE_DELAY_MS; - let initialSuccessfulLaunches = 0; - - const finish = (fallback: string): Array> => - results.map((result, index) => result ?? { task: tasks[index]!, status: 'failed', error: fallback }); - - const finishInterrupted = (): Array> => { - const activeAgentIds = new Map(); - for (const attempt of active) { - activeAgentIds.set(attempt.pending.index, attempt.agentId ?? attempt.pending.agentId); - } - const queuedAgentIds = new Map(); - for (const pending of queued) { - if (pending.agentId !== undefined) queuedAgentIds.set(pending.index, pending.agentId); - } - - return results.map((result, index) => { - if (result !== undefined) return result; - const task = tasks[index]!; - const wasStarted = activeAgentIds.has(index) || queuedAgentIds.has(index); - return { - task, - agentId: activeAgentIds.get(index) ?? queuedAgentIds.get(index) ?? task.resumeAgentId, - status: 'aborted', - state: wasStarted ? 'started' : 'not_started', - error: wasStarted - ? 'The user manually interrupted this subagent batch before this subagent finished.' - : 'The user manually interrupted this subagent batch before this subagent was started.', - }; - }); - }; - - const requeueRateLimited = (pending: QueuedSubagentPending): void => { - if (results[pending.index] !== undefined) return; - queued.unshift(pending); - }; - - const failQueued = (error: string): void => { - for (const { index } of queued.splice(0)) { - results[index] = { task: tasks[index]!, status: 'failed', error }; - } - }; - - const reduceSlotsAfterRateLimit = (): void => { - const now = Date.now(); - if ( - rateLimitReductionWindowStartMs === undefined || - now - rateLimitReductionWindowStartMs >= RATE_LIMIT_SLOT_REDUCTION_WINDOW_MS - ) { - rateLimitReductionWindowStartMs = now; - slotLimit = Math.max(1, slotLimit - 1); - } - }; - - const rateLimitRetryDelayMs = (retryAttempt: number): number => - RATE_LIMIT_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, retryAttempt - 1); - - const launch = (pending: QueuedSubagentPending): QueuedSubagentAttempt => { - const readiness = createControlledPromise(); - let agentId = pending.agentId; - let ready = false; - let launchSucceeded = false; - const markReadyOnly = (): void => { - if (ready) return; - ready = true; - clearTimeout(readinessTimer); - readiness.resolve(); - }; - const markReady = (): void => { - if (!launchSucceeded && !rateLimitMode) { - initialSuccessfulLaunches += 1; - } - launchSucceeded = true; - markReadyOnly(); - if (!rateLimitMode) return; - rateLimitedLaunchDelayMs = RATE_LIMIT_RETRY_BASE_DELAY_MS; - nextRateLimitedLaunchAtMs = Date.now() + RATE_LIMIT_RETRY_BASE_DELAY_MS; - }; - const readinessTimer = setTimeout(markReadyOnly, SUBAGENT_QUEUE_LAUNCH_DELAY_MS); - const outcome = this.runAttempt(tasks[pending.index]!, { - ...options, - totalTimedOut, - markAgentId: (id) => { - agentId = id; - }, - markReady, - retryAgentId: pending.agentId, - }); - const attempt: QueuedSubagentAttempt = { - pending, - outcome, - readiness, - get agentId() { return agentId; }, - get ready() { return ready; }, - get launchSucceeded() { return launchSucceeded; }, - settled: false, - }; - const settle = (): void => { - attempt.settled = true; - markReadyOnly(); - }; - void outcome.then(settle, settle); - active.push(attempt); - return attempt; - }; - - const processAttempt = async (attempt: QueuedSubagentAttempt): Promise => { - active.splice(active.indexOf(attempt), 1); - const outcome = await attempt.outcome; - if (isRateLimitedOutcome(outcome)) { - if (!rateLimitMode) { - slotLimit = Math.max(1, initialSuccessfulLaunches); - } - rateLimitMode = true; - reduceSlotsAfterRateLimit(); - const agentId = outcome.agentId ?? attempt.pending.agentId; - const retryAttempt = (attempt.pending.rateLimitAttempts ?? 0) + 1; - const now = Date.now(); - const retryDelayMs = rateLimitRetryDelayMs(retryAttempt); - if (nextRateLimitedLaunchAtMs <= now) { - nextRateLimitedLaunchAtMs = now + RATE_LIMIT_RETRY_BASE_DELAY_MS; - } - if (!attempt.launchSucceeded) { - rateLimitedLaunchDelayMs = Math.max(rateLimitedLaunchDelayMs * 2, retryDelayMs); - nextRateLimitedLaunchAtMs = now + rateLimitedLaunchDelayMs; - } - if (agentId !== undefined) { - this.events.onSuspended?.({ - task: tasks[attempt.pending.index]!, - agentId, - reason: RATE_LIMIT_SUSPENDED_REASON, - retryAttempt, - }); - } - requeueRateLimited({ - index: attempt.pending.index, - agentId, - rateLimitAttempts: retryAttempt, - nextRetryAtMs: now + retryDelayMs, - }); - return true; - } - results[attempt.pending.index] = outcome; - return true; - }; - - const processSettledAttempts = async (): Promise => { - while (true) { - const attempt = active.find((item) => item.settled); - if (attempt === undefined) return true; - if (!(await processAttempt(attempt))) return false; - } - }; - - const nextSettled = (): Promise => - Promise.race(active.map((attempt) => attempt.outcome.then(() => undefined))); - - const nextReadiness = (): Promise | undefined => { - const unready = active.filter((attempt) => !attempt.ready); - if (unready.length === 0) return undefined; - return Promise.race(unready.map((attempt) => attempt.readiness)); - }; - - const waitForInitialIncrement = async (): Promise => { - const delay = sleep(SUBAGENT_INITIAL_INCREMENT_DELAY_MS).then(() => 'delay' as const); - while (true) { - if (rateLimitMode) return; - options.signal.throwIfAborted(); - const waits: Array> = [delay]; - const settled = nextSettled().then(() => 'settled' as const); - const readiness = nextReadiness()?.then(() => 'readiness' as const); - waits.push(settled); - if (readiness !== undefined) waits.push(readiness); - const waitResult = await abortable(Promise.race(waits), options.signal); - if (waitResult === 'delay') return; - if (waitResult === 'settled') await processSettledAttempts(); - } - }; - - const eligibleRateLimitedQueuedIndex = (): number => { - const now = Date.now(); - return queued.findIndex((pending) => (pending.nextRetryAtMs ?? 0) <= now); - }; - - const nextRateLimitedLaunchWakeAt = (): number | undefined => { - if (!rateLimitMode || queued.length === 0 || active.length >= slotLimit) return undefined; - const nextPendingAt = Math.min( - ...queued.map((pending) => pending.nextRetryAtMs ?? 0), - ); - return Math.max(nextRateLimitedLaunchAtMs, nextPendingAt); - }; - - const launchRateLimitedQueued = (): number => { - if (!rateLimitMode || queued.length === 0 || active.length >= slotLimit) return 0; - const now = Date.now(); - if (now < nextRateLimitedLaunchAtMs) return 0; - const index = eligibleRateLimitedQueuedIndex(); - if (index < 0) return 0; - launch(queued.splice(index, 1)[0]!); - nextRateLimitedLaunchAtMs = now + rateLimitedLaunchDelayMs; - return 1; - }; - - const launchInitialBatch = (): void => { - for (const pending of queued.splice(0, Math.min(SUBAGENT_LAUNCH_BATCH_SIZE, queued.length))) { - launch(pending); - } - }; - - try { - launchInitialBatch(); - while (queued.length > 0) { - if (rateLimitMode) break; - await waitForInitialIncrement(); - if (!rateLimitMode && queued.length > 0) launch(queued.shift()!); - } - - while (active.length > 0 || queued.length > 0) { - await processSettledAttempts(); - - const launched = launchRateLimitedQueued(); - if (launched > 0) continue; - - if (active.length === 0) { - const wakeAt = nextRateLimitedLaunchWakeAt(); - if (wakeAt === undefined) { - failQueued('No running subagents remained to open queue slots after rate-limited launches.'); - break; - } - await abortable(sleep(Math.max(0, wakeAt - Date.now())), options.signal); - continue; - } - - const wakeAt = nextRateLimitedLaunchWakeAt(); - const waitForLaunch = - wakeAt === undefined - ? undefined - : sleep(Math.max(0, wakeAt - Date.now())).then(() => undefined); - const waitForReadiness = nextReadiness(); - await abortable( - Promise.race( - [nextSettled(), waitForLaunch, waitForReadiness].filter( - (wait): wait is Promise => wait !== undefined, - ), - ), - options.signal, - ); - } - - return finish('Subagent stopped before it could finish.'); - } catch (error) { - if (totalTimedOut()) return finish(totalTimeoutMessage(options.totalTimeoutMs)); - if (isUserCancellation(options.signal.reason)) { - try { - await processSettledAttempts(); - } catch { - // Children may observe the same user abort before returning handles. - } - return finishInterrupted(); - } - throw error; - } finally { - totalDeadline?.clear(); - } - } -} - -export function totalTimeoutMessage(timeoutMs: number | undefined): string { - return timeoutMs === undefined - ? 'Subagent batch total timeout elapsed.' - : `Subagent batch total timeout after ${formatTimeoutMs(timeoutMs)}.`; -} - -function isRateLimitedOutcome( - outcome: QueuedSubagentAttemptOutcome, -): outcome is QueuedSubagentRateLimitOutcome { - return 'type' in outcome && outcome.type === 'rate_limited'; -} - -export function formatTimeoutMs(timeoutMs: number): string { - return `${String(timeoutMs / 1000)}s`; } From 2f965c0f943708a9d68a2a8de93e991267274fed Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 13:23:27 +0800 Subject: [PATCH 43/72] wip --- .../agent-core/src/session/subagent-batch.ts | 567 ++++++++++++++++++ .../agent-core/src/session/subagent-host.ts | 4 +- .../src/session/subagent-launch-queue.ts | 46 -- .../test/session/subagent-host.test.ts | 2 +- packages/kosong/src/errors.ts | 40 ++ packages/kosong/src/index.ts | 1 + packages/kosong/test/errors.test.ts | 31 + 7 files changed, 642 insertions(+), 49 deletions(-) create mode 100644 packages/agent-core/src/session/subagent-batch.ts delete mode 100644 packages/agent-core/src/session/subagent-launch-queue.ts 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..fb077250d --- /dev/null +++ b/packages/agent-core/src/session/subagent-batch.ts @@ -0,0 +1,567 @@ +import { isProviderRateLimitError, type TokenUsage } from '@moonshot-ai/kosong'; +import * as retry from 'retry'; + +import type { RunSubagentOptions, SpawnSubagentOptions, SubagentHandle } from '.'; +import type { PromptOrigin } from '../agent/context'; +import { isUserCancellation } from '../utils/abort'; + +const INITIAL_LAUNCH_LIMIT = 5; +const INITIAL_LAUNCH_INTERVAL_MS = 700; +const START_CONFIRMATION_TIMEOUT_MS = 500; +const RATE_LIMIT_RETRY_BASE_MS = 3000; +const RATE_LIMIT_RETRY_FACTOR = 2; +const RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS = 2000; +const RATE_LIMIT_SUSPENDED_REASON = 'Provider rate limit; subagent requeued for retry.'; + +export type QueuedSubagentTask = { + readonly data: T; + readonly profileName: string; + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string; + readonly prompt: string; + readonly description: string; + readonly swarmItem?: string; + readonly runInBackground: boolean; + readonly origin?: PromptOrigin; + readonly resumeAgentId?: string; + readonly timeout?: number; + readonly signal?: AbortSignal; +}; + +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 interface SubagentLauncher { + spawn(options: SpawnSubagentOptions): Promise; + resume(agentId: string, options: RunSubagentOptions): Promise; + retry(agentId: string, options: RunSubagentOptions): Promise; + suspended?(event: SubagentSuspendedEvent): void; +} + +type SubagentSuspendedEvent = { + readonly task: QueuedSubagentTask; + readonly agentId: string; + readonly reason: string; +}; + +type AttemptOutcome = + | SubagentResult + | { + readonly type: 'rate_limited'; + readonly agentId?: string; + }; + +type TaskState = { + readonly index: number; + readonly task: QueuedSubagentTask; + agentId?: string; + retryAgentId?: string; + retryCount: number; + retryReadyAt: number; + started: boolean; + readyCounted: boolean; +}; + +type ActiveAttempt = { + readonly id: number; + readonly state: TaskState; + readonly controller: AbortController; + readonly readyTimer: ReturnType; + cleanup: () => void; + agentId?: string; + ready: boolean; + confirmationExpired: boolean; + timedOut: boolean; +}; + +export class SubagentBatch { + private readonly states: Array>; + private readonly pending: Array>; + private readonly results: Array | undefined>; + private readonly active = new Map>(); + private readonly controller = new AbortController(); + private readonly batchSignal: AbortSignal | undefined; + private readonly batchAbortListener: () => void; + private nextAttemptId = 0; + 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 lastCapacityShrinkAt: number | undefined; + private globalRetryIntervalMs = RATE_LIMIT_RETRY_BASE_MS; + private nextRateLimitLaunchAt = 0; + + constructor( + private readonly launcher: SubagentLauncher, + tasks: readonly QueuedSubagentTask[], + ) { + this.states = tasks.map((task, index) => ({ + index, + task, + retryCount: 0, + retryReadyAt: 0, + started: false, + readyCounted: 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 || this.active.size >= this.rateLimitCapacity) return; + + const now = Date.now(); + const nextAllowedAt = Math.max(this.nextRateLimitLaunchAt, this.nextPendingReadyAt()); + if (nextAllowedAt > now) { + this.rateLimitLaunchTimer = setTimeout(() => { + this.rateLimitLaunchTimer = undefined; + this.schedule(); + }, nextAllowedAt - 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; + } + + private startAttempt(state: TaskState): void { + if (this.finished || this.controller.signal.aborted) return; + + const attempt: ActiveAttempt = { + id: this.nextAttemptId, + state, + controller: new AbortController(), + cleanup: () => {}, + ready: false, + confirmationExpired: false, + timedOut: false, + readyTimer: setTimeout(() => { + attempt.confirmationExpired = true; + }, START_CONFIRMATION_TIMEOUT_MS), + }; + this.nextAttemptId += 1; + attempt.cleanup = this.linkAttemptSignals(attempt, state.task); + this.active.set(attempt.id, 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; + let handle: SubagentHandle | undefined; + const runOptions: RunSubagentOptions = { + parentToolCallId: task.parentToolCallId, + parentToolCallUuid: task.parentToolCallUuid, + prompt: task.prompt, + description: task.description, + runInBackground: task.runInBackground, + origin: task.origin, + signal: attempt.controller.signal, + onStarted: () => { + this.markAttemptReady(attempt); + }, + onFirstOutput: () => { + this.markAttemptReady(attempt); + }, + suppressRateLimitFailureEvent: true, + }; + + try { + attempt.controller.signal.throwIfAborted(); + if (attempt.state.retryAgentId !== undefined) { + handle = await this.launcher.retry(attempt.state.retryAgentId, runOptions); + } else if (task.resumeAgentId !== undefined) { + 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); + } + + attempt.agentId = handle.agentId; + attempt.state.agentId = handle.agentId; + 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 ?? attempt.agentId }; + } + + const status = + attempt.controller.signal.aborted && isUserCancellation(attempt.controller.signal.reason) + ? 'aborted' + : 'failed'; + return { + task, + agentId: handle?.agentId ?? attempt.agentId, + status, + state: handle === undefined && attempt.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.id)) return; + if (attempt.confirmationExpired) return; + + attempt.ready = true; + attempt.state.started = true; + if (!this.rateLimitMode && !attempt.state.readyCounted) { + attempt.state.readyCounted = true; + 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 (isRateLimitedOutcome(outcome)) { + this.requeueRateLimited(attempt, outcome.agentId); + } else { + this.results[attempt.state.index] = outcome; + } + 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 { + const active = this.active.get(attempt.id); + if (active !== attempt) return false; + + this.active.delete(attempt.id); + clearTimeout(attempt.readyTimer); + attempt.cleanup(); + return true; + } + + private requeueRateLimited(attempt: ActiveAttempt, agentId: string | undefined): void { + const state = attempt.state; + const knownAgentId = agentId ?? attempt.agentId ?? state.agentId; + if (knownAgentId !== undefined) { + state.agentId = knownAgentId; + state.retryAgentId = knownAgentId; + this.launcher.suspended?.({ + task: state.task, + agentId: knownAgentId, + reason: RATE_LIMIT_SUSPENDED_REASON, + }); + } + + const now = Date.now(); + state.retryCount += 1; + const retryDelay = rateLimitRetryDelayMs(state.retryCount); + 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 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 as Array>); + return true; + } + return false; + } + + 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()) { + clearTimeout(attempt.readyTimer); + 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 after ${formatTimeoutMs(attempt.state.task.timeout)}.`; + } + if (status === 'aborted') return 'The user manually interrupted this subagent batch.'; + return error instanceof Error ? error.message : String(error); + } +} + +function rateLimitRetryDelayMs(retryCount: number): number { + return retry.createTimeout(Math.max(0, retryCount - 1), { + minTimeout: RATE_LIMIT_RETRY_BASE_MS, + factor: RATE_LIMIT_RETRY_FACTOR, + randomize: false, + }); +} + +function isRateLimitedOutcome(outcome: AttemptOutcome): outcome is Extract< + AttemptOutcome, + { readonly type: 'rate_limited' } +> { + return (outcome as { readonly type?: string }).type === 'rate_limited'; +} + +export function formatTimeoutMs(timeoutMs: number): string { + if (timeoutMs % 1000 === 0) return `${String(timeoutMs / 1000)}s`; + return `${String(timeoutMs)}ms`; +} diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 3012ed3e9..14c9ab807 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -30,14 +30,14 @@ import { type QueuedSubagentRunResult, type QueuedSubagentSuspended, type QueuedSubagentTask, -} from './subagent-launch-queue'; +} from './subagent-batch'; import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md'; export type { QueuedSubagentRunOptions, QueuedSubagentRunResult, QueuedSubagentTask, -} from './subagent-launch-queue'; +} from './subagent-batch'; /** * A subagent summary shorter than this many characters triggers one diff --git a/packages/agent-core/src/session/subagent-launch-queue.ts b/packages/agent-core/src/session/subagent-launch-queue.ts deleted file mode 100644 index d15c2c630..000000000 --- a/packages/agent-core/src/session/subagent-launch-queue.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { TokenUsage } from '@moonshot-ai/kosong'; - -import type { RunSubagentOptions, SpawnSubagentOptions, SubagentHandle } from '.'; -import type { PromptOrigin } from '../agent/context'; - -export type QueuedSubagentTask = { - readonly data: T; - readonly profileName: string; - readonly parentToolCallId: string; - readonly parentToolCallUuid?: string; - readonly prompt: string; - readonly description: string; - readonly swarmItem?: string; - readonly runInBackground: boolean; - readonly origin?: PromptOrigin; - readonly resumeAgentId?: string; - readonly timeout?: number; - readonly signal?: AbortSignal; -}; - -export type QueuedSubagentRunResult = { - 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 interface SubagentLauncher { - spawn(options: SpawnSubagentOptions): Promise; - resume(agentId: string, options: RunSubagentOptions): Promise; - retry(agentId: string): Promise; -} - -export class SubagentLaunchQueue { - constructor( - private launcher: SubagentLauncher, - private tasks: QueuedSubagentTask[] - ) { } - - run(): Promise>> { - } -} diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index d77c83974..66b67e4d9 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -21,7 +21,7 @@ import { SubagentLaunchQueue, type QueuedSubagentAttemptOptions, type QueuedSubagentAttemptOutcome, -} from '../../src/session/subagent-launch-queue'; +} from '../../src/session/subagent-batch'; import { abortError, userCancellationReason } from '../../src/utils/abort'; import { testAgent, type AgentTestContext } from '../agent/harness/agent'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 686e093f3..949b1c042 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -98,6 +98,16 @@ const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [ /request.*exceed(?:ed|s|ing)?.*model token limit/, ] as const; +const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ + /(?:apistatuserror.*429|429.*apistatuserror)/, + /429.*too many requests/, + /too many requests/, + /provider\.rate_limit/, + /reached .*max rpm/, + /rate[ _-]?limit(?:ed)?/, + /rate-limited/, +] as const; + export function isContextOverflowErrorCode(code: string | null | undefined): boolean { return code === 'context_length_exceeded'; } @@ -118,3 +128,33 @@ export function isContextOverflowStatusError(statusCode: number, message: string const lowerMessage = message.toLowerCase(); return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } + +export function isProviderRateLimitError(error: unknown): boolean { + const statusCode = getStatusCode(error); + if (statusCode !== undefined) return statusCode === 429; + + const lowerMessage = errorMessage(error).toLowerCase(); + return PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + +function getStatusCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + + const record = error as Record; + const statusCode = record['statusCode']; + if (typeof statusCode === 'number') return statusCode; + const status = record['status']; + if (typeof status === 'number') return status; + + const response = record['response']; + if (typeof response !== 'object' || response === null) return undefined; + const responseRecord = response as Record; + const responseStatusCode = responseRecord['statusCode']; + if (typeof responseStatusCode === 'number') return responseStatusCode; + const responseStatus = responseRecord['status']; + return typeof responseStatus === 'number' ? responseStatus : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index c6d8b1339..2c7ce34f1 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -66,6 +66,7 @@ export { APITimeoutError, ChatProviderError, isContextOverflowStatusError, + isProviderRateLimitError, isRetryableGenerateError, } from './errors'; diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index 5e4c05bba..299317bf0 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -5,6 +5,7 @@ import { APIStatusError, APITimeoutError, ChatProviderError, + isProviderRateLimitError, isRetryableGenerateError, normalizeAPIStatusError, } from '#/errors'; @@ -188,3 +189,33 @@ describe('normalizeAPIStatusError', () => { expect(error).not.toBeInstanceOf(APIContextOverflowError); }); }); + +describe('isProviderRateLimitError', () => { + it('matches explicit HTTP 429 status errors', () => { + expect(isProviderRateLimitError(new APIStatusError(429, 'rate limited'))).toBe(true); + expect(isProviderRateLimitError({ response: { status: 429 } })).toBe(true); + expect(isProviderRateLimitError({ statusCode: 503, message: 'rate limit' })).toBe(false); + }); + + it('matches wrapped provider rate-limit messages without status metadata', () => { + expect( + isProviderRateLimitError( + new Error( + 'APIStatusError: 429 request id: req-429, request reached user+model max RPM: 50', + ), + ), + ).toBe(true); + expect( + isProviderRateLimitError( + "[provider.api_error] We're receiving too many requests at the moment. Please wait.", + ), + ).toBe(true); + expect(isProviderRateLimitError(new Error('[provider.rate_limit] slow down'))).toBe(true); + }); + + it('does not match non-rate-limit provider errors', () => { + expect(isProviderRateLimitError(new APIStatusError(401, 'unauthorized'))).toBe(false); + expect(isProviderRateLimitError('APIStatusError: 401 unauthorized')).toBe(false); + expect(isProviderRateLimitError(new Error('context length exceeded'))).toBe(false); + }); +}); From 3e79efa8bae89b9c3f5f0f034b2c16ee911f4614 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 13:43:15 +0800 Subject: [PATCH 44/72] wip --- packages/agent-core/src/session/index.ts | 1 - .../agent-core/src/session/subagent-batch.ts | 5 +- .../agent-core/src/session/subagent-host.ts | 157 +++--------------- .../builtin/collaboration/agent-swarm.ts | 8 +- 4 files changed, 31 insertions(+), 140 deletions(-) diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 218a885c9..5a0ee234a 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -338,7 +338,6 @@ export class Session { 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/subagent-batch.ts b/packages/agent-core/src/session/subagent-batch.ts index fb077250d..47564bf08 100644 --- a/packages/agent-core/src/session/subagent-batch.ts +++ b/packages/agent-core/src/session/subagent-batch.ts @@ -2,7 +2,6 @@ import { isProviderRateLimitError, type TokenUsage } from '@moonshot-ai/kosong'; import * as retry from 'retry'; import type { RunSubagentOptions, SpawnSubagentOptions, SubagentHandle } from '.'; -import type { PromptOrigin } from '../agent/context'; import { isUserCancellation } from '../utils/abort'; const INITIAL_LAUNCH_LIMIT = 5; @@ -22,7 +21,6 @@ export type QueuedSubagentTask = { readonly description: string; readonly swarmItem?: string; readonly runInBackground: boolean; - readonly origin?: PromptOrigin; readonly resumeAgentId?: string; readonly timeout?: number; readonly signal?: AbortSignal; @@ -45,7 +43,7 @@ export interface SubagentLauncher { suspended?(event: SubagentSuspendedEvent): void; } -type SubagentSuspendedEvent = { +export type SubagentSuspendedEvent = { readonly task: QueuedSubagentTask; readonly agentId: string; readonly reason: string; @@ -253,7 +251,6 @@ export class SubagentBatch { prompt: task.prompt, description: task.description, runInBackground: task.runInBackground, - origin: task.origin, signal: attempt.controller.signal, onStarted: () => { this.markAttemptReady(attempt); diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 14c9ab807..74da7d4ac 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -1,4 +1,4 @@ -import type { TokenUsage } from '@moonshot-ai/kosong'; +import { isProviderRateLimitError, type TokenUsage } from '@moonshot-ai/kosong'; import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; @@ -13,29 +13,21 @@ import { } from '../profile'; import type { AgentEvent } from '../rpc'; import { - createDeadlineAbortSignal, - isUserCancellation, linkAbortSignal, userCancellationReason, } from '../utils/abort'; import { collectGitContext } from './git-context'; import type { Session } from './index'; import { - SubagentLaunchQueue, - formatTimeoutMs, - totalTimeoutMessage, - type QueuedSubagentAttemptOptions, - type QueuedSubagentAttemptOutcome, - type QueuedSubagentRunOptions, - type QueuedSubagentRunResult, - type QueuedSubagentSuspended, + SubagentBatch, + type SubagentResult, + type SubagentSuspendedEvent, type QueuedSubagentTask, } from './subagent-batch'; import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md'; export type { - QueuedSubagentRunOptions, - QueuedSubagentRunResult, + SubagentResult as QueuedSubagentRunResult, QueuedSubagentTask, } from './subagent-batch'; @@ -49,12 +41,9 @@ const SUMMARY_CONTINUATION_ATTEMPTS = 1; const HOOK_TEXT_PREVIEW_LENGTH = 500; const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; -const RATE_LIMIT_429_MESSAGE = - "429 We're receiving too many requests at the moment. Please wait a moment and try again."; -const RATE_LIMIT_429_BODY = - "We're receiving too many requests at the moment. Please wait a moment and try again."; 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. @@ -76,7 +65,6 @@ export interface RunSubagentOptions { readonly prompt: string; readonly description: string; readonly runInBackground: boolean; - readonly origin?: PromptOrigin; readonly signal: AbortSignal; readonly onStarted?: () => void; readonly onFirstOutput?: () => void; @@ -107,21 +95,11 @@ export type SubagentHandle = { export class SessionSubagentHost { private readonly activeChildren = new Map(); - readonly launchQueue: SubagentLaunchQueue; constructor( private readonly session: Session, private readonly ownerAgentId: string, - ) { - this.launchQueue = new SubagentLaunchQueue( - (task, options) => this.runQueuedTaskAttempt(task, options), - { - onSuspended: (event) => { - this.emitSubagentSuspended(event); - }, - }, - ); - } + ) {} async spawn(options: SpawnSubagentOptions): Promise { options.signal.throwIfAborted(); @@ -225,11 +203,12 @@ export class SessionSubagentHost { return { agentId, profileName, resumed: true, completion }; } - async runQueued( - tasks: readonly QueuedSubagentTask[], - options: QueuedSubagentRunOptions, - ): Promise>> { - return this.launchQueue.enqueue(tasks, options); + async runQueued(tasks: readonly QueuedSubagentTask[]): Promise>> { + return new SubagentBatch(this, tasks).run(); + } + + suspended(event: SubagentSuspendedEvent): void { + this.emitSubagentSuspended(event); } async startBtw(): Promise { @@ -286,75 +265,6 @@ export class SessionSubagentHost { return metadata.swarmItem; } - private async runQueuedTaskAttempt( - task: QueuedSubagentTask, - options: QueuedSubagentAttemptOptions, - ): Promise> { - const subagentDeadline = - options.timeoutMs === undefined - ? undefined - : createDeadlineAbortSignal(options.signal, options.timeoutMs); - const runSignal = subagentDeadline?.signal ?? options.signal; - let handle: SubagentHandle | undefined; - try { - runSignal.throwIfAborted(); - const runOptions = { - parentToolCallId: task.parentToolCallId, - parentToolCallUuid: task.parentToolCallUuid, - prompt: task.prompt, - description: task.description, - swarmItem: task.swarmItem, - runInBackground: task.runInBackground, - origin: task.origin, - signal: runSignal, - onStarted: options.markReady, - onFirstOutput: options.markReady, - suppressRateLimitFailureEvent: true, - }; - if (options.retryAgentId !== undefined) { - handle = await this.retry(options.retryAgentId, runOptions); - } else if (task.resumeAgentId !== undefined) { - handle = await this.resume(task.resumeAgentId, runOptions); - } else { - handle = await this.spawn({ - profileName: task.profileName, - ...runOptions, - }); - } - options.markAgentId(handle.agentId); - const completion = await handle.completion; - return { - task, - agentId: handle.agentId, - status: 'completed', - result: completion.result, - usage: completion.usage, - }; - } catch (error) { - if (isRateLimit429Error(error)) { - return { type: 'rate_limited', agentId: handle?.agentId }; - } - if (handle === undefined) { - throw error; - } - const status: QueuedSubagentRunResult['status'] = isUserCancellation(runSignal.reason) ? 'aborted' : 'failed'; - const state = status === 'aborted' ? 'started' : undefined; - const message = - subagentDeadline?.timedOut() === true && options.timeoutMs !== undefined - ? `Subagent timed out after ${formatTimeoutMs(options.timeoutMs)}.` - : options.totalTimedOut() && options.totalTimeoutMs !== undefined - ? totalTimeoutMessage(options.totalTimeoutMs) - : status === 'aborted' - ? 'The user manually interrupted this subagent batch.' - : isAbortError(error) - ? 'The subagent was stopped before it finished.' - : error instanceof Error ? error.message : String(error); - return { task, agentId: handle.agentId, status, state, error: message }; - } finally { - subagentDeadline?.clear(); - } - } - private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile { const profile = DEFAULT_AGENT_PROFILES[parent.config.profileName ?? 'agent']?.subagents?.[profileName] ?? @@ -389,11 +299,10 @@ export class SessionSubagentHost { 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' }; this.emitSubagentStarted(parent, childId, profileName, options); options.onStarted?.(); - child.turn.prompt([{ type: 'text', text: childPrompt }], origin); - return this.waitForChildCompletion(parent, childId, child, profileName, options, origin); + child.turn.prompt([{ type: 'text', text: childPrompt }], SUBAGENT_PROMPT_ORIGIN); + return this.waitForChildCompletion(parent, childId, child, profileName, options); }); } @@ -413,7 +322,7 @@ export class SessionSubagentHost { if (child.turn.retry('agent-host') === null) { throw new Error(`Agent instance "${childId}" could not start a retry turn`); } - return this.waitForChildCompletion(parent, childId, child, profileName, options, origin); + return this.waitForChildCompletion(parent, childId, child, profileName, options); }); } @@ -448,7 +357,6 @@ export class SessionSubagentHost { child: Agent, profileName: string, options: RunSubagentOptions, - origin: PromptOrigin, ): Promise { await runChildTurnToCompletion(child, options.signal); @@ -461,7 +369,7 @@ export class SessionSubagentHost { while (remainingContinuations > 0 && result.length < SUMMARY_MIN_LENGTH) { remainingContinuations -= 1; options.signal.throwIfAborted(); - child.turn.prompt([{ type: 'text', text: SUMMARY_CONTINUATION_PROMPT }], origin); + child.turn.prompt([{ type: 'text', text: SUMMARY_CONTINUATION_PROMPT }], SUBAGENT_PROMPT_ORIGIN); await runChildTurnToCompletion(child, options.signal); result = lastAssistantText(child); } @@ -527,11 +435,16 @@ export class SessionSubagentHost { ): (() => void) | undefined { if (onFirstOutput === undefined) return undefined; let emitted = false; - return child.onEvent((event) => { + const emitEvent = child.emitEvent.bind(child); + child.emitEvent = (event: AgentEvent) => { + emitEvent(event); if (emitted || !isFirstOutputEvent(event)) return; emitted = true; onFirstOutput(); - }); + }; + return () => { + child.emitEvent = emitEvent; + }; } private emitSubagentSpawned( @@ -574,7 +487,7 @@ export class SessionSubagentHost { }); } - private emitSubagentSuspended(event: QueuedSubagentSuspended): void { + private emitSubagentSuspended(event: SubagentSuspendedEvent): void { const parent = this.session.getReadyAgent?.(this.ownerAgentId); parent?.emitEvent({ type: 'subagent.suspended', @@ -631,29 +544,11 @@ function isFirstOutputEvent(event: AgentEvent): boolean { return event.type === 'tool.call.started'; } -function isRateLimit429Error(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - if (hasRateLimitStatus(error)) return true; - if ([RATE_LIMIT_429_MESSAGE, RATE_LIMIT_429_BODY, 'provider.rate_limit'].some((part) => message.includes(part))) return true; - const normalized = message.toLowerCase(); - const loosePatterns = ['too many requests', 'max rpm', 'max tpm', 'requests per minute', 'tokens per minute']; - if (loosePatterns.some((pattern) => normalized.includes(pattern))) return true; - if (!/\b429\b/.test(normalized)) return false; - return ['apistatuserror', 'rate limit', 'rate_limit', 'rate-limited'].some((pattern) => normalized.includes(pattern)); -} - function shouldSuppressQueuedAttemptFailureEvent( options: RunSubagentOptions, error: unknown, ): boolean { if (options.suppressRateLimitFailureEvent !== true) return false; - if (isRateLimit429Error(error)) return true; + if (isProviderRateLimitError(error)) return true; return isAbortError(error) || options.signal.aborted; } - -function hasRateLimitStatus(error: unknown): boolean { - if (typeof error !== 'object' || error === null) return false; - const statusCode = (error as { readonly statusCode?: unknown }).statusCode; - const status = (error as { readonly status?: unknown }).status; - return statusCode === 429 || status === 429; -} diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 5a74b56f6..7d188531d 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -180,6 +180,7 @@ export class AgentSwarmTool implements BuiltinTool { item: this.subagentHost.getSwarmItem(spec.agentId), }; }); + const timeout = args.timeout === undefined ? undefined : args.timeout * 1000; const tasks = specsWithPersistedItems.map((spec): QueuedSubagentTask => { const resumeAgentId = spec.kind === 'resume' ? spec.agentId : undefined; return { @@ -195,12 +196,11 @@ export class AgentSwarmTool implements BuiltinTool { swarmItem: spec.item, runInBackground: false, resumeAgentId, + signal, + timeout, }; }); - const results = await this.subagentHost.runQueued(tasks, { - signal, - timeoutMs: args.timeout === undefined ? undefined : args.timeout * 1000, - }); + const results = await this.subagentHost.runQueued(tasks); return renderSwarmResults(results.map(({ task, ...result }) => ({ spec: task.data, ...result }))); } } From 09918722782fc265dc08189c46dfa4690ff26610 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 13:53:43 +0800 Subject: [PATCH 45/72] wip --- .../messages/agent-swarm-progress.ts | 138 ++-- .../tui/controllers/session-event-handler.ts | 567 ++-------------- .../src/tui/controllers/session-replay.ts | 12 +- .../tui/controllers/subagent-event-handler.ts | 616 ++++++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 10 +- .../kimi-code/src/tui/utils/subagent-error.ts | 9 + .../kimi-code/test/tui/message-replay.test.ts | 18 +- .../builtin/collaboration/agent-swarm.ts | 12 - 8 files changed, 749 insertions(+), 633 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/subagent-event-handler.ts create mode 100644 apps/kimi-code/src/tui/utils/subagent-error.ts 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 index 920fff485..ffa2795f8 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -30,6 +30,7 @@ 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...'; @@ -59,6 +60,16 @@ type ClearableMemberKey = | 'failureText' | 'suspendedReason'; +const COMPLETED_CLEAR_KEYS = ['failedAtMs', 'failureText', 'suspendedReason'] as const satisfies readonly ClearableMemberKey[]; +const FAILED_CLEAR_KEYS = ['completedAtMs', 'completedText', 'suspendedReason'] as const satisfies readonly ClearableMemberKey[]; +const TERMINAL_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; + interface AgentSwarmMember { readonly id: string; agentId?: string; @@ -188,37 +199,30 @@ export class AgentSwarmProgressComponent implements Component { 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 fullResumeItems = agentSwarmResumeItemsFromArgs(args); - const partialResumeItems = - options.streamingArguments === undefined - ? [] - : agentSwarmPartialResumeItemsFromArguments(options.streamingArguments); - const fullItems = agentSwarmItemsFromArgs(args); - const partialItems = - options.streamingArguments === undefined - ? [] - : agentSwarmPartialItemsFromArguments(options.streamingArguments); - const fullRows = [...fullResumeItems, ...fullItems]; - const partialRows = [...partialResumeItems, ...partialItems]; + const fullRows = [...agentSwarmResumeItemsFromArgs(args), ...agentSwarmItemsFromArgs(args)]; + const partialRows = streamingArguments === undefined + ? [] + : [ + ...agentSwarmPartialResumeItemsFromArguments(streamingArguments), + ...agentSwarmPartialItemsFromArguments(streamingArguments), + ]; if ( fullRows.length > 0 || partialRows.length > 0 || - ( - options.streamingArguments !== undefined && - agentSwarmWorkItemsStartedFromArguments(options.streamingArguments) - ) + (streamingArguments !== undefined && agentSwarmWorkItemsStartedFromArguments(streamingArguments)) ) { this.itemsStarted = true; } const fullPromptTemplate = agentSwarmPromptTemplateFromArgs(args); const partialPromptTemplate = - options.streamingArguments === undefined + streamingArguments === undefined ? '' - : agentSwarmPartialPromptTemplateFromArguments(options.streamingArguments); + : agentSwarmPartialPromptTemplateFromArguments(streamingArguments); const promptTemplate = fullPromptTemplate.length > 0 ? fullPromptTemplate : partialPromptTemplate; if (promptTemplate.length > 0 || this.promptTemplateText.length === 0) { @@ -294,14 +298,7 @@ export class AgentSwarmProgressComponent implements Component { const member = this.findMemberByAgentId(agentId); if (member === undefined || member.phase === 'failed' || member.phase === 'cancelled') return; const nowMs = Date.now(); - if (member.phase !== 'completed') { - this.progressEstimator.markCompleted(member.id, nowMs); - member.completedAtMs = nowMs; - } - const normalizedCompletedText = normalizeFinalOutputText(completedText); - if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; - clearMemberState(member, 'failedAtMs', 'failureText', 'suspendedReason'); - member.phase = 'completed'; + this.completeMember(member, nowMs, completedText); this.startAnimationIfNeeded(); } @@ -316,7 +313,7 @@ export class AgentSwarmProgressComponent implements Component { member.agentId = input.agentId; this.progressEstimator.markQueued(member.id, Date.now()); member.phase = 'queued'; - clearMemberState(member, 'suspendedReason', 'completedAtMs', 'completedText', 'failedAtMs', 'failureText'); + clearMemberState(member, ...TERMINAL_CLEAR_KEYS); this.startAnimationIfNeeded(); } @@ -324,29 +321,17 @@ export class AgentSwarmProgressComponent implements Component { const member = this.findMemberByAgentId(agentId); if (member === undefined) return; const nowMs = Date.now(); - 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, 'completedAtMs', 'completedText', 'suspendedReason'); + this.failMember(member, nowMs, failureText); this.startAnimationIfNeeded(); } markSwarmFailed(failureText?: string): void { this.failed = true; this.aborted = false; - const normalizedFailureText = normalizeFailureText(failureText); const nowMs = Date.now(); for (const member of this.members) { if (isTerminalPhase(member.phase)) continue; - this.progressEstimator.markFailed(member.id, nowMs); - member.failedAtMs = nowMs; - if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; - member.phase = 'failed'; - clearMemberState(member, 'completedAtMs', 'completedText', 'suspendedReason'); + this.failMember(member, nowMs, failureText); } this.startAnimationIfNeeded(); } @@ -354,9 +339,7 @@ export class AgentSwarmProgressComponent implements Component { markCancelled(agentId: string): void { const member = this.findMemberByAgentId(agentId); if (member === undefined) return; - this.progressEstimator.markCancelled(member.id, Date.now()); - member.phase = 'cancelled'; - clearMemberState(member, 'completedAtMs', 'completedText', 'failedAtMs', 'failureText', 'suspendedReason'); + this.cancelMember(member, Date.now()); } markActiveCancelled(): void { @@ -364,9 +347,7 @@ export class AgentSwarmProgressComponent implements Component { const nowMs = Date.now(); for (const member of this.members) { if (isTerminalPhase(member.phase)) continue; - this.progressEstimator.markCancelled(member.id, nowMs); - member.phase = 'cancelled'; - clearMemberState(member, 'completedAtMs', 'completedText', 'failedAtMs', 'failureText', 'suspendedReason'); + this.cancelMember(member, nowMs); } this.startAnimationIfNeeded(); } @@ -381,33 +362,12 @@ export class AgentSwarmProgressComponent implements Component { const member = this.members[entry.index - 1]; if (member === undefined) continue; if (entry.status === 'completed') { - if (member.phase !== 'completed') { - this.progressEstimator.markCompleted(member.id, nowMs); - member.completedAtMs = nowMs; - } - const normalizedCompletedText = normalizeFinalOutputText(entry.completedText); - if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; - clearMemberState(member, 'failedAtMs', 'failureText', 'suspendedReason'); + this.completeMember(member, nowMs, entry.completedText); } else if (entry.status === 'failed') { - if (member.phase !== 'failed') { - this.progressEstimator.markFailed(member.id, nowMs); - member.failedAtMs = nowMs; - } - const normalizedFailureText = normalizeFailureText(entry.failureText); - if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; - clearMemberState(member, 'completedAtMs', 'completedText', 'suspendedReason'); + this.failMember(member, nowMs, entry.failureText); } else { - this.progressEstimator.markCancelled(member.id, nowMs); - clearMemberState( - member, - 'completedAtMs', - 'completedText', - 'failedAtMs', - 'failureText', - 'suspendedReason', - ); + this.cancelMember(member, nowMs); } - member.phase = entry.status; } this.startAnimationIfNeeded(); return true; @@ -735,6 +695,34 @@ export class AgentSwarmProgressComponent implements Component { } 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 { + this.progressEstimator.markCancelled(member.id, nowMs); + member.phase = 'cancelled'; + clearMemberState(member, ...TERMINAL_CLEAR_KEYS); + } } function createMembers(count: number, phase: AgentSwarmPhase): AgentSwarmMember[] { @@ -1092,22 +1080,18 @@ function compactColumnsForLayout( function compactBarCellsForCellWidth(cellWidth: number, idWidth: number): number { return Math.max( 1, - cellWidth - compactFixedWidth(idWidth) - compactTerminalMarkWidth(), + cellWidth - compactFixedWidth(idWidth) - COMPACT_TERMINAL_MARK_WIDTH, ); } function compactCellWidth(idWidth: number, barCells: number): number { - return compactFixedWidth(idWidth) + Math.max(1, barCells) + compactTerminalMarkWidth(); + return compactFixedWidth(idWidth) + Math.max(1, barCells) + COMPACT_TERMINAL_MARK_WIDTH; } function compactFixedWidth(idWidth: number): number { return idWidth + 1 + 2; } -function compactTerminalMarkWidth(): number { - return 1; -} - function summarizeSnapshots(snapshots: readonly AgentSwarmSnapshot[]): AgentSwarmSummary { let completed = 0; let failed = 0; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index ed3a65be1..3a8450741 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -17,11 +17,6 @@ import type { Session, SessionMetaUpdatedEvent, SkillActivatedEvent, - SubagentCompletedEvent, - SubagentFailedEvent, - SubagentSpawnedEvent, - SubagentStartedEvent, - SubagentSuspendedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -37,15 +32,9 @@ import type { import { buildGoalCompletionMessage } from '@moonshot-ai/kimi-code-sdk'; import { MoonLoader } from '../components/chrome/moon-loader'; -import { - AgentSwarmProgressComponent, - agentSwarmDescriptionFromArgs, - agentSwarmGridHeightForTerminalRows, -} from '../components/messages/agent-swarm-progress'; import { buildGoalMarker } from '../components/messages/goal-markers'; import { StatusMessageComponent } from '../components/messages/status-message'; import { - MAIN_AGENT_ID, OAUTH_LOGIN_REQUIRED_CODE, OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, } from '../constant/kimi-tui'; @@ -63,9 +52,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, @@ -80,9 +68,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, @@ -120,31 +108,27 @@ export interface SessionEventHost { readonly tasksBrowserController: TasksBrowserController; } -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 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(); mcpServers: Map = new Map(); - agentSwarmProgress: Map = new Map(); private goalCompletionAwaitingClear = false; private goalCompletionTurnEnded = false; private queuedGoalPromotionPending = false; @@ -152,14 +136,12 @@ 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(); - this.clearAgentSwarmProgress(); this.goalCompletionAwaitingClear = false; this.goalCompletionTurnEnded = false; this.queuedGoalPromotionPending = false; @@ -169,11 +151,15 @@ export class SessionEventHandler { } clearAgentSwarmProgress(): void { - for (const progress of this.agentSwarmProgress.values()) { - progress.dispose(); - } - this.agentSwarmProgress.clear(); - this.host.updateActivityPane(); + this.subAgentEventHandler.clearAgentSwarmProgress(); + } + + hasActiveAgentSwarmToolCall(): boolean { + return this.subAgentEventHandler.hasActiveAgentSwarmToolCall(); + } + + syncAgentSwarmActivitySpinner(spinner: MoonLoader | undefined): void { + this.subAgentEventHandler.syncAgentSwarmActivitySpinner(spinner); } startSubscription(): void { @@ -232,7 +218,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)); @@ -262,11 +248,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.started': this.handleSubagentStarted(event); break; - case 'subagent.suspended': this.handleSubagentSuspended(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; @@ -288,124 +275,6 @@ 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); - const swarmProgress = this.agentSwarmProgress.get(parentToolCallId); - if (swarmProgress !== undefined) { - if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { - swarmProgress.appendModelDelta({ - agentId: subagentId, - delta: event.delta, - }); - } else if (event.type === 'tool.call.started') { - swarmProgress.recordToolCall({ - agentId: subagentId, - toolCallId: event.toolCallId, - }); - } else if (event.type === 'subagent.started') { - swarmProgress.markStarted(event.subagentId); - } else if (event.type === 'subagent.suspended') { - swarmProgress.markSuspended({ - agentId: event.subagentId, - reason: event.reason, - description: event.description, - }); - } else if (event.type === 'subagent.failed') { - if (isUserCancelledSubagentError(event.error)) { - swarmProgress.markCancelled(event.subagentId); - } else { - swarmProgress.markFailed(event.subagentId, event.error); - } - } - this.host.state.ui.requestRender(); - return true; - } - 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 'goal.updated': - case 'warning': - case 'session.meta.updated': - case 'skill.activated': - case 'subagent.completed': - case 'subagent.failed': - case 'subagent.spawned': - case 'subagent.started': - case 'subagent.suspended': - 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(); @@ -498,32 +367,7 @@ export class SessionEventHandler { } private 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.host.state.ui.requestRender(); - } - - 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(); + this.subAgentEventHandler.markActiveAgentSwarmsCancelled(); } private isAnthropicSessionActive(): boolean { @@ -615,9 +459,7 @@ export class SessionEventHandler { }; streamingUI.registerToolCall(toolCall); if (event.name === 'AgentSwarm') { - const progress = this.ensureAgentSwarmProgress(event.toolCallId, toolCall.args); - progress.markInputComplete(); - this.host.state.ui.requestRender(); + this.subAgentEventHandler.handleAgentSwarmToolCallStarted(event.toolCallId, toolCall.args); } this.host.patchLivePane({ mode: 'tool', @@ -633,12 +475,11 @@ export class SessionEventHandler { const preview = streamingUI.getStreamingToolCallPreview(event.toolCallId); if ( preview !== undefined && - (preview.name === 'AgentSwarm' || this.agentSwarmProgress.has(event.toolCallId)) + (preview.name === 'AgentSwarm' || this.subAgentEventHandler.hasAgentSwarmProgress(event.toolCallId)) ) { - this.ensureAgentSwarmProgress(event.toolCallId, preview.args, { + this.subAgentEventHandler.handleAgentSwarmToolCallDelta(event.toolCallId, preview.args, { streamingArguments: preview.argumentsText, }); - this.host.state.ui.requestRender(); } this.host.patchLivePane({ @@ -652,52 +493,6 @@ export class SessionEventHandler { streamingUI.scheduleFlush(); } - 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; - } - - let progress: AgentSwarmProgressComponent; - progress = new AgentSwarmProgressComponent({ - description: agentSwarmDescriptionFromArgs(args), - colors: this.host.state.theme.colors, - availableGridHeight: () => this.agentSwarmGridHeight(), - requestRender: () => { - this.host.state.ui.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.host.state.ui.requestRender(); - return progress; - } - - 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 handleToolProgress(event: ToolProgressEvent): void { if (event.update.kind !== 'status') return; const text = event.update.text; @@ -717,27 +512,11 @@ export class SessionEventHandler { synthetic: event.synthetic, }; const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); - const progress = this.agentSwarmProgress.get(event.toolCallId); - if (progress !== undefined) { - if (event.isError === true && isUserCancelledSubagentError(resultData.output)) { - if (progress.isRequestStreaming()) { - this.removeAgentSwarmProgress(event.toolCallId, progress); - } else { - progress.markToolCallEnded(); - progress.markActiveCancelled(); - } - } else if (event.isError === true) { - progress.markToolCallEnded(); - if (!progress.applyResult(resultData.output)) { - progress.markSwarmFailed(resultData.output); - } - } else { - progress.markToolCallEnded(); - progress.applyResult(resultData.output); - } - this.host.updateActivityPane(); - this.host.state.ui.requestRender(); - } + 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)) { @@ -1115,262 +894,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; - } - - const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); - if (swarmProgress !== undefined) { - swarmProgress.registerSubagent({ - agentId: event.subagentId, - description: event.description, - }); - this.host.state.ui.requestRender(); - 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 handleSubagentStarted(event: SubagentStartedEvent): void { - const { streamingUI } = this.host; - const existing = this.subagentInfo.get(event.subagentId); - if (existing === undefined) { - this.subagentInfo.set(event.subagentId, { - parentToolCallId: event.parentToolCallId, - name: event.subagentName, - }); - } - - if (event.runInBackground) return; - - const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); - if (swarmProgress !== undefined) { - swarmProgress.markStarted(event.subagentId); - this.host.state.ui.requestRender(); - 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); - } - } - if (tc === undefined) return; - tc.onSubagentStarted({ - agentId: event.subagentId, - agentName: event.subagentName, - runInBackground: event.runInBackground, - }); - } - - private handleSubagentSuspended(event: SubagentSuspendedEvent): void { - const existing = this.subagentInfo.get(event.subagentId); - if (existing === undefined) { - this.subagentInfo.set(event.subagentId, { - parentToolCallId: event.parentToolCallId, - name: event.subagentName, - }); - } - - if (event.runInBackground) return; - - const swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); - if (swarmProgress !== undefined) { - swarmProgress.markSuspended({ - agentId: event.subagentId, - reason: event.reason, - description: event.description, - }); - this.host.state.ui.requestRender(); - } - } - - 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 swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); - if (swarmProgress !== undefined) { - swarmProgress.markCompleted(event.subagentId, event.resultSummary); - this.host.state.ui.requestRender(); - streamingUI.removeToolComponentIfInactive(event.parentToolCallId); - 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 swarmProgress = this.agentSwarmProgress.get(event.parentToolCallId); - if (swarmProgress !== undefined) { - if (isUserCancelledSubagentError(event.error)) { - swarmProgress.markCancelled(event.subagentId); - } else { - swarmProgress.markFailed(event.subagentId, event.error); - } - this.host.state.ui.requestRender(); - streamingUI.removeToolComponentIfInactive(event.parentToolCallId); - 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 // --------------------------------------------------------------------------- @@ -1474,13 +997,3 @@ export class SessionEventHandler { state.ui.requestRender(); } } - -function isUserCancelledSubagentError(error: string): boolean { - const normalized = error.trim(); - return ( - normalized === 'Aborted by the user' || - normalized === 'The user manually interrupted this subagent batch.' || - normalized.startsWith('The user manually interrupted this subagent ') || - normalized.includes('This was a deliberate user action, not a system error') - ); -} 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/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts new file mode 100644 index 000000000..3a314af2e --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -0,0 +1,616 @@ +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 { isUserCancelledSubagentError } from '../utils/subagent-error'; +import { nextTranscriptId } from '../utils/transcript-id'; +import type { SessionEventHost } from './session-event-handler'; + +export interface SubagentInfo { + readonly parentToolCallId: string; + readonly name: string; +} + +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 { + this.rememberSubagent(event); + if (!event.runInBackground) this.handleForegroundSubagentStarted(event); + } + + private handleSubagentSuspended( + event: SubagentLifecycleEventOf<'subagent.suspended'>, + ): void { + this.rememberSubagent(event); + if (!event.runInBackground) this.handleForegroundSubagentSuspended(event); + } + + 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; + } + + this.handleForegroundSubagentCompleted(event); + } + + 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; + } + + this.handleForegroundSubagentFailed(event); + } + + 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'> + | SubagentLifecycleEventOf<'subagent.started'> + | SubagentLifecycleEventOf<'subagent.suspended'>, + ): void { + if (event.type !== 'subagent.spawned' && this.subagentInfo.has(event.subagentId)) return; + this.subagentInfo.set(event.subagentId, { + parentToolCallId: event.parentToolCallId, + name: event.subagentName, + }); + } + + private handleForegroundSubagentSpawned( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): void { + if (this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => { + progress.registerSubagent({ + agentId: event.subagentId, + description: event.description, + }); + })) { + 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'>, + ): void { + if (this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => { + progress.markStarted(event.subagentId); + })) { + return; + } + + const tc = this.getOrActivateToolComponent(event.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentStarted({ + agentId: event.subagentId, + agentName: event.subagentName, + runInBackground: event.runInBackground, + }); + } + + private handleForegroundSubagentSuspended( + event: SubagentLifecycleEventOf<'subagent.suspended'>, + ): void { + this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => { + progress.markSuspended({ + agentId: event.subagentId, + reason: event.reason, + description: event.description, + }); + }); + } + + private handleForegroundSubagentCompleted( + event: SubagentLifecycleEventOf<'subagent.completed'>, + ): void { + if (this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => { + progress.markCompleted(event.subagentId, event.resultSummary); + })) { + this.host.streamingUI.removeToolComponentIfInactive(event.parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(event.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentCompleted({ + contextTokens: event.contextTokens, + usage: event.usage, + resultSummary: event.resultSummary, + }); + this.host.streamingUI.removeToolComponentIfInactive(event.parentToolCallId); + } + + private handleForegroundSubagentFailed( + event: SubagentLifecycleEventOf<'subagent.failed'>, + ): void { + if (this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => { + this.markAgentSwarmFailedOrCancelled(progress, event.subagentId, event.error); + })) { + this.host.streamingUI.removeToolComponentIfInactive(event.parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(event.parentToolCallId); + if (tc === undefined) return; + tc.onSubagentFailed({ error: event.error }); + this.host.streamingUI.removeToolComponentIfInactive(event.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' + ); +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index e97755eb3..44608e41e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1665,19 +1665,13 @@ export class KimiTUI { private shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode: EffectiveActivityPaneMode): boolean { return ( - Array.from(this.sessionEventHandler.agentSwarmProgress.values()).some((progress) => - progress.isToolCallActive() - ) && + this.sessionEventHandler.hasActiveAgentSwarmToolCall() && (effectiveMode === 'waiting' || effectiveMode === 'tool') ); } private syncAgentSwarmActivitySpinner(spinner: MoonLoader | undefined): void { - for (const progress of this.sessionEventHandler.agentSwarmProgress.values()) { - progress.setActivitySpinnerText( - spinner === undefined ? undefined : () => spinner.renderInline(), - ); - } + this.sessionEventHandler.syncAgentSwarmActivitySpinner(spinner); } private syncTerminalProgress(active: boolean): void { diff --git a/apps/kimi-code/src/tui/utils/subagent-error.ts b/apps/kimi-code/src/tui/utils/subagent-error.ts new file mode 100644 index 000000000..db939e84c --- /dev/null +++ b/apps/kimi-code/src/tui/utils/subagent-error.ts @@ -0,0 +1,9 @@ +export function isUserCancelledSubagentError(error: string): boolean { + const normalized = error.trim(); + return ( + normalized === 'Aborted by the user' || + normalized === 'The user manually interrupted this subagent batch.' || + normalized.startsWith('The user manually interrupted this subagent ') || + normalized.includes('This was a deliberate user action, not a system error') + ); +} diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index b97daff64..f22cb9ea9 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -440,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( { @@ -459,7 +463,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'); }); @@ -503,7 +509,11 @@ describe('KimiTUI resume message replay', () => { ); 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/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts index 7d188531d..960c8ba81 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import type { SwarmMode } from '../../../agent/swarm'; import type { BuiltinTool } from '../../../agent/tool'; import type { - QueuedSubagentRunResult, QueuedSubagentTask, SessionSubagentHost, } from '../../../session/subagent-host'; @@ -24,15 +23,6 @@ export const AgentSwarmToolInputSchema = z .trim() .min(1) .describe('Short description for the whole swarm.'), - timeout: z - .number() - .int() - .min(60) - .max(3600) - .optional() - .describe( - 'Timeout in seconds for each subagent. Set a generous value so every child agent has enough time to complete its full assigned task.', - ), subagent_type: z .string() .trim() @@ -180,7 +170,6 @@ export class AgentSwarmTool implements BuiltinTool { item: this.subagentHost.getSwarmItem(spec.agentId), }; }); - const timeout = args.timeout === undefined ? undefined : args.timeout * 1000; const tasks = specsWithPersistedItems.map((spec): QueuedSubagentTask => { const resumeAgentId = spec.kind === 'resume' ? spec.agentId : undefined; return { @@ -197,7 +186,6 @@ export class AgentSwarmTool implements BuiltinTool { runInBackground: false, resumeAgentId, signal, - timeout, }; }); const results = await this.subagentHost.runQueued(tasks); From fa64e52e548682f48e2df73d5d89d7dc0591f73c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 14:02:38 +0800 Subject: [PATCH 46/72] upd --- apps/kimi-code/src/tui/controllers/btw-panel.ts | 1 - docs/en/reference/kimi-command.md | 10 ---------- docs/zh/reference/kimi-command.md | 10 ---------- packages/agent-core/src/agent/swarm/index.ts | 4 ++-- 4 files changed, 2 insertions(+), 23 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index b22de92e8..ac7ffaf98 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -133,7 +133,6 @@ export class BtwPanelController { case 'compaction.started': case 'cron.fired': case 'error': - case 'goal.updated': case 'mcp.server.status': case 'session.meta.updated': case 'skill.activated': diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 7a35b2808..b8be01096 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -206,16 +206,6 @@ kimi migrate For full migration instructions, see [Migrating from kimi-cli](../guides/migration.md). -### `kimi swarm-demo` - -Run an animated terminal UI demo that shows progress bars for multiple swarms. It does not start an LLM session or call a provider; it is only for previewing the swarm UI. - -```sh -kimi swarm-demo [count] -``` - -When `count` is omitted, the demo renders 32 swarms. Pass a positive integer to change the number shown. The demo keeps animating until you press `q`, `Esc`, or `Ctrl-C`. - ### `kimi upgrade` Immediately check for the latest version and display an update prompt; exits after you make a selection. diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index cb682073a..36fb20d4c 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -206,16 +206,6 @@ kimi migrate 完整迁移说明见[从 kimi-cli 迁移](../guides/migration.md)。 -### `kimi swarm-demo` - -运行一个终端 UI 动画演示,展示多个 swarm 的进度条。该命令不会启动 LLM 会话,也不会调用供应商,只用于预览 swarm UI。 - -```sh -kimi swarm-demo [count] -``` - -省略 `count` 时默认展示 32 个 swarm。传入正整数可以调整数量。演示会持续动画,直到按下 `q`、`Esc` 或 `Ctrl-C` 退出。 - ### `kimi upgrade` 立即检查最新版本并展示更新提示,选择操作后退出。 diff --git a/packages/agent-core/src/agent/swarm/index.ts b/packages/agent-core/src/agent/swarm/index.ts index b374fab89..6f4906cf6 100644 --- a/packages/agent-core/src/agent/swarm/index.ts +++ b/packages/agent-core/src/agent/swarm/index.ts @@ -20,7 +20,7 @@ export class SwarmMode { variant: 'swarm_mode', }); } - this.agent.emitStatusUpdated({ swarmMode: true }); + this.agent.emitStatusUpdated(); } restoreEnter(trigger: SwarmModeTrigger): void { @@ -32,7 +32,7 @@ export class SwarmMode { this.agent.records.logRecord({ type: 'swarm_mode.exit' }); const trigger = this.active; this.active = null; - this.agent.emitStatusUpdated({ swarmMode: false }); + this.agent.emitStatusUpdated(); if (trigger !== 'explicit') return; if (this.agent.context.popMatchedMessage((origin) => origin?.kind === 'injection' && origin.variant === 'swarm_mode')) { return; From a0cd20cee711adce8d2f41053f3ba00a9e625b44 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 14:47:12 +0800 Subject: [PATCH 47/72] wip --- packages/agent-core/src/agent/turn/index.ts | 54 +++++++++++-- .../agent-core/src/session/subagent-batch.ts | 5 +- .../agent-core/src/session/subagent-host.ts | 76 +++++++++---------- packages/kosong/src/errors.ts | 16 ++++ packages/kosong/src/index.ts | 1 + .../kosong/src/providers/openai-responses.ts | 4 +- packages/kosong/test/anthropic-errors.test.ts | 19 ++--- packages/kosong/test/errors.test.ts | 20 +++++ packages/kosong/test/google-genai.test.ts | 9 +++ .../kosong/test/openai-common-errors.test.ts | 9 +++ packages/kosong/test/openai-responses.test.ts | 15 ++-- 11 files changed, 161 insertions(+), 67 deletions(-) diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index f011b6946..2145fcc6e 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 { @@ -160,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; } @@ -209,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; @@ -228,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 @@ -700,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); @@ -707,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/session/subagent-batch.ts b/packages/agent-core/src/session/subagent-batch.ts index 47564bf08..082208b86 100644 --- a/packages/agent-core/src/session/subagent-batch.ts +++ b/packages/agent-core/src/session/subagent-batch.ts @@ -252,10 +252,7 @@ export class SubagentBatch { description: task.description, runInBackground: task.runInBackground, signal: attempt.controller.signal, - onStarted: () => { - this.markAttemptReady(attempt); - }, - onFirstOutput: () => { + onReady: () => { this.markAttemptReady(attempt); }, suppressRateLimitFailureEvent: true, diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 74da7d4ac..9a2839ea8 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -1,7 +1,12 @@ -import { isProviderRateLimitError, 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'; @@ -11,7 +16,6 @@ import { prepareSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; -import type { AgentEvent } from '../rpc'; import { linkAbortSignal, userCancellationReason, @@ -66,8 +70,7 @@ export interface RunSubagentOptions { readonly description: string; readonly runInBackground: boolean; readonly signal: AbortSignal; - readonly onStarted?: () => void; - readonly onFirstOutput?: () => void; + readonly onReady?: () => void; readonly suppressRateLimitFailureEvent?: boolean; }; @@ -285,8 +288,7 @@ export class SessionSubagentHost { emitSpawnedEvent = true, ): Promise { if (emitSpawnedEvent) this.emitSubagentSpawned(parent, childId, profileName, options); - const unwatchFirstOutput = this.watchFirstOutput(child, options.onFirstOutput); - return this.runChildWithErrorHandling(parent, childId, options, unwatchFirstOutput, async () => { + return this.runChildWithErrorHandling(parent, childId, options, async () => { await prepareChild(); options.signal.throwIfAborted(); await this.triggerSubagentStart(parent, profileName, options.prompt, options.signal); @@ -300,8 +302,11 @@ export class SessionSubagentHost { if (gitContext) childPrompt = `${gitContext}\n\n${childPrompt}`; } this.emitSubagentStarted(parent, childId, profileName, options); - options.onStarted?.(); - child.turn.prompt([{ type: 'text', text: childPrompt }], SUBAGENT_PROMPT_ORIGIN); + 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); }); } @@ -313,15 +318,15 @@ export class SessionSubagentHost { profileName: string, options: RunSubagentOptions, ): Promise { - const unwatchFirstOutput = this.watchFirstOutput(child, options.onFirstOutput); - return this.runChildWithErrorHandling(parent, childId, options, unwatchFirstOutput, async () => { + return this.runChildWithErrorHandling(parent, childId, options, async () => { options.signal.throwIfAborted(); child.config.update({ modelAlias: parent.config.modelAlias }); this.emitSubagentStarted(parent, childId, profileName, options); - options.onStarted?.(); - if (child.turn.retry('agent-host') === null) { + const turnId = child.turn.retry('agent-host'); + if (turnId === null) { throw new Error(`Agent instance "${childId}" could not start a retry turn`); } + this.observeFirstRequest(child, options); return this.waitForChildCompletion(parent, childId, child, profileName, options); }); } @@ -330,7 +335,6 @@ export class SessionSubagentHost { parent: Agent, childId: string, options: RunSubagentOptions, - unwatchFirstOutput: (() => void) | undefined, run: () => Promise, ): Promise { try { @@ -346,8 +350,6 @@ export class SessionSubagentHost { }); } throw error; - } finally { - unwatchFirstOutput?.(); } } @@ -429,22 +431,17 @@ export class SessionSubagentHost { }); } - private watchFirstOutput( + private observeFirstRequest( child: Agent, - onFirstOutput: (() => void) | undefined, - ): (() => void) | undefined { - if (onFirstOutput === undefined) return undefined; - let emitted = false; - const emitEvent = child.emitEvent.bind(child); - child.emitEvent = (event: AgentEvent) => { - emitEvent(event); - if (emitted || !isFirstOutputEvent(event)) return; - emitted = true; - onFirstOutput(); - }; - return () => { - child.emitEvent = emitEvent; - }; + options: RunSubagentOptions, + ): void { + if (options.onReady === undefined) return; + void child.turn + .waitForTurnFirstRequest() + .then(() => { + options.onReady?.(); + }) + .catch(() => {}); } private emitSubagentSpawned( @@ -507,6 +504,9 @@ async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Prom 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}` @@ -516,6 +516,12 @@ async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Prom throwIfSubagentStoppedAtMaxTokens(completion.stopReason); } +function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError { + const requestId = + typeof error.details?.['requestId'] === 'string' ? error.details['requestId'] : null; + return new APIProviderRateLimitError(error.message, requestId); +} + function throwIfSubagentStoppedAtMaxTokens(stopReason: LoopTurnStopReason | undefined): void { if (stopReason === 'max_tokens') { throw new Error(`${SUBAGENT_MAX_TOKENS_ERROR}.`); @@ -534,16 +540,6 @@ function lastAssistantText(agent: Agent): string { return ''; } -function isFirstOutputEvent(event: AgentEvent): boolean { - if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { - return event.delta.length > 0; - } - if (event.type === 'tool.call.delta') { - return (event.name?.length ?? 0) > 0 || (event.argumentsPart?.length ?? 0) > 0; - } - return event.type === 'tool.call.started'; -} - function shouldSuppressQueuedAttemptFailureEvent( options: RunSubagentOptions, error: unknown, diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 949b1c042..989f02249 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -56,6 +56,17 @@ export class APIContextOverflowError extends APIStatusError { } } +/** + * HTTP status error that specifically means the provider rate-limited the + * request. + */ +export class APIProviderRateLimitError extends APIStatusError { + constructor(message: string, requestId?: string | null) { + super(429, message, requestId); + this.name = 'APIProviderRateLimitError'; + } +} + /** * The API returned an empty response (no content, no tool calls). */ @@ -117,6 +128,9 @@ export function normalizeAPIStatusError( message: string, requestId?: string | null, ): APIStatusError { + if (statusCode === 429) { + return new APIProviderRateLimitError(message, requestId); + } if (isContextOverflowStatusError(statusCode, message)) { return new APIContextOverflowError(statusCode, message, requestId); } @@ -130,6 +144,8 @@ export function isContextOverflowStatusError(statusCode: number, message: string } export function isProviderRateLimitError(error: unknown): boolean { + if (error instanceof APIProviderRateLimitError) return true; + const statusCode = getStatusCode(error); if (statusCode !== undefined) return statusCode === 429; diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 2c7ce34f1..0bc380e89 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -62,6 +62,7 @@ export { APIConnectionError, APIContextOverflowError, APIEmptyResponseError, + APIProviderRateLimitError, APIStatusError, APITimeoutError, ChatProviderError, diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 00e31e3cf..1afa70696 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -1,7 +1,7 @@ import type { ModelCapability } from '#/capability'; import { APIContextOverflowError, - APIStatusError, + APIProviderRateLimitError, ChatProviderError, isContextOverflowErrorCode, } from '#/errors'; @@ -247,7 +247,7 @@ function errorFromOpenAIResponsesEvent( return new APIContextOverflowError(400, fullMessage); } if (code === 'rate_limit_exceeded') { - return new APIStatusError(429, fullMessage); + return new APIProviderRateLimitError(fullMessage); } return new ChatProviderError(fullMessage); } diff --git a/packages/kosong/test/anthropic-errors.test.ts b/packages/kosong/test/anthropic-errors.test.ts index fd36fcb48..e39444346 100644 --- a/packages/kosong/test/anthropic-errors.test.ts +++ b/packages/kosong/test/anthropic-errors.test.ts @@ -1,6 +1,7 @@ import { APIConnectionError, APIContextOverflowError, + APIProviderRateLimitError, APIStatusError, APITimeoutError, ChatProviderError, @@ -72,7 +73,7 @@ describe('convertAnthropicError', () => { expect((result as APIStatusError).statusCode).toBe(401); }); - it('RateLimitError -> APIStatusError with 429', () => { + it('RateLimitError -> APIProviderRateLimitError with 429', () => { const err = new AnthropicRateLimitError( 429, { type: 'error', error: { type: 'rate_limit_error', message: 'rate limited' } }, @@ -80,8 +81,8 @@ describe('convertAnthropicError', () => { new Headers(), ); const result = convertAnthropicError(err); - expect(result).toBeInstanceOf(APIStatusError); - expect((result as APIStatusError).statusCode).toBe(429); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect((result as APIProviderRateLimitError).statusCode).toBe(429); }); it('generic AnthropicError -> ChatProviderError', () => { @@ -161,7 +162,7 @@ describe('non-stream error propagation', () => { ).rejects.toThrow(APIStatusError); }); - it('RateLimitError during generate is converted to APIStatusError(429)', async () => { + it('RateLimitError during generate is converted to APIProviderRateLimitError(429)', async () => { const provider = createNonStreamProvider(); const sdkError = new AnthropicRateLimitError( 429, @@ -179,8 +180,8 @@ describe('non-stream error propagation', () => { ); expect.unreachable('Should have thrown'); } catch (error) { - expect(error).toBeInstanceOf(APIStatusError); - expect((error as APIStatusError).statusCode).toBe(429); + expect(error).toBeInstanceOf(APIProviderRateLimitError); + expect((error as APIProviderRateLimitError).statusCode).toBe(429); } }); @@ -298,7 +299,7 @@ describe('stream error propagation', () => { ).rejects.toThrow(APIStatusError); }); - it('RateLimitError during stream iteration is converted to APIStatusError(429)', async () => { + it('RateLimitError during stream iteration is converted to APIProviderRateLimitError(429)', async () => { const provider = createStreamProvider(); const sdkError = new AnthropicRateLimitError( 429, @@ -321,8 +322,8 @@ describe('stream error propagation', () => { } expect.unreachable('Should have thrown'); } catch (error) { - expect(error).toBeInstanceOf(APIStatusError); - expect((error as APIStatusError).statusCode).toBe(429); + expect(error).toBeInstanceOf(APIProviderRateLimitError); + expect((error as APIProviderRateLimitError).statusCode).toBe(429); } }); diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index 299317bf0..0317f405a 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -2,6 +2,7 @@ import { APIConnectionError, APIContextOverflowError, APIEmptyResponseError, + APIProviderRateLimitError, APIStatusError, APITimeoutError, ChatProviderError, @@ -98,6 +99,17 @@ describe('APIContextOverflowError', () => { }); }); +describe('APIProviderRateLimitError', () => { + it('extends APIStatusError and preserves HTTP details', () => { + const err = new APIProviderRateLimitError('Rate limited', 'req-rate'); + expect(err).toBeInstanceOf(APIStatusError); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err.name).toBe('APIProviderRateLimitError'); + expect(err.statusCode).toBe(429); + expect(err.requestId).toBe('req-rate'); + }); +}); + describe('isRetryableGenerateError', () => { it('matches transient provider errors and empty generate responses', () => { expect(isRetryableGenerateError(new APIConnectionError('conn'))).toBe(true); @@ -158,6 +170,13 @@ describe('error hierarchy instanceof checks', () => { }); describe('normalizeAPIStatusError', () => { + it('normalizes HTTP 429 to APIProviderRateLimitError', () => { + const error = normalizeAPIStatusError(429, 'Too many requests', 'req-rate'); + expect(error).toBeInstanceOf(APIProviderRateLimitError); + expect(error.statusCode).toBe(429); + expect(error.requestId).toBe('req-rate'); + }); + it.each([ [400, 'Context length exceeded'], [400, 'Exceeded max tokens'], @@ -192,6 +211,7 @@ describe('normalizeAPIStatusError', () => { describe('isProviderRateLimitError', () => { it('matches explicit HTTP 429 status errors', () => { + expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true); expect(isProviderRateLimitError(new APIStatusError(429, 'rate limited'))).toBe(true); expect(isProviderRateLimitError({ response: { status: 429 } })).toBe(true); expect(isProviderRateLimitError({ statusCode: 503, message: 'rate limit' })).toBe(false); diff --git a/packages/kosong/test/google-genai.test.ts b/packages/kosong/test/google-genai.test.ts index 66ee1c4a0..61c51f42c 100644 --- a/packages/kosong/test/google-genai.test.ts +++ b/packages/kosong/test/google-genai.test.ts @@ -1,6 +1,7 @@ import { APIConnectionError, APIContextOverflowError, + APIProviderRateLimitError, APIStatusError, APITimeoutError, ChatProviderError, @@ -1463,6 +1464,14 @@ describe('convertGoogleGenAIError (unit)', () => { expect((result as APIStatusError).statusCode).toBe(503); }); + it('normalizes numeric 429 code property as APIProviderRateLimitError', () => { + const error = new Error('too many requests'); + (error as Error & { code: number }).code = 429; + const result = convertGoogleGenAIError(error); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect((result as APIProviderRateLimitError).statusCode).toBe(429); + }); + it('normalizes numeric context overflow errors', () => { const error = new Error( 'input token count 131072 exceeds the maximum number of tokens allowed', diff --git a/packages/kosong/test/openai-common-errors.test.ts b/packages/kosong/test/openai-common-errors.test.ts index fd9d9c5f2..52ebe6a25 100644 --- a/packages/kosong/test/openai-common-errors.test.ts +++ b/packages/kosong/test/openai-common-errors.test.ts @@ -1,6 +1,7 @@ import { APIConnectionError, APIContextOverflowError, + APIProviderRateLimitError, APIStatusError, APITimeoutError, ChatProviderError, @@ -110,6 +111,14 @@ describe('convertOpenAIError: context overflow', () => { expect((result as APIContextOverflowError).statusCode).toBe(413); }); }); +describe('convertOpenAIError: provider rate limit', () => { + it('normalizes HTTP 429 status errors to APIProviderRateLimitError', () => { + const err = new OpenAIAPIError(429, undefined, 'Too many requests', new Headers()); + const result = convertOpenAIError(err); + expect(result).toBeInstanceOf(APIProviderRateLimitError); + expect((result as APIProviderRateLimitError).statusCode).toBe(429); + }); +}); describe('convertOpenAIError: subclass errors still match first', () => { it('APIConnectionError matches its own case', () => { const connErr = new OpenAIConnectionError({ message: 'Connection error.' }); diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 3cfbd41ca..3ce2fd414 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -1,4 +1,9 @@ -import { APIContextOverflowError, APIStatusError, ChatProviderError } from '#/errors'; +import { + APIContextOverflowError, + APIProviderRateLimitError, + APIStatusError, + ChatProviderError, +} from '#/errors'; import { generate } from '#/generate'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message'; import { @@ -1689,8 +1694,8 @@ describe('OpenAIResponsesChatProvider', () => { caughtError = error; } - expect(caughtError).toBeInstanceOf(APIStatusError); - expect((caughtError as APIStatusError).statusCode).toBe(429); + expect(caughtError).toBeInstanceOf(APIProviderRateLimitError); + expect((caughtError as APIProviderRateLimitError).statusCode).toBe(429); expect((caughtError as Error).message).toMatch(/rate_limit_exceeded.*too many/); }); @@ -1710,8 +1715,8 @@ describe('OpenAIResponsesChatProvider', () => { caughtError = error; } - expect(caughtError).toBeInstanceOf(APIStatusError); - expect((caughtError as APIStatusError).statusCode).toBe(429); + expect(caughtError).toBeInstanceOf(APIProviderRateLimitError); + expect((caughtError as APIProviderRateLimitError).statusCode).toBe(429); expect((caughtError as Error).message).toContain('Rate limit reached for gpt-5.5'); expect((caughtError as Error).message).not.toContain('stream event.type must be a string'); }); From e8f24ebab5f3f1ca051fb1e9792f47d59036e288 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 14:53:41 +0800 Subject: [PATCH 48/72] fix --- .../test/session/subagent-host.test.ts | 2 +- plans/repro-agent-1440-kimi.ts | 415 ------------------ 2 files changed, 1 insertion(+), 416 deletions(-) delete mode 100644 plans/repro-agent-1440-kimi.ts diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 66b67e4d9..70ea09930 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -461,7 +461,7 @@ describe('SessionSubagentHost', () => { ) => { const outcome = createControlledPromise>(); if (attempts.length >= 7) { - setTimeout(options.markReady, 100); + setTimeout(() => options.markReady(), 100); } attempts.push({ task: task as unknown as QueuedSubagentTask, diff --git a/plans/repro-agent-1440-kimi.ts b/plans/repro-agent-1440-kimi.ts deleted file mode 100644 index c1bab7d01..000000000 --- a/plans/repro-agent-1440-kimi.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { basename, dirname, join } from 'node:path'; - -import { - createProvider, - type ChatProvider, - type ContentPart, - type Message, - type StreamedMessagePart, - type Tool, - type ToolCall, -} from '../packages/kosong/src/index.ts'; -import { - createKimiDefaultHeaders, - KIMI_CODE_FLOW_CONFIG, - KIMI_CODE_PROVIDER_NAME, - KimiOAuthToolkit, - kimiCodeBaseUrl, - resolveKimiCodeOAuthRef, -} from '../packages/oauth/src/index.ts'; - -const DEFAULT_WIRE = - '/Users/moonshot/.kimi-code/sessions/wd_lug-2026-annual-audit_be08e3cd25e2/session_0069f26b-bd7c-498f-aa2a-340362199ef2/agents/agent-1440/wire.jsonl'; -const DEFAULT_TARGET_STEP_UUID = 'eb9c6131-61a8-4012-97c5-f48a9c2e19e9'; -const DEFAULT_MAX_COMPLETION_TOKENS = 32_000; - -type JsonRecord = Record; - -interface ProjectedContext { - systemPrompt: string; - messages: Message[]; - config: { - modelAlias?: string; - thinkingLevel?: string; - cwd?: string; - }; - stoppedAtLine: number; -} - -interface AssistantMessage extends Message { - content: ContentPart[]; - toolCalls: ToolCall[]; -} - -function parseArgs(): { - wirePath: string; - targetStepUuid: string; - dropEmptyAssistants: boolean; - maxCompletionTokens: number; -} { - const args = process.argv.slice(2); - let wirePath = DEFAULT_WIRE; - let targetStepUuid = DEFAULT_TARGET_STEP_UUID; - let dropEmptyAssistants = false; - let maxCompletionTokens = DEFAULT_MAX_COMPLETION_TOKENS; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (arg === '--wire') { - wirePath = requiredValue(args, ++i, '--wire'); - } else if (arg === '--target-step') { - targetStepUuid = requiredValue(args, ++i, '--target-step'); - } else if (arg === '--drop-empty-assistants') { - dropEmptyAssistants = true; - } else if (arg === '--max-completion-tokens') { - maxCompletionTokens = Number(requiredValue(args, ++i, '--max-completion-tokens')); - if (!Number.isInteger(maxCompletionTokens) || maxCompletionTokens <= 0) { - throw new Error('--max-completion-tokens must be a positive integer'); - } - } else { - throw new Error(`Unknown argument: ${arg}`); - } - } - - return { wirePath, targetStepUuid, dropEmptyAssistants, maxCompletionTokens }; -} - -function requiredValue(args: string[], index: number, flag: string): string { - const value = args[index]; - if (value === undefined || value.startsWith('--')) { - throw new Error(`${flag} requires a value`); - } - return value; -} - -async function projectWire(input: { - wirePath: string; - targetStepUuid: string; - dropEmptyAssistants: boolean; -}): Promise { - const text = await readFile(input.wirePath, 'utf8'); - const records = text.split(/\r?\n/).filter(Boolean).map((line, index) => ({ - lineNo: index + 1, - record: JSON.parse(line) as JsonRecord, - })); - - const blobsDir = join(dirname(input.wirePath), 'blobs'); - const messages: Message[] = []; - const openSteps = new Map(); - const config: ProjectedContext['config'] = {}; - let systemPrompt = ''; - let stoppedAtLine = records.length; - - for (const { lineNo, record } of records) { - if ( - record.type === 'context.append_loop_event' && - record.event?.type === 'step.begin' && - record.event.uuid === input.targetStepUuid - ) { - stoppedAtLine = lineNo; - break; - } - - if (record.type === 'config.update') { - if (typeof record.systemPrompt === 'string') systemPrompt = record.systemPrompt; - if (typeof record.modelAlias === 'string') config.modelAlias = record.modelAlias; - if (typeof record.thinkingLevel === 'string') config.thinkingLevel = record.thinkingLevel; - if (typeof record.cwd === 'string') config.cwd = record.cwd; - continue; - } - - if (record.type === 'context.append_message') { - const message = cloneMessage(record.message); - await rehydrateParts(message.content, blobsDir); - messages.push(message); - continue; - } - - if (record.type !== 'context.append_loop_event') continue; - const event = record.event; - - if (event.type === 'step.begin') { - const message: AssistantMessage = { - role: 'assistant', - content: [], - toolCalls: [], - }; - messages.push(message); - openSteps.set(event.uuid, message); - continue; - } - - if (event.type === 'content.part') { - const message = openSteps.get(event.stepUuid); - if (message !== undefined) { - const part = structuredClone(event.part) as ContentPart; - await rehydrateParts([part], blobsDir); - message.content.push(part); - } - continue; - } - - if (event.type === 'tool.call') { - const message = openSteps.get(event.stepUuid); - if (message !== undefined) { - message.toolCalls.push({ - type: 'function', - id: event.toolCallId, - name: event.name, - arguments: typeof event.args === 'string' ? event.args : JSON.stringify(event.args ?? {}), - }); - } - continue; - } - - if (event.type === 'step.end') { - openSteps.delete(event.uuid); - continue; - } - - if (event.type === 'tool.result') { - const output = event.result?.output; - const content: ContentPart[] = - typeof output === 'string' - ? [{ type: 'text', text: output }] - : structuredClone(output as ContentPart[]); - await rehydrateParts(content, blobsDir); - messages.push({ - role: 'tool', - content, - toolCalls: [], - toolCallId: event.toolCallId, - }); - } - } - - return { - systemPrompt, - messages: input.dropEmptyAssistants - ? messages.filter((message) => { - if (message.role !== 'assistant') return true; - return message.content.length > 0 || message.toolCalls.length > 0; - }) - : messages, - config, - stoppedAtLine, - }; -} - -function cloneMessage(raw: any): Message { - return { - role: raw.role, - name: raw.name, - content: structuredClone(raw.content ?? []) as ContentPart[], - toolCalls: structuredClone(raw.toolCalls ?? []) as ToolCall[], - toolCallId: raw.toolCallId, - partial: raw.partial, - }; -} - -async function rehydrateParts(parts: ContentPart[], blobsDir: string): Promise { - for (const part of parts) { - for (const key of ['imageUrl', 'audioUrl', 'videoUrl'] as const) { - const media = (part as any)[key]; - if (typeof media?.url !== 'string') continue; - const url = media.url as string; - if (!url.startsWith('blobref:')) continue; - media.url = await blobRefToDataUrl(url, blobsDir); - } - } -} - -async function blobRefToDataUrl(url: string, blobsDir: string): Promise { - const rest = url.slice('blobref:'.length); - const semi = rest.indexOf(';'); - if (semi === -1) return url; - const mimeType = rest.slice(0, semi); - const hash = rest.slice(semi + 1); - if (!/^[0-9a-f]{64}$/i.test(hash)) return url; - const bytes = await readFile(join(blobsDir, hash)); - return `data:${mimeType};base64,${bytes.toString('base64')}`; -} - -function summarizeContext(context: ProjectedContext): Record { - let textChars = 0; - let thinkChars = 0; - let imageParts = 0; - let toolCalls = 0; - const roles: Record = {}; - - for (const message of context.messages) { - roles[message.role] = (roles[message.role] ?? 0) + 1; - toolCalls += message.toolCalls.length; - for (const part of message.content) { - if (part.type === 'text') textChars += part.text.length; - if (part.type === 'think') thinkChars += part.think.length; - if (part.type === 'image_url') imageParts += 1; - } - } - - return { - stoppedAtLine: context.stoppedAtLine, - config: context.config, - messageCount: context.messages.length, - roles, - textChars, - thinkChars, - imageParts, - toolCalls, - systemPromptChars: context.systemPrompt.length, - }; -} - -function makeTools(): Tool[] { - return [ - tool('Bash', 'Run a shell command in the current working directory.', { - command: { type: 'string' }, - description: { type: 'string' }, - }, ['command']), - tool('Read', 'Read a UTF-8 text file.', { path: { type: 'string' } }, ['path']), - tool('ReadMediaFile', 'Read an image or video file and return multimodal content.', { - path: { type: 'string' }, - }, ['path']), - tool('Glob', 'Find files by glob pattern.', { pattern: { type: 'string' } }, ['pattern']), - tool('Grep', 'Search file contents.', { - pattern: { type: 'string' }, - path: { type: 'string' }, - }, ['pattern']), - tool('Write', 'Create or overwrite a file.', { - path: { type: 'string' }, - content: { type: 'string' }, - }, ['path', 'content']), - tool('Edit', 'Edit a file by replacing text.', { - path: { type: 'string' }, - old_string: { type: 'string' }, - new_string: { type: 'string' }, - }, ['path', 'old_string', 'new_string']), - tool('WebSearch', 'Search the web.', { query: { type: 'string' } }, ['query']), - tool('FetchURL', 'Fetch a URL.', { url: { type: 'string' } }, ['url']), - ]; -} - -function tool( - name: string, - description: string, - properties: Record, - required: string[], -): Tool { - return { - name, - description, - parameters: { - type: 'object', - properties, - required, - additionalProperties: true, - }, - }; -} - -async function createKimiProvider(maxCompletionTokens: number): Promise<{ - provider: ChatProvider; - auth: { apiKey?: string; headers?: Record }; -}> { - const homeDir = join(homedir(), '.kimi-code'); - const baseUrl = kimiCodeBaseUrl(); - const oauthRef = resolveKimiCodeOAuthRef({ - oauthHost: KIMI_CODE_FLOW_CONFIG.oauthHost, - baseUrl, - }); - const identity = { - userAgentProduct: 'kimi-code-cli', - version: '0.9.0', - }; - const toolkit = new KimiOAuthToolkit({ homeDir, identity }); - const apiKey = await toolkit.ensureFresh(KIMI_CODE_PROVIDER_NAME, { oauthRef }); - let provider = createProvider({ - type: 'kimi', - model: 'kimi-for-coding', - baseUrl, - defaultHeaders: createKimiDefaultHeaders({ - homeDir, - ...identity, - }), - }).withThinking('high'); - provider = provider.withMaxCompletionTokens?.(maxCompletionTokens) ?? provider; - return { provider, auth: { apiKey } }; -} - -async function collect( - provider: ChatProvider, - context: ProjectedContext, - tools: Tool[], - auth: { apiKey?: string; headers?: Record }, -): Promise { - const stream = await provider.generate(context.systemPrompt, tools, context.messages, { auth }); - let textChars = 0; - let thinkChars = 0; - let toolCalls = 0; - const samples: string[] = []; - const partCounts: Record = {}; - - for await (const part of stream) { - partCounts[part.type] = (partCounts[part.type] ?? 0) + 1; - if (part.type === 'text') { - textChars += part.text.length; - if (samples.length < 3) samples.push(`text:${part.text.slice(0, 240)}`); - } else if (part.type === 'think') { - thinkChars += part.think.length; - if (samples.length < 3) samples.push(`think:${part.think.slice(0, 240)}`); - } else if (part.type === 'function') { - toolCalls += 1; - if (samples.length < 3) samples.push(`tool:${part.name}(${part.id})`); - } else { - if (samples.length < 3) samples.push(`${part.type}:${JSON.stringify(part).slice(0, 240)}`); - } - } - - const outcome = - thinkChars > 0 && textChars === 0 && toolCalls === 0 - ? 'think-only' - : textChars > 0 || toolCalls > 0 - ? 'normal-output' - : 'empty'; - - console.log(JSON.stringify({ - outcome, - streamId: stream.id, - finishReason: stream.finishReason, - rawFinishReason: stream.rawFinishReason, - usage: stream.usage, - partCounts, - textChars, - thinkChars, - toolCalls, - samples, - }, null, 2)); -} - -async function main(): Promise { - const args = parseArgs(); - const context = await projectWire(args); - console.log(JSON.stringify({ - script: basename(import.meta.url), - context: summarizeContext(context), - options: { - targetStepUuid: args.targetStepUuid, - dropEmptyAssistants: args.dropEmptyAssistants, - maxCompletionTokens: args.maxCompletionTokens, - }, - }, null, 2)); - - const { provider, auth } = await createKimiProvider(args.maxCompletionTokens); - await collect(provider, context, makeTools(), auth); -} - -main().catch((error: unknown) => { - console.error(JSON.stringify({ - errorName: error instanceof Error ? error.name : undefined, - errorMessage: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack?.split('\n').slice(0, 8) : undefined, - }, null, 2)); - process.exitCode = 1; -}); From 896dfde052e825697dc15340092da323432d7aac Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 15:17:27 +0800 Subject: [PATCH 49/72] update --- apps/kimi-code/src/tui/commands/dispatch.ts | 1 - apps/kimi-code/src/tui/commands/swarm.ts | 12 +- apps/kimi-code/src/tui/kimi-tui.ts | 8 - .../kimi-code/src/tui/utils/message-replay.ts | 1 + apps/kimi-code/test/tui/activity-pane.test.ts | 63 ++- .../kimi-code/test/tui/commands/swarm.test.ts | 58 ++- .../agent-core/src/session/subagent-batch.ts | 19 +- .../agent-core/src/session/subagent-host.ts | 257 ++++----- .../test/session/subagent-host.test.ts | 486 ++++++++---------- 9 files changed, 410 insertions(+), 495 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dfa209bf6..47216e121 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -129,7 +129,6 @@ export interface SlashCommandHost { showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; showProgressSpinner(label: string): LoginProgressSpinnerHandle; clearAgentSwarmProgress(): void; - renderSwarmModeMarker(active: boolean): void; // Theme applyTheme(theme: Theme, resolved?: ResolvedTheme): void; diff --git a/apps/kimi-code/src/tui/commands/swarm.ts b/apps/kimi-code/src/tui/commands/swarm.ts index a521e560d..aedd479de 100644 --- a/apps/kimi-code/src/tui/commands/swarm.ts +++ b/apps/kimi-code/src/tui/commands/swarm.ts @@ -4,6 +4,7 @@ import { SwarmStartPermissionPromptComponent, type SwarmStartPermissionChoice, } from '../components/dialogs/swarm-start-permission-prompt'; +import { SwarmModeMarkerComponent } 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'; @@ -91,7 +92,7 @@ async function startSwarmTask(host: SlashCommandHost, prompt: string): Promise { if (!host.state.appState.swarmMode && !(await setSwarmMode(host, true))) return false; - host.renderSwarmModeMarker(true); + renderSwarmModeMarker(host, true); return true; } @@ -105,7 +106,7 @@ async function applySwarmMode(host: SlashCommandHost, enabled: boolean): Promise return; } if (!(await setSwarmMode(host, enabled))) return; - host.renderSwarmModeMarker(enabled); + renderSwarmModeMarker(host, enabled); } async function setSwarmMode(host: SlashCommandHost, enabled: boolean): Promise { @@ -127,3 +128,10 @@ function swarmModeSubcommand(input: string): boolean | undefined { if (command === 'off') return false; return undefined; } + +function renderSwarmModeMarker(host: SlashCommandHost, active: boolean): void { + host.state.transcriptContainer.addChild( + new SwarmModeMarkerComponent(active, host.state.theme.colors), + ); + host.state.ui.requestRender(); +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 44608e41e..e114ed99f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -76,7 +76,6 @@ import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; -import { SwarmModeMarkerComponent } from './components/messages/swarm-markers'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; import { UserMessageComponent } from './components/messages/user-message'; @@ -995,13 +994,6 @@ export class KimiTUI { this.sessionEventHandler.clearAgentSwarmProgress(); } - renderSwarmModeMarker(active: boolean): void { - this.state.transcriptContainer.addChild( - new SwarmModeMarkerComponent(active, this.state.theme.colors), - ); - this.state.ui.requestRender(); - } - // ========================================================================= // Session Runtime // ========================================================================= diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 1c516c288..b1478697c 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -256,6 +256,7 @@ function isReplayUserTurnRecord(record: AgentReplayRecord): boolean { case 'cron_missed': case 'hook_result': case 'injection': + case 'retry': case 'system_trigger': return false; } diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index fe9885bba..458053737 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -51,6 +51,35 @@ function makeDriverWithTerminalProgress(): { return { driver, state: driver.state, setProgress }; } +function startSwarmProgress(driver: ActivityDriver, state: TUIState): AgentSwarmProgressComponent { + const handler = driver.sessionEventHandler.subAgentEventHandler; + handler.handleAgentSwarmToolCallStarted('call_swarm', { + description: 'Review changed files', + }); + handler.handleLifecycleEvent({ + type: 'subagent.spawned', + subagentId: 'agent-1', + subagentName: 'coder', + parentToolCallId: 'call_swarm', + description: 'Review changed files #1 (coder)', + runInBackground: false, + } as Parameters[0]); + handler.handleLifecycleEvent({ + type: 'subagent.started', + subagentId: 'agent-1', + subagentName: 'coder', + parentToolCallId: 'call_swarm', + description: 'Review changed files #1 (coder)', + runInBackground: false, + } 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(); @@ -123,14 +152,7 @@ describe('updateActivityPane terminal progress', () => { vi.useFakeTimers(); try { const { driver, state, setProgress } = makeDriverWithTerminalProgress(); - const progress = new AgentSwarmProgressComponent({ - description: 'Review changed files', - colors: state.theme.colors, - }); - progress.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); - progress.markInputComplete(); - progress.markStarted('agent-1'); - driver.sessionEventHandler.agentSwarmProgress.set('call_swarm', progress); + const progress = startSwarmProgress(driver, state); state.livePane = { ...state.livePane, mode: 'tool' }; driver.updateActivityPane(); @@ -142,8 +164,7 @@ describe('updateActivityPane terminal progress', () => { expect(strip(progress.render(80).join('\n'))).toContain('🌑 Working...'); state.activitySpinner?.instance.stop(); - progress.dispose(); - driver.sessionEventHandler.agentSwarmProgress.clear(); + driver.sessionEventHandler.clearAgentSwarmProgress(); } finally { vi.useRealTimers(); } @@ -153,15 +174,16 @@ describe('updateActivityPane terminal progress', () => { vi.useFakeTimers(); try { const { driver, state } = makeDriverWithTerminalProgress(); - const progress = new AgentSwarmProgressComponent({ - description: 'Review changed files', - colors: state.theme.colors, - }); - progress.registerSubagent({ agentId: 'agent-1', description: 'Review changed files #1 (coder)' }); - progress.markInputComplete(); - progress.markStarted('agent-1'); - progress.markToolCallEnded(); - driver.sessionEventHandler.agentSwarmProgress.set('call_swarm', progress); + 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(); @@ -173,8 +195,7 @@ describe('updateActivityPane terminal progress', () => { expect(output).not.toContain('🌑 Working...'); state.activitySpinner?.instance.stop(); - progress.dispose(); - driver.sessionEventHandler.agentSwarmProgress.clear(); + driver.sessionEventHandler.clearAgentSwarmProgress(); } finally { vi.useRealTimers(); } diff --git a/apps/kimi-code/test/tui/commands/swarm.test.ts b/apps/kimi-code/test/tui/commands/swarm.test.ts index 6937e54ad..24f829c22 100644 --- a/apps/kimi-code/test/tui/commands/swarm.test.ts +++ b/apps/kimi-code/test/tui/commands/swarm.test.ts @@ -12,6 +12,10 @@ function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } +interface TestComponent { + render(width: number): string[]; +} + function makeHost( overrides: { model?: string; @@ -33,13 +37,14 @@ function makeHost( 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(), - renderSwarmModeMarker: vi.fn(), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), restoreInputText: vi.fn(), @@ -58,6 +63,16 @@ function mountedPicker(host: SlashCommandHost): TestPicker { return mock.mock.calls[0]?.[0] as TestPicker; } +function markerAddChild(host: SlashCommandHost): ReturnType { + return host.state.transcriptContainer.addChild as ReturnType; +} + +function expectSwarmMarker(host: SlashCommandHost, active: boolean): void { + const components = markerAddChild(host).mock.calls.map(([component]) => component as TestComponent); + const text = stripAnsi(components.at(-1)?.render(80).join('\n') ?? ''); + expect(text).toContain(active ? 'Swarm activated' : 'Swarm deactivated'); +} + describe('handleSwarmCommand', () => { it('sends the swarm prompt as a normal prompt after enabling swarm mode', async () => { const { host, session } = makeHost({ permissionMode: 'auto' }); @@ -66,7 +81,7 @@ describe('handleSwarmCommand', () => { expect(session.setPermission).not.toHaveBeenCalled(); expect(session.setSwarmMode).toHaveBeenCalledWith(true); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expectSwarmMarker(host, true); expect(host.mountEditorReplacement).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); @@ -77,7 +92,7 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, 'Ship feature X'); expect(session.setSwarmMode).not.toHaveBeenCalled(); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expectSwarmMarker(host, true); expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); }); @@ -88,7 +103,7 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledWith(true); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expectSwarmMarker(host, true); expect(host.showStatus).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); @@ -100,7 +115,7 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledWith(true); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expectSwarmMarker(host, true); expect(host.showError).not.toHaveBeenCalled(); expect(host.showStatus).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); @@ -113,7 +128,7 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).not.toHaveBeenCalled(); expect(host.setAppState).not.toHaveBeenCalledWith({ swarmMode: true }); - expect(host.renderSwarmModeMarker).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); expect(host.showStatus).toHaveBeenCalledWith('Swarm mode is already on.'); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); @@ -125,7 +140,7 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledWith(false); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(false); + expectSwarmMarker(host, false); expect(host.showStatus).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); @@ -137,7 +152,7 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledWith(false); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: false }); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(false); + expectSwarmMarker(host, false); expect(host.showError).not.toHaveBeenCalled(); expect(host.showStatus).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); @@ -150,7 +165,7 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).not.toHaveBeenCalled(); expect(host.setAppState).not.toHaveBeenCalledWith({ swarmMode: false }); - expect(host.renderSwarmModeMarker).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); expect(host.showStatus).toHaveBeenCalledWith('Swarm mode is already off.'); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); @@ -161,10 +176,9 @@ describe('handleSwarmCommand', () => { await handleSwarmCommand(host, 'Ship feature X'); expect(session.setSwarmMode).toHaveBeenCalledWith(true); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expectSwarmMarker(host, true); expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); - const markerOrder = (host.renderSwarmModeMarker as ReturnType).mock - .invocationCallOrder[0]; + const markerOrder = markerAddChild(host).mock.invocationCallOrder[0]; const promptOrder = (host.mountEditorReplacement as ReturnType).mock .invocationCallOrder[0]; expect(markerOrder).toBeDefined(); @@ -191,8 +205,8 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); - expect(host.renderSwarmModeMarker).toHaveBeenCalledTimes(1); + expectSwarmMarker(host, true); + expect(markerAddChild(host)).toHaveBeenCalledTimes(1); }); it('can start a Manual-mode swarm task without changing permission', async () => { @@ -210,8 +224,8 @@ describe('handleSwarmCommand', () => { expect(session.setPermission).not.toHaveBeenCalled(); expect(session.setSwarmMode).toHaveBeenCalledWith(true); expect(session.setSwarmMode).toHaveBeenCalledTimes(1); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); - expect(host.renderSwarmModeMarker).toHaveBeenCalledTimes(1); + expectSwarmMarker(host, true); + expect(markerAddChild(host)).toHaveBeenCalledTimes(1); }); it('can switch to YOLO when starting a Manual-mode swarm task', async () => { @@ -230,8 +244,8 @@ describe('handleSwarmCommand', () => { expect(session.setSwarmMode).toHaveBeenCalledTimes(1); expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); expect(host.setAppState).toHaveBeenCalledWith({ swarmMode: true }); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); - expect(host.renderSwarmModeMarker).toHaveBeenCalledTimes(1); + expectSwarmMarker(host, true); + expect(markerAddChild(host)).toHaveBeenCalledTimes(1); }); it('returns the command to the input box when a Manual-mode swarm start is cancelled', async () => { @@ -244,7 +258,7 @@ describe('handleSwarmCommand', () => { expect(host.showStatus).toHaveBeenCalledWith('Swarm task not started.'); expect(session.setPermission).not.toHaveBeenCalled(); expect(session.setSwarmMode).toHaveBeenCalledWith(true); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expectSwarmMarker(host, true); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); @@ -261,7 +275,7 @@ describe('handleSwarmCommand', () => { ); }); expect(session.setSwarmMode).toHaveBeenCalledWith(true); - expect(host.renderSwarmModeMarker).toHaveBeenCalledWith(true); + expectSwarmMarker(host, true); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); @@ -274,7 +288,7 @@ describe('handleSwarmCommand', () => { expect(host.showError).toHaveBeenCalledWith( expect.stringContaining('Failed to enable swarm mode'), ); - expect(host.renderSwarmModeMarker).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); expect(host.mountEditorReplacement).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); @@ -288,7 +302,7 @@ describe('handleSwarmCommand', () => { expect(host.showError).toHaveBeenCalledWith( expect.stringContaining('Failed to enable swarm mode'), ); - expect(host.renderSwarmModeMarker).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); }); diff --git a/packages/agent-core/src/session/subagent-batch.ts b/packages/agent-core/src/session/subagent-batch.ts index 082208b86..4e11d0fef 100644 --- a/packages/agent-core/src/session/subagent-batch.ts +++ b/packages/agent-core/src/session/subagent-batch.ts @@ -1,7 +1,12 @@ import { isProviderRateLimitError, type TokenUsage } from '@moonshot-ai/kosong'; import * as retry from 'retry'; -import type { RunSubagentOptions, SpawnSubagentOptions, SubagentHandle } from '.'; +import type { + RunSubagentOptions, + SessionSubagentHost, + SpawnSubagentOptions, + SubagentHandle, +} from './subagent-host'; import { isUserCancellation } from '../utils/abort'; const INITIAL_LAUNCH_LIMIT = 5; @@ -36,13 +41,6 @@ export type SubagentResult = { readonly error?: string; }; -export interface SubagentLauncher { - spawn(options: SpawnSubagentOptions): Promise; - resume(agentId: string, options: RunSubagentOptions): Promise; - retry(agentId: string, options: RunSubagentOptions): Promise; - suspended?(event: SubagentSuspendedEvent): void; -} - export type SubagentSuspendedEvent = { readonly task: QueuedSubagentTask; readonly agentId: string; @@ -103,7 +101,10 @@ export class SubagentBatch { private nextRateLimitLaunchAt = 0; constructor( - private readonly launcher: SubagentLauncher, + private readonly launcher: Pick< + SessionSubagentHost, + 'spawn' | 'resume' | 'retry' | 'suspended' + >, tasks: readonly QueuedSubagentTask[], ) { this.states = tasks.map((task, index) => ({ diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index 9a2839ea8..0193e83db 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -9,7 +9,6 @@ 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, @@ -72,23 +71,18 @@ export interface RunSubagentOptions { 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; @@ -97,7 +91,13 @@ 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, @@ -113,28 +113,15 @@ export class SessionSubagentHost { { type: 'sub', generate: parent.rawGenerate }, { parentAgentId: this.ownerAgentId, swarmItem: options.swarmItem }, ); - const controller = new AbortController(); - const unlinkAbortSignal = linkAbortSignal(options.signal, controller); - this.activeChildren.set(id, { - controller, - runInBackground: options.runInBackground, - }); - - this.emitSubagentSpawned(parent, id, profile.name, options); - const completion = this.runChild( - parent, - id, - agent, - profile.name, - { - ...options, - signal: controller.signal, - }, - () => this.configureChild(parent, agent, profile), - false, - ).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, @@ -145,20 +132,46 @@ export class SessionSubagentHost { } async resume(agentId: string, options: RunSubagentOptions): Promise { - return this.resumeOrRetry(agentId, options, 'resume'); + 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 { - return this.resumeOrRetry(agentId, options, 'retry'); + 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, profileName, runOptions); + 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 resumeOrRetry( + private async ensureIdleSubagent( agentId: string, - options: RunSubagentOptions, - operation: 'resume' | 'retry', - ): Promise { - options.signal.throwIfAborted(); - + ): 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') { @@ -169,41 +182,11 @@ 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 ${operation}d concurrently`, - ); + throw new Error(`Agent instance "${agentId}" is already running and cannot run concurrently`); } const profileName = child.config.profileName ?? 'subagent'; - - const controller = new AbortController(); - const unlinkAbortSignal = linkAbortSignal(options.signal, controller); - this.activeChildren.set(agentId, { - controller, - runInBackground: options.runInBackground, - }); - - const runPromise = - operation === 'resume' - ? this.runChild( - parent, - agentId, - child, - profileName, - { ...options, signal: controller.signal }, - () => { - child.config.update({ modelAlias: parent.config.modelAlias }); - return Promise.resolve(); - }, - ) - : this.runChildRetry(parent, agentId, child, profileName, { ...options, signal: controller.signal }); - - const completion = runPromise.finally(() => { - unlinkAbortSignal(); - this.activeChildren.delete(agentId); - }); - - return { agentId, profileName, resumed: true, completion }; + return { parent, child, profileName }; } async runQueued(tasks: readonly QueuedSubagentTask[]): Promise>> { @@ -211,7 +194,18 @@ export class SessionSubagentHost { } suspended(event: SubagentSuspendedEvent): void { - this.emitSubagentSuspended(event); + const parent = this.session.getReadyAgent?.(this.ownerAgentId); + parent?.emitEvent({ + type: 'subagent.suspended', + subagentId: event.agentId, + subagentName: event.task.profileName, + parentToolCallId: event.task.parentToolCallId, + parentToolCallUuid: event.task.parentToolCallUuid, + parentAgentId: this.ownerAgentId, + description: event.task.description, + runInBackground: event.task.runInBackground, + reason: event.reason, + }); } async startBtw(): Promise { @@ -278,79 +272,48 @@ export class SessionSubagentHost { return profile; } - private async runChild( - parent: Agent, + private runWithActiveChild( childId: string, - child: Agent, - profileName: string, options: RunSubagentOptions, - prepareChild: () => Promise, - emitSpawnedEvent = true, + run: (options: RunSubagentOptions) => Promise, ): Promise { - if (emitSpawnedEvent) this.emitSubagentSpawned(parent, childId, profileName, options); - return this.runChildWithErrorHandling(parent, childId, options, async () => { - await prepareChild(); - options.signal.throwIfAborted(); - await this.triggerSubagentStart(parent, profileName, options.prompt, options.signal); - options.signal.throwIfAborted(); + const controller = new AbortController(); + const unlinkAbortSignal = linkAbortSignal(options.signal, controller); + this.activeChildren.set(childId, { + controller, + runInBackground: options.runInBackground, + }); - // 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}`; - } - this.emitSubagentStarted(parent, childId, profileName, options); - 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); + return run({ ...options, signal: controller.signal }).finally(() => { + unlinkAbortSignal(); + this.activeChildren.delete(childId); }); } - private async runChildRetry( + private async runPromptTurn( parent: Agent, childId: string, child: Agent, profileName: string, options: RunSubagentOptions, ): Promise { - return this.runChildWithErrorHandling(parent, childId, options, async () => { - options.signal.throwIfAborted(); - child.config.update({ modelAlias: parent.config.modelAlias }); - this.emitSubagentStarted(parent, childId, profileName, options); - const turnId = child.turn.retry('agent-host'); - if (turnId === null) { - throw new Error(`Agent instance "${childId}" could not start a retry turn`); - } - this.observeFirstRequest(child, options); - return this.waitForChildCompletion(parent, childId, child, profileName, options); - }); - } + options.signal.throwIfAborted(); + await this.triggerSubagentStart(parent, profileName, options.prompt, options.signal); + options.signal.throwIfAborted(); - private async runChildWithErrorHandling( - parent: Agent, - childId: string, - options: RunSubagentOptions, - run: () => Promise, - ): Promise { - try { - return await run(); - } catch (error) { - if (!shouldSuppressQueuedAttemptFailureEvent(options, error)) { - const message = error instanceof Error ? error.message : String(error); - parent.emitEvent({ - type: 'subagent.failed', - subagentId: childId, - parentToolCallId: options.parentToolCallId, - error: message, - }); - } - throw error; + let childPrompt = options.prompt; + if (profileName === 'explore') { + const gitContext = await collectGitContext(child.kaos, child.config.cwd); + if (gitContext) childPrompt = `${gitContext}\n\n${childPrompt}`; } + + this.emitSubagentStarted(parent, childId, profileName, options); + 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( @@ -484,18 +447,18 @@ export class SessionSubagentHost { }); } - private emitSubagentSuspended(event: SubagentSuspendedEvent): void { - const parent = this.session.getReadyAgent?.(this.ownerAgentId); - parent?.emitEvent({ - type: 'subagent.suspended', - subagentId: event.agentId, - subagentName: event.task.profileName, - parentToolCallId: event.task.parentToolCallId, - parentToolCallUuid: event.task.parentToolCallUuid, - parentAgentId: this.ownerAgentId, - description: event.task.description, - runInBackground: event.task.runInBackground, - reason: event.reason, + private emitSubagentFailed( + parent: Agent, + childId: string, + options: RunSubagentOptions, + error: unknown, + ): void { + if (shouldSuppressQueuedAttemptFailureEvent(options, error)) return; + parent.emitEvent({ + type: 'subagent.failed', + subagentId: childId, + parentToolCallId: options.parentToolCallId, + error: error instanceof Error ? error.message : String(error), }); } } @@ -513,7 +476,9 @@ async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Prom : `[${turnEnded.error.code}] ${turnEnded.error.message}`, ); } - throwIfSubagentStoppedAtMaxTokens(completion.stopReason); + if (completion.stopReason === 'max_tokens') { + throw new Error(`${SUBAGENT_MAX_TOKENS_ERROR}.`); + } } function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError { @@ -522,12 +487,6 @@ function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProvider return new APIProviderRateLimitError(error.message, requestId); } -function throwIfSubagentStoppedAtMaxTokens(stopReason: LoopTurnStopReason | undefined): void { - if (stopReason === 'max_tokens') { - throw new Error(`${SUBAGENT_MAX_TOKENS_ERROR}.`); - } -} - function lastAssistantText(agent: Agent): string { for (const message of [...agent.context.history].toReversed()) { if (message.role !== 'assistant') continue; diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 70ea09930..14e01461e 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -4,7 +4,12 @@ import { join } from 'pathe'; import { createControlledPromise } from '@antfu/utils'; import { testKaos } from '../fixtures/test-kaos'; -import { APIStatusError, type Message, type ToolCall } from '@moonshot-ai/kosong'; +import { + APIProviderRateLimitError, + APIStatusError, + type Message, + type ToolCall, +} from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Agent, AgentOptions } from '../../src/agent'; @@ -16,11 +21,14 @@ import { collectGitContext } from '../../src/session/git-context'; import { SessionSubagentHost, type QueuedSubagentTask, + type RunSubagentOptions, + type SpawnSubagentOptions, + type SubagentHandle, } from '../../src/session/subagent-host'; import { - SubagentLaunchQueue, - type QueuedSubagentAttemptOptions, - type QueuedSubagentAttemptOutcome, + SubagentBatch, + type SubagentResult, + type SubagentSuspendedEvent, } from '../../src/session/subagent-batch'; import { abortError, userCancellationReason } from '../../src/utils/abort'; import { testAgent, type AgentTestContext } from '../agent/harness/agent'; @@ -35,8 +43,6 @@ vi.mock('../../src/session/git-context', () => ({ })); const signal = new AbortController().signal; -const rateLimit429Message = - "429 We're receiving too many requests at the moment. Please wait a moment and try again."; const tempDirs: string[] = []; type GenerateFn = NonNullable; @@ -50,8 +56,8 @@ describe('SessionSubagentHost', () => { it('runQueued starts five subagents and then adds one more every 700ms', async () => { vi.useFakeTimers(); try { - const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.enqueue( + const { runBatch, attempts } = createRecordedBatchRunner(); + const running = runBatch( Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { signal }, ); @@ -98,8 +104,8 @@ describe('SessionSubagentHost', () => { vi.useFakeTimers(); try { const controller = new AbortController(); - const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.enqueue(Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { + const { runBatch, attempts } = createRecordedBatchRunner(); + const running = runBatch(Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); void running.catch(() => {}); @@ -124,7 +130,8 @@ describe('SessionSubagentHost', () => { }); await vi.advanceTimersByTimeAsync(3000); expect(attempts).toHaveLength(6); - expect(attempts[5]!.retryAgentId).toBe('agent-1'); + expect(attempts[5]!.task.data).toBe(6); + expect(attempts[5]!.retryAgentId).toBeUndefined(); controller.abort(); await expect(running).rejects.toThrow(); @@ -137,16 +144,13 @@ describe('SessionSubagentHost', () => { vi.useFakeTimers(); try { const controller = new AbortController(); - const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.enqueue(Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)), { + const { runBatch, attempts } = createRecordedBatchRunner(); + const running = runBatch(Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); await vi.advanceTimersByTimeAsync(0); expect(attempts).toHaveLength(5); - attempts.forEach((attempt, index) => { - attempt.markAgentId(`agent-${String(index + 1)}`); - }); attempts[0]!.outcome.resolve({ task: attempts[0]!.task, @@ -224,8 +228,8 @@ describe('SessionSubagentHost', () => { it('runQueued keeps processing completions while waiting for the next initial launch', async () => { vi.useFakeTimers(); try { - const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.enqueue( + const { runBatch, attempts } = createRecordedBatchRunner(); + const running = runBatch( Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)), { signal }, ); @@ -259,13 +263,13 @@ describe('SessionSubagentHost', () => { } }); - it('runQueued requeues 429s and relaunches one at a time after the retry delay', async () => { + it('runQueued requeues 429s and throttles additional launches', async () => { vi.useFakeTimers(); try { const controller = new AbortController(); const onSuspended = vi.fn(); - const { queue, attempts } = createRecordedLaunchQueue({ onSuspended }); - const running = queue.enqueue(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { + const { runBatch, attempts } = createRecordedBatchRunner({ onSuspended }); + const running = runBatch(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); void running.catch(() => {}); @@ -280,36 +284,13 @@ describe('SessionSubagentHost', () => { attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-2' }); await vi.advanceTimersByTimeAsync(0); expect(onSuspended).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(500); - expect(attempts).toHaveLength(5); - - attempts[2]!.outcome.resolve({ - task: attempts[2]!.task, - agentId: 'agent-3', - status: 'completed', - result: 'completed 3', - }); - await vi.advanceTimersByTimeAsync(2499); - expect(attempts).toHaveLength(5); - - await vi.advanceTimersByTimeAsync(1); expect(attempts).toHaveLength(6); - expect(attempts[5]!.retryAgentId).toBe('agent-2'); + expect(attempts[5]!.task.data).toBe(6); + expect(attempts[5]!.retryAgentId).toBeUndefined(); - attempts[3]!.outcome.resolve({ - task: attempts[3]!.task, - agentId: 'agent-4', - status: 'completed', - result: 'completed 4', - }); - await vi.advanceTimersByTimeAsync(2999); + await vi.advanceTimersByTimeAsync(500); expect(attempts).toHaveLength(6); - await vi.advanceTimersByTimeAsync(1); - expect(attempts).toHaveLength(7); - expect(attempts[6]!.retryAgentId).toBe('agent-1'); - controller.abort(); await expect(running).rejects.toThrow(); } finally { @@ -317,12 +298,12 @@ describe('SessionSubagentHost', () => { } }); - it('runQueued initializes rate-limit slots from initially started subagents', async () => { + it('runQueued does not retry while active attempts fill rate-limit slots', async () => { vi.useFakeTimers(); try { const controller = new AbortController(); - const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.enqueue(Array.from({ length: 12 }, (_, index) => queuedTask(index + 1)), { + const { runBatch, attempts } = createRecordedBatchRunner(); + const running = runBatch(Array.from({ length: 12 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); void running.catch(() => {}); @@ -352,8 +333,7 @@ describe('SessionSubagentHost', () => { await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(3000); - expect(attempts).toHaveLength(13); - expect(attempts[12]!.retryAgentId).toBe('agent-8'); + expect(attempts).toHaveLength(12); controller.abort(); await expect(running).rejects.toThrow(); @@ -362,12 +342,12 @@ describe('SessionSubagentHost', () => { } }); - it('runQueued reduces slots at most once every two seconds and counts active attempts', async () => { + it('runQueued keeps throttled launches bounded after repeated 429s', async () => { vi.useFakeTimers(); try { const controller = new AbortController(); - const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.enqueue(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { + const { runBatch, attempts } = createRecordedBatchRunner(); + const running = runBatch(Array.from({ length: 8 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal, }); void running.catch(() => {}); @@ -386,26 +366,9 @@ describe('SessionSubagentHost', () => { await vi.advanceTimersByTimeAsync(0); } - await vi.advanceTimersByTimeAsync(3000); - expect(attempts).toHaveLength(6); - expect(attempts[5]!.retryAgentId).toBe('agent-3'); - - attempts[3]!.markReady(); - attempts[3]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-4' }); - await vi.advanceTimersByTimeAsync(3000); - expect(attempts).toHaveLength(7); - await vi.advanceTimersByTimeAsync(3000); expect(attempts).toHaveLength(7); - - attempts[4]!.outcome.resolve({ - task: attempts[4]!.task, - agentId: 'agent-5', - status: 'completed', - result: 'completed 5', - }); - await vi.advanceTimersByTimeAsync(0); - expect(attempts).toHaveLength(8); + expect(attempts.slice(5).map((attempt) => attempt.task.data)).toEqual([6, 7]); controller.abort(); await expect(running).rejects.toThrow(); @@ -414,66 +377,42 @@ describe('SessionSubagentHost', () => { } }); - it('runQueued keeps retrying 429s until the batch total timeout elapses', async () => { + it('runQueued reports a failed result when a task timeout elapses', async () => { vi.useFakeTimers(); try { - const { queue, attempts } = createRecordedLaunchQueue(); - const running = queue.enqueue([queuedTask(1)], { signal, totalTimeoutMs: 10_000 }); + const { runBatch, attempts } = createRecordedBatchRunner(); + const running = runBatch([{ ...queuedTask(1), timeout: 10_000 }], { signal }); await vi.advanceTimersByTimeAsync(0); attempts[0]!.markReady(); - attempts[0]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); - await vi.advanceTimersByTimeAsync(3000); - expect(attempts).toHaveLength(2); - attempts[1]!.markReady(); - attempts[1]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); - await vi.advanceTimersByTimeAsync(5999); - expect(attempts).toHaveLength(2); + await vi.advanceTimersByTimeAsync(9999); + expect(attempts).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); - expect(attempts).toHaveLength(3); - attempts[2]!.markReady(); - attempts[2]!.outcome.resolve({ type: 'rate_limited', agentId: 'agent-1' }); - - await vi.advanceTimersByTimeAsync(1000); - await expect(running).resolves.toEqual([ + await expect(running).resolves.toMatchObject([ { - task: queuedTask(1), + task: { data: 1 }, + agentId: 'agent-1', status: 'failed', - error: 'Subagent batch total timeout after 10s.', + state: 'started', + error: 'Subagent timed out after 10s.', }, ]); - } finally { vi.useRealTimers(); } }); - it('runQueued resets retry launch spacing to three seconds after a launch succeeds', async () => { + it('runQueued continues throttled launches after rate-limited attempts settle', async () => { vi.useFakeTimers(); try { const controller = new AbortController(); - const attempts: Array = []; - const queue = new SubagentLaunchQueue(( - task: QueuedSubagentTask, - options: QueuedSubagentAttemptOptions, - ) => { - const outcome = createControlledPromise>(); - if (attempts.length >= 7) { - setTimeout(() => options.markReady(), 100); - } - attempts.push({ - task: task as unknown as QueuedSubagentTask, - retryAgentId: options.retryAgentId, - markAgentId: options.markAgentId, - markReady: options.markReady, - outcome: outcome as unknown as QueuedAttemptRecord['outcome'], - }); - return outcome; + const { runBatch, attempts } = createRecordedBatchRunner({ + readyDelay: (attemptIndex) => (attemptIndex >= 7 ? 100 : undefined), }); - const running = queue.enqueue( + const running = runBatch( Array.from({ length: 9 }, (_, index) => queuedTask(index + 1)), { signal: controller.signal }, ); @@ -506,31 +445,8 @@ describe('SessionSubagentHost', () => { result: 'completed 2', }); await vi.advanceTimersByTimeAsync(11_999); - expect(attempts).toHaveLength(7); - - await vi.advanceTimersByTimeAsync(1); - expect(attempts).toHaveLength(8); - expect(attempts[7]!.retryAgentId).toBe('agent-7'); - - await vi.advanceTimersByTimeAsync(99); expect(attempts).toHaveLength(8); - - await vi.advanceTimersByTimeAsync(1); - expect(attempts).toHaveLength(8); - - await vi.advanceTimersByTimeAsync(2999); - expect(attempts).toHaveLength(8); - - attempts[2]!.outcome.resolve({ - task: attempts[2]!.task, - agentId: 'agent-3', - status: 'completed', - result: 'completed 3', - }); - - await vi.advanceTimersByTimeAsync(1); - expect(attempts).toHaveLength(9); - expect(attempts[8]!.retryAgentId).toBe('agent-6'); + expect(attempts[7]!.task.data).toBe(8); controller.abort(); await expect(running).rejects.toThrow(); @@ -539,53 +455,20 @@ describe('SessionSubagentHost', () => { } }); - it('runQueued emits a suspended event when a rate-limited child is requeued', async () => { + it('emits a suspended event for a requeued child', () => { const parent = testAgent(); parent.configure(); parent.newEvents(); - - const summary = - 'Recovered from a provider rate limit by retrying the queued subagent with its existing context, then completed the delegated review with enough concrete details for the parent to continue. '.repeat( - 2, - ); - let generateCalls = 0; - const generate: GenerateFn = async ( - _provider, - _systemPrompt, - _tools, - _history, - callbacks, - ) => { - generateCalls += 1; - if (generateCalls === 1) { - throw new Error( - 'APIStatusError: 429 request id: req-429, Your account example-account request reached user+model max RPM: 50 (current: 389) for model example-model, please try again later', - ); - } - await callbacks?.onMessagePart?.({ type: 'text', text: summary }); - return textResult(summary); - }; - const child = testAgent({ - generate, - initialConfig: { - providers: {}, - loopControl: { maxRetriesPerStep: 1 }, - }, - }); - child.configure(); - + const child = testAgent(); const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - await expect(host.runQueued([queuedTask(1)], { signal })).resolves.toMatchObject([ - { - agentId: 'agent-0', - status: 'completed', - result: summary.trim(), - }, - ]); + host.suspended({ + task: queuedTask(1), + agentId: 'agent-0', + reason: 'Provider rate limit; subagent requeued for retry.', + }); - expect(generateCalls).toBe(2); expect(parent.allEvents).toContainEqual( expect.objectContaining({ type: '[rpc]', @@ -599,90 +482,7 @@ describe('SessionSubagentHost', () => { }), }), ); - expect( - parent.allEvents - .filter((event) => event.type === '[rpc]') - .map((event) => event.event) - .filter((event) => typeof event === 'string' && event.startsWith('subagent.')), - ).toEqual([ - 'subagent.spawned', - 'subagent.started', - 'subagent.suspended', - 'subagent.started', - 'subagent.completed', - ]); - expect(parent.allEvents).not.toContainEqual( - expect.objectContaining({ - type: '[rpc]', - event: 'subagent.failed', - }), - ); - }, 10_000); - - it('runQueued treats wrapped provider too-many-requests errors as rate limits', async () => { - const parent = testAgent(); - parent.configure(); - parent.newEvents(); - - const summary = - 'Recovered after the provider asked us to slow down, retried the queued subagent with its existing context, and completed the delegated review with enough detail for the parent to continue. '.repeat( - 2, - ); - let generateCalls = 0; - const generate: GenerateFn = async ( - _provider, - _systemPrompt, - _tools, - _history, - callbacks, - ) => { - generateCalls += 1; - if (generateCalls === 1) { - throw new Error( - "[provider.api_error] We're receiving too many requests at the moment. Please wait a moment and try again.", - ); - } - await callbacks?.onMessagePart?.({ type: 'text', text: summary }); - return textResult(summary); - }; - const child = testAgent({ - generate, - initialConfig: { - providers: {}, - loopControl: { maxRetriesPerStep: 1 }, - }, - }); - child.configure(); - - const session = fakeSession(parent.agent, child.agent); - const host = new SessionSubagentHost(session, 'main'); - - await expect(host.runQueued([queuedTask(1)], { signal })).resolves.toMatchObject([ - { - agentId: 'agent-0', - status: 'completed', - result: summary.trim(), - }, - ]); - - expect(generateCalls).toBe(2); - expect(parent.allEvents).toContainEqual( - expect.objectContaining({ - type: '[rpc]', - event: 'subagent.suspended', - args: expect.objectContaining({ - subagentId: 'agent-0', - reason: 'Provider rate limit; subagent requeued for retry.', - }), - }), - ); - expect(parent.allEvents).not.toContainEqual( - expect.objectContaining({ - type: '[rpc]', - event: 'subagent.failed', - }), - ); - }, 10_000); + }); it('runQueued suppresses raw live Aborted failures from queued attempts', async () => { const parent = testAgent(); @@ -695,7 +495,7 @@ describe('SessionSubagentHost', () => { const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const running = host.runQueued([queuedTask(1)], { signal: controller.signal }); + const running = host.runQueued([{ ...queuedTask(1), signal: controller.signal }]); void running.catch(() => {}); await child.untilApprovalRequest(); @@ -830,7 +630,7 @@ describe('SessionSubagentHost', () => { child.mockNextResponse({ type: 'think', think: 'I can start.' }, { type: 'text', text: summary }); const session = fakeSession(parent.agent, child.agent); const host = new SessionSubagentHost(session, 'main'); - const onFirstOutput = vi.fn(); + const onReady = vi.fn(); const handle = await host.spawn({ profileName: 'coder', @@ -839,14 +639,14 @@ describe('SessionSubagentHost', () => { description: 'Fix bug', runInBackground: false, signal, - onFirstOutput, + onReady, }); await vi.waitFor(() => { - expect(onFirstOutput).toHaveBeenCalledTimes(1); + expect(onReady).toHaveBeenCalledTimes(1); }); await expect(handle.completion).resolves.toMatchObject({ result: summary.trim() }); - expect(onFirstOutput).toHaveBeenCalledTimes(1); + expect(onReady).toHaveBeenCalledTimes(1); }); it('runs a child agent turn and returns the last assistant text', async () => { @@ -1541,9 +1341,9 @@ describe('SessionSubagentHost', () => { ...queuedTask(1), prompt: 'Continue the previous swarm task', resumeAgentId: 'agent-0', + signal, }, ], - { signal }, ), ).resolves.toMatchObject([ { @@ -1577,7 +1377,7 @@ describe('SessionSubagentHost', () => { const host = new SessionSubagentHost(session, 'main'); await expect( - host.runQueued([{ ...queuedTask(1), swarmItem: 'src/a.ts' }], { signal }), + host.runQueued([{ ...queuedTask(1), swarmItem: 'src/a.ts', signal }]), ).resolves.toMatchObject([ { agentId: 'agent-0', @@ -1647,7 +1447,7 @@ describe('SessionSubagentHost', () => { runInBackground: false, signal, }); - await expect(handle.completion).rejects.toThrow('provider.rate_limit'); + await expect(handle.completion).rejects.toThrow('Rate limited'); const retryHandle = await host.retry(handle.agentId, { parentToolCallId: 'call_agent', @@ -2115,36 +1915,156 @@ async function flushPromises(count = 2): Promise { } } +type RecordedAttemptOutcome = + | SubagentResult + | { + readonly type: 'rate_limited'; + readonly agentId?: string; + }; + type QueuedAttemptRecord = { readonly task: QueuedSubagentTask; readonly retryAgentId?: string; - readonly markAgentId: (agentId: string) => void; readonly markReady: () => void; - readonly outcome: ReturnType>>; + readonly outcome: ReturnType>>; +}; + +type RecordedBatchRunnerOptions = { + readonly onSuspended?: (event: SubagentSuspendedEvent) => void; + readonly readyDelay?: (attemptIndex: number) => number | undefined; }; -function createRecordedLaunchQueue( - events?: ConstructorParameters[1], +function createRecordedBatchRunner( + options: RecordedBatchRunnerOptions = {}, ): { - readonly queue: SubagentLaunchQueue; + readonly runBatch: ( + tasks: readonly QueuedSubagentTask[], + options?: { readonly signal?: AbortSignal }, + ) => Promise>>; readonly attempts: QueuedAttemptRecord[]; } { const attempts: QueuedAttemptRecord[] = []; - const queue = new SubagentLaunchQueue(( - task: QueuedSubagentTask, - options: QueuedSubagentAttemptOptions, - ) => { - const outcome = createControlledPromise>(); + let activeTasks: readonly QueuedSubagentTask[] = []; + + const createHandle = ( + runOptions: RunSubagentOptions, + agentId: string, + profileName: string, + resumed: boolean, + retryAgentId?: string, + ): SubagentHandle => { + const task = findRecordedTask(activeTasks, runOptions); + const outcome = createControlledPromise>(); + const markReady = () => { + runOptions.onReady?.(); + }; + const attemptIndex = attempts.length; attempts.push({ task: task as unknown as QueuedSubagentTask, - retryAgentId: options.retryAgentId, - markAgentId: options.markAgentId, - markReady: options.markReady, + retryAgentId, + markReady, outcome: outcome as unknown as QueuedAttemptRecord['outcome'], }); - return outcome; - }, events); - return { queue, attempts }; + + const delay = options.readyDelay?.(attemptIndex); + if (delay !== undefined) setTimeout(markReady, delay); + + return { + agentId, + profileName, + resumed, + completion: completionFromRecordedOutcome(outcome, runOptions.signal), + }; + }; + + const launcher = { + spawn: async (spawnOptions: SpawnSubagentOptions) => { + const task = findRecordedTask(activeTasks, spawnOptions); + return createHandle( + spawnOptions, + recordedAgentId(task, attempts.length), + spawnOptions.profileName, + false, + ); + }, + resume: async (agentId: string, runOptions: RunSubagentOptions) => + createHandle(runOptions, agentId, 'subagent', true), + retry: async (agentId: string, runOptions: RunSubagentOptions) => + createHandle(runOptions, agentId, 'subagent', true, agentId), + suspended: (event: SubagentSuspendedEvent) => { + options.onSuspended?.(event); + }, + }; + + return { + runBatch: ( + tasks: readonly QueuedSubagentTask[], + runOptions?: { readonly signal?: AbortSignal }, + ) => { + activeTasks = tasks.map((task) => ({ + ...task, + signal: task.signal ?? runOptions?.signal, + })); + return new SubagentBatch(launcher, activeTasks as readonly QueuedSubagentTask[]).run(); + }, + attempts, + }; +} + +function findRecordedTask( + tasks: readonly QueuedSubagentTask[], + options: RunSubagentOptions, +): QueuedSubagentTask { + const task = tasks.find( + (candidate) => + candidate.prompt === options.prompt && + candidate.parentToolCallId === options.parentToolCallId, + ); + if (task === undefined) { + throw new Error(`No recorded queued task for prompt "${options.prompt}"`); + } + return task as QueuedSubagentTask; +} + +function recordedAgentId(task: QueuedSubagentTask, attemptIndex: number): string { + if (typeof task.data === 'number') return `agent-${String(task.data)}`; + return `agent-${String(attemptIndex + 1)}`; +} + +function completionFromRecordedOutcome( + outcome: ReturnType>>, + signal: AbortSignal, +): SubagentHandle['completion'] { + return new Promise((resolve, reject) => { + const abort = () => { + reject(signal.reason ?? new Error('Aborted')); + }; + signal.addEventListener('abort', abort, { once: true }); + outcome.then( + (result) => { + signal.removeEventListener('abort', abort); + if (isRecordedRateLimitOutcome(result)) { + reject(new APIProviderRateLimitError('Rate limited', result.agentId ?? null)); + return; + } + if (result.status === 'completed') { + resolve({ result: result.result ?? '', usage: result.usage }); + return; + } + reject(new Error(result.error ?? result.status)); + }, + (error: unknown) => { + signal.removeEventListener('abort', abort); + reject(error); + }, + ); + }); +} + +function isRecordedRateLimitOutcome( + outcome: RecordedAttemptOutcome, +): outcome is Extract, { readonly type: 'rate_limited' }> { + return 'type' in outcome && outcome.type === 'rate_limited'; } function queuedTask(index: number): QueuedSubagentTask { From d529b753c186ee6dd891be00d357d8748c53dcba Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 6 Jun 2026 15:23:46 +0800 Subject: [PATCH 50/72] fix --- packages/agent-core/test/agent/basic.test.ts | 6 ++--- .../test/agent/compaction/full.test.ts | 24 +++++++++---------- packages/agent-core/test/agent/config.test.ts | 12 +++++----- .../agent-core/test/agent/permission.test.ts | 20 ++++++++-------- packages/agent-core/test/agent/plan.test.ts | 16 ++++++------- packages/agent-core/test/agent/tool.test.ts | 10 ++++---- packages/agent-core/test/agent/turn.test.ts | 4 ++-- packages/agent-core/test/session/init.test.ts | 2 +- .../agent-core/test/skill/scanner.test.ts | 6 +++-- .../test/tools/builtin-current.test.ts | 19 ++++----------- 10 files changed, 56 insertions(+), 63 deletions(-) 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": "