diff --git a/.changeset/subagent-model-failover.md b/.changeset/subagent-model-failover.md new file mode 100644 index 0000000000..bbabe75ae7 --- /dev/null +++ b/.changeset/subagent-model-failover.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +web: Add experimental runtime model failover for subagents so exhausted retries or structured quota failures can replay the current step on an ordered fallback route. Enable `KIMI_CODE_EXPERIMENTAL_MODEL_FAILOVER=1` and configure `[subagent_failover]`. diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index d4ebbd09a0..f50896a454 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -46,6 +46,7 @@ const MAIN_AGENT_TRANSCRIPT_FRAMES = new Set([ 'turn.step.started', 'turn.step.completed', 'turn.step.retrying', + 'turn.step.failover', 'turn.step.interrupted', 'turn.ended', 'thinking.delta', @@ -225,6 +226,12 @@ export function subagentProgressText(rawType: string, payload: Record + task.id === event.taskId ? { ...task, text: undefined } : task, + ); + break; + } + + case 'agentStreamReset': + break; + // ------------------------------------------------------------------------- case 'taskCompleted': { const sid = event.sessionId; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 6c4679f165..2b1b6c3802 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -460,6 +460,8 @@ export type AppEvent = */ kind?: 'line' | 'text'; } + | { type: 'taskTextReset'; sessionId: string; taskId: string } + | { type: 'agentStreamReset'; sessionId: string; agentId: string } | { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number } // Prompt-level lifecycle (distinct from turn-level): a prompt that never // produced a turn — blocked by a pre-submit hook, or aborted while queued — diff --git a/apps/kimi-web/src/composables/client/useSideChat.ts b/apps/kimi-web/src/composables/client/useSideChat.ts index 10322ae46a..14a3961331 100644 --- a/apps/kimi-web/src/composables/client/useSideChat.ts +++ b/apps/kimi-web/src/composables/client/useSideChat.ts @@ -142,6 +142,12 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { }); } + function resetSideChatAssistantText(agentId: string): void { + updateSideChatMessages(agentId, (messages) => + messages.at(-1)?.role === 'assistant' ? messages.slice(0, -1) : messages, + ); + } + function finishSideChatAgent(agentId: string, sessionId: string, outputPreview?: string): void { rawState.sideChatSendingByAgent = { ...rawState.sideChatSendingByAgent, [agentId]: false }; if (!outputPreview) return; @@ -281,6 +287,7 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { sideChatRunning, sideChatTurns, appendSideChatAssistantText, + resetSideChatAssistantText, finishSideChatAgent, openSideChat, openSideChatOn, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index f6e3f6a694..19ff356542 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -908,6 +908,8 @@ function processEvent(appEvent: AppEvent, meta: KimiEventMeta): void { } } else if (appEvent.type === 'agentTurnEnded' && appEvent.agentId === agentId) { sideChat.finishSideChatAgent(agentId, parentId); + } else if (appEvent.type === 'agentStreamReset' && appEvent.agentId === agentId) { + sideChat.resetSideChatAssistantText(agentId); } else if (appEvent.type === 'taskProgress' && appEvent.taskId === agentId) { sideChat.appendSideChatAssistantText(agentId, parentId, appEvent.outputChunk); } else if (appEvent.type === 'taskCompleted' && appEvent.taskId === agentId) { diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts index 763e2fe2b9..1e5665cc87 100644 --- a/apps/kimi-web/test/agent-event-projector.test.ts +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -1,6 +1,8 @@ /** * Web daemon projector contract for transcript isolation, task progress, and - * client-visible error projection. + * client-visible error projection. The real pure projector is used directly. + * Run with `pnpm --filter @moonshot-ai/kimi-web exec vitest run + * test/agent-event-projector.test.ts`. */ import { describe, expect, it } from 'vitest'; @@ -11,6 +13,15 @@ describe('subagentProgressText', () => { expect(subagentProgressText('turn.step.started', {})).toBeNull(); }); + it('summarizes a model failover with both aliases', () => { + expect( + subagentProgressText('turn.step.failover', { + fromModel: 'secondary-model', + toModel: 'primary-model', + }), + ).toBe('Model failover: secondary-model → primary-model'); + }); + it('summarizes a read tool call with its path', () => { const text = subagentProgressText('tool.use', { name: 'read', args: { path: 'src/foo.ts' } }); expect(text).toContain('src/foo.ts'); @@ -64,6 +75,59 @@ describe('subagent streaming text', () => { const events = projector.project('assistant.delta', { agentId: 'sub-1', delta: '' }, 's1'); expect(events).toEqual([]); }); + + it('resets abandoned subagent text when failover starts', () => { + const projector = createAgentProjector(); + projector.project('assistant.delta', { agentId: 'sub-1', delta: 'partial' }, 's1'); + + const events = projector.project( + 'turn.step.failover', + { + agentId: 'sub-1', + turnId: 1, + step: 1, + fromModel: 'secondary-model', + toModel: 'primary-model', + }, + 's1', + ); + + expect(events).toContainEqual({ + type: 'taskTextReset', + sessionId: 's1', + taskId: 'sub-1', + }); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'taskProgress', + taskId: 'sub-1', + outputChunk: 'Model failover: secondary-model → primary-model', + }), + ); + }); + + it('resets a side-channel stream when failover starts', () => { + const projector = createAgentProjector(); + projector.markSideChannelAgent('side-1'); + + expect( + projector.project( + 'turn.step.failover', + { + agentId: 'side-1', + turnId: 1, + step: 1, + }, + 's1', + ), + ).toEqual([ + { + type: 'agentStreamReset', + sessionId: 's1', + agentId: 'side-1', + }, + ]); + }); }); describe('agent error projection', () => { diff --git a/apps/kimi-web/test/event-reducer.test.ts b/apps/kimi-web/test/event-reducer.test.ts index 9df96de2ed..52dbcdf46a 100644 --- a/apps/kimi-web/test/event-reducer.test.ts +++ b/apps/kimi-web/test/event-reducer.test.ts @@ -1,3 +1,10 @@ +/** + * Scenario: projected daemon events reduce into immutable web application + * state. Responsibilities include messages, sessions, tasks, streaming, and + * recovery resets. The real pure reducer is used directly. Run with + * `pnpm --filter @moonshot-ai/kimi-web test`. + */ + import { describe, expect, it } from 'vitest'; import { createInitialState, reduceAppEvent } from '../src/api/daemon/eventReducer'; import type { AppMessage, AppSession, AppTask } from '../src/api/types'; @@ -429,6 +436,30 @@ describe('reduceAppEvent taskProgress', () => { expect(task?.outputLines ?? []).toHaveLength(0); }); + it('clears abandoned subagent text when taskTextReset arrives', () => { + const state = { + ...createInitialState(), + tasksBySession: { + s1: [ + { + ...makeSubagentTask('t1', 's1'), + text: 'abandoned partial', + outputLines: ['Calling Read'], + }, + ], + }, + }; + + const next = reduceAppEvent( + state, + { type: 'taskTextReset', sessionId: 's1', taskId: 't1' }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.tasksBySession['s1']?.[0]?.text).toBeUndefined(); + expect(next.tasksBySession['s1']?.[0]?.outputLines).toEqual(['Calling Read']); + }); + it('preserves accumulated text across a taskCreated replacement', () => { const state = { ...createInitialState(), diff --git a/apps/kimi-web/test/side-chat.test.ts b/apps/kimi-web/test/side-chat.test.ts index 9c76f1fbf1..83aac0f072 100644 --- a/apps/kimi-web/test/side-chat.test.ts +++ b/apps/kimi-web/test/side-chat.test.ts @@ -1,4 +1,8 @@ -// apps/kimi-web/test/side-chat.test.ts +/** + * Scenario: side-channel chat preserves parent runtime controls and manages + * its isolated optimistic transcript. The real composable uses a mocked daemon + * boundary. Run with `pnpm --filter @moonshot-ai/kimi-web test`. + */ import { describe, expect, it, vi } from 'vitest'; import { createInitialState } from '../src/api/daemon/eventReducer'; import { useSideChat } from '../src/composables/client/useSideChat'; @@ -140,3 +144,40 @@ describe('useSideChat — sendSideChatPromptOn', () => { ); }); }); + +describe('useSideChat — recovery reset', () => { + it('removes the abandoned assistant message when a model replay starts', () => { + const state = createState(); + state.sideChatMessagesByAgent = { + agent_btw_1: [ + { + id: 'user-1', + sessionId: 'sess_1', + role: 'user', + content: [{ type: 'text', text: 'question' }], + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'assistant-1', + sessionId: 'sess_1', + role: 'assistant', + content: [{ type: 'text', text: 'abandoned partial' }], + createdAt: '2026-01-01T00:00:01.000Z', + }, + ], + }; + const sideChat = useSideChat(state, { + pushOperationFailure: vi.fn(), + nextOptimisticMsgId: () => 'msg-opt', + connectEventsIfNeeded: vi.fn(), + getEventConn: () => null, + resolveThinkingForPrompt: async () => undefined, + }); + + sideChat.resetSideChatAssistantText('agent_btw_1'); + + expect(state.sideChatMessagesByAgent['agent_btw_1']).toEqual([ + expect.objectContaining({ id: 'user-1' }), + ]); + }); +}); diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index d89be92abe..30af1ec31f 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -8,7 +8,7 @@ # commented "# field: type" lines describe the remaining schema fields. # Values resolve as: default -> config.toml -> env overlay -> memory. -# Index (22 sections · 3 overlay(s)) +# Index (23 sections · 3 overlay(s)) # background src/agent/task/configSection.ts # cron src/app/cron/configSection.ts # defaultPermissionMode src/agent/permissionMode/configSection.ts @@ -28,6 +28,7 @@ # secondaryModel src/app/kosongConfig/configSection.ts # services src/app/auth/configSection.ts # subagent src/session/subagent/configSection.ts +# subagentFailover src/agent/modelFailover/configSection.ts # task src/agent/task/configSection.ts # thinking src/app/kosongConfig/configSection.ts # tools src/agent/toolPolicy/configSection.ts @@ -352,6 +353,17 @@ merge_all_available_skills = true [subagent] timeout_ms = 7200000 +# ########################################################################## +# subagentFailover (config.toml: subagent_failover) +# owner: src/agent/modelFailover/configSection.ts +# scope: core +# ########################################################################## + +[subagent_failover] +fallbacks = [] +on = ["retry_exhausted", "quota_exhausted"] +max_switches_per_turn = 1 + # ########################################################################## # task # owner: src/agent/task/configSection.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 4f6bded88c..9762fbadbd 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -23,7 +23,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (Session: 28 keys · Agent: 68 keys) +// Index (Session: 28 keys · Agent: 72 keys) // Session // cron.inFlight src/session/cron/sessionCronServiceImpl.ts // cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts @@ -94,6 +94,10 @@ // mcp.mcpToolsByServer src/agent/mcp/mcpService.ts // media.registeredKey src/agent/media/mediaToolsRegistrar.ts // media.resolved src/agent/media/videoResolverService.ts +// modelFailover.activeTurnId src/agent/modelFailover/modelFailoverService.ts +// modelFailover.emittedWarnings src/agent/modelFailover/modelFailoverService.ts +// modelFailover.nextFallbackIndex src/agent/modelFailover/modelFailoverService.ts +// modelFailover.switchesInTurn src/agent/modelFailover/modelFailoverService.ts // permissionMode.lastMode src/agent/permissionMode/injection/permissionModeInjection.ts // plan.wasActive src/agent/plan/injection/planModeInjection.ts // profile.activeToolNamesOverlay src/agent/profile/profileService.ts @@ -1010,7 +1014,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map; + // src/agent/modelFailover/modelFailoverService.ts + 'modelFailover.activeTurnId': number | undefined; + 'modelFailover.emittedWarnings': Set; + 'modelFailover.nextFallbackIndex': number; + 'modelFailover.switchesInTurn': number; // src/agent/permissionMode/injection/permissionModeInjection.ts 'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined; // src/agent/plan/injection/planModeInjection.ts diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 01917f495d..cec05a1a2d 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -21,7 +21,7 @@ // owning model offloads inline media to blob storage), cross-reducers // (foreign models that also reduce this record on dispatch and replay). -// Index (44 record types) +// Index (45 record types) // config.update profile persisted src/agent/profile/profileOps.ts // context_size.measured contextSize transient src/agent/contextSize/contextSizeOps.ts // context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts @@ -44,6 +44,7 @@ // llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts // llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts // mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts +// model.failover modelFailover persisted src/agent/modelFailover/modelFailoverOps.ts // permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts // permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts // permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts @@ -365,6 +366,26 @@ interface McpToolsDiscoveredPayload { }[]; } +/** + * model: modelFailover · persisted · toEvent + * owner: src/agent/modelFailover/modelFailoverOps.ts + */ +interface ModelFailoverPayload { + _name: 'model.failover'; + turnId: number; + step: number; + stepId?: string; + fromModel: string; + toModel: string; + fromProvider: string; + toProvider: string; + fromEffort: string; + toEffort: string; + reason: 'retry_exhausted' | 'quota_exhausted'; + switchIndex: number; + maxSwitches: number; +} + /** * model: permissionRules · persisted * owner: src/agent/permissionRules/permissionRulesOps.ts @@ -637,6 +658,7 @@ interface WirePayloadMap { "llm.request": LlmRequestPayload; "llm.tools_snapshot": LlmToolsSnapshotPayload; "mcp.tools_discovered": McpToolsDiscoveredPayload; + "model.failover": ModelFailoverPayload; "permission.record_approval_result": PermissionRecordApprovalResultPayload; "permission.rules.add": PermissionRulesAddPayload; "permission.set_mode": PermissionSetModePayload; diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index b29f110e9e..2e2bcaf52c 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -196,6 +196,7 @@ const DOMAIN_LAYER = new Map([ ['fullCompaction', 4], ['loop', 4], ['stepRetry', 4], + ['modelFailover', 4], ['media', 4], // `edit` spans two scopes: the App-scope `IFileEditService` capability (pure // TextModel / EditService + os-backed read/write over the L1 hostFs bridge) diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts index a7a9f694d1..ea1c2ffce5 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts @@ -58,6 +58,7 @@ export interface IAgentLLMRequesterService { readonly _serviceBrand: undefined; prepareTurnConfig(turnId: number): PreparedTurnRequestConfig | undefined; + invalidateTurnConfig(turnId: number): void; request( overrides?: AgentLLMRequestOverrides, diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 057bebd882..e71455d2c5 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -15,7 +15,10 @@ * `