diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts index 241f04108..0ec4ce49a 100644 --- a/src/QueryEngine.ts +++ b/src/QueryEngine.ts @@ -977,6 +977,15 @@ export class QueryEngine { uuid: msg.uuid, } } + // Proactive truncation: prevent unbounded growth when API doesn't + // return compact_boundary (e.g. third-party compat layers). + if (feature('HISTORY_SNIP') && snipModule) { + const truncated = snipModule.proactiveTruncate(this.mutableMessages) + if (truncated !== this.mutableMessages) { + this.mutableMessages.length = 0 + this.mutableMessages.push(...truncated) + } + } // Don't yield other system messages in headless mode break } diff --git a/src/services/compact/snipCompact.ts b/src/services/compact/snipCompact.ts index ecd72176e..3be2ef624 100644 --- a/src/services/compact/snipCompact.ts +++ b/src/services/compact/snipCompact.ts @@ -1,17 +1,239 @@ -// Auto-generated stub — replace with real implementation -export {}; +import type { Message } from 'src/types/message.js' -import type { Message } from 'src/types/message'; +/** + * Estimated characters per token (conservative for mixed code/text). + */ +const CHARS_PER_TOKEN = 4 -export const isSnipMarkerMessage: (message: Message) => boolean = () => false; -export const snipCompactIfNeeded: ( +/** + * Minimum message count before nudging the model to consider snipping. + */ +const SNIP_NUDGE_THRESHOLD = 30 + +/** + * Text shown to the model as a nudge when the conversation is long enough + * to benefit from snipping. + */ +export const SNIP_NUDGE_TEXT: string = + 'The conversation history is getting long. Consider using the /force-snip command or the snip tool to compress older messages, freeing context window space for continued work.' + +/** + * Check whether a message is an internal snip marker (not user-facing). + * Snip markers are system messages injected by the snip tool to track + * which messages have been registered for future removal. + */ +export function isSnipMarkerMessage(message: Message): boolean { + if (message.type !== 'system') return false + return (message as Record).subtype === 'snip_marker' +} + +/** + * Estimate the token count of a single message by serialising its content. + * This is a rough heuristic (~4 chars per token) used to report + * tokensFreed; it does not need to be exact. + */ +function estimateMessageTokens(message: Message): number { + const content = message.message?.content + let chars = 0 + if (typeof content === 'string') { + chars = content.length + } else if (Array.isArray(content)) { + for (const block of content) { + if (typeof block === 'string') { + chars += (block as string).length + } else if (block && typeof block === 'object') { + const obj = block as unknown as Record + const text = obj.text ?? obj.content + if (typeof text === 'string') { + chars += text.length + } else { + chars += JSON.stringify(block).length + } + } + } + } else if (content !== null && content !== undefined) { + chars = JSON.stringify(content).length + } + return Math.max(1, Math.ceil(chars / CHARS_PER_TOKEN)) +} + +/** + * Scan the message array for the last `snip_boundary` system message and, + * if found, remove all messages whose UUIDs appear in its + * `snipMetadata.removedUuids`. + * + * This is the core memory-saving function. When a snip boundary exists: + * 1. All messages listed in `removedUuids` are filtered out. + * 2. The boundary message itself is kept (it records what was removed). + * 3. Messages not in `removedUuids` (including post-boundary messages) + * are preserved. + * + * Called from: + * - `query.ts` — strips snipped messages from the model-facing array + * before sending to the API. + * - `QueryEngine.ts` `snipReplay` — trims `mutableMessages` so the + * in-memory store does not grow without bound in long SDK sessions. + * + * @param messages Full message array (may contain a snip_boundary). + * @param options `force` — if true, always execute when a boundary is + * present. Without `force`, the function still executes + * if a boundary is found (the "if needed" refers to + * whether a boundary exists, not a token threshold). + */ +export function snipCompactIfNeeded( messages: Message[], options?: { force?: boolean }, -) => { messages: Message[]; executed: boolean; tokensFreed: number; boundaryMessage?: Message } = (messages) => ({ - messages, - executed: false, - tokensFreed: 0, -}); -export const isSnipRuntimeEnabled: () => boolean = () => false; -export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false; -export const SNIP_NUDGE_TEXT: string = ''; +): { + messages: Message[] + executed: boolean + tokensFreed: number + boundaryMessage?: Message +} { + // Find the last snip_boundary message + let boundaryIdx = -1 + let removedUuids: string[] | undefined + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]! + if ( + msg.type === 'system' && + (msg as Record).subtype === 'snip_boundary' + ) { + boundaryIdx = i + const meta = (msg as Record).snipMetadata as + | { removedUuids?: string[] } + | undefined + removedUuids = meta?.removedUuids + break + } + } + + if (boundaryIdx === -1) { + return { messages, executed: false, tokensFreed: 0 } + } + + const boundaryMessage = messages[boundaryIdx]! + + // No removedUuids metadata — fallback: keep boundary + everything after + if (!removedUuids || removedUuids.length === 0) { + const kept = messages.slice(boundaryIdx) + return { + messages: kept, + executed: true, + tokensFreed: 0, + boundaryMessage, + } + } + + // Filter out messages whose UUIDs are listed in removedUuids + const removedSet = new Set(removedUuids) + const kept: Message[] = [] + let tokensFreed = 0 + + for (const msg of messages) { + if (removedSet.has(msg.uuid)) { + tokensFreed += estimateMessageTokens(msg) + continue + } + kept.push(msg) + } + + return { + messages: kept, + executed: true, + tokensFreed, + boundaryMessage, + } +} + +/** + * Returns true when the snip runtime is active. + * Because this module is only loaded when the HISTORY_SNIP feature flag + * is enabled, this always returns true. + */ +export function isSnipRuntimeEnabled(): boolean { + return true +} + +/** + * Determine whether the conversation is long enough to warrant a nudge + * to the model to consider snipping. Uses a simple message-count + * threshold rather than an expensive token count. + */ +export function shouldNudgeForSnips(messages: Message[]): boolean { + return messages.length >= SNIP_NUDGE_THRESHOLD +} + +/** + * Maximum total character length of message content before proactive + * truncation kicks in. ~150 MB of string data corresponds to roughly + * 1.5x the default 200k-token context window at 4 chars/token — well + * beyond what any model can actually use in a single request. + */ +const PROACTIVE_TRUNCATE_CHARS = 150_000_000 + +/** + * Minimum number of messages to keep when falling back to tail-only + * retention (i.e. when no compact_boundary exists in the array). + */ +const PROACTIVE_TRUNCATE_MIN_TAIL = 50 + +/** + * Proactively truncate old messages when the in-memory store grows too + * large. Unlike `snipCompactIfNeeded` (which waits for a snip_boundary + * from the API), this runs client-side after every push — ensuring + * unbounded growth cannot happen even when the API never returns a + * compact_boundary (e.g. third-party compat layers). + * + * Strategy: + * 1. If a `compact_boundary` exists, keep it and everything after it. + * 2. Otherwise, keep only the last `PROACTIVE_TRUNCATE_MIN_TAIL` messages. + * + * Returns the same array reference when no truncation is needed. + */ +export function proactiveTruncate(messages: Message[]): Message[] { + if (messages.length < PROACTIVE_TRUNCATE_MIN_TAIL) return messages + + let totalChars = 0 + for (const msg of messages) { + const content = msg.message?.content + if (typeof content === 'string') { + totalChars += content.length + } else if (Array.isArray(content)) { + for (const block of content) { + if (typeof block === 'string') { + totalChars += (block as string).length + } else if (block && typeof block === 'object') { + const obj = block as unknown as Record + const text = obj.text ?? obj.content + if (typeof text === 'string') { + totalChars += text.length + } + } + } + } + } + + if (totalChars < PROACTIVE_TRUNCATE_CHARS) return messages + + // Find last compact_boundary — the standard anchor point + let boundaryIdx = -1 + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]! + if ( + msg.type === 'system' && + (msg as Record).subtype === 'compact_boundary' + ) { + boundaryIdx = i + break + } + } + + const keepFrom = + boundaryIdx >= 0 + ? boundaryIdx + : Math.max(0, messages.length - PROACTIVE_TRUNCATE_MIN_TAIL) + if (keepFrom === 0) return messages + + return messages.slice(keepFrom) +} diff --git a/src/utils/telemetry/instrumentation.ts b/src/utils/telemetry/instrumentation.ts index 224f04815..936e009f3 100644 --- a/src/utils/telemetry/instrumentation.ts +++ b/src/utils/telemetry/instrumentation.ts @@ -205,10 +205,49 @@ async function getOtlpReaders() { return exporters.map(exporter => { if ('export' in exporter) { - return new PeriodicExportingMetricReader({ + const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: exportInterval, }) + // Wrap the export callback to auto-shutdown the reader on auth + // failures (401/403). Without this the PeriodicExportingMetricReader's + // internal setInterval keeps retrying forever, leaking handles. + const originalExport = ( + exporter as unknown as { + export: ( + metrics: unknown, + callback: (result: { error?: Error }) => void, + ) => unknown + } + ).export.bind(exporter) + ;( + exporter as unknown as { + export: ( + metrics: unknown, + callback: (result: { error?: Error }) => void, + ) => unknown + } + ).export = (metrics, callback) => { + return originalExport(metrics, result => { + if (result.error) { + const msg = result.error.message || '' + if ( + msg.includes('401') || + msg.includes('403') || + msg.includes('Unauthorized') || + msg.includes('authentication') + ) { + logForDebugging( + `[3P telemetry] Auth error detected, shutting down metric reader`, + { level: 'error' }, + ) + void reader.shutdown() + } + } + callback(result) + }) + } + return reader } return exporter })