Apply P0 telemetry memory guard

This commit is contained in:
James Feng 2026-06-01 20:35:08 +08:00
parent 042cf907c3
commit a8398413ea
3 changed files with 284 additions and 14 deletions

View File

@ -977,6 +977,15 @@ export class QueryEngine {
uuid: msg.uuid, 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 // Don't yield other system messages in headless mode
break break
} }

View File

@ -1,17 +1,239 @@
// Auto-generated stub — replace with real implementation import type { Message } from 'src/types/message.js'
export {};
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<string, unknown>).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<string, unknown>
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[], messages: Message[],
options?: { force?: boolean }, options?: { force?: boolean },
) => { messages: Message[]; executed: boolean; tokensFreed: number; boundaryMessage?: Message } = (messages) => ({ ): {
messages, messages: Message[]
executed: false, 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<string, unknown>).subtype === 'snip_boundary'
) {
boundaryIdx = i
const meta = (msg as Record<string, unknown>).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, tokensFreed: 0,
}); boundaryMessage,
export const isSnipRuntimeEnabled: () => boolean = () => false; }
export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false; }
export const SNIP_NUDGE_TEXT: string = '';
// 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<string, unknown>
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<string, unknown>).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)
}

View File

@ -205,10 +205,49 @@ async function getOtlpReaders() {
return exporters.map(exporter => { return exporters.map(exporter => {
if ('export' in exporter) { if ('export' in exporter) {
return new PeriodicExportingMetricReader({ const reader = new PeriodicExportingMetricReader({
exporter, exporter,
exportIntervalMillis: exportInterval, 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 return exporter
}) })