diff --git a/.gitignore b/.gitignore index 8ce8a30a..29868af5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ test/.artifacts # LLM CLAUDE.md +.kiro/ 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..ae43bebd --- /dev/null +++ b/src/conversation-manager/__tests__/summarizing-conversation-manager.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect } from 'vitest' +import { SummarizingConversationManager } from '../summarizing-conversation-manager.js' +import { ContextWindowOverflowError, Message, TextBlock } from '../../index.js' +import { AfterModelCallEvent } from '../../hooks/events.js' +import { createMockAgent, invokeTrackedHook, type MockAgent } 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: MockAgent, + error: Error +): Promise { + manager.initAgent(agent as any) + const event = new AfterModelCallEvent({ agent: agent as any, error }) + await invokeTrackedHook(agent, event) + return event +} + +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('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 via initAgent', () => { + const manager = new SummarizingConversationManager() + const agent = createMockAgent() + + manager.initAgent(agent as any) + + expect(agent.trackedHooks.some((h) => h.eventType === 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/__tests__/utils.test.ts b/src/conversation-manager/__tests__/utils.test.ts new file mode 100644 index 00000000..14324f29 --- /dev/null +++ b/src/conversation-manager/__tests__/utils.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { findValidSplitPoint } from '../utils.js' +import { Message, TextBlock, ToolUseBlock, ToolResultBlock } from '../../index.js' + +function textMsg(role: 'user' | 'assistant', text: string): Message { + return new Message({ role, content: [new TextBlock(text)] }) +} + +function toolUseMsg(): Message { + return new Message({ + role: 'assistant', + content: [new ToolUseBlock({ toolUseId: 'tool-1', name: 'test', input: {} })], + }) +} + +function toolResultMsg(): Message { + return new Message({ + role: 'user', + content: [new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('result')] })], + }) +} + +describe('findValidSplitPoint', () => { + it('returns the initial split point when it is already valid', () => { + const messages = [textMsg('user', 'a'), textMsg('assistant', 'b'), textMsg('user', 'c')] + expect(findValidSplitPoint(messages, 1)).toBe(1) + }) + + it('skips past a toolResultBlock at the split point', () => { + const messages = [toolUseMsg(), toolResultMsg(), textMsg('user', 'next')] + expect(findValidSplitPoint(messages, 1)).toBe(2) + }) + + it('skips a toolUseBlock when next message is NOT a toolResult', () => { + const messages = [textMsg('user', 'a'), toolUseMsg(), textMsg('user', 'next')] + expect(findValidSplitPoint(messages, 1)).toBe(2) + }) + + it('keeps toolUseBlock when next message IS a toolResult', () => { + const messages = [textMsg('user', 'a'), toolUseMsg(), toolResultMsg(), textMsg('user', 'next')] + expect(findValidSplitPoint(messages, 1)).toBe(1) + }) + + it('returns -1 when no valid split point exists', () => { + const messages = [toolUseMsg(), toolResultMsg()] + expect(findValidSplitPoint(messages, 1)).toBe(-1) + }) + + it('returns the split point at the boundary', () => { + const messages = [textMsg('user', 'a')] + expect(findValidSplitPoint(messages, 0)).toBe(0) + }) +}) diff --git a/src/conversation-manager/index.ts b/src/conversation-manager/index.ts index 151160fe..92a0e877 100644 --- a/src/conversation-manager/index.ts +++ b/src/conversation-manager/index.ts @@ -10,3 +10,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/sliding-window-conversation-manager.ts b/src/conversation-manager/sliding-window-conversation-manager.ts index da22ddb3..485a3017 100644 --- a/src/conversation-manager/sliding-window-conversation-manager.ts +++ b/src/conversation-manager/sliding-window-conversation-manager.ts @@ -10,6 +10,7 @@ import type { LocalAgent } from '../types/agent.js' import { AfterInvocationEvent } from '../hooks/events.js' import { ConversationManager, type ConversationManagerReduceOptions } from './conversation-manager.js' import { logger } from '../logging/logger.js' +import { findValidSplitPoint } from './utils.js' /** * Configuration for the sliding window conversation manager. @@ -132,42 +133,13 @@ export class SlidingWindowConversationManager extends ConversationManager { // Try to trim messages when tool result cannot be truncated anymore // If the number of messages is less than the window_size, then we default to 2, otherwise, trim to window size - let trimIndex = messages.length <= this._windowSize ? 2 : messages.length - this._windowSize + const initialTrimIndex = messages.length <= this._windowSize ? 2 : messages.length - this._windowSize // Find the next valid trim_index - while (trimIndex < messages.length) { - const oldestMessage = messages[trimIndex] - if (!oldestMessage) { - break - } - - // Check if oldest message would be a toolResult (invalid - needs preceding toolUse) - const hasToolResult = oldestMessage.content.some((block) => block.type === 'toolResultBlock') - if (hasToolResult) { - trimIndex++ - continue - } - - // Check if oldest message would be a toolUse without immediately following toolResult - const hasToolUse = oldestMessage.content.some((block) => block.type === 'toolUseBlock') - if (hasToolUse) { - // Check if next message has toolResult - const nextMessage = messages[trimIndex + 1] - const nextHasToolResult = nextMessage && nextMessage.content.some((block) => block.type === 'toolResultBlock') - - if (!nextHasToolResult) { - // toolUse without following toolResult - invalid trim point - trimIndex++ - continue - } - } - - // Valid trim point found - break - } + const trimIndex = findValidSplitPoint(messages, initialTrimIndex) // If no valid trim point was found, return false and let the caller handle it - if (trimIndex >= messages.length) { + if (trimIndex < 0 || trimIndex >= messages.length) { logger.warn( `window_size=<${this._windowSize}>, messages=<${messages.length}> | unable to trim conversation context, no valid trim point found` ) diff --git a/src/conversation-manager/summarizing-conversation-manager.ts b/src/conversation-manager/summarizing-conversation-manager.ts new file mode 100644 index 00000000..cce87159 --- /dev/null +++ b/src/conversation-manager/summarizing-conversation-manager.ts @@ -0,0 +1,254 @@ +/** + * 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 { ConversationManager, type ConversationManagerReduceOptions } from './conversation-manager.js' +import { AfterModelCallEvent } from '../hooks/events.js' +import type { LocalAgent } from '../types/agent.js' +import { Message, TextBlock } from '../types/messages.js' +import type { StreamOptions } from '../models/model.js' +import { findValidSplitPoint } from './utils.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 extends ConversationManager { + readonly name = 'strands:summarizing-conversation-manager' + private readonly _summaryRatio: number + private readonly _preserveRecentMessages: number + private readonly _summarizationAgent?: Agent + private readonly _summarizationSystemPrompt?: string + + /** + * Initialize the summarizing conversation manager. + * + * @param config - Configuration options for the summarizing manager. + */ + constructor(config?: SummarizingConversationManagerConfig) { + super() + 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 + } + } + + /** + * Reduce context by summarizing older messages (sync check only). + * The actual async summarization is handled by initAgent's hook. + */ + reduce(_options: ConversationManagerReduceOptions): boolean { + // Summarization is async — handled by our custom initAgent hook + return false + } + + /** + * Initialize with the agent, registering an async overflow handler. + * Overrides the base class sync reduce with async summarization. + */ + initAgent(agent: LocalAgent): void { + agent.addHook(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. + */ + 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 = findValidSplitPoint(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) + + const summaryMessage = await this.generateSummary(messagesToSummarize, agent) + + agent.messages.splice(0, agent.messages.length, 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)) + } + + /** + * 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.') + // Summary injected as 'user' role for Python SDK parity and to ensure + // the model treats it as conversation context rather than its own output. + 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 + + // Summary injected as 'user' role for Python SDK parity and to ensure + // the model treats it as conversation context rather than its own output. + return new Message({ + role: 'user', + content: message.content, + }) + } +} diff --git a/src/conversation-manager/utils.ts b/src/conversation-manager/utils.ts new file mode 100644 index 00000000..7e2ad060 --- /dev/null +++ b/src/conversation-manager/utils.ts @@ -0,0 +1,45 @@ +/** + * Shared utilities for conversation managers. + */ + +import type { Message } from '../types/messages.js' + +/** + * Find a valid split point that doesn't break ToolUse/ToolResult pairs. + * + * Walks forward from the initial split point, skipping positions where: + * - The message starts with a toolResultBlock (orphaned result) + * - The message has a toolUseBlock without a paired toolResult following it + * + * @param messages - The full list of messages. + * @param initialSplitPoint - The starting split point to adjust from. + * @returns The adjusted split point, or -1 if no valid point exists. + */ +export function findValidSplitPoint(messages: Message[], initialSplitPoint: number): number { + let splitPoint = initialSplitPoint + + 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 + } + + return splitPoint >= messages.length ? -1 : splitPoint +} diff --git a/src/index.ts b/src/index.ts index a40fe89d..8c1a0bee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -212,6 +212,10 @@ export { SlidingWindowConversationManager, type SlidingWindowConversationManagerConfig, } from './conversation-manager/sliding-window-conversation-manager.js' +export { + SummarizingConversationManager, + type SummarizingConversationManagerConfig, +} from './conversation-manager/summarizing-conversation-manager.js' // Logging export { configureLogging } from './logging/logger.js'