545 lines
16 KiB
JavaScript
545 lines
16 KiB
JavaScript
/**
|
|
* 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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (tc).function
|
|
if (fn && typeof fn === 'object') {
|
|
const n = /** @type {Record<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} cfg
|
|
* @param {Record<string, string>} [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
|
|
}
|
|
}
|