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