663 lines
20 KiB
JavaScript
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,
|
|
};
|