← vibesdk
worker/agents/utils/conversationCompactifier.ts
11.1 KB
Download file
import { ConversationMessage, MessageRole, createUserMessage } from "../inferutils/common";
import { executeInference } from "../inferutils/infer";
import { StructuredLogger } from "../../logger";
import { IdGenerator } from './idGenerator';
import { OperationOptions } from "../operations/common";
import type { ChatCompletionMessageFunctionToolCall } from 'openai/resources';
/**
* Compactification configuration constants
*/
export const COMPACTIFICATION_CONFIG = {
MAX_TURNS: 40, // Trigger after 40 conversation turns
MAX_ESTIMATED_TOKENS: 100000,
PRESERVE_RECENT_MESSAGES: 10, // Always keep last 10 messages uncompacted
CHARS_PER_TOKEN: 4, // Rough estimation: 1 token ≈ 4 characters
} as const;
/**
* Tool call renderer type for UI feedback during compactification
* Compatible with RenderToolCall from UserConversationProcessor
*/
export type CompactificationRenderer = (args: {
name: string;
status: 'start' | 'success' | 'error';
args?: Record<string, unknown>;
result?: string;
}) => void;
/**
* Count conversation turns (user message to next user message)
*/
function countTurns(messages: ConversationMessage[]): number {
return messages.filter(m => m.role === 'user').length;
}
/**
* Convert character count to estimated token count
*/
function tokensFromChars(chars: number): number {
return Math.ceil(chars / COMPACTIFICATION_CONFIG.CHARS_PER_TOKEN);
}
/**
* Remove system context tags from message content
*/
function stripSystemContext(text: string): string {
return text.replace(/<system_context>[\s\S]*?<\/system_context>\n?/gi, '').trim();
}
/**
* Estimate token count for messages (4 chars ≈ 1 token)
*/
function estimateTokens(messages: ConversationMessage[]): number {
let totalChars = 0;
for (const msg of messages) {
if (typeof msg.content === 'string') {
totalChars += msg.content.length;
} else if (Array.isArray(msg.content)) {
// Multi-modal content
for (const part of msg.content) {
if (part.type === 'text') {
totalChars += part.text.length;
} else if (part.type === 'image_url') {
// Images use ~1000 tokens each (approximate)
totalChars += 4000;
}
}
}
// Account for tool calls
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls as ChatCompletionMessageFunctionToolCall[]) {
// Function name
if (tc.function?.name) {
totalChars += tc.function.name.length;
}
// Function arguments (JSON string)
if (tc.function?.arguments) {
totalChars += tc.function.arguments.length;
}
// Tool call structure overhead (id, type, etc.) - rough estimate
totalChars += 50;
}
}
}
return tokensFromChars(totalChars);
}
/**
* Check if compactification should be triggered
*/
export function shouldCompactify(messages: ConversationMessage[]): {
should: boolean;
reason?: 'turns' | 'tokens';
turns: number;
estimatedTokens: number;
} {
const turns = countTurns(messages);
const estimatedTokens = estimateTokens(messages);
console.log(`[ConversationCompactifier] shouldCompactify: turns=${turns}, estimatedTokens=${estimatedTokens}`);
if (turns >= COMPACTIFICATION_CONFIG.MAX_TURNS) {
return { should: true, reason: 'turns', turns, estimatedTokens };
}
if (estimatedTokens >= COMPACTIFICATION_CONFIG.MAX_ESTIMATED_TOKENS) {
return { should: true, reason: 'tokens', turns, estimatedTokens };
}
return { should: false, turns, estimatedTokens };
}
/**
* Find the last valid turn boundary before the preserve threshold
* A turn boundary is right before a user message
*/
function findTurnBoundary(messages: ConversationMessage[], preserveCount: number): number {
// Start from the point where we want to split
const targetSplitIndex = messages.length - preserveCount;
if (targetSplitIndex <= 0) {
return 0;
}
// Walk backwards to find the nearest user message boundary
for (let i = targetSplitIndex; i >= 0; i--) {
if (messages[i].role === 'user') {
// Split right before this user message to preserve turn integrity
return i;
}
}
// If no user message found, don't split
return 0;
}
/**
* Generate LLM-powered conversation summary
* Sends the full conversation history as-is to the LLM with a summarization instruction
*/
async function generateConversationSummary(
messages: ConversationMessage[],
env: Env,
options: OperationOptions,
logger: StructuredLogger
): Promise<string> {
try {
// Prepare summarization instruction
const summarizationInstruction = createUserMessage(
`Please provide a comprehensive summary of the entire conversation above. Your summary should:
1. Capture the key features, changes, and fixes discussed
2. Note any recurring issues or important bugs mentioned
3. Highlight the current state of the project
4. Preserve critical technical details and decisions made
5. Maintain chronological flow of major changes and developments
Format your summary as a cohesive, well-structured narrative. Focus on what matters for understanding the project's evolution and current state.
Provide the summary now:`
);
logger.info('Generating conversation summary via LLM', {
messageCount: messages.length,
estimatedInputTokens: estimateTokens(messages)
});
// Send full conversation history + summarization request
const summaryResult = await executeInference({
env,
messages: [...messages, summarizationInstruction],
agentActionName: 'conversationalResponse',
context: options.inferenceContext,
});
const summary = summaryResult.string.trim();
logger.info('Generated conversation summary', {
summaryLength: summary.length,
summaryTokens: tokensFromChars(summary.length)
});
return summary;
} catch (error) {
logger.error('Failed to generate conversation summary', { error });
// Fallback to simple concatenation
return messages
.map(m => {
const content = typeof m.content === 'string' ? m.content : '[complex content]';
return `${m.role}: ${stripSystemContext(content).substring(0, 200)}`;
})
.join('\n')
.substring(0, 2000);
}
}
/**
* Intelligent conversation compactification system
*
* Strategy:
* - Monitors turns (user message to user message) and token count
* - Triggers at 40 turns OR ~100k tokens
* - Uses LLM to generate intelligent summary
* - Preserves last 10 messages in full
* - Respects turn boundaries to avoid tool call fragmentation
*/
export async function compactifyContext(
runningHistory: ConversationMessage[],
env: Env,
options: OperationOptions,
toolCallRenderer: CompactificationRenderer,
logger: StructuredLogger
): Promise<ConversationMessage[]> {
try {
// Check if compactification is needed on the running history
const analysis = shouldCompactify(runningHistory);
if (!analysis.should) {
// No compactification needed
return runningHistory;
}
logger.info('Compactification triggered', {
reason: analysis.reason,
turns: analysis.turns,
estimatedTokens: analysis.estimatedTokens,
totalRunningMessages: runningHistory.length,
});
// Find turn boundary for splitting
const splitIndex = findTurnBoundary(
runningHistory,
COMPACTIFICATION_CONFIG.PRESERVE_RECENT_MESSAGES
);
// Safety check: ensure we have something to compactify
if (splitIndex <= 0) {
logger.warn('Cannot find valid turn boundary for compactification, preserving all messages');
return runningHistory;
}
// Split messages
const messagesToSummarize = runningHistory.slice(0, splitIndex);
const recentMessages = runningHistory.slice(splitIndex);
logger.info('Compactification split determined', {
summarizeCount: messagesToSummarize.length,
preserveCount: recentMessages.length,
splitIndex
});
toolCallRenderer({
name: 'summarize_history',
status: 'start',
args: {
messageCount: messagesToSummarize.length,
recentCount: recentMessages.length
}
});
// Generate LLM-powered summary
const summary = await generateConversationSummary(
messagesToSummarize,
env,
options,
logger
);
// Create summary message - its conversationId will be the archive ID
const summarizedTurns = countTurns(messagesToSummarize);
const archiveId = `archive-${Date.now()}-${IdGenerator.generateConversationId()}`;
const summaryMessage: ConversationMessage = {
role: 'assistant' as MessageRole,
content: `[Conversation History Summary: ${messagesToSummarize.length} messages, ${summarizedTurns} turns]\n[Archive ID: ${archiveId}]\n\n${summary}`,
conversationId: archiveId
};
toolCallRenderer({
name: 'summarize_history',
status: 'success',
args: {
summary: summary.substring(0, 200) + '...',
archiveId
}
});
// Return summary + recent messages
const compactifiedHistory = [summaryMessage, ...recentMessages];
logger.info('Compactification completed with archival', {
originalMessageCount: runningHistory.length,
newMessageCount: compactifiedHistory.length,
compressionRatio: (compactifiedHistory.length / runningHistory.length).toFixed(2),
estimatedTokenSavings: analysis.estimatedTokens - estimateTokens(compactifiedHistory),
archivedMessageCount: messagesToSummarize.length,
archiveId
});
return compactifiedHistory;
} catch (error) {
logger.error('Compactification failed, preserving original messages', { error });
// Safe fallback: if we have too many messages, keep recent ones
if (runningHistory.length > COMPACTIFICATION_CONFIG.PRESERVE_RECENT_MESSAGES * 3) {
const fallbackCount = COMPACTIFICATION_CONFIG.PRESERVE_RECENT_MESSAGES * 2;
logger.warn(`Applying emergency fallback: keeping last ${fallbackCount} messages`);
return runningHistory.slice(-fallbackCount);
}
return runningHistory;
}
}