| |
| |
|
|
| import type { |
| Message, |
| ParsedResponse, |
| ToolResult, |
| ProviderAdapter, |
| CompactionConfig, |
| ContextManager, |
| ContentBlock, |
| UsageInfo, |
| } from './types'; |
|
|
| export class ContextManagerImpl implements ContextManager { |
| private messages: Message[] = []; |
| private compactionCount = 0; |
| onMessageAdded?: (message: Message) => void; |
| onMessagesReplaced?: (newMessages: Message[]) => void; |
|
|
| constructor(private config: CompactionConfig) {} |
|
|
| getMessages(): Message[] { |
| return this.messages; |
| } |
|
|
| getCompactionCount(): number { |
| return this.compactionCount; |
| } |
|
|
| setSystemPrompt(prompt: string): void { |
| if (this.messages.length > 0 && this.messages[0].role === 'system') { |
| this.messages[0] = { role: 'system', content: prompt }; |
| } else { |
| this.messages.unshift({ role: 'system', content: prompt }); |
| } |
| } |
|
|
| addUserMessage(content: string | ContentBlock[]): void { |
| const msg: Message = { role: 'user', content }; |
| this.messages.push(msg); |
| this.onMessageAdded?.(msg); |
| } |
|
|
| addAssistantTurn(response: ParsedResponse): void { |
| |
| if (response.toolCalls?.length) { |
| for (const tc of response.toolCalls) { |
| if (tc.function?.arguments) { |
| try { JSON.parse(tc.function.arguments); } catch { |
| tc.function.arguments = '{}'; |
| } |
| } |
| } |
| } |
| const msg: Message = { |
| role: 'assistant', |
| content: response.content || '', |
| ...(response.toolCalls?.length ? { tool_calls: response.toolCalls } : {}), |
| ...(response.reasoningDetails?.length ? { reasoning_details: response.reasoningDetails } : {}), |
| }; |
| this.messages.push(msg); |
| this.onMessageAdded?.(msg); |
| } |
|
|
| addToolResults(results: ToolResult[]): void { |
| for (const r of results) { |
| const msg: Message = { role: 'tool', content: r.content, tool_call_id: r.tool_call_id }; |
| this.messages.push(msg); |
| this.onMessageAdded?.(msg); |
| } |
| } |
|
|
| importMessages(messages: Message[]): void { |
| this.messages = [...messages]; |
| } |
|
|
| getTokenEstimate(): number { |
| return this.messages.reduce((sum, m) => sum + ContextManagerImpl.estimateMessageTokens(m), 0); |
| } |
|
|
| needsCompaction(tokenCount: number): boolean { |
| return tokenCount >= this.config.threshold; |
| } |
|
|
| |
| |
| |
| |
| getSanitizedMessages(): Message[] { |
| return this.repairOrphanToolCalls(this.messages); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async compact( |
| provider: ProviderAdapter, |
| opts?: { freshSystemPrompt?: string; projectContext?: string; signal?: AbortSignal } |
| ): Promise<UsageInfo | undefined> { |
| |
| const systemMessages = this.messages.filter(m => m.role === 'system'); |
| const nonSystemMessages = this.messages.filter(m => m.role !== 'system'); |
|
|
| if (nonSystemMessages.length < 3) { |
| return undefined; |
| } |
|
|
| |
| |
| |
| |
| interface Turn { messages: Message[]; tokens: number } |
| const turns: Turn[] = []; |
| let currentTurn: Turn | null = null; |
|
|
| for (const msg of nonSystemMessages) { |
| if (msg.role === 'tool') { |
| |
| if (currentTurn) { |
| const t = ContextManagerImpl.estimateMessageTokens(msg); |
| currentTurn.messages.push(msg); |
| currentTurn.tokens += t; |
| } |
| } else { |
| |
| if (currentTurn) turns.push(currentTurn); |
| const t = ContextManagerImpl.estimateMessageTokens(msg); |
| currentTurn = { messages: [msg], tokens: t }; |
| } |
| } |
| if (currentTurn) turns.push(currentTurn); |
|
|
| if (turns.length < 2) { |
| return undefined; |
| } |
|
|
| |
| |
| const recentTokenBudget = Math.round(this.config.contextLength * this.config.recentKeepRatio); |
| let recentTokens = 0; |
| let recentTurnCount = 0; |
|
|
| for (let i = turns.length - 1; i >= 0; i--) { |
| if (recentTurnCount >= 1 && recentTokens + turns[i].tokens > recentTokenBudget) { |
| break; |
| } |
| recentTokens += turns[i].tokens; |
| recentTurnCount++; |
| } |
|
|
| const splitTurnIndex = turns.length - recentTurnCount; |
|
|
| if (splitTurnIndex <= 0) { |
| return undefined; |
| } |
|
|
| const olderMessages = turns.slice(0, splitTurnIndex).flatMap(t => t.messages); |
| const recentMessages = turns.slice(splitTurnIndex).flatMap(t => t.messages); |
|
|
| |
| |
| |
| const flattenedMessages: Message[] = []; |
| for (const msg of olderMessages) { |
| if (msg.role === 'assistant') { |
| |
| let text = typeof msg.content === 'string' ? msg.content : ''; |
| if (msg.tool_calls) { |
| for (const tc of msg.tool_calls) { |
| const args = tc.function?.arguments || ''; |
| |
| const truncatedArgs = args.length > 500 ? args.slice(0, 500) + '...[truncated]' : args; |
| text += `\n[Called ${tc.function?.name}(${truncatedArgs})]`; |
| } |
| } |
| if (text.trim()) { |
| flattenedMessages.push({ role: 'assistant', content: text.trim() }); |
| } |
| } else if (msg.role === 'tool') { |
| |
| const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); |
| const truncated = content.length > 500 ? content.slice(0, 500) + '...[truncated]' : content; |
| if (truncated.trim()) { |
| flattenedMessages.push({ role: 'user', content: `[Tool result: ${truncated.trim()}]` }); |
| } |
| } else { |
| flattenedMessages.push(msg); |
| } |
| } |
|
|
| |
| const mergedMessages: Message[] = []; |
| for (const msg of flattenedMessages) { |
| const last = mergedMessages[mergedMessages.length - 1]; |
| if (last && last.role === msg.role && typeof last.content === 'string' && typeof msg.content === 'string') { |
| last.content += '\n' + msg.content; |
| } else { |
| mergedMessages.push({ ...msg }); |
| } |
| } |
|
|
| |
| let previousSummary: string | undefined; |
| for (const msg of olderMessages) { |
| if (msg.role === 'assistant' && msg.metadata?.isCompactSummary) { |
| previousSummary = typeof msg.content === 'string' |
| ? msg.content.replace(/^Here is a summary of the conversation so far:\n\n/, '') |
| : undefined; |
| } |
| } |
|
|
| |
| const compactionMessages: Message[] = [ |
| ...systemMessages, |
| ...mergedMessages, |
| { role: 'user', content: this.config.buildCompactionPrompt(previousSummary) }, |
| ]; |
|
|
| const summaryMaxTokens = Math.min( |
| 16384, |
| Math.max(256, Math.round(this.config.contextLength * this.config.summaryTokenRatio)) |
| ); |
|
|
| |
| const result = await provider.call({ |
| messages: compactionMessages, |
| maxTokens: summaryMaxTokens, |
| signal: opts?.signal, |
| silent: true, |
| }); |
|
|
| const summary = result.content || ''; |
| if (!summary) { |
| return undefined; |
| } |
|
|
| |
| |
| let freshFromConfig: { systemPrompt?: string; projectContext?: string } = {}; |
| if ((!opts?.freshSystemPrompt || !opts?.projectContext) && this.config.getFreshContext) { |
| try { |
| freshFromConfig = await this.config.getFreshContext(); |
| } catch { } |
| } |
| const freshSystemPrompt = opts?.freshSystemPrompt |
| || freshFromConfig.systemPrompt |
| || (systemMessages[0] && typeof systemMessages[0].content === 'string' ? systemMessages[0].content : ''); |
| const summaryContent = `Here is a summary of the conversation so far:\n\n${summary}`; |
|
|
| const projectContext = opts?.projectContext ?? freshFromConfig.projectContext; |
| const contextUserContent = projectContext |
| ? `${projectContext}\n\nThe earlier conversation was compacted into the summary below.` |
| : 'The earlier conversation was compacted into the summary below.'; |
|
|
| this.messages = [ |
| { role: 'system', content: freshSystemPrompt }, |
| { role: 'user', content: contextUserContent }, |
| { role: 'assistant', content: summaryContent, metadata: { isCompactSummary: true } }, |
| ...recentMessages, |
| ]; |
|
|
| this.compactionCount++; |
| this.onMessagesReplaced?.(this.messages); |
|
|
| return result.usage; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private repairOrphanToolCalls(messages: Message[]): Message[] { |
| const out: Message[] = []; |
|
|
| for (let i = 0; i < messages.length; i++) { |
| const msg = messages[i]; |
|
|
| if (msg.role !== 'assistant' || !msg.tool_calls || msg.tool_calls.length === 0) { |
| out.push(msg); |
| continue; |
| } |
|
|
| |
| const validCalls = msg.tool_calls.filter(tc => { |
| const args = tc.function?.arguments; |
| if (typeof args !== 'string' || args.trim() === '') return false; |
| try { |
| JSON.parse(args); |
| } catch { |
| |
| |
| tc.function.arguments = '{}'; |
| } |
| return true; |
| }); |
|
|
| const contentEmpty = typeof msg.content === 'string' |
| ? msg.content.trim() === '' |
| : !msg.content || (Array.isArray(msg.content) && msg.content.length === 0); |
|
|
| if (validCalls.length === 0) { |
| |
| if (contentEmpty) { |
| |
| continue; |
| } |
| |
| const { tool_calls: _, ...rest } = msg; |
| out.push(rest); |
| continue; |
| } |
|
|
| |
| out.push({ ...msg, tool_calls: validCalls }); |
|
|
| |
| const matchedIds = new Set<string>(); |
| for (let j = i + 1; j < messages.length; j++) { |
| const next = messages[j]; |
| if (next.role === 'assistant') break; |
| if (next.role === 'tool' && next.tool_call_id) { |
| matchedIds.add(next.tool_call_id); |
| } |
| } |
|
|
| |
| for (const tc of validCalls) { |
| if (!matchedIds.has(tc.id)) { |
| out.push({ |
| role: 'tool', |
| tool_call_id: tc.id, |
| content: 'No result — call was cancelled or aborted before completion.', |
| }); |
| } |
| } |
| } |
|
|
| return out; |
| } |
|
|
| |
| |
| |
| |
| static estimateMessageTokens(msg: Message): number { |
| const contentLen = typeof msg.content === 'string' ? msg.content.length : JSON.stringify(msg.content).length; |
| const argsLen = msg.tool_calls?.reduce((s, tc) => s + (tc.function?.arguments?.length ?? 0), 0) ?? 0; |
| return Math.round((contentLen + argsLen) / 3.5); |
| } |
| } |
|
|