/** * Advanced context-window compaction for /bin/agent. * Tiered rollover: shrink fat tool payloads → digest older turns → * rolling summary message → hard trim fallback. */ /** * @param {unknown} value */ function bareAgentCompactEstimateTokens(value) { if (typeof bareAgentEstimateTokens === 'function') { return bareAgentEstimateTokens(value) } try { return Math.max(0, Math.ceil(JSON.stringify(value == null ? '' : value).length / 4)) } catch { return 0 } } /** * @param {unknown} msg * @returns {string} */ function bareAgentMessagePlainText(msg) { if (!msg || typeof msg !== 'object') return '' const m = /** @type {Record} */ (msg) const c = m.content if (typeof c === 'string') return c if (Array.isArray(c)) { /** @type {string[]} */ const parts = [] for (const part of c) { if (typeof part === 'string') parts.push(part) else if (part && typeof part === 'object') { const p = /** @type {Record} */ (part) if (typeof p.text === 'string') parts.push(p.text) else if (typeof p.content === 'string') parts.push(p.content) } } return parts.join('\n') } if (m.tool_calls && Array.isArray(m.tool_calls)) { /** @type {string[]} */ const names = [] for (const tc of m.tool_calls) { if (!tc || typeof tc !== 'object') continue const fn = /** @type {Record} */ (tc).function if (fn && typeof fn === 'object') { const n = /** @type {Record} */ (fn).name if (typeof n === 'string' && n) names.push(n) } } if (names.length) return 'tool_calls: ' + names.join(', ') } return '' } /** * @param {string} s * @param {number} max */ function bareAgentClipText(s, max) { const t = String(s || '').replace(/\s+/g, ' ').trim() if (t.length <= max) return t if (max < 24) return t.slice(0, max) const head = Math.floor(max * 0.62) const tail = Math.max(8, max - head - 5) return t.slice(0, head) + ' … ' + t.slice(-tail) } /** * Shrink oversized tool / assistant payloads in-place (clone). * @param {unknown[]} msgs * @param {number} toolMaxChars * @returns {unknown[]} */ function bareAgentShrinkFatMessages(msgs, toolMaxChars) { const max = Math.max(240, Math.floor(toolMaxChars || 1600)) /** @type {unknown[]} */ const out = [] for (const msg of msgs) { if (!msg || typeof msg !== 'object') { out.push(msg) continue } const m = /** @type {Record} */ (msg) const role = String(m.role || '') if (role === 'tool' && typeof m.content === 'string' && m.content.length > max) { out.push({ ...m, content: bareAgentClipText(m.content, max) + '\n[tool output compacted ' + String(m.content.length) + '→' + String(max) + ' chars]' }) continue } if ( (role === 'assistant' || role === 'user') && typeof m.content === 'string' && m.content.length > max * 4 ) { out.push({ ...m, content: bareAgentClipText(m.content, max * 4) + '\n[message compacted]' }) continue } out.push(msg) } return out } /** * Group chat into [system?] + turn groups (user | assistant+tools*). * @param {unknown[]} msgs * @returns {{ system: unknown | null, groups: unknown[][] }} */ function bareAgentGroupMessageTurns(msgs) { /** @type {unknown | null} */ let system = null /** @type {unknown[][]} */ const groups = [] /** @type {unknown[]} */ let cur = [] function flush() { if (cur.length) { groups.push(cur) cur = [] } } for (const msg of Array.isArray(msgs) ? msgs : []) { if (!msg || typeof msg !== 'object') continue const role = String(/** @type {Record} */ (msg).role || '') if (role === 'system' && system == null && groups.length === 0 && !cur.length) { system = msg continue } if (role === 'user') { flush() cur = [msg] continue } if (role === 'assistant' || role === 'tool') { if (!cur.length) cur = [msg] else cur.push(msg) continue } // unknown roles — attach to current or solo group if (!cur.length) cur = [msg] else cur.push(msg) } flush() return { system, groups } } /** * True if message is a prior compaction summary we injected. * @param {unknown} msg */ function bareAgentIsCompactionMessage(msg) { if (!msg || typeof msg !== 'object') return false const m = /** @type {Record} */ (msg) if (m.bare_os_compaction === true) return true const c = typeof m.content === 'string' ? m.content : '' return ( String(m.role || '') === 'user' && /^\[context compaction\]/i.test(c.trim()) ) } /** * One-line digest for a message (extractive). * @param {unknown} msg * @param {number} max */ function bareAgentDigestMessage(msg, max) { if (!msg || typeof msg !== 'object') return '' const m = /** @type {Record} */ (msg) const role = String(m.role || '?') const text = bareAgentMessagePlainText(m) if (role === 'tool') { const name = typeof m.name === 'string' ? m.name : typeof m.tool_call_id === 'string' ? m.tool_call_id.slice(0, 12) : 'tool' return '- tool:' + name + ' ' + bareAgentClipText(text, Math.max(40, max - 24)) } if (role === 'assistant') { return '- assistant ' + bareAgentClipText(text || '(tools only)', max) } if (role === 'user') { if (bareAgentIsCompactionMessage(m)) { return '- prior-compaction ' + bareAgentClipText(text.replace(/^\[context compaction\][^\n]*\n?/i, ''), max) } return '- user ' + bareAgentClipText(text, max) } return '- ' + role + ' ' + bareAgentClipText(text, max) } /** * Build a rolling summary from older turn groups. * @param {unknown[][]} groups * @param {{ maxChars?: number, perMsg?: number }} [opts] */ function bareAgentBuildCompactionSummary(groups, opts) { const maxChars = Math.max(400, Math.floor((opts && opts.maxChars) || 3500)) const perMsg = Math.max(60, Math.floor((opts && opts.perMsg) || 180)) /** @type {string[]} */ const lines = [ '[context compaction] Older turns were summarized to fit the model context window. Preserve goals, paths, errors, and decisions below.', '' ] let used = lines.join('\n').length let kept = 0 for (const g of groups) { /** @type {string[]} */ const block = [] for (const msg of g) { const d = bareAgentDigestMessage(msg, perMsg) if (d) block.push(d) } if (!block.length) continue const chunk = block.join('\n') + '\n' if (used + chunk.length > maxChars && kept > 0) { lines.push('… (' + String(groups.length - kept) + ' older turns omitted)') break } lines.push(chunk.trimEnd()) used += chunk.length kept++ } lines.push('') lines.push( 'Continue from the recent messages after this summary. Prefer tools over guessing.' ) let body = lines.join('\n') if (body.length > maxChars) body = body.slice(0, maxChars - 20) + '\n… truncated' return body } /** * @param {unknown[]} msgs * @param {number} ctxSize * @param {{ * tools?: unknown[], * reserveCompletion?: number, * keepRecent?: number, * toolMaxChars?: number, * summaryMaxChars?: number, * softRatio?: number, * mode?: 'auto' | 'off' | 'aggressive' * }} [opts] * @returns {{ * messages: unknown[], * meta: { * compacted: boolean, * mode: string, * beforeTokens: number, * afterTokens: number, * budget: number, * droppedGroups: number, * tiers: string[] * } * }} */ function bareAgentCompactMessagesForCtx(msgs, ctxSize, opts) { const modeRaw = String((opts && opts.mode) || 'auto').trim().toLowerCase() const mode = modeRaw === 'off' || modeRaw === 'aggressive' ? modeRaw : 'auto' /** @type {string[]} */ const tiers = [] const ctx = Math.max(2048, Math.floor(Number(ctxSize) || 4096)) const reserve = opts && Number.isFinite(Number(opts.reserveCompletion)) ? Math.max(256, Math.floor(Number(opts.reserveCompletion))) : Math.min(2048, Math.max(256, Math.floor(ctx * 0.12))) const toolsTok = bareAgentCompactEstimateTokens( opts && Array.isArray(opts.tools) && opts.tools.length ? opts.tools : [] ) const budget = Math.max(512, ctx - reserve - toolsTok) const softRatio = mode === 'aggressive' ? 0.7 : Math.min(0.95, Math.max(0.55, Number(opts && opts.softRatio) || 0.82)) const softBudget = Math.floor(budget * softRatio) const keepRecentDefault = mode === 'aggressive' ? 4 : 8 let keepRecent = Math.max( 2, Math.floor( Number(opts && opts.keepRecent) > 0 ? Number(opts.keepRecent) : keepRecentDefault ) ) const toolMax = mode === 'aggressive' ? Math.min(900, Number(opts && opts.toolMaxChars) || 900) : Math.max(400, Number(opts && opts.toolMaxChars) || 1600) const summaryMax = mode === 'aggressive' ? Math.min(2200, Number(opts && opts.summaryMaxChars) || 2200) : Math.max(800, Number(opts && opts.summaryMaxChars) || 3500) const input = Array.isArray(msgs) ? msgs.slice() : [] const beforeTokens = bareAgentCompactEstimateTokens(input) if (mode === 'off') { const trimmed = typeof bareAgentTrimMessagesForCtx === 'function' ? bareAgentTrimMessagesForCtx(input, ctxSize, opts) : input return { messages: trimmed, meta: { compacted: false, mode, beforeTokens, afterTokens: bareAgentCompactEstimateTokens(trimmed), budget, droppedGroups: 0, tiers: ['off'] } } } /** @type {unknown[]} */ let out = bareAgentShrinkFatMessages(input, toolMax) if (out !== input && bareAgentCompactEstimateTokens(out) < beforeTokens) { tiers.push('shrink-tools') } // Already fits soft budget — keep light shrink only. if (bareAgentCompactEstimateTokens(out) <= softBudget) { return { messages: out, meta: { compacted: tiers.length > 0, mode, beforeTokens, afterTokens: bareAgentCompactEstimateTokens(out), budget, droppedGroups: 0, tiers: tiers.length ? tiers : ['noop'] } } } // Merge prior compaction blobs in the middle into the new summary. const { system, groups } = bareAgentGroupMessageTurns(out) let droppedGroups = 0 if (groups.length > keepRecent) { const older = groups.slice(0, Math.max(0, groups.length - keepRecent)) const recent = groups.slice(Math.max(0, groups.length - keepRecent)) // Drop nested prior compaction-only groups from "older" but fold text in. /** @type {unknown[][]} */ const olderFlat = [] for (const g of older) { if (g.length === 1 && bareAgentIsCompactionMessage(g[0])) { olderFlat.push(g) } else olderFlat.push(g) } const summaryText = bareAgentBuildCompactionSummary(olderFlat, { maxChars: summaryMax, perMsg: mode === 'aggressive' ? 120 : 180 }) const summaryMsg = { role: 'user', content: summaryText, bare_os_compaction: true } /** @type {unknown[]} */ const next = [] if (system) next.push(system) next.push(summaryMsg) for (const g of recent) for (const m of g) next.push(m) out = next droppedGroups = older.length tiers.push('rolling-summary') } // Still over: reduce keepRecent iteratively. let guard = 0 while ( bareAgentCompactEstimateTokens(out) > budget && keepRecent > 2 && guard < 8 ) { keepRecent-- guard++ const again = bareAgentGroupMessageTurns(out) // Re-split: system + optional summary + recent /** @type {unknown[][]} */ const gs = again.groups // Find existing summary at start of groups let start = 0 if (gs[0] && gs[0].length === 1 && bareAgentIsCompactionMessage(gs[0][0])) { start = 1 } const body = gs.slice(start) if (body.length <= keepRecent) break const older = body.slice(0, body.length - keepRecent) const recent = body.slice(body.length - keepRecent) const priorSummary = start === 1 && gs[0] && gs[0][0] ? bareAgentMessagePlainText(gs[0][0]) : '' const folded = priorSummary ? [ [ { role: 'user', content: priorSummary, bare_os_compaction: true } ], ...older ] : older const summaryText = bareAgentBuildCompactionSummary(folded, { maxChars: summaryMax, perMsg: 120 }) /** @type {unknown[]} */ const next = [] if (again.system) next.push(again.system) next.push({ role: 'user', content: summaryText, bare_os_compaction: true }) for (const g of recent) for (const m of g) next.push(m) out = next droppedGroups += older.length tiers.push('tighten-keep=' + String(keepRecent)) } // Hard fallback if (bareAgentCompactEstimateTokens(out) > budget) { tiers.push('hard-trim') out = typeof bareAgentTrimMessagesForCtx === 'function' ? bareAgentTrimMessagesForCtx(out, ctxSize, opts) : out } const afterTokens = bareAgentCompactEstimateTokens(out) return { messages: out, meta: { compacted: tiers.some((t) => t !== 'noop'), mode, beforeTokens, afterTokens, budget, droppedGroups, tiers } } } /** * Disk history compaction: shrink tools + optional rolling summary when huge. * @param {unknown[]} msgs * @param {number} maxBytes * @param {{ keepRecent?: number }} [opts] */ function bareAgentCompactMessagesForDisk(msgs, maxBytes, opts) { const cap = Math.max(20_000, Math.floor(maxBytes || 500_000)) let out = bareAgentShrinkFatMessages( Array.isArray(msgs) ? msgs : [], 2400 ) if (JSON.stringify(out).length <= cap) return out // Approximate tokens from byte budget (chars/4). const approxCtx = Math.max(4096, Math.floor(cap / 4)) const packed = bareAgentCompactMessagesForCtx(out, approxCtx, { mode: 'aggressive', keepRecent: (opts && opts.keepRecent) || 12, reserveCompletion: 256, toolMaxChars: 1200, summaryMaxChars: 6000 }) out = packed.messages if ( typeof bareAgentTrimMessages === 'function' && JSON.stringify(out).length > cap ) { out = bareAgentTrimMessages(out, cap) } return out } /** * Resolve compaction settings from agent config + env. * @param {Record} cfg * @param {Record} [env] */ function bareAgentCompactionSettings(cfg, env) { const e = env && typeof env === 'object' ? env : {} const envMode = String(e.BARE_OS_AGENT_COMPACTION || '').trim().toLowerCase() const cfgMode = String( (cfg && cfg.context_compaction) || '' ) .trim() .toLowerCase() let mode = envMode || cfgMode || 'auto' if (mode !== 'off' && mode !== 'aggressive' && mode !== 'auto') mode = 'auto' const keepRecent = Number( e.BARE_OS_AGENT_COMPACTION_KEEP || (cfg && cfg.compaction_keep_recent) || 0 ) const toolMaxChars = Number( e.BARE_OS_AGENT_COMPACTION_TOOL_CHARS || (cfg && cfg.compaction_tool_chars) || 0 ) return { mode: /** @type {'auto'|'off'|'aggressive'} */ (mode), keepRecent: Number.isFinite(keepRecent) && keepRecent > 0 ? Math.min(32, Math.floor(keepRecent)) : undefined, toolMaxChars: Number.isFinite(toolMaxChars) && toolMaxChars > 0 ? Math.min(8000, Math.floor(toolMaxChars)) : undefined } }