agents
Version:
A home for your AI agents
340 lines (324 loc) • 13.8 kB
JavaScript
//#region src/experimental/memory/utils/tokens.ts
/** Approximate characters per token for English text */
const CHARS_PER_TOKEN = 4;
/** Approximate token multiplier per whitespace-separated word */
const WORDS_TOKEN_MULTIPLIER = 1.3;
/** Approximate overhead tokens per message (role, framing) */
const TOKENS_PER_MESSAGE = 4;
/**
* Estimate token count for a string using a hybrid heuristic.
*
* Takes the max of two estimates:
* - Character-based: `length / 4` — better for dense content (JSON, code, URLs)
* - Word-based: `words * 1.3` — better for natural language prose
*
* This is a heuristic. Do not use where exact counts are required.
*/
function estimateStringTokens(text) {
if (!text) return 0;
const charEstimate = text.length / 4;
const wordEstimate = text.split(/\s+/).filter(Boolean).length * WORDS_TOKEN_MULTIPLIER;
return Math.ceil(Math.max(charEstimate, wordEstimate));
}
function estimateUnknownTokens(value) {
if (value === null || value === void 0) return 0;
if (typeof value === "string") return estimateStringTokens(value);
try {
return estimateStringTokens(JSON.stringify(value));
} catch {
return estimateStringTokens(String(value));
}
}
/**
* Estimate total token count for an array of UIMessages.
*
* Walks each message's parts (text, reasoning, tool invocations, tool results)
* and applies per-message overhead.
*
* This is a heuristic. Do not use where exact counts are required.
*/
function estimateMessageTokens(messages) {
let tokens = 0;
for (const msg of messages) {
tokens += 4;
for (const part of msg.parts) if (part.type === "text" || part.type === "reasoning") tokens += estimateUnknownTokens(part.text ?? part.reasoning);
else if (part.type.startsWith("tool-") || part.type === "dynamic-tool") {
tokens += estimateUnknownTokens(part.input);
tokens += estimateUnknownTokens(part.output ?? part.result);
} else if (part.text !== void 0) tokens += estimateUnknownTokens(part.text);
else if (part.result !== void 0) tokens += estimateUnknownTokens(part.result);
}
return tokens;
}
//#endregion
//#region src/experimental/memory/utils/compaction-helpers.ts
/** Prefix for all compaction messages (overlays and summaries) */
const COMPACTION_PREFIX = "compaction_";
/** Check if a message is a compaction message */
function isCompactionMessage(msg) {
return msg.id.startsWith(COMPACTION_PREFIX);
}
/**
* Check if a message contains tool invocations.
*/
function hasToolCalls(msg) {
return msg.parts.some((p) => p.type.startsWith("tool-") || p.type === "dynamic-tool");
}
/**
* Get tool call IDs from a message's parts.
*/
function getToolCallIds(msg) {
const ids = /* @__PURE__ */ new Set();
for (const part of msg.parts) if ((part.type.startsWith("tool-") || part.type === "dynamic-tool") && "toolCallId" in part) ids.add(part.toolCallId);
return ids;
}
/**
* Check if a message is a tool result referencing a specific call ID.
*/
function isToolResultFor(msg, callIds) {
return msg.parts.some((p) => (p.type.startsWith("tool-") || p.type === "dynamic-tool") && "toolCallId" in p && callIds.has(p.toolCallId));
}
/**
* Align a boundary index forward to avoid splitting tool call/result groups.
* If the boundary falls between an assistant message with tool calls and its
* tool results, move it forward past the results.
*/
function alignBoundaryForward(messages, idx) {
if (idx <= 0 || idx >= messages.length) return idx;
const prev = messages[idx - 1];
if (prev.role === "assistant" && hasToolCalls(prev)) {
const callIds = getToolCallIds(prev);
while (idx < messages.length && isToolResultFor(messages[idx], callIds)) idx++;
}
return idx;
}
/**
* Align a boundary index backward to avoid splitting tool call/result groups.
* If the boundary falls in the middle of tool results, move it backward to
* include the assistant message that made the calls.
*/
function alignBoundaryBackward(messages, idx) {
if (idx <= 0 || idx >= messages.length) return idx;
while (idx > 0) {
const msg = messages[idx];
if (msg.role === "assistant" && hasToolCalls(msg)) break;
const prev = messages[idx - 1];
if (prev.role === "assistant" && hasToolCalls(prev)) {
if (isToolResultFor(msg, getToolCallIds(prev))) {
idx--;
continue;
}
}
break;
}
return idx;
}
/**
* Find the compression end boundary using a token budget for the tail.
* Walks backward from the end, accumulating tokens until budget is reached.
* Returns the index where compression should stop (everything from this
* index onward is protected).
*
* @param messages All messages
* @param headEnd Index where the protected head ends (compression starts here)
* @param tailTokenBudget Maximum tokens to keep in the tail
* @param minTailMessages Minimum messages to protect in the tail (fallback)
*/
function findTailCutByTokens(messages, headEnd, tailTokenBudget = 2e4, minTailMessages = 2) {
const n = messages.length;
let accumulated = 0;
let tokenCut = n;
for (let i = n - 1; i >= headEnd; i--) {
const msgTokens = estimateMessageTokens([messages[i]]);
if (accumulated + msgTokens > tailTokenBudget && tokenCut < n) break;
accumulated += msgTokens;
tokenCut = i;
}
const minCut = n - minTailMessages;
return alignBoundaryBackward(messages, minCut >= headEnd ? Math.min(tokenCut, minCut) : tokenCut);
}
async function findTailCutByTokensWithCounter(messages, headEnd, tokenCounter, tailTokenBudget = 2e4, minTailMessages = 2) {
const n = messages.length;
let accumulated = 0;
let tokenCut = n;
for (let i = n - 1; i >= headEnd; i--) {
const msgTokens = await tokenCounter([messages[i]]);
if (accumulated + msgTokens > tailTokenBudget && tokenCut < n) break;
accumulated += msgTokens;
tokenCut = i;
}
const minCut = n - minTailMessages;
return alignBoundaryBackward(messages, minCut >= headEnd ? Math.min(tokenCut, minCut) : tokenCut);
}
/**
* Fix orphaned tool call/result pairs after compaction.
*
* Two failure modes:
* 1. Tool result references a call_id whose assistant tool_call was removed
* → Remove the orphaned result
* 2. Assistant has tool_calls whose results were dropped
* → Add stub results so the API doesn't error
*
* @param messages Messages after compaction
* @returns Sanitized messages with no orphaned pairs
*/
function sanitizeToolPairs(messages) {
const survivingCallIds = /* @__PURE__ */ new Set();
for (const msg of messages) if (msg.role === "assistant") for (const id of getToolCallIds(msg)) survivingCallIds.add(id);
const resultCallIds = /* @__PURE__ */ new Set();
for (const msg of messages) for (const part of msg.parts) if ((part.type.startsWith("tool-") || part.type === "dynamic-tool") && "toolCallId" in part && "output" in part) resultCallIds.add(part.toolCallId);
const orphanedResults = /* @__PURE__ */ new Set();
for (const id of resultCallIds) if (!survivingCallIds.has(id)) orphanedResults.add(id);
let result = messages;
if (orphanedResults.size > 0) result = result.map((msg) => {
const filteredParts = msg.parts.filter((part) => {
if ((part.type.startsWith("tool-") || part.type === "dynamic-tool") && "toolCallId" in part && "output" in part) return !orphanedResults.has(part.toolCallId);
return true;
});
if (filteredParts.length !== msg.parts.length) return {
...msg,
parts: filteredParts
};
return msg;
});
const missingResults = /* @__PURE__ */ new Set();
for (const id of survivingCallIds) if (!resultCallIds.has(id) && !orphanedResults.has(id)) missingResults.add(id);
if (missingResults.size > 0) {
const patched = [];
for (const msg of result) {
patched.push(msg);
if (msg.role === "assistant") {
for (const id of getToolCallIds(msg)) if (missingResults.has(id)) {
const callPart = msg.parts.find((p) => "toolCallId" in p && p.toolCallId === id);
patched.push({
id: `stub-${id}`,
role: "assistant",
parts: [{
type: "tool-result",
toolCallId: id,
toolName: callPart?.toolName ?? "unknown",
result: "[Result from earlier conversation — see context summary above]"
}],
createdAt: /* @__PURE__ */ new Date()
});
}
}
}
result = patched;
}
return result.filter((msg) => msg.parts.length > 0);
}
/**
* Compute a summary token budget based on the content being compressed.
* 20% of the compressed content, clamped to 2K-8K tokens.
*/
function computeSummaryBudget(messages) {
const contentTokens = estimateMessageTokens(messages);
const budget = Math.floor(contentTokens * .2);
return Math.max(100, budget);
}
/**
* Build a prompt for LLM summarization of compressed messages.
*
* @param messages Messages to summarize
* @param previousSummary Previous summary for iterative updates (or null for first compaction)
* @param budget Target token count for the summary
*/
function buildSummaryPrompt(messages, previousSummary, budget) {
const content = messages.map((msg) => {
const textParts = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n");
const toolParts = msg.parts.filter((p) => p.type.startsWith("tool-") || p.type === "dynamic-tool").map((p) => {
const tp = p;
const parts = [`[Tool: ${tp.toolName ?? "unknown"}]`];
if (tp.input) parts.push(`Input: ${JSON.stringify(tp.input).slice(0, 500)}`);
if (tp.output) parts.push(`Output: ${String(tp.output).slice(0, 500)}`);
return parts.join("\n");
}).join("\n");
return `[${msg.role}]\n${textParts}${toolParts ? "\n" + toolParts : ""}`;
}).join("\n\n---\n\n");
if (previousSummary) return `You are updating a conversation summary. A previous summary exists below. New conversation turns have occurred since then and need to be incorporated.
PREVIOUS SUMMARY:
${previousSummary}
NEW TURNS TO INCORPORATE:
${content}
Update the summary. PRESERVE existing information that is still relevant. ADD new information. Remove information only if it is clearly obsolete.
## Topic
[What the conversation is about]
## Key Points
[Important information, decisions, and conclusions from the conversation]
## Current State
[Where things stand now — what has been done, what is in progress]
## Open Items
[Unresolved questions, pending tasks, or next steps discussed]
Target ~${budget} tokens. Be factual — only include information that was explicitly discussed in the conversation. Do NOT invent file paths, commands, or details that were not mentioned. Write only the summary body.`;
return `Create a concise summary of this conversation that preserves the important information for future context.
CONVERSATION TO SUMMARIZE:
${content}
Use this structure:
## Topic
[What the conversation is about]
## Key Points
[Important information, decisions, and conclusions from the conversation]
## Current State
[Where things stand now — what has been done, what is in progress]
## Open Items
[Unresolved questions, pending tasks, or next steps discussed]
Target ~${budget} tokens. Be factual — only include information that was explicitly discussed in the conversation. Do NOT invent file paths, commands, or details that were not mentioned. Write only the summary body.`;
}
/**
* Reference compaction implementation.
*
* Implements the full hermes-style compaction algorithm:
* 1. Protect head messages (first N)
* 2. Protect tail by token budget (walk backward)
* 3. Align boundaries to tool call groups
* 4. Summarize middle section with LLM (structured format)
* 5. Sanitize orphaned tool pairs
* 6. Iterative summary updates on subsequent compactions
*
* @example
* ```typescript
* import { createCompactFunction } from "agents/experimental/memory/utils";
*
* const session = new Session(provider, {
* compaction: {
* tokenThreshold: 100000,
* fn: createCompactFunction({
* summarize: (prompt) => generateText({ model, prompt }).then(r => r.text)
* })
* }
* });
* ```
*/
function createCompactFunction(opts) {
const protectHead = opts.protectHead ?? 3;
const tailTokenBudget = opts.tailTokenBudget ?? 2e4;
const minTailMessages = opts.minTailMessages ?? 2;
return async (messages, context) => {
if (messages.length <= protectHead + minTailMessages) return null;
const sessionCounter = context?.tokenCounter;
const tailCounter = opts.tokenCounter ?? (sessionCounter ? (msgs) => sessionCounter({
messages: msgs,
systemPrompt: "",
contextBlocks: []
}) : void 0);
let compressStart = protectHead;
compressStart = alignBoundaryForward(messages, compressStart);
let compressEnd = tailCounter ? await findTailCutByTokensWithCounter(messages, compressStart, tailCounter, tailTokenBudget, minTailMessages) : findTailCutByTokens(messages, compressStart, tailTokenBudget, minTailMessages);
if (compressEnd <= compressStart) return null;
const middleMessages = messages.slice(compressStart, compressEnd).filter((m) => !isCompactionMessage(m));
if (middleMessages.length === 0) return null;
const existingCompaction = messages.find(isCompactionMessage);
const prompt = buildSummaryPrompt(middleMessages, existingCompaction ? existingCompaction.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n") : null, computeSummaryBudget(middleMessages));
const summary = await opts.summarize(prompt);
if (!summary.trim()) return null;
return {
fromMessageId: middleMessages[0].id,
toMessageId: middleMessages[middleMessages.length - 1].id,
summary
};
};
}
//#endregion
export { computeSummaryBudget as a, isCompactionMessage as c, TOKENS_PER_MESSAGE as d, WORDS_TOKEN_MULTIPLIER as f, buildSummaryPrompt as i, sanitizeToolPairs as l, estimateStringTokens as m, alignBoundaryBackward as n, createCompactFunction as o, estimateMessageTokens as p, alignBoundaryForward as r, findTailCutByTokens as s, COMPACTION_PREFIX as t, CHARS_PER_TOKEN as u };
//# sourceMappingURL=compaction-helpers-iiKMr2TQ.js.map