Apply P0 telemetry memory guard
This commit is contained in:
parent
042cf907c3
commit
a8398413ea
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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[],
|
||||
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<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,
|
||||
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<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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user