Files
openclaw/src/agents/pi-embedded-runner/tool-result-context-guard.ts
2026-03-03 02:42:43 +00:00

224 lines
6.5 KiB
TypeScript

import type { AgentMessage } from "@mariozechner/pi-agent-core";
import {
CHARS_PER_TOKEN_ESTIMATE,
TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE,
type MessageCharEstimateCache,
createMessageCharEstimateCache,
estimateContextChars,
estimateMessageCharsCached,
getToolResultText,
invalidateMessageCharsCacheEntry,
isToolResultMessage,
} from "./tool-result-char-estimator.js";
// Keep a conservative input budget to absorb tokenizer variance and provider framing overhead.
const CONTEXT_INPUT_HEADROOM_RATIO = 0.75;
const SINGLE_TOOL_RESULT_CONTEXT_SHARE = 0.5;
export const CONTEXT_LIMIT_TRUNCATION_NOTICE = "[truncated: output exceeded context limit]";
const CONTEXT_LIMIT_TRUNCATION_SUFFIX = `\n${CONTEXT_LIMIT_TRUNCATION_NOTICE}`;
export const PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER =
"[compacted: tool output removed to free context]";
type GuardableTransformContext = (
messages: AgentMessage[],
signal: AbortSignal,
) => AgentMessage[] | Promise<AgentMessage[]>;
type GuardableAgent = object;
type GuardableAgentRecord = {
transformContext?: GuardableTransformContext;
};
function truncateTextToBudget(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
if (maxChars <= 0) {
return CONTEXT_LIMIT_TRUNCATION_NOTICE;
}
const bodyBudget = Math.max(0, maxChars - CONTEXT_LIMIT_TRUNCATION_SUFFIX.length);
if (bodyBudget <= 0) {
return CONTEXT_LIMIT_TRUNCATION_NOTICE;
}
let cutPoint = bodyBudget;
const newline = text.lastIndexOf("\n", bodyBudget);
if (newline > bodyBudget * 0.7) {
cutPoint = newline;
}
return text.slice(0, cutPoint) + CONTEXT_LIMIT_TRUNCATION_SUFFIX;
}
function replaceToolResultText(msg: AgentMessage, text: string): AgentMessage {
const content = (msg as { content?: unknown }).content;
const replacementContent =
typeof content === "string" || content === undefined ? text : [{ type: "text", text }];
const sourceRecord = msg as unknown as Record<string, unknown>;
const { details: _details, ...rest } = sourceRecord;
return {
...rest,
content: replacementContent,
} as AgentMessage;
}
function truncateToolResultToChars(
msg: AgentMessage,
maxChars: number,
cache: MessageCharEstimateCache,
): AgentMessage {
if (!isToolResultMessage(msg)) {
return msg;
}
const estimatedChars = estimateMessageCharsCached(msg, cache);
if (estimatedChars <= maxChars) {
return msg;
}
const rawText = getToolResultText(msg);
if (!rawText) {
return replaceToolResultText(msg, CONTEXT_LIMIT_TRUNCATION_NOTICE);
}
const truncatedText = truncateTextToBudget(rawText, maxChars);
return replaceToolResultText(msg, truncatedText);
}
function compactExistingToolResultsInPlace(params: {
messages: AgentMessage[];
charsNeeded: number;
cache: MessageCharEstimateCache;
}): number {
const { messages, charsNeeded, cache } = params;
if (charsNeeded <= 0) {
return 0;
}
let reduced = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!isToolResultMessage(msg)) {
continue;
}
const before = estimateMessageCharsCached(msg, cache);
if (before <= PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER.length) {
continue;
}
const compacted = replaceToolResultText(msg, PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
applyMessageMutationInPlace(msg, compacted, cache);
const after = estimateMessageCharsCached(msg, cache);
if (after >= before) {
continue;
}
reduced += before - after;
if (reduced >= charsNeeded) {
break;
}
}
return reduced;
}
function applyMessageMutationInPlace(
target: AgentMessage,
source: AgentMessage,
cache?: MessageCharEstimateCache,
): void {
if (target === source) {
return;
}
const targetRecord = target as unknown as Record<string, unknown>;
const sourceRecord = source as unknown as Record<string, unknown>;
for (const key of Object.keys(targetRecord)) {
if (!(key in sourceRecord)) {
delete targetRecord[key];
}
}
Object.assign(targetRecord, sourceRecord);
if (cache) {
invalidateMessageCharsCacheEntry(cache, target);
}
}
function enforceToolResultContextBudgetInPlace(params: {
messages: AgentMessage[];
contextBudgetChars: number;
maxSingleToolResultChars: number;
}): void {
const { messages, contextBudgetChars, maxSingleToolResultChars } = params;
const estimateCache = createMessageCharEstimateCache();
// Ensure each tool result has an upper bound before considering total context usage.
for (const message of messages) {
if (!isToolResultMessage(message)) {
continue;
}
const truncated = truncateToolResultToChars(message, maxSingleToolResultChars, estimateCache);
applyMessageMutationInPlace(message, truncated, estimateCache);
}
let currentChars = estimateContextChars(messages, estimateCache);
if (currentChars <= contextBudgetChars) {
return;
}
// Compact oldest tool outputs first until the context is back under budget.
compactExistingToolResultsInPlace({
messages,
charsNeeded: currentChars - contextBudgetChars,
cache: estimateCache,
});
}
export function installToolResultContextGuard(params: {
agent: GuardableAgent;
contextWindowTokens: number;
}): () => void {
const contextWindowTokens = Math.max(1, Math.floor(params.contextWindowTokens));
const contextBudgetChars = Math.max(
1_024,
Math.floor(contextWindowTokens * CHARS_PER_TOKEN_ESTIMATE * CONTEXT_INPUT_HEADROOM_RATIO),
);
const maxSingleToolResultChars = Math.max(
1_024,
Math.floor(
contextWindowTokens * TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE * SINGLE_TOOL_RESULT_CONTEXT_SHARE,
),
);
// Agent.transformContext is private in pi-coding-agent, so access it via a
// narrow runtime view to keep callsites type-safe while preserving behavior.
const mutableAgent = params.agent as GuardableAgentRecord;
const originalTransformContext = mutableAgent.transformContext;
mutableAgent.transformContext = (async (messages: AgentMessage[], signal: AbortSignal) => {
const transformed = originalTransformContext
? await originalTransformContext.call(mutableAgent, messages, signal)
: messages;
const contextMessages = Array.isArray(transformed) ? transformed : messages;
enforceToolResultContextBudgetInPlace({
messages: contextMessages,
contextBudgetChars,
maxSingleToolResultChars,
});
return contextMessages;
}) as GuardableTransformContext;
return () => {
mutableAgent.transformContext = originalTransformContext;
};
}