Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subagent-model-failover.md
Original file line number Diff line number Diff line change
@@ -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]`.
37 changes: 36 additions & 1 deletion apps/kimi-web/src/api/daemon/agentEventProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const MAIN_AGENT_TRANSCRIPT_FRAMES = new Set<string>([
'turn.step.started',
'turn.step.completed',
'turn.step.retrying',
'turn.step.failover',
'turn.step.interrupted',
'turn.ended',
'thinking.delta',
Expand Down Expand Up @@ -225,6 +226,12 @@ export function subagentProgressText(rawType: string, payload: Record<string, un
// "Started a step" fires on every step and adds no information — the phase
// badge already shows the subagent is working, so skip it to cut the noise.
if (rawType === 'turn.step.started') return null;
if (rawType === 'turn.step.failover') {
const fromModel = stringField(payload, 'fromModel');
const toModel = stringField(payload, 'toModel');
if (fromModel && toModel) return `Model failover: ${fromModel} → ${toModel}`;
return 'Model failover';
}
if (rawType === 'tool.use' || rawType === 'tool.call.started') {
const name = stringField(payload, 'name') ?? stringField(payload, 'toolName') ?? 'tool';
const label = toolLabel(cleanToolName(name));
Expand Down Expand Up @@ -282,6 +289,32 @@ function projectSubagentProgress(
// placeholders like "Started a step".
if (sideChannelAgents.has(subagentId) && rawType === 'turn.step.started') return [];

if (rawType === 'turn.step.retrying' || rawType === 'turn.step.failover') {
if (sideChannelAgents.has(subagentId)) {
return [{ type: 'agentStreamReset', sessionId, agentId: subagentId }];
}
const previous = state.subagentMeta.get(subagentId);
const task = patchSubagent(state, sessionId, subagentId, {
status: 'running',
subagentPhase: 'working',
startedAt: previous?.startedAt ?? new Date().toISOString(),
});
const out: AppEvent[] = [];
if (task) out.push({ type: 'taskCreated', sessionId, task });
out.push({ type: 'taskTextReset', sessionId, taskId: subagentId });
const text = subagentProgressText(rawType, payload);
if (text !== null) {
out.push({
type: 'taskProgress',
sessionId,
taskId: subagentId,
outputChunk: text,
stream: 'stdout',
});
}
return out;
}

// The subagent's own streamed text: forward each delta as a `text`-kind
// progress chunk so the reducer concatenates it into `AppTask.text`, letting
// the right-side detail panel show the subagent's output growing live (like
Expand Down Expand Up @@ -1052,7 +1085,8 @@ export function createAgentProjector(): AgentProjector {
}

// -----------------------------------------------------------------------
case 'turn.step.retrying': {
case 'turn.step.retrying':
case 'turn.step.failover': {
// The step's stream restarts from offset 0. Reuse the abandoned
// bubble instead of stacking a new one: strip its streamed parts and
// keep the id in retryReuseMsgId so the retried step.started refills
Expand Down Expand Up @@ -1445,6 +1479,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([
'turn.step.started',
'turn.step.completed',
'turn.step.retrying',
'turn.step.failover',
'turn.step.interrupted',
'turn.ended',
'thinking.delta',
Expand Down
12 changes: 12 additions & 0 deletions apps/kimi-web/src/api/daemon/eventReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,18 @@ export function reduceAppEvent(
break;
}

case 'taskTextReset': {
const sid = event.sessionId;
const list = next.tasksBySession[sid] ?? [];
next.tasksBySession[sid] = list.map((task) =>
task.id === event.taskId ? { ...task, text: undefined } : task,
);
break;
}

case 'agentStreamReset':
break;

// -------------------------------------------------------------------------
case 'taskCompleted': {
const sid = event.sessionId;
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-web/src/composables/client/useSideChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -281,6 +287,7 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
sideChatRunning,
sideChatTurns,
appendSideChatAssistantText,
resetSideChatAssistantText,
finishSideChatAgent,
openSideChat,
openSideChatOn,
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
66 changes: 65 additions & 1 deletion apps/kimi-web/test/agent-event-projector.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down
31 changes: 31 additions & 0 deletions apps/kimi-web/test/event-reducer.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(),
Expand Down
43 changes: 42 additions & 1 deletion apps/kimi-web/test/side-chat.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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' }),
]);
});
});
14 changes: 13 additions & 1 deletion packages/agent-core-v2/docs/config-manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1010,7 +1014,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2671": undefined;
readonly "__@mediaStripSnapshotBrand@2723": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {
Expand Down Expand Up @@ -1082,6 +1086,11 @@ export interface AgentStateSnapshot {
id?: string;
};
}>;
// src/agent/modelFailover/modelFailoverService.ts
'modelFailover.activeTurnId': number | undefined;
'modelFailover.emittedWarnings': Set<string>;
'modelFailover.nextFallbackIndex': number;
'modelFailover.switchesInTurn': number;
// src/agent/permissionMode/injection/permissionModeInjection.ts
'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined;
// src/agent/plan/injection/planModeInjection.ts
Expand Down
Loading