From d132588072c764861baf116b39d2e59c63c15e4e Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Wed, 15 Jul 2026 15:09:20 +0800 Subject: [PATCH 1/3] fix(agent): preserve unfinished turn context for follow-ups --- .../preserve-interrupted-turn-context.md | 5 + .../kimi-code/test/tui/message-replay.test.ts | 112 +++++++++ .../src/agent/contextMemory/contextMemory.ts | 2 + .../contextMemory/contextMemoryService.ts | 25 +- .../src/agent/contextMemory/loopEventFold.ts | 8 + .../src/agent/loop/loopService.ts | 72 +++++- .../src/agent/loop/turnOutcomeReminder.ts | 106 ++++++++ .../test/agent/contextMemory/context.test.ts | 57 +++++ .../test/agent/contextMemory/stubs.ts | 4 + .../test/agent/loop/loop.test.ts | 235 +++++++++++++++++- .../toolSelect/toolSelectService.test.ts | 4 + .../externalHooksRunner/integration.test.ts | 1 + packages/agent-core/src/agent/turn/index.ts | 27 ++ .../src/agent/turn/outcome-reminder.ts | 95 +++++++ .../test/agent/compaction/full.test.ts | 1 + packages/agent-core/test/agent/turn.test.ts | 146 +++++++++++ .../test/harness/skill-session.test.ts | 18 ++ .../test/session/subagent-host.test.ts | 13 +- 18 files changed, 926 insertions(+), 5 deletions(-) create mode 100644 .changeset/preserve-interrupted-turn-context.md create mode 100644 packages/agent-core-v2/src/agent/loop/turnOutcomeReminder.ts create mode 100644 packages/agent-core/src/agent/turn/outcome-reminder.ts diff --git a/.changeset/preserve-interrupted-turn-context.md b/.changeset/preserve-interrupted-turn-context.md new file mode 100644 index 0000000000..d1d91e0f52 --- /dev/null +++ b/.changeset/preserve-interrupted-turn-context.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Tell the model when a previous turn failed or was interrupted so follow-up prompts retain the unfinished request context. diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 5e4e11670f..e19ccd41e3 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -4,6 +4,7 @@ import type { AgentReplayRecord, BackgroundTaskInfo, ContentPart, + Event, GoalSnapshot, PromptOrigin, ResumedAgentState, @@ -289,6 +290,117 @@ function backgroundTask( } describe('KimiTUI resume message replay', () => { + it('does not render turn outcome reminders between visible user messages', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'Finish the requested change.' }]), + message( + 'user', + [ + { + type: 'text', + text: + '\n' + + 'The previous turn ended before producing a final response.\n\n' + + 'Error: API request failed with HTTP 500.\n\n' + + 'The preceding user request may still be unfinished. Treat the next user message as a follow-up.\n' + + '', + }, + ], + { origin: { kind: 'injection', variant: 'turn_outcome' } }, + ), + message('user', [{ type: 'text', text: 'Continue.' }]), + ]); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'user') + .map((entry) => entry.content), + ).toEqual(['Finish the requested change.', 'Continue.']); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).not.toContain('API request failed with HTTP 500'); + expect(transcript).not.toContain('preceding user request'); + }); + + it('renders one synthetic tool failure without exposing its turn outcome reminder', async () => { + const abandonedOutput = + 'Tool call did not complete: the turn failed before its result was recorded. Do not assume the tool completed successfully.'; + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'Run the operation.' }]), + message( + 'assistant', + [{ type: 'text', text: 'Starting the operation.' }], + { toolCalls: [toolCall('call_abandoned', 'Run', {})] }, + ), + message('tool', [{ type: 'text', text: abandonedOutput }], { + toolCallId: 'call_abandoned', + isError: true, + }), + message( + 'user', + [ + { + type: 'text', + text: + '\n' + + 'The previous turn ended before producing a final response.\n\n' + + 'Error: tool result dispatch failed\n\n' + + 'The preceding user request may still be unfinished. Treat the next user message as a follow-up.\n' + + '', + }, + ], + { origin: { kind: 'injection', variant: 'turn_outcome' } }, + ), + message('user', [{ type: 'text', text: 'Continue.' }]), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript.split('Tool call did not complete').length - 1).toBe(1); + expect(transcript).not.toContain('tool result dispatch failed'); + expect(transcript).not.toContain('preceding user request'); + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'user') + .map((entry) => entry.content), + ).toEqual(['Run the operation.', 'Continue.']); + }); + + it('does not render live context splice events containing turn outcome reminders', async () => { + const driver = await replayIntoDriver([]); + + // The v2 server forwards this domain event on the v1 session channel even + // though it is intentionally absent from the SDK's public Event union. + driver.sessionEventHandler.handleEvent( + { + type: 'context.spliced', + sessionId: 'ses-replay', + agentId: 'main', + start: 1, + deleteCount: 0, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: + '\n' + + 'The user interrupted the previous turn before it finished.\n' + + '', + }, + ], + toolCalls: [], + origin: { kind: 'injection', variant: 'turn_outcome' }, + }, + ], + } as unknown as Event, + () => {}, + ); + + expect(driver.state.transcriptEntries).toEqual([]); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).not.toContain('user interrupted the previous turn'); + }); + it('does not render legacy goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index 3334d76ab4..19e35566eb 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -35,6 +35,8 @@ export interface IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void; + closeAbandonedToolExchange(output: string): number; + clear(): void; undo(count: number): UndoCut; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index d18d7c6942..689359c50f 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -47,7 +47,7 @@ import { isFullyUndoable, type UndoCut, } from './contextOps'; -import type { LoopRecordedEvent } from './loopEventFold'; +import { openStepUuid, pendingToolCallIds, type LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; declare module '#/app/event/eventBus' { @@ -86,6 +86,29 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte this.wire.dispatch(contextAppendLoopEvent({ event })); } + closeAbandonedToolExchange(output: string): number { + const history = this.get(); + const toolCallIds = pendingToolCallIds(history); + if (toolCallIds.length === 0) return 0; + const stepUuid = openStepUuid(history); + this.wire.dispatch( + ...toolCallIds.map((toolCallId) => + contextAppendLoopEvent({ + event: { + type: 'tool.result', + parentUuid: toolCallId, + toolCallId, + result: { output, isError: true }, + }, + }), + ), + ...(stepUuid === undefined + ? [] + : [contextAppendLoopEvent({ event: { type: 'step.end', uuid: stepUuid } })]), + ); + return toolCallIds.length; + } + clear(): void { const deleteCount = this.get().length; if (deleteCount === 0) return; diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 546efab460..1fc813690a 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -139,6 +139,14 @@ export function foldAppendMessage( return bind([...state, message], ctx); } +export function pendingToolCallIds(state: readonly ContextMessage[]): readonly string[] { + return [...ctxOf(state).pending]; +} + +export function openStepUuid(state: readonly ContextMessage[]): string | undefined { + return ctxOf(state).openStepUuid; +} + export function foldLoopEvent( state: readonly ContextMessage[], event: LoopRecordedEvent, diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index f15f148d4f..a5b0696da5 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -23,8 +23,10 @@ * `stepRetry` re-enqueues the failed driver after backoff, `fullCompaction` * compacts and re-enqueues it — so the loop only learns caught-or-not, while * an unclaimed or uncaught error fails the turn. Emits `turn.*` / delta - * events through `event`, persists loop events through `contextMemory`, and - * reads the step budget from `config`. Bound at Agent scope. + * events through `event`, persists loop events through `contextMemory`, appends + * abnormal-turn context through `systemReminder`, reads the step budget from + * `config`, and reports reminder-persistence failures through `log`. Bound at + * Agent scope. */ import { randomUUID } from 'node:crypto'; @@ -34,6 +36,7 @@ import { createControlledPromise } from '@antfu/utils'; import { InstantiationType } from '#/_base/di/extensions'; import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; import { abortError, isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { toErrorMessage } from '#/_base/errors/errorMessage'; import { IAgentLLMRequesterService, type LLMRequestFinish } from '#/agent/llmRequester/llmRequester'; @@ -49,6 +52,7 @@ import { OrderedHookSlot } from '#/hooks'; import type { ActivityLease } from '#/activity/activity'; import { IAgentActivityService } from '#/activity/activity'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import type { TurnEndedEvent as TurnEndedTelemetryEvent, @@ -82,6 +86,11 @@ import { } from './stepRequest'; import { StepRequestQueue, type StepRequestBatch } from './stepRequestQueue'; import { cancelTurn, promptTurn, TurnModel } from './turnOps'; +import { + renderTurnCancellationReminder, + renderTurnFailureReminder, + TURN_OUTCOME_REMINDER_VARIANT, +} from './turnOutcomeReminder'; // Loads the `DomainEventMap` augmentation for the `turn.*` / delta events this // service publishes (the augmentation lives with the event definitions; // without an import it would not enter every consumer's program). @@ -107,6 +116,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @ILogService private readonly log: ILogService, @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, @IEventBus private readonly eventBus: IEventBus, @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, @@ -367,6 +378,8 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { lease.end(outcome, result?.type === 'failed' ? { error: result.error } : undefined); if (result !== undefined) { const error = result.type === 'failed' ? toKimiErrorPayload(result.error) : undefined; + this.closeAbandonedToolExchange(turn.id, result, error); + this.appendTurnOutcomeReminder(turn.id, result, error); this.eventBus.publish({ type: 'turn.ended', turnId: turn.id, @@ -400,6 +413,48 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } } + private appendTurnOutcomeReminder( + turnId: number, + result: TurnResult, + error: ReturnType | undefined, + ): void { + try { + const content = + result.type === 'failed' + ? renderTurnFailureReminder(error) + : result.type === 'cancelled' + ? renderTurnCancellationReminder(result.reason, isUserCancellation(result.reason)) + : undefined; + if (content === undefined) return; + this.reminders.appendSystemReminder(content, { + kind: 'injection', + variant: TURN_OUTCOME_REMINDER_VARIANT, + }); + } catch (reminderError) { + this.log.warn('failed to append turn outcome reminder', { turnId, error: reminderError }); + } + } + + private closeAbandonedToolExchange( + turnId: number, + result: TurnResult, + error: ReturnType | undefined, + ): void { + try { + const closed = this.context.closeAbandonedToolExchange( + abandonedToolResultOutput(result, error), + ); + if (closed === 0) return; + this.log.warn('closed abandoned tool exchange at turn end', { + turnId, + reason: result.type, + closed, + }); + } catch (closeError) { + this.log.warn('failed to close abandoned tool exchange', { turnId, error: closeError }); + } + } + private resultFromTurnError(lease: ActivityLease, error: unknown): TurnResult { if (!lease.signal.aborted) return { type: 'failed', error, steps: 0 }; return { type: 'cancelled', steps: 0, reason: lease.signal.reason ?? error }; @@ -991,6 +1046,19 @@ function interruptReasonFor( return 'error'; } +function abandonedToolResultOutput( + result: TurnResult, + error: ReturnType | undefined, +): string { + const cause = + result.type === 'cancelled' + ? 'the turn was cancelled' + : result.type === 'failed' + ? `the turn failed${error !== undefined ? ` (${error.message})` : ''}` + : 'the turn ended'; + return `Tool call did not complete: ${cause} before its result was recorded. Do not assume the tool completed successfully.`; +} + type StepExecutionResult = { readonly stopReason: FinishReason; readonly hookStopTurn: boolean; diff --git a/packages/agent-core-v2/src/agent/loop/turnOutcomeReminder.ts b/packages/agent-core-v2/src/agent/loop/turnOutcomeReminder.ts new file mode 100644 index 0000000000..176aa6b158 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/turnOutcomeReminder.ts @@ -0,0 +1,106 @@ +/** + * `loop` domain (L4) — renders model-visible context for abnormal turn endings. + * + * Converts terminal failures and cancellations into compact, XML-safe reminders. + * Provider failures expose only public categories and status codes; raw provider + * payloads, request identifiers, and internal infrastructure details stay out of + * model context. + */ + +import { escapeXml } from '#/_base/utils/xml-escape'; + +export const TURN_OUTCOME_REMINDER_VARIANT = 'turn_outcome'; + +const MAX_ERROR_SUMMARY_LENGTH = 500; + +interface TurnErrorSummary { + readonly code: string; + readonly message: string; + readonly name?: string; + readonly details?: Readonly>; +} + +export function renderTurnFailureReminder(error: TurnErrorSummary | undefined): string { + return [ + 'The previous turn ended before producing a final response.', + '', + `Error: ${renderPublicError(error)}`, + '', + 'The preceding user request may still be unfinished. Treat the next user message as a follow-up.', + ].join('\n'); +} + +export function renderTurnCancellationReminder(reason: unknown, userInitiated: boolean): string { + const lines = [ + userInitiated + ? 'The user interrupted the previous turn before it finished.' + : 'The previous turn was interrupted by the runtime before it finished.', + ]; + const renderedReason = userInitiated ? undefined : renderInterruptionReason(reason); + if (renderedReason !== undefined) lines.push('', `Reason: ${renderedReason}`); + lines.push( + '', + 'Some operations may already have taken effect. Treat the next user message as a follow-up, and check existing state before repeating operations.', + ); + return lines.join('\n'); +} + +function renderPublicError(error: TurnErrorSummary | undefined): string { + if (error === undefined) return 'The turn failed.'; + switch (error.code) { + case 'provider.api_error': { + const statusCode = numericDetail(error, 'statusCode'); + return statusCode === undefined + ? 'The model provider API request failed.' + : `API request failed with HTTP ${String(statusCode)}.`; + } + case 'provider.rate_limit': + return 'The model provider rate-limited the request.'; + case 'provider.auth_error': + return 'Authentication with the model provider failed.'; + case 'provider.connection_error': + return error.name?.toLowerCase().includes('timeout') === true + ? 'The model provider request timed out.' + : 'The request could not reach the model provider.'; + case 'provider.overloaded': + return 'The model provider was overloaded.'; + case 'provider.filtered': + return 'The model provider blocked the response due to its safety policy.'; + case 'context.overflow': + return 'The request exceeded the model context limit.'; + case 'loop.max_steps_exceeded': { + const maxSteps = numericDetail(error, 'maxSteps'); + return maxSteps === undefined + ? 'The turn exceeded its step limit.' + : `The turn exceeded its ${String(maxSteps)}-step limit.`; + } + case 'model.not_configured': + return 'No model is configured.'; + case 'model.config_invalid': + return 'The model configuration is invalid.'; + default: + return escapeXml(normalizeSummary(error.message) || 'The turn failed.'); + } +} + +function renderInterruptionReason(reason: unknown): string | undefined { + const raw = reason instanceof Error ? reason.message : typeof reason === 'string' ? reason : undefined; + if (raw === undefined) return undefined; + const normalized = normalizeSummary(raw); + if (normalized.length === 0 || normalized === 'Aborted' || normalized === 'This operation was aborted') { + return undefined; + } + return escapeXml(normalized); +} + +function numericDetail(error: TurnErrorSummary, key: string): number | undefined { + const value = error.details?.[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function normalizeSummary(value: string): string { + const normalized = value.replaceAll(/\s+/g, ' ').trim(); + return normalized.length <= MAX_ERROR_SUMMARY_LENGTH + ? normalized + : `${normalized.slice(0, MAX_ERROR_SUMMARY_LENGTH - 3)}...`; +} diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 859ead8399..0ef4b04fd8 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -116,6 +116,63 @@ describe('Agent context', () => { ]); }); + it('closes an abandoned tool exchange before releasing deferred reminders', () => { + context.appendLoopEvent({ type: 'step.begin', uuid: 'step_abandoned' }); + context.appendLoopEvent({ + type: 'tool.call', + stepUuid: 'step_abandoned', + toolCallId: 'call_abandoned', + name: 'Run', + args: {}, + }); + context.appendLoopEvent({ + type: 'tool.call', + stepUuid: 'step_abandoned', + toolCallId: 'call_also_abandoned', + name: 'Read', + args: {}, + }); + ctx.appendSystemReminder('Turn failed.', { + kind: 'injection', + variant: 'turn_outcome', + }); + + expect(context.get().map((message) => message.role)).toEqual(['assistant']); + expect( + context.closeAbandonedToolExchange( + 'Tool call did not complete because the turn failed.', + ), + ).toBe(2); + + expect(context.get()).toMatchObject([ + { + role: 'assistant', + toolCalls: [ + { id: 'call_abandoned', name: 'Run' }, + { id: 'call_also_abandoned', name: 'Read' }, + ], + }, + { + role: 'tool', + toolCallId: 'call_abandoned', + isError: true, + content: [{ type: 'text', text: 'Tool call did not complete because the turn failed.' }], + }, + { + role: 'tool', + toolCallId: 'call_also_abandoned', + isError: true, + content: [{ type: 'text', text: 'Tool call did not complete because the turn failed.' }], + }, + { + role: 'user', + origin: { kind: 'injection', variant: 'turn_outcome' }, + content: [{ type: 'text', text: '\nTurn failed.\n' }], + }, + ]); + expect(context.closeAbandonedToolExchange('unused')).toBe(0); + }); + it('drops empty text parts only in LLM projection', () => { const history: ContextMessage[] = [ { diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index ba6aef5624..6ca0032321 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -53,6 +53,7 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory { publishSplice(eventBus, { start, deleteCount: 0, messages: [...inserted] }); }, appendLoopEvent: () => {}, + closeAbandonedToolExchange: () => 0, clear: () => { const deleteCount = messages.length; if (deleteCount === 0) return; @@ -106,6 +107,9 @@ class StubContextMemoryService implements IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void { this.impl.appendLoopEvent(event); } + closeAbandonedToolExchange(output: string): number { + return this.impl.closeAbandonedToolExchange(output); + } undo(count: number): UndoCut { return this.impl.undo(count); } diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 889d83135a..907e5044a9 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -1,12 +1,15 @@ -import { type ToolCall } from '#/app/llmProtocol/message'; +import { APIStatusError } from '#/app/llmProtocol/errors'; +import { type Message, type ToolCall } from '#/app/llmProtocol/message'; import { emptyUsage } from '#/app/llmProtocol/usage'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IAgentProfileService } from '#/index'; import { IAgentLLMRequesterService, type LLMStreamTiming } from '#/agent/llmRequester/llmRequester'; import { IAgentGoalService } from '#/agent/goal/goal'; +import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector'; import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ExecutableTool } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -21,6 +24,7 @@ import { type TestAgentOptions, } from '../../harness'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { stubToolExecutor } from './stubs'; type GenerateFn = NonNullable; @@ -116,6 +120,7 @@ describe('Agent loop', () => { [wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "", "turnId": "0", "step": 1, "stepUuid": "", "part": { "type": "text", "text": "blocked" } }, "time": "