Files
2026-07-13 12:20:32 +08:00

663 lines
20 KiB
JavaScript

/**
* Context Optimizer — message pruning + BM25 tool selection
*
* Sits between the pi agent and the LLM to keep the context window
* lean for local models with limited capacity.
*/
// ── BM25 Tool Selector ──────────────────────────────────────────────
const BM25_K1 = 1.2;
const BM25_B = 0.75;
function tokenize(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9]/g, " ")
.split(/\s+/)
.filter((t) => t.length > 1);
}
function toolToText(tool) {
const fn = tool.function || tool;
const parts = [fn.name || "", fn.description || ""];
if (fn.parameters?.properties) {
for (const [key, prop] of Object.entries(fn.parameters.properties)) {
parts.push(key);
if (prop.description) parts.push(prop.description);
}
}
return parts.join(" ");
}
const CORE_TOOLS = new Set([
"bash",
"read",
"write",
"edit",
"ask_user",
"save_deliverable",
]);
/**
* Select the most relevant tools for the current query using BM25 scoring.
*
* @param {string} query - The user's current message/prompt
* @param {Object[]} tools - Full tool array from the chat completions request
* @param {Object} opts
* @param {number} opts.topN - Max tools to return (default 12)
* @param {string[]} opts.alwaysInclude - Tool names that are always kept
* @returns {Object[]} Filtered tool array
*/
function selectTools(query, tools, opts = {}) {
const {
topN = parseInt(process.env.TOOL_SELECT_TOP_N) || 12,
alwaysInclude = [...CORE_TOOLS],
} = opts;
if (!tools || tools.length <= topN) return tools;
if (!query) return tools;
const docs = tools.map((t) => tokenize(toolToText(t)));
const N = docs.length;
const avgDl = docs.reduce((s, d) => s + d.length, 0) / N;
// Document frequency
const df = {};
for (const doc of docs) {
const seen = new Set(doc);
for (const term of seen) {
df[term] = (df[term] || 0) + 1;
}
}
const queryTerms = tokenize(query);
const scores = docs.map((doc, i) => {
const tf = {};
for (const term of doc) tf[term] = (tf[term] || 0) + 1;
let score = 0;
for (const term of queryTerms) {
if (!tf[term]) continue;
const idf = Math.log(
(N - (df[term] || 0) + 0.5) / ((df[term] || 0) + 0.5) + 1,
);
const tfNorm =
(tf[term] * (BM25_K1 + 1)) /
(tf[term] + BM25_K1 * (1 - BM25_B + (BM25_B * doc.length) / avgDl));
score += idf * tfNorm;
}
return { index: i, score };
});
scores.sort((a, b) => b.score - a.score);
const selected = new Set();
const alwaysSet = new Set(alwaysInclude.map((n) => n.toLowerCase()));
// Always include core tools
for (let i = 0; i < tools.length; i++) {
const name = (tools[i].function?.name || tools[i].name || "").toLowerCase();
if (alwaysSet.has(name)) selected.add(i);
}
// Fill remaining slots with highest-scoring tools
for (const { index } of scores) {
if (selected.size >= topN) break;
selected.add(index);
}
const result = [...selected].sort((a, b) => a - b).map((i) => tools[i]);
const kept = result.map(
(t) => t.function?.name || t.name || "?",
);
console.log(
`[tool-select] ${tools.length}${result.length} tools: ${kept.join(", ")}`,
);
return result;
}
// ── Message Pruner ───────────────────────────────────────────────────
const FAILURE_PATTERNS = [
"(not found)",
"not found",
"no element found",
"no tab matching",
"econnrefused",
"no browser pages",
];
function isFailedToolResult(content) {
if (!content) return false;
const text = (typeof content === "string" ? content : "").toLowerCase();
return FAILURE_PATTERNS.some((p) => text.includes(p));
}
function isRedundantPageState(msg, prevStates) {
if (msg.role !== "assistant" || !msg.tool_calls) return false;
for (const tc of msg.tool_calls) {
const name = tc.function?.name || "";
if (name === "page_state" || name === "page_read") {
const args = tc.function?.arguments || "{}";
const key = `${name}:${args}`;
if (prevStates.has(key)) return true;
prevStates.add(key);
}
}
return false;
}
/**
* Estimate token count from a message array (rough: 1 token ≈ 4 chars).
*/
function estimateMessageTokens(messages) {
let chars = 0;
for (const msg of messages) {
if (typeof msg.content === "string") {
chars += msg.content.length;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.text) chars += part.text.length;
}
}
if (msg.tool_calls) {
chars += JSON.stringify(msg.tool_calls).length;
}
}
return Math.ceil(chars / 4);
}
/**
* Build a tool-call signature from an assistant message's tool_calls.
* Returns a string like "page_click:{...}|page_state:{...}" that can
* be compared for equality to detect repeating cycles.
*/
function toolCallSignature(msg) {
if (!msg.tool_calls || msg.tool_calls.length === 0) return null;
return msg.tool_calls
.map((tc) => `${tc.function?.name || "?"}:${tc.function?.arguments || ""}`)
.join("|");
}
/**
* Collapse repeating tool-call cycles in a message array.
* When a sequence like [A, resultA, B, resultB, A, resultA, B, resultB, ...]
* repeats 3+ times, replace with: first occurrence + a summary assistant
* message warning the model it was stuck.
*
* Returns { messages, loopsCollapsed }.
*/
function collapseToolLoops(messages, minRepeats = 3) {
if (!messages || messages.length < 6) return { messages, loopsCollapsed: 0 };
let loopsCollapsed = 0;
// Build signature list for assistant messages with tool_calls
// Index maps: sigIdx → original message index
const sigs = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "assistant" && msg.tool_calls) {
sigs.push({ sig: toolCallSignature(msg), idx: i });
}
}
if (sigs.length < minRepeats) return { messages, loopsCollapsed: 0 };
// Find runs of identical signatures
const dropRanges = []; // [startMsgIdx, endMsgIdx) to remove
let runStart = 0;
while (runStart < sigs.length) {
let runEnd = runStart + 1;
while (runEnd < sigs.length && sigs[runEnd].sig === sigs[runStart].sig) {
runEnd++;
}
// Also check 2-element cycles: A,B,A,B,A,B...
let cycleLen = 1;
if (runEnd - runStart < minRepeats && runStart + 1 < sigs.length) {
// Check for AB cycle
let pairRepeats = 1;
let j = runStart + 2;
while (
j + 1 < sigs.length &&
sigs[j].sig === sigs[runStart].sig &&
sigs[j + 1].sig === sigs[runStart + 1].sig
) {
pairRepeats++;
j += 2;
}
if (pairRepeats >= minRepeats) {
cycleLen = 2;
runEnd = runStart + pairRepeats * 2;
}
}
const repeats = cycleLen === 1
? runEnd - runStart
: Math.floor((runEnd - runStart) / cycleLen);
if (repeats >= minRepeats) {
// Keep first cycle, drop the rest
const keepEnd = sigs[runStart + cycleLen - 1].idx;
const dropStart = keepEnd + 1;
// Find the end of the last repeated cycle's tool results
let dropEnd = sigs[runEnd - 1].idx;
// Include tool result messages that follow the last assistant msg
while (dropEnd + 1 < messages.length && messages[dropEnd + 1].role === "tool") {
dropEnd++;
}
// Also skip tool results after the kept first cycle
const actualDropStart = keepEnd + 1;
// But we need to keep the tool results for the first cycle
let firstCycleResultEnd = keepEnd;
while (
firstCycleResultEnd + 1 < messages.length &&
messages[firstCycleResultEnd + 1].role === "tool"
) {
firstCycleResultEnd++;
}
if (firstCycleResultEnd + 1 <= dropEnd) {
dropRanges.push({
start: firstCycleResultEnd + 1,
end: dropEnd + 1,
repeats,
toolName: sigs[runStart].sig.split(":")[0],
});
loopsCollapsed++;
}
}
runStart = runEnd;
}
if (dropRanges.length === 0) return { messages, loopsCollapsed: 0 };
// Build new message array, replacing dropped ranges with warnings
const dropSet = new Set();
const warnings = new Map(); // insertAtIdx → warning text
for (const range of dropRanges) {
for (let i = range.start; i < range.end; i++) {
dropSet.add(i);
}
const warning =
`[LOOP DETECTED] You called ${range.toolName} ${range.repeats} times in a row with the same arguments and got the same result each time. ` +
`The repeated calls have been removed from context. Try a DIFFERENT approach — use a different tool, different arguments, or move on to another step.`;
warnings.set(range.start, warning);
}
const result = [];
for (let i = 0; i < messages.length; i++) {
if (warnings.has(i)) {
result.push({ role: "user", content: warnings.get(i) });
}
if (!dropSet.has(i)) {
result.push(messages[i]);
}
}
console.log(
`[prune] Collapsed ${loopsCollapsed} tool-call loop(s), removed ${dropSet.size} messages`,
);
return { messages: result, loopsCollapsed };
}
/**
* Prune the conversation to remove bloat from failed/redundant tool calls.
*
* Strategy:
* 0. Collapse repeating tool-call loops (click→state→click→state…)
* 1. Always keep: system msg, first user msg, last N messages
* 2. Middle section: remove failed tool call+result pairs,
* collapse redundant page_state calls, truncate oversized results
* 3. If still over budget, aggressively summarize old tool results
*
* @param {Object[]} messages - The full message array
* @param {Object} opts
* @param {number} opts.keepRecent - How many recent messages to always keep (default 10)
* @param {number} opts.maxResultChars - Max chars per tool result in the middle section (default 800)
* @param {number} opts.targetTokens - Target token budget; if exceeded, applies aggressive truncation
* @returns {Object[]} Pruned message array
*/
function pruneMessages(messages, opts = {}) {
const {
keepRecent = parseInt(process.env.PRUNE_KEEP_RECENT) || 10,
maxResultChars = parseInt(process.env.PRUNE_MAX_RESULT_CHARS) || 800,
targetTokens = parseInt(process.env.PRUNE_TARGET_TOKENS) || 0,
} = opts;
if (!messages || messages.length <= keepRecent + 2) return messages;
// Pass 0: collapse tool-call loops before any other pruning
const { messages: delooped } = collapseToolLoops(messages);
const systemMsgs = [];
let firstUserIdx = -1;
for (let i = 0; i < delooped.length; i++) {
if (delooped[i].role === "system") {
systemMsgs.push(delooped[i]);
} else if (delooped[i].role === "user" && firstUserIdx === -1) {
firstUserIdx = i;
break;
}
}
const splitPoint = delooped.length - keepRecent;
const middleStart = firstUserIdx === -1 ? systemMsgs.length : firstUserIdx + 1;
if (splitPoint <= middleStart) return delooped;
const middle = delooped.slice(middleStart, splitPoint);
const recent = delooped.slice(splitPoint);
// Pass 1: identify failed tool results and their corresponding tool_call IDs
const failedToolCallIds = new Set();
for (const msg of middle) {
if (msg.role === "tool" && isFailedToolResult(msg.content)) {
failedToolCallIds.add(msg.tool_call_id);
}
}
// Pass 2: build pruned middle — remove failed pairs, truncate results, collapse redundant calls
const prevStates = new Set();
const prunedMiddle = [];
for (const msg of middle) {
// Skip tool results for failed calls
if (msg.role === "tool" && failedToolCallIds.has(msg.tool_call_id)) {
continue;
}
// For assistant messages with tool_calls, filter out failed ones
if (msg.role === "assistant" && msg.tool_calls) {
const surviving = msg.tool_calls.filter(
(tc) => !failedToolCallIds.has(tc.id),
);
if (surviving.length === 0) continue;
// Skip if all remaining calls are redundant page_state
const clone = { ...msg, tool_calls: surviving };
if (isRedundantPageState(clone, prevStates)) continue;
prunedMiddle.push(clone);
continue;
}
// Truncate oversized tool results
if (
msg.role === "tool" &&
typeof msg.content === "string" &&
msg.content.length > maxResultChars
) {
prunedMiddle.push({
...msg,
content:
msg.content.slice(0, maxResultChars) + "\n[...truncated...]",
});
continue;
}
prunedMiddle.push(msg);
}
let result = [
...systemMsgs,
...(firstUserIdx >= 0 ? [delooped[firstUserIdx]] : []),
...prunedMiddle,
...recent,
];
// Pass 3: if we have a target token budget and we're over it,
// aggressively trim old tool results to 200 chars
if (targetTokens > 0) {
let est = estimateMessageTokens(result);
if (est > targetTokens) {
const aggressiveMax = 200;
for (
let i = systemMsgs.length + 1;
i < result.length - keepRecent;
i++
) {
if (
result[i].role === "tool" &&
typeof result[i].content === "string" &&
result[i].content.length > aggressiveMax
) {
result[i] = {
...result[i],
content:
result[i].content.slice(0, aggressiveMax) +
"\n[...aggressively truncated...]",
};
}
}
est = estimateMessageTokens(result);
// If still over, drop oldest middle messages in pairs
while (
est > targetTokens &&
result.length > systemMsgs.length + 1 + keepRecent + 2
) {
const dropIdx = systemMsgs.length + 1;
result.splice(dropIdx, 1);
est = estimateMessageTokens(result);
}
}
}
const origTokens = estimateMessageTokens(messages);
const newTokens = estimateMessageTokens(result);
const pctSaved = origTokens > 0
? Math.round(((origTokens - newTokens) / origTokens) * 100)
: 0;
console.log(
`[prune] ${messages.length}${result.length} msgs | ~${origTokens.toLocaleString()} → ~${newTokens.toLocaleString()} tokens (${pctSaved}% reduction)`,
);
return result;
}
// ── Extract user query from messages ─────────────────────────────────
function extractUserQuery(messages) {
// Walk backwards to find the most recent user message
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "user") continue;
if (typeof msg.content === "string") return msg.content;
if (Array.isArray(msg.content)) {
const text = msg.content
.filter((p) => p.type === "text")
.map((p) => p.text)
.join(" ");
if (text) return text;
}
}
return "";
}
// ── Emergency Compression ─────────────────────────────────────────────
//
// Used when the upstream LLM returns a context-window error. Collapses
// the entire middle section (everything between the system message and the
// last `keepRecent` messages) into a single synthetic summary note, and
// keeps a hard cap on tool results everywhere.
//
// This is intentionally more aggressive than pruneMessages — we'd rather
// lose some history than have the agent fail silently.
/**
* Extract a 1-line synopsis of an assistant or tool message for the summary.
*/
function summariseMsg(msg) {
if (msg.role === "tool") {
const raw =
typeof msg.content === "string"
? msg.content
: (msg.content || []).map((p) => p.text || "").join("");
return ` • tool result (${msg.tool_call_id || "?"}): ${raw.slice(0, 120).replace(/\n/g, " ")}`;
}
if (msg.role === "assistant") {
const text =
typeof msg.content === "string"
? msg.content
: (msg.content || []).map((p) => p.text || "").join("");
const calls = (msg.tool_calls || [])
.map((tc) => tc.function?.name || "?")
.join(", ");
if (calls) return ` • assistant called: ${calls}`;
return ` • assistant: ${text.slice(0, 120).replace(/\n/g, " ")}`;
}
return null;
}
/**
* Emergency compression: keep system + first user + summary note + last N messages.
*
* @param {Object[]} messages
* @param {Object} opts
* @param {number} opts.keepRecent Messages to keep verbatim from the tail (default 6)
* @param {number} opts.maxToolChars Hard cap on each tool result in the kept tail (default 400)
* @returns {Object[]}
*/
function emergencyCompress(messages, opts = {}) {
const { keepRecent = 6, maxToolChars = 400 } = opts;
if (!messages || messages.length <= keepRecent + 2) return messages;
const system = messages.filter((m) => m.role === "system");
const nonSystem = messages.filter((m) => m.role !== "system");
// First user message
const firstUserIdx = nonSystem.findIndex((m) => m.role === "user");
const firstUser = firstUserIdx >= 0 ? nonSystem[firstUserIdx] : null;
// Middle section to collapse
const afterFirst = firstUserIdx >= 0 ? nonSystem.slice(firstUserIdx + 1) : nonSystem;
const middle = afterFirst.slice(0, Math.max(0, afterFirst.length - keepRecent));
const recent = afterFirst.slice(Math.max(0, afterFirst.length - keepRecent));
// Cap tool results in the recent tail
const cappedRecent = recent.map((m) => {
if (
m.role === "tool" &&
typeof m.content === "string" &&
m.content.length > maxToolChars
) {
return { ...m, content: m.content.slice(0, maxToolChars) + "\n[...truncated for context]" };
}
return m;
});
if (middle.length === 0) {
return [...system, ...(firstUser ? [firstUser] : []), ...cappedRecent];
}
// Build a compact summary of what was dropped
const lines = middle
.map(summariseMsg)
.filter(Boolean)
.slice(0, 40); // cap summary length
const summaryNote = {
role: "user",
content:
`[Context compressed — ${middle.length} earlier messages summarised to fit context window]\n` +
`Key history:\n${lines.join("\n") || " (no salient content)"}\n` +
`[End of summary — recent messages follow]`,
};
const result = [
...system,
...(firstUser ? [firstUser] : []),
summaryNote,
...cappedRecent,
];
const origEst = estimateMessageTokens(messages);
const newEst = estimateMessageTokens(result);
console.log(
`[emergency-compress] ${messages.length}${result.length} msgs | ` +
`~${origEst.toLocaleString()} → ~${newEst.toLocaleString()} tokens`,
);
return result;
}
// ── Main optimize function ───────────────────────────────────────────
/**
* Collect tool names that appear in recent assistant tool_calls so
* they stay available even if BM25 wouldn't rank them highly.
*/
function extractRecentToolNames(messages, lookback = 6) {
const names = new Set();
const start = Math.max(0, messages.length - lookback);
for (let i = start; i < messages.length; i++) {
const msg = messages[i];
if (msg.role === "assistant" && msg.tool_calls) {
for (const tc of msg.tool_calls) {
const name = tc.function?.name;
if (name) names.add(name);
}
}
}
return [...names];
}
/**
* Optimize a chat completions request body by pruning messages and
* selecting relevant tools.
*
* @param {Object} body - The incoming /v1/chat/completions request body
* @param {Object} opts
* @param {number} opts.contextWindow - Total context window size for the model
* @returns {Object} Modified request body
*/
function optimizeRequest(body, opts = {}) {
const { contextWindow = 32000 } = opts;
const query = extractUserQuery(body.messages || []);
// Prune messages — reserve ~25% of context for the response
const targetTokens = Math.floor(contextWindow * 0.75);
const prunedMessages = pruneMessages(body.messages || [], { targetTokens });
// Select tools — keep any tool the agent recently called so it can
// continue multi-step workflows without losing access mid-chain
const recentTools = extractRecentToolNames(body.messages || []);
const alwaysInclude = [...CORE_TOOLS, ...recentTools];
const selectedTools =
body.tools && body.tools.length > 0
? selectTools(query, body.tools, { alwaysInclude })
: body.tools;
return {
...body,
messages: prunedMessages,
tools: selectedTools,
};
}
module.exports = {
selectTools,
pruneMessages,
emergencyCompress,
optimizeRequest,
extractUserQuery,
estimateMessageTokens,
};