/** * Context Pressure Utilities * * Pure functions for context overflow management. These handle: * 1. Multi-document detection — counting attached documents in messages * 2. Multi-document delegation hint — injected when 3+ documents detected * 3. Post-prune context note — injected after pruning/summarization * * DESIGN PRINCIPLE: The LLM never sees raw token numbers. Context overflow * is handled mechanically by pruning (Graph) + auto-continuation (client.js). * Only task-driven hints (multi-document) are injected — never budget-based. * * @see docs/context-overflow-architecture.md */ import type { BaseMessage } from '@langchain/core/messages'; import { MULTI_DOCUMENT_THRESHOLD } from '@/common/constants'; /** Result of scanning messages for attached documents */ export interface DocumentDetectionResult { /** Total unique documents detected */ count: number; /** Names of detected documents */ names: string[]; } /** * Scan messages for attached documents using known content patterns. * * Detects documents from: * 1. `# "filename"` headers in "Attached document(s):" blocks (text content) * 2. `**filename1, filename2**` in "The user has attached:" blocks (embedded files) * * @param messages - Conversation messages to scan * @returns Document count and names (deduplicated) */ export function detectDocuments( messages: BaseMessage[] ): DocumentDetectionResult { const documentNames: string[] = []; for (const msg of messages) { const content = extractTextContent(msg); // Pattern 1: # "filename" headers in attached document blocks const docMatches = content.match(/# "([^"]+)"/g); if (docMatches) { for (const match of docMatches) { const name = match.replace(/# "/, '').replace(/"$/, ''); if (!documentNames.includes(name)) { documentNames.push(name); } } } // Pattern 2: "The user has attached: **file1, file2**" (embedded files) const attachedMatch = content.match( /user has attached:\s*\*\*([^*]+)\*\*/i ); if (attachedMatch) { const names = attachedMatch[1] .split(',') .map((n: string) => n.trim()) .filter(Boolean); for (const name of names) { if (!documentNames.includes(name)) { documentNames.push(name); } } } } return { count: documentNames.length, names: documentNames }; } /** * Determine whether the multi-document delegation hint should be injected. * * Only fires on the first iteration (before any AI response) when the * document count meets the threshold. This ensures the agent delegates * upfront rather than trying to process all documents itself. * * @param documentCount - Number of detected documents * @param hasAiResponse - Whether the agent has already responded in this chain * @returns Whether to inject the delegation hint */ export function shouldInjectMultiDocHint( documentCount: number, hasAiResponse: boolean ): boolean { return documentCount >= MULTI_DOCUMENT_THRESHOLD && !hasAiResponse; } /** * Build the multi-document delegation hint message content. * * @param documentCount - Number of detected documents * @param documentNames - Names of detected documents * @returns Message content string for injection as HumanMessage */ export function buildMultiDocHintContent( documentCount: number, documentNames: string[] ): string { return ( `[MULTI-DOCUMENT PROCESSING — ${documentCount} documents detected]\n` + `Documents: ${documentNames.join(', ')}\n\n` + `You have ${documentCount} documents attached. For thorough analysis, use the "task" tool ` + 'to delegate each document (or group of related documents) to a sub-agent.\n' + 'Each sub-agent has its own fresh context window and can use file_search to retrieve the full document content.\n' + 'After all sub-agents complete, synthesize their results into a comprehensive response.\n\n' + 'This approach ensures each document gets full attention without context limitations.' ); } /** * Build the post-prune context note injected after messages are pruned * and summarized. No token numbers — just a contextual signal that * earlier conversation was compressed. * * @param discardedCount - Number of messages that were pruned * @param hasSummary - Whether a summary was successfully generated * @returns Message content string for injection as SystemMessage, or null if no note needed */ export function buildPostPruneNote( discardedCount: number, hasSummary: boolean ): string | null { if (discardedCount <= 0) { return null; } if (hasSummary) { return ( '[Context Compressed] Earlier conversation messages have been summarized above. ' + 'For complex remaining work that requires deep analysis, consider delegating to ' + 'sub-agents using the "task" tool — each gets a fresh context window.' ); } return ( '[Context Compressed] Some earlier conversation messages were removed to maintain context capacity. ' + 'For complex remaining work, consider delegating to sub-agents using the "task" tool.' ); } /** * Check whether a tool named "task" exists in the agent's tool set. * * @param tools - Array of tool objects or structured tools * @returns Whether the task tool is available */ export function hasTaskTool( tools: Array<{ name?: string } | unknown> | undefined ): boolean { if (!tools) { return false; } return tools.some((tool) => { const toolName = typeof tool === 'object' && tool !== null && 'name' in tool ? (tool as { name: string }).name : ''; return toolName === 'task'; }); } /** * Extract text content from a BaseMessage, handling both string and * array content formats. * * @param msg - A LangChain BaseMessage * @returns Flattened text content */ function extractTextContent(msg: BaseMessage): string { if (typeof msg.content === 'string') { return msg.content; } if (Array.isArray(msg.content)) { return msg.content .map((p: unknown) => { const part = p as Record; return String(part.text ?? part.content ?? ''); }) .join(' '); } return ''; }