Skip to content
Merged
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/interrupt-reminder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Preserve the assistant's partial output when a turn is interrupted with Esc, and remind the model that the previous turn was deliberately interrupted.
106 changes: 59 additions & 47 deletions packages/agent-core-v2/docs/wire-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,52 +21,53 @@
// owning model offloads inline media to blob storage), cross-reducers
// (foreign models that also reduce this record on dispatch and replay).

// 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
// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts
// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts
// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts
// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts
// cron.add cron transient src/session/cron/cronOps.ts
// cron.cursor cron transient src/session/cron/cronOps.ts
// cron.delete cron transient src/session/cron/cronOps.ts
// forked goal persisted src/agent/goal/goalOps.ts
// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts
// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts
// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts
// goal.clear goal persisted src/agent/goal/goalOps.ts
// goal.create goal persisted src/agent/goal/goalOps.ts
// goal.update goal persisted src/agent/goal/goalOps.ts
// interaction.request interaction persisted src/session/interaction/interactionOps.ts
// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts
// 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
// 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
// plan_mode.cancel plan persisted src/agent/plan/planOps.ts
// plan_mode.enter plan persisted src/agent/plan/planOps.ts
// plan_mode.exit plan persisted src/agent/plan/planOps.ts
// plan.revision plan persisted src/agent/plan/planOps.ts
// profile.bind profile persisted src/agent/profile/profileOps.ts
// skill.activate skill transient src/agent/skill/skillOps.ts
// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts
// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts
// task.started task persisted src/agent/task/taskOps.ts
// task.terminated task persisted src/agent/task/taskOps.ts
// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts
// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts
// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts
// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts
// tools.update_store todo persisted src/session/todo/todoOps.ts
// turn.cancel turn persisted src/agent/loop/turnOps.ts
// turn.ended turn persisted src/agent/loop/turnOps.ts
// turn.prompt turn persisted src/agent/loop/turnOps.ts
// turn.steer turn persisted src/agent/loop/turnOps.ts
// usage.record usage persisted src/agent/usage/usageOps.ts
// Index (46 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
// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts
// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts
// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts
// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts
// cron.add cron transient src/session/cron/cronOps.ts
// cron.cursor cron transient src/session/cron/cronOps.ts
// cron.delete cron transient src/session/cron/cronOps.ts
// forked goal persisted src/agent/goal/goalOps.ts
// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts
// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts
// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts
// goal.clear goal persisted src/agent/goal/goalOps.ts
// goal.create goal persisted src/agent/goal/goalOps.ts
// goal.update goal persisted src/agent/goal/goalOps.ts
// interaction.request interaction persisted src/session/interaction/interactionOps.ts
// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts
// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts
// 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
// 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
// plan_mode.cancel plan persisted src/agent/plan/planOps.ts
// plan_mode.enter plan persisted src/agent/plan/planOps.ts
// plan_mode.exit plan persisted src/agent/plan/planOps.ts
// plan.revision plan persisted src/agent/plan/planOps.ts
// profile.bind profile persisted src/agent/profile/profileOps.ts
// skill.activate skill transient src/agent/skill/skillOps.ts
// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts
// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts
// task.started task persisted src/agent/task/taskOps.ts
// task.terminated task persisted src/agent/task/taskOps.ts
// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts
// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts
// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts
// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts
// tools.update_store todo persisted src/session/todo/todoOps.ts
// turn.cancel turn persisted src/agent/loop/turnOps.ts
// turn.ended turn persisted src/agent/loop/turnOps.ts
// turn.prompt turn persisted src/agent/loop/turnOps.ts
// turn.steer turn persisted src/agent/loop/turnOps.ts
// usage.record usage persisted src/agent/usage/usageOps.ts

/**
* model: profile · persisted
Expand Down Expand Up @@ -306,6 +307,15 @@ interface InteractionResolvedPayload {
response: any;
}

/**
* model: interruptionReminder · persisted
* owner: src/agent/interruptionReminder/interruptionReminderOps.ts
*/
interface InterruptionReminderRecordedPayload {
_name: 'interruptionReminder.recorded';
turnId: number;
}

/**
* model: llm.requestTrace · persisted
* owner: src/agent/llmRequester/llmRequestOps.ts
Expand Down Expand Up @@ -563,13 +573,14 @@ interface ToolsUpdateStorePayload {
}

/**
* model: turn · persisted
* model: turn · persisted · cross-reducers: interruptionReminder
* owner: src/agent/loop/turnOps.ts
*/
interface TurnCancelPayload {
_name: 'turn.cancel';
turnId?: number;
target?: 'active' | 'queued';
reason?: 'user_cancelled' | 'aborted';
}

/**
Expand Down Expand Up @@ -688,6 +699,7 @@ interface WirePayloadMap {
"goal.update": GoalUpdatePayload;
"interaction.request": InteractionRequestPayload;
"interaction.resolved": InteractionResolvedPayload;
"interruptionReminder.recorded": InterruptionReminderRecordedPayload;
"llm.request": LlmRequestPayload;
"llm.tools_snapshot": LlmToolsSnapshotPayload;
"mcp.tools_discovered": McpToolsDiscoveredPayload;
Expand Down
18 changes: 5 additions & 13 deletions packages/agent-core-v2/src/agent/goal/goalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
const state = this.requireState();
const snapshot = this.toSnapshot(state);
if (state.status === 'active' && this.liveTurnId !== undefined) {
this.loopService.cancel(this.liveTurnId);
this.loopService.cancel(this.liveTurnId, abortError('Goal cancelled'));
}
this.clearInternal(actor);
if (actor === 'user') {
Expand Down Expand Up @@ -985,18 +985,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
const pending = this.pendingContinuation;
if (preserveLiveContinuation && pending?.turnId === this.liveTurnId) return;
this.pendingContinuation = undefined;
const aborted =
reason === undefined ? pending?.receipt.abort() : pending?.receipt.abort(reason);
if (
pending !== undefined &&
!aborted &&
pending.turnId !== undefined
) {
if (reason === undefined) {
this.loopService.cancel(pending.turnId);
} else {
this.loopService.cancel(pending.turnId, reason);
}
const cancellation = reason ?? abortError('Goal continuation cancelled');
const aborted = pending?.receipt.abort(cancellation);
if (pending !== undefined && !aborted && pending.turnId !== undefined) {
this.loopService.cancel(pending.turnId, cancellation);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* `interruptionReminder` domain (L4) — user-interruption reminder contract.
*
* Defines the Agent-scoped aspect that records a model-visible reminder after
* a user-cancelled turn. Bound at Agent scope.
*/

import { createDecorator } from '#/_base/di/instantiation';

export interface IAgentInterruptionReminderService {
readonly _serviceBrand: undefined;
}

export const IAgentInterruptionReminderService =
createDecorator<IAgentInterruptionReminderService>('agentInterruptionReminderService');
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* `interruptionReminder` domain (L4) — persists and restores pending
* user-interruption reminders.
*
* Projects the `loop` domain's `turn.cancel` fact into the set of turns whose
* interruption reminder still has to reach the conversation, and owns the op
* that records a reminder's delivery. Consumed by the Agent-scope
* `interruptionReminderService`.
*/

import { z } from 'zod';

import { defineModel } from '#/wire/model';

export const InterruptionReminderModel = defineModel<readonly number[]>(
'interruptionReminder',
() => [],
{
reducers: {
'turn.cancel': (state, { turnId, target, reason }) => {
if (target !== 'active' || reason !== 'user_cancelled' || turnId === undefined) {
return state;
}
if (state.includes(turnId)) return state;
return [...state, turnId].toSorted((a, b) => a - b);
},
},
},
);

declare module '#/wire/types' {
interface PersistedOpMap {
'interruptionReminder.recorded': typeof interruptionReminderRecorded;
}
}

export const interruptionReminderRecorded = InterruptionReminderModel.defineOp(
'interruptionReminder.recorded',
{
schema: z.object({ turnId: z.number().int().nonnegative() }),
apply: (state, { turnId }) => state.filter((pendingTurnId) => pendingTurnId !== turnId),
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* `interruptionReminder` domain (L4) — `IAgentInterruptionReminderService` implementation.
*
* Observes turn completion through `event`, persists reminder completion through
* its own wire model, reads conversation history through `contextMemory`, and
* appends model-visible notices through `systemReminder`. Reconciles reminders
* left pending by an interrupted restore. Bound at Agent scope.
*/

import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IEventBus } from '#/app/event/eventBus';
import { IWireService } from '#/wire/wire';

import { IAgentInterruptionReminderService } from './interruptionReminder';
import { interruptionReminderRecorded, InterruptionReminderModel } from './interruptionReminderOps';

export const INTERRUPTION_REMINDER_VARIANT = 'interruption';

const INTERRUPTION_REMINDER = [
'The previous turn was interrupted by the user before completion;',
'any partial output shown above is incomplete.',
"The user's next message continues the conversation.",
].join(' ');

export class AgentInterruptionReminderService
extends Disposable
implements IAgentInterruptionReminderService
{
declare readonly _serviceBrand: undefined;

constructor(
@IEventBus eventBus: IEventBus,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IWireService private readonly wire: IWireService,
) {
super();
this._register(
this.wire.hooks.onDidRestore.register('interruption-reminder', async (_ctx, next) => {
this.reconcilePendingReminders();
await next();
}),
);
this._register(
eventBus.subscribe('turn.ended', (event) => {
if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return;
this.recordReminder(event.turnId, true);
}),
);
}

private reconcilePendingReminders(): void {
const pending = this.wire.getModel(InterruptionReminderModel);
for (const turnId of pending) this.recordReminder(turnId);
}

private recordReminder(turnId: number, allowUntracked = false): void {
const pending = this.wire.getModel(InterruptionReminderModel).includes(turnId);
if (!pending && !allowUntracked) return;
if (!this.appendInterruptionReminder()) return;
if (pending) this.wire.dispatch(interruptionReminderRecorded({ turnId }));
}

private appendInterruptionReminder(): boolean {
const before = this.context.get();
const origin = lastDurableMessageOrigin(before);
if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return true;
this.reminders.appendSystemReminder(INTERRUPTION_REMINDER, {
kind: 'injection',
variant: INTERRUPTION_REMINDER_VARIANT,
});
const after = this.context.get();
if (after === before) return false;
const appended = lastDurableMessageOrigin(after);
return appended?.kind === 'injection' && appended.variant === INTERRUPTION_REMINDER_VARIANT;
}
}

function lastDurableMessageOrigin(
messages: readonly ContextMessage[],
): ContextMessage['origin'] | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]!;
if (
message.role === 'assistant' &&
message.partial === true &&
message.toolCalls.length === 0 &&
message.content.every(isVacuousContentPart)
) {
continue;
}
return message.origin;
}
return undefined;
}

registerScopedService(
LifecycleScope.Agent,
IAgentInterruptionReminderService,
AgentInterruptionReminderService,
ScopeActivation.OnScopeCreated,
'interruptionReminder',
);
Loading
Loading