openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
418 lines (417 loc) • 18.1 kB
JavaScript
import { n as estimateStringChars } from "./cjk-chars-CGxY6W63.js";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { h as stripRuntimeContextCustomMessages } from "./internal-runtime-context-UgZVJlox.js";
import { r as stripToolResultDetails } from "./model-context-message-Dmngk3hL.js";
import { d as estimateTokens } from "./compaction-Gq_Mr3Zu.js";
import { i as createToolCallOccurrenceQueue } from "./tool-result-pairing-Bccl6DhI.js";
import { n as extractToolResultId, t as extractToolCallsFromAssistant } from "./tool-call-id-DJzLQ9lS.js";
import { n as repairToolUseResultPairing } from "./session-transcript-repair-CzBxxYMT.js";
//#region src/agents/compaction-planning-projection.ts
/** Builds bounded transcript projections for compaction worker planning. */
const TEXT_TRUNCATE_THRESHOLD_CHARS = 32768;
const TEXT_SAMPLE_CHARS = 8192;
const PLANNING_MAX_CHARS = 262144;
const MAX_ARGUMENT_ESTIMATE_CHARS = 1e6;
const UNMEASURABLE_ARGUMENT_OMITTED_CHARS = Number.MAX_SAFE_INTEGER;
const OMITTED_CHARS_FIELD = "__openclawCompactionPlanningOmittedChars";
function readCompactionPlanningOmittedChars(message) {
const value = Reflect.get(message, OMITTED_CHARS_FIELD);
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
function projectText(text, budget) {
if (text.length <= TEXT_TRUNCATE_THRESHOLD_CHARS && text.length <= budget.remainingChars) {
budget.remainingChars -= text.length;
return null;
}
const sample = truncateUtf16Safe(text, Math.min(TEXT_SAMPLE_CHARS, budget.remainingChars));
budget.remainingChars -= sample.length;
return {
text: sample,
omittedChars: estimateStringChars(text.slice(sample.length))
};
}
function jsonStringLengthWithin(text, maxChars, estimate) {
let length = 2;
for (let index = 0; index < text.length; index += 1) {
const char = text[index] ?? "";
const code = text.charCodeAt(index);
const nextCode = text.charCodeAt(index + 1);
const pairedSurrogate = code >= 55296 && code <= 56319 && nextCode >= 56320 && nextCode <= 57343;
length += pairedSurrogate ? 2 : code >= 55296 && code <= 57343 ? 6 : char === "\"" || char === "\\" || code === 8 || code === 9 || code === 10 || code === 12 || code === 13 ? 2 : code < 32 ? 6 : 1;
if (pairedSurrogate) index += 1;
if (length > maxChars) return;
}
estimate.cjkChars += estimateStringChars(text) - text.length;
return length;
}
function jsonLengthWithin(value, maxChars, estimate, seen = /* @__PURE__ */ new Set()) {
if (typeof value === "string") return jsonStringLengthWithin(value, maxChars, estimate);
if (value === null) return 4;
if (typeof value === "number" || typeof value === "boolean") {
const length = String(value).length;
return length <= maxChars ? length : void 0;
}
if (!value || typeof value !== "object" || seen.has(value)) return;
seen.add(value);
let length = 2;
if (Array.isArray(value)) for (const entry of value) {
const separatorLength = length === 2 ? 0 : 1;
const entryLength = jsonLengthWithin(entry, maxChars - length - separatorLength, estimate, seen);
if (entryLength === void 0) return;
length += separatorLength + entryLength;
if (length > maxChars) return;
}
else {
const record = value;
for (const key in record) {
if (!Object.hasOwn(record, key)) continue;
const separatorLength = length === 2 ? 0 : 1;
const keyLength = jsonStringLengthWithin(key, maxChars - length - separatorLength, estimate);
const entryLength = jsonLengthWithin(record[key], maxChars - length - separatorLength - (keyLength ?? 0) - 1, estimate, seen);
if (keyLength === void 0 || entryLength === void 0) return;
length += separatorLength + keyLength + entryLength + 1;
if (length > maxChars) return;
}
}
seen.delete(value);
return length;
}
function projectToolArguments(value, budget) {
const estimate = { cjkChars: 0 };
const length = jsonLengthWithin(value, MAX_ARGUMENT_ESTIMATE_CHARS, estimate);
if (length !== void 0 && length <= budget.remainingChars) {
budget.remainingChars -= length;
return;
}
budget.remainingChars = 0;
return length === void 0 ? UNMEASURABLE_ARGUMENT_OMITTED_CHARS : length + estimate.cjkChars - 2;
}
function projectContentBlock(block, budget) {
if (!block || typeof block !== "object") return {
block,
omittedChars: 0,
changed: false
};
const record = block;
const type = typeof record.type === "string" ? record.type : "";
if (type === "image" && typeof record.data === "string" && record.data.length > 0) return {
block: {
...record,
data: ""
},
omittedChars: 0,
changed: true
};
const hasText = typeof record.text === "string" && record.text.length > 0;
const textIsModelVisible = type === "text" || (type === "toolResult" || type === "tool_result") && hasText;
const contentIsModelVisible = (type === "toolResult" || type === "tool_result") && !hasText && typeof record.content === "string";
let next;
let omittedChars = 0;
for (const field of [
"text",
"content",
"thinking"
]) {
if (field === "thinking" && type !== "thinking") continue;
const value = record[field];
const projected = typeof value === "string" ? projectText(value, budget) : null;
if (!projected) continue;
next ??= { ...record };
next[field] = projected.text;
omittedChars += field === "thinking" || (field === "text" ? textIsModelVisible : contentIsModelVisible) ? projected.omittedChars : 0;
}
if (type === "toolCall") {
const omittedArguments = projectToolArguments(record.arguments, budget);
if (omittedArguments !== void 0) {
next ??= { ...record };
next.arguments = {};
omittedChars += omittedArguments;
}
}
for (const signature of [
"textSignature",
"thinkingSignature",
"thoughtSignature"
]) if (signature in record) {
next ??= { ...record };
delete next[signature];
}
return next ? {
block: next,
omittedChars,
changed: true
} : {
block,
omittedChars,
changed: false
};
}
function projectStringFields(message, fields, budget) {
let omittedChars = readCompactionPlanningOmittedChars(message);
let next;
for (const field of fields) {
const value = Reflect.get(message, field);
if (typeof value !== "string") continue;
const projected = projectText(value, budget);
if (!projected) continue;
next ??= { ...message };
Reflect.set(next, field, projected.text);
omittedChars += projected.omittedChars;
}
return next ? Object.assign(next, { [OMITTED_CHARS_FIELD]: omittedChars }) : message;
}
function projectMessage(message, budget) {
let source = message;
if (message.role === "assistant") source = {
role: message.role,
content: message.content,
stopReason: message.stopReason,
timestamp: message.timestamp
};
else if (message.role === "bashExecution") {
const { fullOutputPath: _, ...rest } = message;
source = rest;
} else if (message.role === "compactionSummary" || message.role === "custom") {
const { details: _, ...rest } = message;
source = rest;
}
const content = source.content;
if (typeof content === "string") return projectStringFields(source, ["content"], budget);
if (!Array.isArray(content)) switch (source.role) {
case "bashExecution": return projectStringFields(source, ["command", "output"], budget);
case "branchSummary":
case "compactionSummary": return projectStringFields(source, ["summary"], budget);
default: return source;
}
let omittedChars = 0;
let changed = false;
const projectedContent = content.map((block) => {
const projected = projectContentBlock(block, budget);
omittedChars += projected.omittedChars;
changed ||= projected.changed;
return projected.block;
});
if (!changed) return source;
return Object.assign({}, source, {
content: projectedContent,
[OMITTED_CHARS_FIELD]: readCompactionPlanningOmittedChars(source) + omittedChars
});
}
function projectCompactionPlanningMessages(messages) {
const budget = { remainingChars: PLANNING_MAX_CHARS };
return messages.map((message) => projectMessage(message, budget));
}
//#endregion
//#region src/agents/compaction-planning.ts
/**
* Planning helpers for transcript compaction. The module estimates sanitized
* token usage, chooses chunking strategy, and preserves active tool-use pairs
* while splitting history for summaries.
*/
/** Default share of context window targeted for compaction chunks. */
const BASE_CHUNK_RATIO = .4;
/** Lower bound for adaptive compaction chunk sizing. */
const MIN_CHUNK_RATIO = .15;
/** Buffer for estimateTokens() inaccuracy. */
const SAFETY_MARGIN = 1.2;
const DEFAULT_PARTS = 2;
/**
* Overhead reserved for summary prompt, system prompt, prior summary, wrapper
* tags, and high-reasoning summary generation.
*/
const SUMMARIZATION_OVERHEAD_TOKENS = 4096;
/** Estimates compaction tokens after removing fields that must not reach summarization. */
function estimateMessagesTokens(messages) {
return sanitizeCompactionMessages(messages).reduce((sum, message) => sum + estimateCompactionPlanningTokens(message), 0);
}
/**
* Per-original-message token estimates, aligned 1:1 to the input array. Sanitizes
* the full array once instead of wrapping and re-cloning each message in its own
* 1-element array. Runtime-context entries are not model-visible, so they estimate
* to 0 here just as sanitizeCompactionMessages([msg]) would drop them.
*/
function estimatePerMessageTokens(messages) {
const detailStripped = stripToolResultDetails(messages);
const modelVisible = new Set(stripRuntimeContextCustomMessages(detailStripped));
return detailStripped.map((message) => modelVisible.has(message) ? estimateCompactionPlanningTokens(message) : 0);
}
/** Removes runtime-only context and tool-result details before token estimates or summaries. */
function sanitizeCompactionMessages(messages) {
return stripToolResultDetails(stripRuntimeContextCustomMessages(messages));
}
function estimateCompactionPlanningTokens(message) {
return estimateTokens(message) + Math.ceil(readCompactionPlanningOmittedChars(message) / 4);
}
/** Builds a bounded planning projection that preserves token pressure accounting. */
function projectCompactionMessagesForPlanning(messages) {
return projectCompactionPlanningMessages(sanitizeCompactionMessages(messages));
}
/** Clamps requested split parts to a usable count for the available messages. */
function normalizeCompactionParts(parts, messageCount) {
if (!Number.isFinite(parts) || parts <= 1) return 1;
return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount));
}
function groupCompactionMessages(messages, perMessageTokens) {
const groups = [];
let current = [];
let currentTokens = 0;
let pendingToolCalls = createToolCallOccurrenceQueue();
for (const [index, message] of messages.entries()) {
current.push(message);
currentTokens += perMessageTokens[index];
if (message.role === "assistant") {
const stopReason = message.stopReason;
const toolCalls = stopReason === "aborted" || stopReason === "error" ? [] : extractToolCallsFromAssistant(message);
pendingToolCalls = createToolCallOccurrenceQueue();
for (const toolCall of toolCalls) pendingToolCalls.add(toolCall.id, true);
} else if (message.role === "toolResult" && pendingToolCalls.size > 0) {
const resultId = extractToolResultId(message);
if (resultId) pendingToolCalls.claim(resultId);
else pendingToolCalls.clear();
}
if (pendingToolCalls.size === 0) {
groups.push({
messages: current,
tokens: currentTokens
});
current = [];
currentTokens = 0;
}
}
if (current.length > 0) groups.push({
messages: current,
tokens: currentTokens
});
return groups;
}
/** Chunks atomic tool-call groups without splitting a provider-visible call/result pair. */
function chunkCompactionMessageGroups(messages, maxTokens, perMessageTokens, maxChunks = Number.POSITIVE_INFINITY) {
const chunks = [];
let current = [];
let currentTokens = 0;
for (const group of groupCompactionMessages(messages, perMessageTokens)) {
if (current.length > 0 && chunks.length < maxChunks - 1 && currentTokens + group.tokens > maxTokens) {
chunks.push(current);
current = [];
currentTokens = 0;
}
current.push(...group.messages);
currentTokens += group.tokens;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/** Splits messages into roughly equal token-share chunks without separating active tool pairs. */
function splitMessagesByTokenShare(messages, parts = DEFAULT_PARTS) {
if (messages.length === 0) return [];
const normalizedParts = normalizeCompactionParts(parts, messages.length);
if (normalizedParts <= 1) return [messages];
const perMessageTokens = estimatePerMessageTokens(messages);
return chunkCompactionMessageGroups(messages, perMessageTokens.reduce((sum, tokens) => sum + tokens, 0) / normalizedParts, perMessageTokens, normalizedParts);
}
/**
* Compute adaptive chunk ratio based on average message size.
* When messages are large, we use smaller chunks to avoid exceeding model limits.
*/
function computeAdaptiveChunkRatio(messages, contextWindow) {
if (messages.length === 0) return BASE_CHUNK_RATIO;
const avgRatio = estimateMessagesTokens(messages) / messages.length * SAFETY_MARGIN / contextWindow;
if (avgRatio > .1) {
const reduction = Math.min(avgRatio * 2, .25);
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
}
return BASE_CHUNK_RATIO;
}
/** Builds sanitized chunks for summarization prompts. */
function buildSummaryChunks(params) {
const safeMessages = sanitizeCompactionMessages(params.messages);
return chunkCompactionMessageGroups(safeMessages, Math.max(1, Math.floor(params.maxChunkTokens / SAFETY_MARGIN)), estimatePerMessageTokens(safeMessages));
}
/** Separates messages too large to summarize and emits compact placeholder notes for them. */
function buildOversizedFallbackPlan(params) {
const smallMessages = [];
const oversizedNotes = [];
const perMessageTokens = estimatePerMessageTokens(params.messages);
const oversizedThreshold = params.contextWindow * .5;
let messageIndex = 0;
for (const group of groupCompactionMessages(params.messages, perMessageTokens)) {
const retainedMessages = [];
let omitToolBatch = false;
for (const message of group.messages) {
const tokens = perMessageTokens[messageIndex++];
if (tokens * 1.2 > oversizedThreshold) {
oversizedNotes.push(`[Large ${message.role} (~${Math.round(tokens / 1e3)}K tokens) omitted from summary]`);
omitToolBatch ||= message.role === "assistant" || message.role === "toolResult";
} else retainedMessages.push(message);
}
for (const message of retainedMessages) if (!omitToolBatch || message.role !== "assistant" && message.role !== "toolResult") smallMessages.push(message);
}
return {
smallMessages,
oversizedNotes
};
}
/** Plans whether to split a summarization stage based on message count and token budget. */
function buildStageSplitPlan(params) {
const minMessagesForSplit = Math.max(2, params.minMessagesForSplit ?? 4);
const parts = normalizeCompactionParts(params.parts ?? DEFAULT_PARTS, params.messages.length);
const totalTokens = estimateMessagesTokens(params.messages);
if (parts <= 1 || params.messages.length < minMessagesForSplit || totalTokens <= params.maxChunkTokens) return { mode: "single" };
const chunks = splitMessagesByTokenShare(params.messages, parts).filter((chunk) => chunk.length > 0);
return chunks.length > 1 ? {
mode: "split",
chunks
} : { mode: "single" };
}
/** Drops oldest token-share chunks until history fits the requested context share. */
function pruneHistoryForContextShare(params) {
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * params.maxHistoryShare));
let keptMessages = params.messages;
const allDroppedMessages = [];
let droppedChunks = 0;
const parts = normalizeCompactionParts(params.parts ?? DEFAULT_PARTS, keptMessages.length);
const originalMessageIndexes = new Map(params.messages.map((message, index) => [message, index]));
while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) {
const chunks = splitMessagesByTokenShare(keptMessages, parts);
if (chunks.length <= 1) break;
const dropped = chunks[0];
const retained = chunks.slice(1).flat();
const repairReport = repairToolUseResultPairing(retained);
const repairedDropped = repairReport.discarded;
droppedChunks += 1;
allDroppedMessages.push(...dropped, ...repairedDropped);
keptMessages = repairReport.messages;
}
allDroppedMessages.sort((left, right) => (originalMessageIndexes.get(left) ?? params.messages.length) - (originalMessageIndexes.get(right) ?? params.messages.length));
return {
messages: keptMessages,
droppedMessagesList: allDroppedMessages,
droppedChunks,
droppedMessages: allDroppedMessages.length,
droppedTokens: estimateMessagesTokens(allDroppedMessages),
keptTokens: estimateMessagesTokens(keptMessages),
budgetTokens
};
}
/** Computes whether new content exceeds the history budget and plans pruning when needed. */
function buildHistoryPrunePlan(params) {
const summarizableTokens = estimateMessagesTokens(params.messagesToSummarize) + estimateMessagesTokens(params.turnPrefixMessages);
const newContentTokens = Math.max(0, Math.floor(params.tokensBefore - summarizableTokens));
const maxHistoryTokens = Math.floor(params.contextWindowTokens * params.maxHistoryShare * SAFETY_MARGIN);
const plan = {
summarizableTokens,
newContentTokens,
maxHistoryTokens
};
return newContentTokens <= maxHistoryTokens ? plan : {
...plan,
pruned: pruneHistoryForContextShare({
messages: params.messagesToSummarize,
maxContextTokens: params.contextWindowTokens,
maxHistoryShare: params.maxHistoryShare,
parts: params.parts
})
};
}
//#endregion
export { buildHistoryPrunePlan as a, buildSummaryChunks as c, projectCompactionMessagesForPlanning as d, sanitizeCompactionMessages as f, SUMMARIZATION_OVERHEAD_TOKENS as i, computeAdaptiveChunkRatio as l, MIN_CHUNK_RATIO as n, buildOversizedFallbackPlan as o, SAFETY_MARGIN as r, buildStageSplitPlan as s, BASE_CHUNK_RATIO as t, estimateMessagesTokens as u };