From d46af48089f5e062df8b8947af91a50c9d011c99 Mon Sep 17 00:00:00 2001 From: Joshua Samuel Date: Fri, 13 Feb 2026 21:48:37 +1100 Subject: [PATCH] feat: add SummarizingConversationManager Implements summarizing conversation manager that preserves context by summarizing older messages instead of simply trimming them. Features: - Configurable summary ratio (0.1-0.8, default 0.3) - Configurable recent message preservation (default 10) - Two summarization paths: direct model call or dedicated agent - Tool pair boundary detection to maintain conversation validity - Async hook integration with AfterModelCallEvent Closes #279 --- .kiro/settings/lsp.json | 198 +++++++++++ .../summarizing-conversation-manager.test.ts | 333 ++++++++++++++++++ src/conversation-manager/index.ts | 4 + .../summarizing-conversation-manager.ts | 294 ++++++++++++++++ 4 files changed, 829 insertions(+) create mode 100644 .kiro/settings/lsp.json create mode 100644 src/conversation-manager/__tests__/summarizing-conversation-manager.test.ts create mode 100644 src/conversation-manager/summarizing-conversation-manager.ts diff --git a/.kiro/settings/lsp.json b/.kiro/settings/lsp.json new file mode 100644 index 00000000..c7462aa9 --- /dev/null +++ b/.kiro/settings/lsp.json @@ -0,0 +1,198 @@ +{ + "languages": { + "rust": { + "name": "rust-analyzer", + "command": "rust-analyzer", + "args": [], + "file_extensions": [ + "rs" + ], + "project_patterns": [ + "Cargo.toml" + ], + "exclude_patterns": [ + "**/target/**" + ], + "multi_workspace": false, + "initialization_options": { + "cargo": { + "buildScripts": { + "enable": true + } + }, + "diagnostics": { + "enable": true, + "enableExperimental": true + }, + "workspace": { + "symbol": { + "search": { + "scope": "workspace" + } + } + } + }, + "request_timeout_secs": 60 + }, + "python": { + "name": "pyright", + "command": "pyright-langserver", + "args": [ + "--stdio" + ], + "file_extensions": [ + "py" + ], + "project_patterns": [ + "pyproject.toml", + "setup.py", + "requirements.txt", + "pyrightconfig.json" + ], + "exclude_patterns": [ + "**/__pycache__/**", + "**/venv/**", + "**/.venv/**", + "**/.pytest_cache/**" + ], + "multi_workspace": false, + "initialization_options": {}, + "request_timeout_secs": 60 + }, + "ruby": { + "name": "solargraph", + "command": "solargraph", + "args": [ + "stdio" + ], + "file_extensions": [ + "rb" + ], + "project_patterns": [ + "Gemfile", + "Rakefile" + ], + "exclude_patterns": [ + "**/vendor/**", + "**/tmp/**" + ], + "multi_workspace": false, + "initialization_options": {}, + "request_timeout_secs": 60 + }, + "cpp": { + "name": "clangd", + "command": "clangd", + "args": [ + "--background-index" + ], + "file_extensions": [ + "cpp", + "cc", + "cxx", + "c", + "h", + "hpp", + "hxx" + ], + "project_patterns": [ + "CMakeLists.txt", + "compile_commands.json", + "Makefile" + ], + "exclude_patterns": [ + "**/build/**", + "**/cmake-build-**/**" + ], + "multi_workspace": false, + "initialization_options": {}, + "request_timeout_secs": 60 + }, + "typescript": { + "name": "typescript-language-server", + "command": "typescript-language-server", + "args": [ + "--stdio" + ], + "file_extensions": [ + "ts", + "js", + "tsx", + "jsx" + ], + "project_patterns": [ + "package.json", + "tsconfig.json" + ], + "exclude_patterns": [ + "**/node_modules/**", + "**/dist/**" + ], + "multi_workspace": false, + "initialization_options": { + "preferences": { + "disableSuggestions": false + } + }, + "request_timeout_secs": 60 + }, + "java": { + "name": "jdtls", + "command": "jdtls", + "args": [], + "file_extensions": [ + "java" + ], + "project_patterns": [ + "pom.xml", + "build.gradle", + "build.gradle.kts", + ".project" + ], + "exclude_patterns": [ + "**/target/**", + "**/build/**", + "**/.gradle/**" + ], + "multi_workspace": false, + "initialization_options": { + "settings": { + "java": { + "compile": { + "nullAnalysis": { + "mode": "automatic" + } + }, + "configuration": { + "annotationProcessing": { + "enabled": true + } + } + } + } + }, + "request_timeout_secs": 60 + }, + "go": { + "name": "gopls", + "command": "gopls", + "args": [], + "file_extensions": [ + "go" + ], + "project_patterns": [ + "go.mod", + "go.sum" + ], + "exclude_patterns": [ + "**/vendor/**" + ], + "multi_workspace": false, + "initialization_options": { + "usePlaceholders": true, + "completeUnimported": true + }, + "request_timeout_secs": 60 + } + } +} \ No newline at end of file diff --git a/src/conversation-manager/__tests__/summarizing-conversation-manager.test.ts b/src/conversation-manager/__tests__/summarizing-conversation-manager.test.ts new file mode 100644 index 00000000..52e3332c --- /dev/null +++ b/src/conversation-manager/__tests__/summarizing-conversation-manager.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect } from 'vitest' +import { SummarizingConversationManager } from '../summarizing-conversation-manager.js' +import { ContextWindowOverflowError, Message, TextBlock, ToolUseBlock, ToolResultBlock } from '../../index.js' +import { HookRegistryImplementation } from '../../hooks/registry.js' +import { AfterModelCallEvent } from '../../hooks/events.js' +import { createMockAgent } from '../../__fixtures__/agent-helpers.js' +import { MockMessageModel } from '../../__fixtures__/mock-message-model.js' +import type { Agent } from '../../agent/agent.js' + +async function triggerContextOverflow( + manager: SummarizingConversationManager, + agent: Agent, + error: Error +): Promise<{ retry?: boolean }> { + const registry = new HookRegistryImplementation() + registry.addHook(manager) + return await registry.invokeCallbacks(new AfterModelCallEvent({ agent, error })) +} + +describe('SummarizingConversationManager', () => { + describe('constructor', () => { + it('sets default summaryRatio to 0.3', () => { + const manager = new SummarizingConversationManager() + expect((manager as any)._summaryRatio).toBe(0.3) + }) + + it('sets default preserveRecentMessages to 10', () => { + const manager = new SummarizingConversationManager() + expect((manager as any)._preserveRecentMessages).toBe(10) + }) + + it('accepts custom summaryRatio', () => { + const manager = new SummarizingConversationManager({ summaryRatio: 0.5 }) + expect((manager as any)._summaryRatio).toBe(0.5) + }) + + it('clamps summaryRatio to 0.1 minimum', () => { + const manager = new SummarizingConversationManager({ summaryRatio: 0.05 }) + expect((manager as any)._summaryRatio).toBe(0.1) + }) + + it('clamps summaryRatio to 0.8 maximum', () => { + const manager = new SummarizingConversationManager({ summaryRatio: 0.9 }) + expect((manager as any)._summaryRatio).toBe(0.8) + }) + + it('accepts custom preserveRecentMessages', () => { + const manager = new SummarizingConversationManager({ preserveRecentMessages: 5 }) + expect((manager as any)._preserveRecentMessages).toBe(5) + }) + + it('throws error when both summarizationAgent and summarizationSystemPrompt are provided', () => { + const mockAgent = createMockAgent() + expect( + () => + new SummarizingConversationManager({ + summarizationAgent: mockAgent, + summarizationSystemPrompt: 'Custom prompt', + }) + ).toThrow('Cannot provide both summarizationAgent and summarizationSystemPrompt') + }) + }) + + describe('calculateSummarizeCount', () => { + it('calculates correct count based on summary ratio', () => { + const manager = new SummarizingConversationManager({ summaryRatio: 0.3 }) + const count = (manager as any).calculateSummarizeCount(20) + expect(count).toBe(6) // 20 * 0.3 = 6 + }) + + it('respects preserveRecentMessages limit', () => { + const manager = new SummarizingConversationManager({ summaryRatio: 0.5, preserveRecentMessages: 5 }) + const count = (manager as any).calculateSummarizeCount(20) + expect(count).toBe(10) // min(20 * 0.5, 20 - 5) = min(10, 15) = 10 + }) + + it('returns 0 when not enough messages to preserve recent', () => { + const manager = new SummarizingConversationManager({ preserveRecentMessages: 15 }) + const count = (manager as any).calculateSummarizeCount(10) + expect(count).toBe(0) // 10 - 15 = -5, clamped to 0 + }) + + it('returns 0 when preserveRecentMessages exceeds available messages', () => { + const manager = new SummarizingConversationManager({ summaryRatio: 0.1, preserveRecentMessages: 10 }) + const count = (manager as any).calculateSummarizeCount(5) + expect(count).toBe(0) // 5 - 10 = -5, clamped to 0 + }) + }) + + describe('adjustSplitPointForToolPairs', () => { + it('returns split point when no tool blocks present', () => { + const manager = new SummarizingConversationManager() + const messages = [ + new Message({ role: 'user', content: [new TextBlock('Message 1')] }), + new Message({ role: 'assistant', content: [new TextBlock('Response 1')] }), + new Message({ role: 'user', content: [new TextBlock('Message 2')] }), + ] + const adjusted = (manager as any).adjustSplitPointForToolPairs(messages, 1) + expect(adjusted).toBe(1) + }) + + it('skips toolResult at split point', () => { + const manager = new SummarizingConversationManager() + const messages = [ + new Message({ role: 'user', content: [new TextBlock('Message 1')] }), + new Message({ + role: 'user', + content: [ + new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Result')] }), + ], + }), + new Message({ role: 'user', content: [new TextBlock('Message 2')] }), + ] + const adjusted = (manager as any).adjustSplitPointForToolPairs(messages, 1) + expect(adjusted).toBe(2) // Skip the toolResult + }) + + it('skips toolUse without following toolResult', () => { + const manager = new SummarizingConversationManager() + const messages = [ + new Message({ role: 'user', content: [new TextBlock('Message 1')] }), + new Message({ + role: 'assistant', + content: [new ToolUseBlock({ name: 'tool', toolUseId: 'tool-1', input: {} })], + }), + new Message({ role: 'user', content: [new TextBlock('Message 2')] }), + ] + const adjusted = (manager as any).adjustSplitPointForToolPairs(messages, 1) + expect(adjusted).toBe(2) // Skip the toolUse without result + }) + + it('allows toolUse with following toolResult', () => { + const manager = new SummarizingConversationManager() + const messages = [ + new Message({ role: 'user', content: [new TextBlock('Message 1')] }), + new Message({ + role: 'assistant', + content: [new ToolUseBlock({ name: 'tool', toolUseId: 'tool-1', input: {} })], + }), + new Message({ + role: 'user', + content: [ + new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Result')] }), + ], + }), + new Message({ role: 'user', content: [new TextBlock('Message 2')] }), + ] + const adjusted = (manager as any).adjustSplitPointForToolPairs(messages, 1) + expect(adjusted).toBe(1) // Valid split point + }) + + it('throws when no valid split point found', () => { + const manager = new SummarizingConversationManager() + const messages = [ + new Message({ + role: 'user', + content: [ + new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Result')] }), + ], + }), + ] + expect(() => (manager as any).adjustSplitPointForToolPairs(messages, 0)).toThrow( + 'Unable to trim conversation context!' + ) + }) + + it('throws when split point exceeds message length', () => { + const manager = new SummarizingConversationManager() + const messages = [new Message({ role: 'user', content: [new TextBlock('Message 1')] })] + expect(() => (manager as any).adjustSplitPointForToolPairs(messages, 5)).toThrow( + 'Split point exceeds message array length' + ) + }) + }) + + describe('reduceContext', () => { + it('throws when insufficient messages for summarization', async () => { + const manager = new SummarizingConversationManager({ preserveRecentMessages: 10 }) + const messages = [ + new Message({ role: 'user', content: [new TextBlock('Message 1')] }), + new Message({ role: 'assistant', content: [new TextBlock('Response 1')] }), + ] + const mockAgent = createMockAgent({ messages }) + + await expect(triggerContextOverflow(manager, mockAgent, new ContextWindowOverflowError('Test'))).rejects.toThrow( + 'Cannot summarize: insufficient messages for summarization' + ) + }) + + it('summarizes messages and replaces with summary', async () => { + const model = new MockMessageModel().addTurn(new TextBlock('Summary of conversation')) + const messages = Array.from({ length: 20 }, (_, i) => + i % 2 === 0 + ? new Message({ role: 'user', content: [new TextBlock(`Message ${i}`)] }) + : new Message({ role: 'assistant', content: [new TextBlock(`Response ${i}`)] }) + ) + const mockAgent = createMockAgent({ messages }) + ;(mockAgent as any).model = model + + const manager = new SummarizingConversationManager({ summaryRatio: 0.3, preserveRecentMessages: 5 }) + + const result = await triggerContextOverflow(manager, mockAgent, new ContextWindowOverflowError('Test')) + + expect(result.retry).toBe(true) + expect(mockAgent.messages.length).toBeLessThan(20) + expect(mockAgent.messages[0]?.role).toBe('user') + expect(mockAgent.messages[0]?.content[0]?.type).toBe('textBlock') + }) + + it('preserves recent messages', async () => { + const model = new MockMessageModel().addTurn(new TextBlock('Summary')) + const messages = Array.from( + { length: 20 }, + (_, i) => new Message({ role: 'user', content: [new TextBlock(`Message ${i}`)] }) + ) + const mockAgent = createMockAgent({ messages }) + ;(mockAgent as any).model = model + + const manager = new SummarizingConversationManager({ summaryRatio: 0.5, preserveRecentMessages: 10 }) + + await triggerContextOverflow(manager, mockAgent, new ContextWindowOverflowError('Test')) + + // Should have summary + 10 recent messages + expect(mockAgent.messages.length).toBe(11) + expect(mockAgent.messages[mockAgent.messages.length - 1]?.content[0]).toMatchObject({ text: 'Message 19' }) + }) + }) + + describe('generateSummaryWithModel', () => { + it('calls model with summarization prompt', async () => { + const model = new MockMessageModel().addTurn(new TextBlock('Generated summary')) + const messages = [ + new Message({ role: 'user', content: [new TextBlock('Message 1')] }), + new Message({ role: 'assistant', content: [new TextBlock('Response 1')] }), + ] + const mockAgent = createMockAgent({ messages: [] }) + ;(mockAgent as any).model = model + + const manager = new SummarizingConversationManager() + const summary = await (manager as any).generateSummaryWithModel(messages, mockAgent) + + expect(summary.role).toBe('user') + expect(summary.content[0]).toMatchObject({ type: 'textBlock', text: 'Generated summary' }) + }) + + it('uses custom summarization prompt when provided', async () => { + const model = new MockMessageModel().addTurn(new TextBlock('Custom summary')) + const messages = [new Message({ role: 'user', content: [new TextBlock('Message')] })] + const mockAgent = createMockAgent({ messages: [] }) + ;(mockAgent as any).model = model + + const manager = new SummarizingConversationManager({ + summarizationSystemPrompt: 'Custom summarization instructions', + }) + const summary = await (manager as any).generateSummaryWithModel(messages, mockAgent) + + expect(summary.content[0]).toMatchObject({ type: 'textBlock', text: 'Custom summary' }) + }) + }) + + describe('generateSummaryWithAgent', () => { + it('uses dedicated summarization agent', async () => { + const summaryModel = new MockMessageModel().addTurn(new TextBlock('Agent summary')) + const summaryAgent = createMockAgent({ messages: [] }) + ;(summaryAgent as any).model = summaryModel + ;(summaryAgent as any).invoke = async () => ({ + lastMessage: new Message({ role: 'assistant', content: [new TextBlock('Agent summary')] }), + }) + + const messages = [new Message({ role: 'user', content: [new TextBlock('Message')] })] + + const manager = new SummarizingConversationManager({ summarizationAgent: summaryAgent }) + const summary = await (manager as any).generateSummaryWithAgent(messages) + + expect(summary.role).toBe('user') + expect(summary.content[0]).toMatchObject({ type: 'textBlock', text: 'Agent summary' }) + }) + + it('restores original messages after summarization', async () => { + const summaryModel = new MockMessageModel().addTurn(new TextBlock('Summary')) + const originalMessages = [new Message({ role: 'user', content: [new TextBlock('Original')] })] + const summaryAgent = createMockAgent({ messages: [...originalMessages] }) + ;(summaryAgent as any).model = summaryModel + ;(summaryAgent as any).invoke = async () => ({ + lastMessage: new Message({ role: 'assistant', content: [new TextBlock('Summary')] }), + }) + + const messages = [new Message({ role: 'user', content: [new TextBlock('To summarize')] })] + + const manager = new SummarizingConversationManager({ summarizationAgent: summaryAgent }) + await (manager as any).generateSummaryWithAgent(messages) + + expect(summaryAgent.messages).toHaveLength(1) + expect(summaryAgent.messages[0]?.content[0]).toMatchObject({ text: 'Original' }) + }) + }) + + describe('hook integration', () => { + it('registers AfterModelCallEvent callback', () => { + const manager = new SummarizingConversationManager() + const registry = new HookRegistryImplementation() + + manager.registerCallbacks(registry) + + expect((registry as any)._callbacks.has(AfterModelCallEvent)).toBe(true) + }) + + it('sets retry flag on context overflow', async () => { + const model = new MockMessageModel().addTurn(new TextBlock('Summary')) + const messages = Array.from( + { length: 20 }, + (_, i) => new Message({ role: 'user', content: [new TextBlock(`Message ${i}`)] }) + ) + const mockAgent = createMockAgent({ messages }) + ;(mockAgent as any).model = model + + const manager = new SummarizingConversationManager() + const result = await triggerContextOverflow(manager, mockAgent, new ContextWindowOverflowError('Test')) + + expect(result.retry).toBe(true) + }) + + it('does not set retry flag for non-overflow errors', async () => { + const messages = [new Message({ role: 'user', content: [new TextBlock('Message')] })] + const mockAgent = createMockAgent({ messages }) + + const manager = new SummarizingConversationManager() + const result = await triggerContextOverflow(manager, mockAgent, new Error('Other error')) + + expect(result.retry).toBeUndefined() + }) + }) +}) diff --git a/src/conversation-manager/index.ts b/src/conversation-manager/index.ts index b702c02f..2f15da0c 100644 --- a/src/conversation-manager/index.ts +++ b/src/conversation-manager/index.ts @@ -9,3 +9,7 @@ export { SlidingWindowConversationManager, type SlidingWindowConversationManagerConfig, } from './sliding-window-conversation-manager.js' +export { + SummarizingConversationManager, + type SummarizingConversationManagerConfig, +} from './summarizing-conversation-manager.js' diff --git a/src/conversation-manager/summarizing-conversation-manager.ts b/src/conversation-manager/summarizing-conversation-manager.ts new file mode 100644 index 00000000..6cd9d83b --- /dev/null +++ b/src/conversation-manager/summarizing-conversation-manager.ts @@ -0,0 +1,294 @@ +/** + * Summarizing conversation history management with configurable options. + * + * This module provides a conversation manager that summarizes older context + * instead of simply trimming it, helping preserve important information while + * staying within context limits. + */ + +import type { Agent } from '../agent/agent.js' +import { ContextWindowOverflowError } from '../errors.js' +import type { HookProvider } from '../hooks/types.js' +import type { HookRegistry } from '../hooks/registry.js' +import { AfterModelCallEvent } from '../hooks/events.js' +import { Message, TextBlock } from '../types/messages.js' +import type { StreamOptions } from '../models/model.js' + +const DEFAULT_SUMMARIZATION_PROMPT = `You are a conversation summarizer. Provide a concise summary of the conversation history. + +Format Requirements: +- You MUST create a structured and concise summary in bullet-point format. +- You MUST NOT respond conversationally. +- You MUST NOT address the user directly. +- You MUST NOT comment on tool availability. + +Assumptions: +- You MUST NOT assume tool executions failed unless otherwise stated. + +Task: +Your task is to create a structured summary document: +- It MUST contain bullet points with key topics and questions covered +- It MUST contain bullet points for all significant tools executed and their results +- It MUST contain bullet points for any code or technical information shared +- It MUST contain a section of key insights gained +- It MUST format the summary in the third person + +Example format: +## Conversation Summary +* Topic 1: Key information +* Topic 2: Key information + +## Tools Executed +* Tool X: Result Y` + +/** + * Configuration for the summarizing conversation manager. + */ +export type SummarizingConversationManagerConfig = { + /** + * Ratio of messages to summarize vs keep when context overflow occurs. + * Value between 0.1 and 0.8. Defaults to 0.3 (summarize 30% of oldest messages). + */ + summaryRatio?: number + + /** + * Minimum number of recent messages to always keep. + * Defaults to 10 messages. + */ + preserveRecentMessages?: number + + /** + * Optional agent to use for summarization instead of the parent agent. + * If provided, this agent can use tools as part of the summarization process. + */ + summarizationAgent?: Agent + + /** + * Optional system prompt override for summarization. + * If not provided, uses the default summarization prompt. + * Cannot be used together with summarizationAgent. + */ + summarizationSystemPrompt?: string +} + +/** + * Implements a summarizing conversation manager. + * + * This manager provides a configurable option to summarize older context instead of + * simply trimming it, helping preserve important information while staying within + * context limits. + * + * As a HookProvider, it registers callbacks for: + * - AfterModelCallEvent: Reduces context on overflow errors and requests retry + */ +export class SummarizingConversationManager implements HookProvider { + private readonly _summaryRatio: number + private readonly _preserveRecentMessages: number + private readonly _summarizationAgent?: Agent + private readonly _summarizationSystemPrompt?: string + private _summaryMessage?: Message + + /** + * Initialize the summarizing conversation manager. + * + * @param config - Configuration options for the summarizing manager. + */ + constructor(config?: SummarizingConversationManagerConfig) { + if (config?.summarizationAgent && config?.summarizationSystemPrompt) { + throw new Error( + 'Cannot provide both summarizationAgent and summarizationSystemPrompt. Agents come with their own system prompt.' + ) + } + + this._summaryRatio = Math.max(0.1, Math.min(0.8, config?.summaryRatio ?? 0.3)) + this._preserveRecentMessages = config?.preserveRecentMessages ?? 10 + + if (config?.summarizationAgent !== undefined) { + this._summarizationAgent = config.summarizationAgent + } + + if (config?.summarizationSystemPrompt !== undefined) { + this._summarizationSystemPrompt = config.summarizationSystemPrompt + } + } + + /** + * Registers callbacks with the hook registry. + * + * Registers: + * - AfterModelCallEvent callback to handle context overflow and request retry + * + * @param registry - The hook registry to register callbacks with + */ + public registerCallbacks(registry: HookRegistry): void { + registry.addCallback(AfterModelCallEvent, async (event) => { + if (event.error instanceof ContextWindowOverflowError) { + await this.reduceContext(event.agent as Agent) + event.retry = true + } + }) + } + + /** + * Reduce context using summarization. + * + * @param agent - The agent whose conversation history will be reduced. + * + * @throws ContextWindowOverflowError If the context cannot be summarized. + */ + private async reduceContext(agent: Agent): Promise { + const messagesToSummarizeCount = this.calculateSummarizeCount(agent.messages.length) + + if (messagesToSummarizeCount <= 0) { + throw new ContextWindowOverflowError('Cannot summarize: insufficient messages for summarization') + } + + const adjustedCount = this.adjustSplitPointForToolPairs(agent.messages, messagesToSummarizeCount) + + if (adjustedCount <= 0) { + throw new ContextWindowOverflowError('Cannot summarize: insufficient messages for summarization') + } + + const messagesToSummarize = agent.messages.slice(0, adjustedCount) + const remainingMessages = agent.messages.slice(adjustedCount) + + this._summaryMessage = await this.generateSummary(messagesToSummarize, agent) + + agent.messages.splice(0, agent.messages.length, this._summaryMessage, ...remainingMessages) + } + + /** + * Calculate how many messages to summarize. + * + * @param totalMessages - Total number of messages in conversation + * @returns Number of messages to summarize + */ + private calculateSummarizeCount(totalMessages: number): number { + const count = Math.max(1, Math.floor(totalMessages * this._summaryRatio)) + return Math.max(0, Math.min(count, totalMessages - this._preserveRecentMessages)) + } + + /** + * Adjust the split point to avoid breaking ToolUse/ToolResult pairs. + * + * @param messages - The full list of messages. + * @param splitPoint - The initially calculated split point. + * @returns The adjusted split point that doesn't break ToolUse/ToolResult pairs. + * + * @throws ContextWindowOverflowError If no valid split point can be found. + */ + private adjustSplitPointForToolPairs(messages: Message[], splitPoint: number): number { + if (splitPoint > messages.length) { + throw new ContextWindowOverflowError('Split point exceeds message array length') + } + + if (splitPoint === messages.length) { + return splitPoint + } + + while (splitPoint < messages.length) { + const message = messages[splitPoint] + if (!message) { + break + } + + const hasToolResult = message.content.some((block) => block.type === 'toolResultBlock') + if (hasToolResult) { + splitPoint++ + continue + } + + const hasToolUse = message.content.some((block) => block.type === 'toolUseBlock') + if (hasToolUse) { + const nextMessage = messages[splitPoint + 1] + const nextHasToolResult = nextMessage?.content.some((block) => block.type === 'toolResultBlock') + + if (!nextHasToolResult) { + splitPoint++ + continue + } + } + + break + } + + if (splitPoint >= messages.length) { + throw new ContextWindowOverflowError('Unable to trim conversation context!') + } + + return splitPoint + } + + /** + * Generate a summary of the provided messages. + * + * @param messages - The messages to summarize. + * @param agent - The agent instance whose model will be used for summarization. + * @returns A message containing the conversation summary. + */ + private async generateSummary(messages: Message[], agent: Agent): Promise { + if (this._summarizationAgent) { + return this.generateSummaryWithAgent(messages) + } + return this.generateSummaryWithModel(messages, agent) + } + + /** + * Generate a summary using the dedicated summarization agent. + * + * @param messages - The messages to summarize. + * @returns A message containing the conversation summary. + */ + private async generateSummaryWithAgent(messages: Message[]): Promise { + const summarizationAgent = this._summarizationAgent! + const originalMessages = [...summarizationAgent.messages] + + try { + summarizationAgent.messages.splice(0, summarizationAgent.messages.length, ...messages) + const result = await summarizationAgent.invoke('Please summarize this conversation.') + return new Message({ + role: 'user', + content: result.lastMessage.content, + }) + } finally { + summarizationAgent.messages.splice(0, summarizationAgent.messages.length, ...originalMessages) + } + } + + /** + * Generate a summary by calling the agent's model directly. + * + * @param messages - The messages to summarize. + * @param agent - The parent agent whose model is used. + * @returns A message containing the conversation summary. + */ + private async generateSummaryWithModel(messages: Message[], agent: Agent): Promise { + const systemPrompt = this._summarizationSystemPrompt ?? DEFAULT_SUMMARIZATION_PROMPT + + const summarizationMessages = [ + ...messages, + new Message({ + role: 'user', + content: [new TextBlock('Please summarize this conversation.')], + }), + ] + + const streamOptions: StreamOptions = { + systemPrompt, + } + + const streamGenerator = agent.model.streamAggregated(summarizationMessages, streamOptions) + + let result = await streamGenerator.next() + while (!result.done) { + result = await streamGenerator.next() + } + + const { message } = result.value + + return new Message({ + role: 'user', + content: message.content, + }) + } +}