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
19 changes: 19 additions & 0 deletions docs/issues/failed-message-context/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Failed Message Context Preservation Plan

## Approach

- Add a shared runtime predicate for context-eligible records: include `sent` records and `assistant:error` records, exclude `pending` and `user:error`.
- Update `recordToChatMessages` to append a readable assistant summary for error blocks while preserving existing content, reasoning, and settled tool call replay behavior.
- Use the same eligibility predicate in new-turn history collection, resume context, and compaction inputs.

## Data Flow

- Failed generation already persists an assistant `error` record with terminal error blocks.
- On the next turn, `buildContext` selects eligible records and converts the failed assistant record into provider-facing assistant text.
- Compaction receives the same eligible assistant error records so summarized history matches provider context.

## Compatibility

- Stored records are unchanged.
- Existing sent history behavior and provider tool-message ordering remain unchanged.
- Pending interactions keep their existing resume path and are not included in normal next-turn context.
22 changes: 22 additions & 0 deletions docs/issues/failed-message-context/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Failed Message Context Preservation

## User Story

When an agent loop fails or is canceled, the next user turn should still know what happened. The failed assistant message, including any partial progress and the failure reason, must be included in the next request context so the model can recover instead of repeating work.

## Acceptance Criteria

- Assistant messages with status `error` are eligible for future agent context.
- User messages with status `error` and all `pending` messages remain excluded from normal next-turn context.
- Error blocks are converted into readable context text:
- `common.error.userCanceledGeneration` becomes `User canceled generation`.
- Unknown error text is preserved as-is.
- If the failed assistant message has partial content, that content is preserved and followed by a failure or cancellation summary.
- Settled tool calls with non-empty responses are still replayed; unfinished tool calls are not fabricated into tool results.
- Auto-compaction and resume context budgeting consider assistant error records consistently with next-turn context.

## Non-Goals

- No database schema changes.
- No IPC, renderer UI, or message storage format changes.
- No attempt to resume unfinished tool calls without stored tool responses.
7 changes: 7 additions & 0 deletions docs/issues/failed-message-context/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Failed Message Context Preservation Tasks

- [x] Add SDD spec, plan, and task documents.
- [x] Add focused context-builder tests for assistant error and cancel records.
- [x] Update context-builder record eligibility and assistant error conversion.
- [x] Reuse eligibility in agent runtime and compaction history selection.
- [x] Run focused tests and required repository quality commands.
31 changes: 24 additions & 7 deletions src/main/presenter/agentRuntimePresenter/compactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
buildUserMessageContent,
createUserChatMessage,
estimateMessagesTokens,
formatAssistantErrorSummary,
isContextHistoryRecord,
normalizeUserInput
} from './contextBuilder'

Expand Down Expand Up @@ -144,6 +146,7 @@ function serializeUserRecord(record: ChatMessageRecord): string {
function serializeAssistantRecord(record: ChatMessageRecord): string {
const blocks = parseAssistantBlocks(record)
const lines: string[] = [`[Assistant][order=${record.orderSeq}]`]
const errorMessages: string[] = []

for (const block of blocks) {
if ((block.type === 'content' || block.type === 'reasoning_content') && block.content) {
Expand Down Expand Up @@ -171,6 +174,20 @@ function serializeAssistantRecord(record: ChatMessageRecord): string {
if (actionContent) {
lines.push(actionContent)
}
continue
}
if (block.type === 'error' && block.content) {
errorMessages.push(block.content)
}
}

const errorSummary = formatAssistantErrorSummary(errorMessages)
if (errorSummary) {
lines.push(errorSummary)
} else if (record.status === 'error') {
const fallbackSummary = formatAssistantErrorSummary(['Unknown error'])
if (fallbackSummary) {
lines.push(fallbackSummary)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down Expand Up @@ -246,14 +263,14 @@ export class CompactionService {
return null
}

const sentRecords = this.messageStore
const historyRecords = this.messageStore
.getMessages(params.sessionId)
.filter((record) => record.status === 'sent' && !isCompactionRecord(record))
.filter(isContextHistoryRecord)
.sort((a, b) => a.orderSeq - b.orderSeq)

return this.prepareCompaction({
...params,
records: sentRecords,
records: historyRecords,
protectedTurnCount: settings.retainRecentPairs,
triggerThreshold: settings.triggerThreshold,
projectedMessages: [createUserChatMessage(params.newUserContent, params.supportsVision)]
Expand Down Expand Up @@ -297,7 +314,7 @@ export class CompactionService {
if (record.id === params.messageId) {
return true
}
return record.status === 'sent'
return isContextHistoryRecord(record)
})

return this.prepareCompaction({
Expand Down Expand Up @@ -330,14 +347,14 @@ export class CompactionService {
return null
}

const sentRecords = this.messageStore
const historyRecords = this.messageStore
.getMessages(params.sessionId)
.filter((record) => record.status === 'sent' && !isCompactionRecord(record))
.filter(isContextHistoryRecord)
.sort((a, b) => a.orderSeq - b.orderSeq)

return this.prepareCompaction({
...params,
records: sentRecords,
records: historyRecords,
protectedTurnCount: settings.retainRecentPairs,
triggerThreshold: settings.triggerThreshold,
projectedMessages: params.projectedMessages,
Expand Down
119 changes: 108 additions & 11 deletions src/main/presenter/agentRuntimePresenter/contextBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import type {
import type { DeepChatMessageStore } from './messageStore'

const IMAGE_TOKEN_ESTIMATE = 512
const UNKNOWN_ASSISTANT_ERROR = 'Unknown error'
const KNOWN_ERROR_REASON_TEXT: Record<string, string> = {
'common.error.userCanceledGeneration': 'User canceled generation',
'common.error.sessionInterrupted':
'Session was unexpectedly interrupted, generation is incomplete',
'common.error.noModelResponse': 'Model did not return any content, it may have timed out'
}

export type ContextBuildOptions = {
summaryCursorOrderSeq?: number
Expand Down Expand Up @@ -106,6 +113,16 @@ function isCompactionRecord(record: ChatMessageRecord): boolean {
}
}

export function isContextHistoryRecord(record: ChatMessageRecord): boolean {
if (isCompactionRecord(record)) {
return false
}
if (record.status === 'sent') {
return true
}
return record.role === 'assistant' && record.status === 'error'
}

function buildNonImageFileContext(files: MessageFile[]): string {
const nonImageFiles = files.filter((file) => !isImageFile(file))
if (nonImageFiles.length === 0) {
Expand Down Expand Up @@ -248,6 +265,69 @@ export function estimateToolDefinitionTokens(toolDefinitions: MCPToolDefinition[
)
}

export function normalizeAssistantErrorReason(value: string): string {
const trimmed = value.trim()
return KNOWN_ERROR_REASON_TEXT[trimmed] ?? trimmed
}

export function formatAssistantErrorSummary(errorMessages: string[]): string | null {
const reasons = errorMessages
.map(normalizeAssistantErrorReason)
.filter((message) => message.length > 0)

if (reasons.length === 0) {
return null
}

const uniqueReasons = [...new Set(reasons)]
const onlyUserCanceled =
uniqueReasons.length === 1 &&
uniqueReasons[0] === KNOWN_ERROR_REASON_TEXT['common.error.userCanceledGeneration']
const label = onlyUserCanceled ? 'Generation canceled' : 'Generation failed'
return `[${label}]\nReason: ${uniqueReasons.join('\n')}`
}

function buildAssistantErrorSummary(
blocks: AssistantMessageBlock[],
record: ChatMessageRecord
): string | null {
const errorMessages = blocks
.filter(
(block): block is AssistantMessageBlock & { content: string } =>
block.type === 'error' &&
typeof block.content === 'string' &&
block.content.trim().length > 0
)
.map((block) => block.content)

if (errorMessages.length > 0) {
return formatAssistantErrorSummary(errorMessages)
}

if (record.status === 'error') {
return formatAssistantErrorSummary([UNKNOWN_ASSISTANT_ERROR])
}

return null
}

function appendAssistantTextContent(
content: ChatMessage['content'],
extraText: string | null
): ChatMessage['content'] {
if (!extraText) {
return content
}

if (Array.isArray(content)) {
return [...content, { type: 'text', text: extraText }]
}

return [typeof content === 'string' ? content : '', extraText]
.filter((value) => value.trim().length > 0)
.join('\n\n')
}

/**
* Convert a ChatMessageRecord from the DB into one or more ChatMessages for the LLM.
* Only settled tool calls (with a non-empty response) are included in history.
Expand All @@ -268,6 +348,7 @@ export function recordToChatMessages(
}

const blocks = JSON.parse(record.content) as AssistantMessageBlock[]
const errorSummary = buildAssistantErrorSummary(blocks, record)
const combinedText = blocks
.filter((block) => block.type === 'content' || block.type === 'reasoning_content')
.map((block) => block.content)
Expand Down Expand Up @@ -325,13 +406,19 @@ export function recordToChatMessages(
)

if (toolCallBlocks.length === 0) {
const contentWithErrorSummary = appendAssistantTextContent(
preserveEmptyInterleavedReasoning || shouldPreserveReasoning
? assistantContent
: combinedText,
errorSummary
)
if (shouldPreserveReasoning) {
return [applyReasoningContent({ role: 'assistant', content: assistantContent })]
return [applyReasoningContent({ role: 'assistant', content: contentWithErrorSummary })]
}
if (preserveEmptyInterleavedReasoning) {
return [{ role: 'assistant', content: assistantContent }]
return [{ role: 'assistant', content: contentWithErrorSummary }]
}
return [{ role: 'assistant', content: combinedText }]
return [{ role: 'assistant', content: contentWithErrorSummary }]
}

const toolCalls: NonNullable<ChatMessage['tool_calls']> = []
Expand All @@ -351,13 +438,19 @@ export function recordToChatMessages(
}

if (toolCalls.length === 0) {
const contentWithErrorSummary = appendAssistantTextContent(
preserveEmptyInterleavedReasoning || shouldPreserveReasoning
? assistantContent
: combinedText,
errorSummary
)
if (shouldPreserveReasoning) {
return [applyReasoningContent({ role: 'assistant', content: assistantContent })]
return [applyReasoningContent({ role: 'assistant', content: contentWithErrorSummary })]
}
if (preserveEmptyInterleavedReasoning) {
return [{ role: 'assistant', content: assistantContent }]
return [{ role: 'assistant', content: contentWithErrorSummary }]
}
return [{ role: 'assistant', content: combinedText }]
return [{ role: 'assistant', content: contentWithErrorSummary }]
}

const assistantMessage: ChatMessage = {
Expand All @@ -375,6 +468,9 @@ export function recordToChatMessages(
content: block.tool_call!.response || ''
})
}
if (errorSummary) {
result.push({ role: 'assistant', content: errorSummary })
}

return result
}
Expand Down Expand Up @@ -542,10 +638,11 @@ export function buildContext(
supportsVision: boolean = false,
options: ContextBuildOptions = {}
): ChatMessage[] {
const sentRecords =
options.historyRecords ??
messageStore.getMessages(sessionId).filter((message) => message.status === 'sent')
const historyRecords = filterRecordsFromCursor(sentRecords, options.summaryCursorOrderSeq ?? 1)
const candidateRecords = options.historyRecords ?? messageStore.getMessages(sessionId)
const historyRecords = filterRecordsFromCursor(
candidateRecords.filter(isContextHistoryRecord),
options.summaryCursorOrderSeq ?? 1
)
const historyTurns = buildHistoryTurns(
historyRecords,
supportsVision,
Expand Down Expand Up @@ -640,7 +737,7 @@ export function buildResumeContext(
if (message.id === assistantMessageId) {
return true
}
if (message.status !== 'sent') {
if (!isContextHistoryRecord(message)) {
return false
}
return message.orderSeq >= cursor
Expand Down
7 changes: 3 additions & 4 deletions src/main/presenter/agentRuntimePresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ import {
buildContext,
buildResumeContext,
createUserChatMessage,
fitMessagesToContextWindow
fitMessagesToContextWindow,
isContextHistoryRecord
} from './contextBuilder'
import {
capAgentDefaultMaxTokens,
Expand Down Expand Up @@ -644,9 +645,7 @@ export class AgentRuntimePresenter implements IAgentImplementation {
activeSkillNames
)
this.throwIfAbortRequested(preStreamAbortSignal)
const historyRecords = this.messageStore
.getMessages(sessionId)
.filter((message) => message.status === 'sent')
const historyRecords = this.messageStore.getMessages(sessionId).filter(isContextHistoryRecord)
const userContent: UserMessageContent = {
text: normalizedInput.text,
files: normalizedInput.files || [],
Expand Down
Loading