Skip to content

Commit 35f0795

Browse files
authored
fix: add in-loop compaction (#2387)
1 parent 46643ba commit 35f0795

File tree

7 files changed

+132
-16
lines changed

7 files changed

+132
-16
lines changed

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,15 +1185,21 @@ describe('AgenticChatController', () => {
11851185

11861186
it('truncate input to 500k character ', async function () {
11871187
const input = 'X'.repeat(GENERATE_ASSISTANT_RESPONSE_INPUT_LIMIT + 10)
1188-
generateAssistantResponseStub.restore()
1189-
generateAssistantResponseStub = sinon.stub(CodeWhispererStreaming.prototype, 'generateAssistantResponse')
1190-
generateAssistantResponseStub.callsFake(() => {})
1191-
await chatController.onChatPrompt({ tabId: mockTabId, prompt: { prompt: input } }, mockCancellationToken)
1192-
assert.ok(generateAssistantResponseStub.called)
1193-
const calledRequestInput: GenerateAssistantResponseCommandInput =
1194-
generateAssistantResponseStub.firstCall.firstArg
1188+
const request: GenerateAssistantResponseCommandInput = {
1189+
conversationState: {
1190+
currentMessage: {
1191+
userInputMessage: {
1192+
content: input,
1193+
},
1194+
},
1195+
chatTriggerType: undefined,
1196+
},
1197+
}
1198+
1199+
chatController.truncateRequest(request)
1200+
11951201
assert.deepStrictEqual(
1196-
calledRequestInput.conversationState?.currentMessage?.userInputMessage?.content?.length,
1202+
request.conversationState?.currentMessage?.userInputMessage?.content?.length,
11971203
GENERATE_ASSISTANT_RESPONSE_INPUT_LIMIT
11981204
)
11991205
})

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ import {
191191
MAX_OVERALL_CHARACTERS,
192192
FSREAD_MEMORY_BANK_MAX_PER_FILE,
193193
FSREAD_MEMORY_BANK_MAX_TOTAL,
194+
MID_LOOP_COMPACTION_HANDOFF_PROMPT,
195+
COMPACTION_PROMPT,
194196
} from './constants/constants'
195197
import {
196198
AgenticChatError,
@@ -943,6 +945,11 @@ export class AgenticChatController implements ChatHandlers {
943945

944946
const compactIds = session.getAllDeferredCompactMessageIds()
945947
await this.#invalidateCompactCommand(params.tabId, compactIds)
948+
// Set compactionDeclined flag if there were pending compaction requests
949+
// This prevents endless compaction warning loops when user declines compaction once
950+
if (compactIds.length > 0) {
951+
session.compactionDeclined = true
952+
}
946953
session.rejectAllDeferredToolExecutions(new ToolApprovalException('Command ignored: new prompt', false))
947954
await this.#invalidateAllShellCommands(params.tabId, session)
948955

@@ -978,6 +985,10 @@ export class AgenticChatController implements ChatHandlers {
978985
session.abortRequest()
979986
const compactIds = session.getAllDeferredCompactMessageIds()
980987
await this.#invalidateCompactCommand(params.tabId, compactIds)
988+
// Set compactionDeclined flag if there were pending compaction requests
989+
if (compactIds.length > 0) {
990+
session.compactionDeclined = true
991+
}
981992
void this.#invalidateAllShellCommands(params.tabId, session)
982993
session.rejectAllDeferredToolExecutions(new CancellationError('user'))
983994

@@ -1087,7 +1098,7 @@ export class AgenticChatController implements ChatHandlers {
10871098
}
10881099

10891100
// Result Handling - This happens only once
1090-
return await this.#handleFinalResult(
1101+
const result = await this.#handleFinalResult(
10911102
finalResult,
10921103
session,
10931104
params.tabId,
@@ -1096,6 +1107,13 @@ export class AgenticChatController implements ChatHandlers {
10961107
isNewConversation,
10971108
chatResultStream
10981109
)
1110+
1111+
// Reset compactionDeclined flag after successful completion
1112+
if (session.compactionDeclined) {
1113+
session.compactionDeclined = false
1114+
}
1115+
1116+
return result
10991117
} catch (err) {
11001118
// HACK: the chat-client needs to have a partial event with the associated messageId sent before it can accept the final result.
11011119
// Without this, the `working` indicator never goes away.
@@ -1167,25 +1185,29 @@ export class AgenticChatController implements ChatHandlers {
11671185
/**
11681186
* Prepares the initial request input for the chat prompt
11691187
*/
1170-
#getCompactionRequestInput(session: ChatSessionService): ChatCommandInput {
1188+
#getCompactionRequestInput(session: ChatSessionService, toolResults?: any[]): ChatCommandInput {
11711189
this.#debug('Preparing compaction request input')
11721190
// Get profileArn from the service manager if available
11731191
const profileArn = this.#serviceManager?.getActiveProfileArn()
11741192
const requestInput = this.#triggerContext.getCompactionChatCommandInput(
11751193
profileArn,
11761194
this.#getTools(session),
11771195
session.modelId,
1178-
this.#origin
1196+
this.#origin,
1197+
toolResults
11791198
)
11801199
return requestInput
11811200
}
11821201

11831202
/**
11841203
* Runs the compaction, making requests and processing tool uses until completion
11851204
*/
1186-
#shouldCompact(currentRequestCount: number): boolean {
1187-
if (currentRequestCount > COMPACTION_CHARACTER_THRESHOLD) {
1188-
this.#debug(`Current request total character count is: ${currentRequestCount}, prompting user to compact`)
1205+
#shouldCompact(currentRequestCount: number, session: ChatSessionService): boolean {
1206+
const EFFECTIVE_COMPACTION_THRESHOLD = COMPACTION_CHARACTER_THRESHOLD - COMPACTION_PROMPT.length
1207+
if (currentRequestCount > EFFECTIVE_COMPACTION_THRESHOLD && !session.compactionDeclined) {
1208+
this.#debug(
1209+
`Current request total character count is: ${currentRequestCount}, prompting user to compact (threshold: ${EFFECTIVE_COMPACTION_THRESHOLD})`
1210+
)
11891211
return true
11901212
} else {
11911213
return false
@@ -1368,6 +1390,10 @@ export class AgenticChatController implements ChatHandlers {
13681390
let currentRequestCount = 0
13691391
const pinnedContext = additionalContext?.filter(item => item.pinned)
13701392

1393+
// Store initial non-empty prompt for compaction handoff
1394+
const initialPrompt =
1395+
initialRequestInput.conversationState?.currentMessage?.userInputMessage?.content?.trim() || ''
1396+
13711397
metric.recordStart()
13721398
this.logSystemInformation()
13731399
while (true) {
@@ -1432,6 +1458,63 @@ export class AgenticChatController implements ChatHandlers {
14321458
this.#llmRequestStartTime = Date.now()
14331459
// Phase 3: Request Execution
14341460
currentRequestInput = sanitizeRequestInput(currentRequestInput)
1461+
1462+
if (this.#shouldCompact(currentRequestCount, session)) {
1463+
this.#features.logging.info(
1464+
`Entering mid-loop compaction at iteration ${iterationCount} with ${currentRequestCount} characters`
1465+
)
1466+
this.#telemetryController.emitMidLoopCompaction(
1467+
currentRequestCount,
1468+
iterationCount,
1469+
this.#features.runtime.serverInfo.version ?? ''
1470+
)
1471+
const messageId = this.#getMessageIdForCompact(uuid())
1472+
const confirmationResult = this.#processCompactConfirmation(messageId, currentRequestCount)
1473+
const cachedButtonBlockId = await chatResultStream.writeResultBlock(confirmationResult)
1474+
await this.waitForCompactApproval(messageId, chatResultStream, cachedButtonBlockId, session)
1475+
1476+
// Run compaction
1477+
const toolResults =
1478+
currentRequestInput.conversationState?.currentMessage?.userInputMessage?.userInputMessageContext
1479+
?.toolResults || []
1480+
const compactionRequestInput = this.#getCompactionRequestInput(session, toolResults)
1481+
const compactionResult = await this.#runCompaction(
1482+
compactionRequestInput,
1483+
session,
1484+
metric,
1485+
chatResultStream,
1486+
tabId,
1487+
promptId,
1488+
CompactHistoryActionType.Nudge,
1489+
session.conversationId,
1490+
token,
1491+
documentReference
1492+
)
1493+
1494+
if (!compactionResult.success) {
1495+
this.#features.logging.error(`Compaction failed: ${compactionResult.error}`)
1496+
return compactionResult
1497+
}
1498+
1499+
// Show compaction summary to user before continuing
1500+
await chatResultStream.writeResultBlock({
1501+
type: 'answer',
1502+
body:
1503+
(compactionResult.data?.chatResult.body || '') +
1504+
'\n\nConversation history has been compacted successfully!',
1505+
messageId: uuid(),
1506+
})
1507+
1508+
currentRequestInput = this.#updateRequestInputWithToolResults(
1509+
currentRequestInput,
1510+
[],
1511+
MID_LOOP_COMPACTION_HANDOFF_PROMPT + initialPrompt
1512+
)
1513+
shouldDisplayMessage = false
1514+
this.#features.logging.info(`Completed mid-loop compaction, restarting loop with handoff prompt`)
1515+
continue
1516+
}
1517+
14351518
// Note: these logs are very noisy, but contain information redacted on the backend.
14361519
this.#debug(
14371520
`generateAssistantResponse/SendMessage Request: ${JSON.stringify(currentRequestInput, this.#imageReplacer, 2)}`
@@ -1660,7 +1743,7 @@ export class AgenticChatController implements ChatHandlers {
16601743
currentRequestInput = this.#updateRequestInputWithToolResults(currentRequestInput, toolResults, content)
16611744
}
16621745

1663-
if (this.#shouldCompact(currentRequestCount)) {
1746+
if (this.#shouldCompact(currentRequestCount, session)) {
16641747
this.#telemetryController.emitCompactNudge(
16651748
currentRequestCount,
16661749
this.#features.runtime.serverInfo.version ?? ''

server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export const GENERATE_ASSISTANT_RESPONSE_INPUT_LIMIT = 500_000
1818
// 200K tokens * 3.5 = 700K characters, intentionally overestimating with 3.5:1 ratio
1919
export const MAX_OVERALL_CHARACTERS = 700_000
2020
export const COMPACTION_CHARACTER_THRESHOLD = 0.7 * MAX_OVERALL_CHARACTERS
21+
// TODO: We need to carefully craft a prompt for this supported by the science team
22+
export const MID_LOOP_COMPACTION_HANDOFF_PROMPT = `CONTEXT HANDOFF: Previous conversation was compacted. Continue with user's request: `
2123
export const COMPACTION_BODY = (threshold: number) =>
2224
`The context window is almost full (${threshold}%) and exceeding it will clear your history. Amazon Q can compact your history instead.`
2325
export const COMPACTION_HEADER_BODY = 'Compact chat history?'

server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContext.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,15 @@ export class AgenticChatTriggerContext {
113113
* @param tools Optional Bedrock tools
114114
* @param modelId Optional model ID
115115
* @param origin Optional origin
116+
* @param toolResults Optional tool results to include in compaction context
116117
* @returns ChatCommandInput - which is either SendMessageInput or GenerateAssistantResponseInput
117118
*/
118119
getCompactionChatCommandInput(
119120
profileArn?: string,
120121
tools: BedrockTools = [],
121122
modelId?: string,
122-
origin?: Origin
123+
origin?: Origin,
124+
toolResults?: any[]
123125
): ChatCommandInput {
124126
const data: ChatCommandInput = {
125127
conversationState: {
@@ -130,6 +132,7 @@ export class AgenticChatTriggerContext {
130132
userInputMessageContext: {
131133
tools,
132134
envState: this.#mapPlatformToEnvState(process.platform),
135+
toolResults: toolResults,
133136
},
134137
userIntent: undefined,
135138
origin: origin ? origin : 'IDE',

server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export class ChatSessionService {
4242
public contextListSent: boolean = false
4343
public modelId: string | undefined
4444
public isMemoryBankGeneration: boolean = false
45+
public compactionDeclined: boolean = false
4546
#lsp?: Features['lsp']
4647
#abortController?: AbortController
4748
#currentPromptId?: string

server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,18 @@ export class ChatTelemetryController {
243243
})
244244
}
245245

246+
public emitMidLoopCompaction(characters: number, iterationCount: number, languageServerVersion: string) {
247+
this.#telemetry.emitMetric({
248+
name: ChatTelemetryEventName.MidLoopCompaction,
249+
data: {
250+
characters,
251+
iterationCount,
252+
credentialStartUrl: this.#credentialsProvider.getConnectionMetadata()?.sso?.startUrl,
253+
languageServerVersion: languageServerVersion,
254+
},
255+
})
256+
}
257+
246258
public emitToolUseSuggested(
247259
toolUse: ToolUse,
248260
conversationId: string,

server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export enum ChatTelemetryEventName {
207207
LoadHistory = 'amazonq_loadHistory',
208208
CompactHistory = 'amazonq_compactHistory',
209209
CompactNudge = 'amazonq_compactNudge',
210+
MidLoopCompaction = 'amazonq_midLoopCompaction',
210211
ChatHistoryAction = 'amazonq_performChatHistoryAction',
211212
ExportTab = 'amazonq_exportTab',
212213
UiClick = 'ui_click',
@@ -233,6 +234,7 @@ export interface ChatTelemetryEventMap {
233234
[ChatTelemetryEventName.LoadHistory]: LoadHistoryEvent
234235
[ChatTelemetryEventName.CompactHistory]: CompactHistoryEvent
235236
[ChatTelemetryEventName.CompactNudge]: CompactNudgeEvent
237+
[ChatTelemetryEventName.MidLoopCompaction]: MidLoopCompactionEvent
236238
[ChatTelemetryEventName.ChatHistoryAction]: ChatHistoryActionEvent
237239
[ChatTelemetryEventName.ExportTab]: ExportTabEvent
238240
[ChatTelemetryEventName.UiClick]: UiClickEvent
@@ -404,6 +406,13 @@ export type CompactNudgeEvent = {
404406
languageServerVersion?: string
405407
}
406408

409+
export type MidLoopCompactionEvent = {
410+
characters: number
411+
iterationCount: number
412+
credentialStartUrl?: string
413+
languageServerVersion?: string
414+
}
415+
407416
export type ChatHistoryActionEvent = {
408417
action: ChatHistoryActionType
409418
result: Result

0 commit comments

Comments
 (0)