From da482ae195ba61fc1ccaef5c5c41461c96f27e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=81=AA=E6=98=8E=E7=89=88=E6=B4=BE=E5=A4=A7=E6=98=9F?= Date: Thu, 26 Mar 2026 23:53:50 +0800 Subject: [PATCH] feat(openclaw-plugin): compress oversized tool results during context assembly --- examples/openclaw-plugin/config.ts | 83 ++++ examples/openclaw-plugin/context-engine.ts | 416 ++++++++++++++++-- examples/openclaw-plugin/openclaw.plugin.json | 61 +++ 3 files changed, 532 insertions(+), 28 deletions(-) diff --git a/examples/openclaw-plugin/config.ts b/examples/openclaw-plugin/config.ts index 90100593f..ea432f23e 100644 --- a/examples/openclaw-plugin/config.ts +++ b/examples/openclaw-plugin/config.ts @@ -26,6 +26,13 @@ export type MemoryOpenVikingConfig = { ingestReplyAssist?: boolean; ingestReplyAssistMinSpeakerTurns?: number; ingestReplyAssistMinChars?: number; + compressToolContext?: boolean; + compressReadToolContext?: boolean; + compressToolContextAboveChars?: number; + compressToolContextMaxChars?: number; + compressToolContextHeadChars?: number; + compressToolContextTailChars?: number; + compressToolContextMaxImportantLines?: number; }; const DEFAULT_BASE_URL = "http://127.0.0.1:1933"; @@ -42,6 +49,13 @@ const DEFAULT_RECALL_TOKEN_BUDGET = 2000; const DEFAULT_INGEST_REPLY_ASSIST = true; const DEFAULT_INGEST_REPLY_ASSIST_MIN_SPEAKER_TURNS = 2; const DEFAULT_INGEST_REPLY_ASSIST_MIN_CHARS = 120; +const DEFAULT_COMPRESS_TOOL_CONTEXT = true; +const DEFAULT_COMPRESS_READ_TOOL_CONTEXT = false; +const DEFAULT_COMPRESS_TOOL_CONTEXT_ABOVE_CHARS = 1200; +const DEFAULT_COMPRESS_TOOL_CONTEXT_MAX_CHARS = 1600; +const DEFAULT_COMPRESS_TOOL_CONTEXT_HEAD_CHARS = 600; +const DEFAULT_COMPRESS_TOOL_CONTEXT_TAIL_CHARS = 400; +const DEFAULT_COMPRESS_TOOL_CONTEXT_MAX_IMPORTANT_LINES = 12; const DEFAULT_LOCAL_CONFIG_PATH = join(homedir(), ".openviking", "ov.conf"); const DEFAULT_AGENT_ID = "default"; @@ -121,6 +135,13 @@ export const memoryOpenVikingConfigSchema = { "ingestReplyAssist", "ingestReplyAssistMinSpeakerTurns", "ingestReplyAssistMinChars", + "compressToolContext", + "compressReadToolContext", + "compressToolContextAboveChars", + "compressToolContextMaxChars", + "compressToolContextHeadChars", + "compressToolContextTailChars", + "compressToolContextMaxImportantLines", ], "openviking config", ); @@ -201,6 +222,28 @@ export const memoryOpenVikingConfigSchema = { Math.floor(toNumber(cfg.ingestReplyAssistMinChars, DEFAULT_INGEST_REPLY_ASSIST_MIN_CHARS)), ), ), + compressToolContext: cfg.compressToolContext !== false, + compressReadToolContext: cfg.compressReadToolContext === true, + compressToolContextAboveChars: Math.max( + 100, + Math.min(100000, Math.floor(toNumber(cfg.compressToolContextAboveChars, DEFAULT_COMPRESS_TOOL_CONTEXT_ABOVE_CHARS))), + ), + compressToolContextMaxChars: Math.max( + 200, + Math.min(100000, Math.floor(toNumber(cfg.compressToolContextMaxChars, DEFAULT_COMPRESS_TOOL_CONTEXT_MAX_CHARS))), + ), + compressToolContextHeadChars: Math.max( + 80, + Math.min(50000, Math.floor(toNumber(cfg.compressToolContextHeadChars, DEFAULT_COMPRESS_TOOL_CONTEXT_HEAD_CHARS))), + ), + compressToolContextTailChars: Math.max( + 80, + Math.min(50000, Math.floor(toNumber(cfg.compressToolContextTailChars, DEFAULT_COMPRESS_TOOL_CONTEXT_TAIL_CHARS))), + ), + compressToolContextMaxImportantLines: Math.max( + 1, + Math.min(200, Math.floor(toNumber(cfg.compressToolContextMaxImportantLines, DEFAULT_COMPRESS_TOOL_CONTEXT_MAX_IMPORTANT_LINES))), + ), }; }, uiHints: { @@ -309,6 +352,46 @@ export const memoryOpenVikingConfigSchema = { help: "Minimum sanitized text length required before ingest reply assist can trigger.", advanced: true, }, + compressToolContext: { + label: "Compress Tool Context", + help: "Compress oversized tool results before they are assembled into the next model run.", + advanced: true, + }, + compressReadToolContext: { + label: "Compress Read Tool Context", + help: "Also compress oversized read tool results. Off by default to avoid hiding useful file content.", + advanced: true, + }, + compressToolContextAboveChars: { + label: "Compress Tool Context Above Chars", + placeholder: String(DEFAULT_COMPRESS_TOOL_CONTEXT_ABOVE_CHARS), + advanced: true, + help: "Only tool results longer than this are compressed during context assembly.", + }, + compressToolContextMaxChars: { + label: "Compress Tool Context Max Chars", + placeholder: String(DEFAULT_COMPRESS_TOOL_CONTEXT_MAX_CHARS), + advanced: true, + help: "Maximum chars kept for a compressed tool result in assembled context.", + }, + compressToolContextHeadChars: { + label: "Compress Tool Context Head Chars", + placeholder: String(DEFAULT_COMPRESS_TOOL_CONTEXT_HEAD_CHARS), + advanced: true, + help: "Chars to preserve from the start of oversized tool results when no strong key lines are found.", + }, + compressToolContextTailChars: { + label: "Compress Tool Context Tail Chars", + placeholder: String(DEFAULT_COMPRESS_TOOL_CONTEXT_TAIL_CHARS), + advanced: true, + help: "Chars to preserve from the end of oversized tool results.", + }, + compressToolContextMaxImportantLines: { + label: "Compress Tool Context Max Important Lines", + placeholder: String(DEFAULT_COMPRESS_TOOL_CONTEXT_MAX_IMPORTANT_LINES), + advanced: true, + help: "Maximum number of error/warn/key lines preserved from oversized tool results.", + }, }, }; diff --git a/examples/openclaw-plugin/context-engine.ts b/examples/openclaw-plugin/context-engine.ts index e9d0e7a8f..a31981fa8 100644 --- a/examples/openclaw-plugin/context-engine.ts +++ b/examples/openclaw-plugin/context-engine.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; - import type { OpenVikingClient } from "./client.js"; import type { MemoryOpenVikingConfig } from "./config.js"; import { @@ -14,6 +13,11 @@ import { type AgentMessage = { role?: string; content?: unknown; + toolName?: string; + toolCallId?: string; + details?: unknown; + isError?: boolean; + [key: string]: unknown; }; type ContextEngineInfo = { @@ -89,8 +93,387 @@ type Logger = { error: (msg: string) => void; }; +function md5Short(input: string): string { + return createHash("md5").update(input).digest("hex").slice(0, 12); +} + +const SAFE_SESSION_KEY_RE = /^[A-Za-z0-9_-]+$/; + +export function mapSessionKeyToOVSessionId(sessionKey: string): string { + const normalized = sessionKey.trim(); + if (!normalized) { + return "openclaw_session"; + } + if (SAFE_SESSION_KEY_RE.test(normalized)) { + return normalized; + } + + const readable = normalized + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 48); + const digest = md5Short(normalized); + return readable ? `openclaw_${readable}_${digest}` : `openclaw_session_${digest}`; +} + function estimateTokens(messages: AgentMessage[]): number { - return Math.max(1, messages.length * 80); + const chars = messages.reduce((sum, message) => sum + estimateMessageChars(message), 0); + return Math.max(1, Math.ceil(chars / 4)); +} + +function estimateMessageChars(message: AgentMessage): number { + if (!message || typeof message !== "object") { + return 0; + } + let chars = 0; + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (!block || typeof block !== "object") { + continue; + } + const text = (block as Record).text; + if (typeof text === "string") { + chars += text.length; + } + } + } else if (typeof message.content === "string") { + chars += message.content.length; + } + if (message.details && typeof message.details === "object") { + try { + chars += JSON.stringify(message.details).length; + } catch { + // ignore + } + } + return chars + 32; +} + +const IMPORTANT_LINE_RE = /(error|warn|exception|traceback|failed|failure|fatal|denied|forbidden|unauthorized|not found|enoent|eacces|eperm|timeout|timed out|refused|unreachable|no such|404|401|403|429|500|curl:|stderr|exit code)/i; +const AGGRESSIVE_DETAIL_KEYS = new Set([ + "aggregated", + "text", + "html", + "markdown", + "content", + "snapshot", + "rawHtml", + "dom", + "console", + "body", +]); +const TOOL_CONTEXT_COMPRESSIBLE = new Set(["exec", "process", "web_fetch", "browser"]); + +function cloneMessage(message: AgentMessage): AgentMessage { + return JSON.parse(JSON.stringify(message)) as AgentMessage; +} + +function getTextBlocks(message: AgentMessage): Array<{ index: number; text: string }> { + const content = message.content; + if (!Array.isArray(content)) { + return []; + } + const blocks: Array<{ index: number; text: string }> = []; + for (let i = 0; i < content.length; i += 1) { + const block = content[i]; + if (!block || typeof block !== "object") { + continue; + } + const blockObj = block as Record; + if (blockObj.type === "text" && typeof blockObj.text === "string") { + blocks.push({ index: i, text: blockObj.text }); + } + } + return blocks; +} + +function setSingleTextBlock(message: AgentMessage, nextText: string): void { + const content = Array.isArray(message.content) ? [...message.content] : []; + let replaced = false; + for (let i = 0; i < content.length; i += 1) { + const block = content[i]; + if (!block || typeof block !== "object") { + continue; + } + const blockObj = block as Record; + if (blockObj.type === "text") { + content[i] = { ...blockObj, text: nextText }; + replaced = true; + break; + } + } + if (!replaced) { + content.unshift({ type: "text", text: nextText }); + } + message.content = content; +} + +function trimChars(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + if (maxChars <= 3) { + return text.slice(0, maxChars); + } + return `${text.slice(0, maxChars - 3)}...`; +} + +function trimMiddle(text: string, headChars: number, tailChars: number): string { + if (text.length <= headChars + tailChars + 5) { + return text; + } + return `${text.slice(0, headChars)}\n...\n${text.slice(-tailChars)}`; +} + +function dedupeLines(lines: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of lines) { + const line = raw.trim(); + if (!line) { + continue; + } + const key = line.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + out.push(line); + } + return out; +} + +function stripExternalWrapper(text: string): string { + let out = text.replace(/<<]*>>>/g, "") + .replace(/<<]*>>>/g, "") + .replace(/\r\n/g, "\n"); + + if (out.startsWith("SECURITY NOTICE:")) { + const marker = out.indexOf("Source:"); + if (marker > 0) { + out = out.slice(marker); + } + } + + return out.replace(/\n{3,}/g, "\n\n").trim(); +} + +function sanitizeInline(text: unknown, maxChars: number): string | undefined { + if (typeof text !== "string") { + return undefined; + } + const normalized = stripExternalWrapper(text).replace(/\s+/g, " ").trim(); + if (!normalized) { + return undefined; + } + return trimChars(normalized, maxChars); +} + +function safeParseJson(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + return null; + } catch { + return null; + } +} + +function compactDetails(toolName: string, details: unknown, cfg: Required): Record | undefined { + if (!details || typeof details !== "object" || Array.isArray(details)) { + return details && typeof details === "object" && !Array.isArray(details) + ? { ...(details as Record) } + : undefined; + } + + const source = details as Record; + if (toolName === "exec" || toolName === "process") { + const next: Record = {}; + for (const key of ["status", "exitCode", "durationMs", "cwd", "sessionId", "retryInMs", "name"]) { + if (typeof source[key] !== "undefined") { + next[key] = key === "name" && typeof source[key] === "string" + ? trimChars(source[key] as string, 120) + : source[key]; + } + } + return next; + } + + const next: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (AGGRESSIVE_DETAIL_KEYS.has(key)) { + continue; + } + next[key] = typeof value === "string" ? trimChars(value, 240) : value; + } + return next; +} + +function summarizeExecLikeText(text: string, toolName: string, details: Record | undefined, cfg: Required): string { + const normalized = String(text ?? "").replace(/\r\n/g, "\n").trim(); + if (!normalized || normalized === "(no output)" || normalized === "(no new output)\n\nProcess still running.") { + return normalized || text; + } + + if (normalized.length <= cfg.compressToolContextAboveChars) { + return normalized; + } + + const lines = normalized.split("\n"); + const importantLines = dedupeLines(lines.filter((line) => IMPORTANT_LINE_RE.test(line))).slice(0, cfg.compressToolContextMaxImportantLines); + const meta: string[] = []; + if (typeof details?.status === "string") meta.push(`status=${details.status}`); + if (typeof details?.exitCode === "number") meta.push(`exitCode=${details.exitCode}`); + if (typeof details?.durationMs === "number") meta.push(`durationMs=${details.durationMs}`); + if (typeof details?.retryInMs === "number") meta.push(`retryInMs=${details.retryInMs}`); + + const parts: string[] = [`[compressed ${toolName} result]`]; + if (meta.length > 0) { + parts.push(meta.join(" ")); + } + parts.push(`originalChars=${normalized.length} originalLines=${lines.length}`); + + if (importantLines.length > 0) { + parts.push("important:"); + parts.push(...importantLines.map((line) => `- ${trimChars(line, Math.max(120, cfg.compressToolContextMaxChars - 40))}`)); + } + + const tail = trimChars(lines.slice(-Math.max(4, Math.min(12, cfg.compressToolContextMaxImportantLines))).join("\n"), cfg.compressToolContextTailChars); + if (tail) { + parts.push("tail:"); + parts.push(tail); + } + + if (importantLines.length === 0) { + parts.push("excerpt:"); + parts.push(trimMiddle(normalized, cfg.compressToolContextHeadChars, cfg.compressToolContextTailChars)); + } + + return trimChars(parts.join("\n"), cfg.compressToolContextMaxChars); +} + +function summarizeStructuredFetch(toolName: string, text: string, details: Record | undefined, cfg: Required): { text: string; details?: Record } | null { + const parsed = safeParseJson(text); + const source = parsed ?? details; + if (!source || typeof source !== "object") { + return null; + } + + const textExcerpt = sanitizeInline( + (source as Record).text + ?? (source as Record).markdown + ?? (source as Record).content, + Math.max(200, Math.min(cfg.compressToolContextMaxChars - 400, 1200)), + ); + + const compact: Record = { + tool: toolName, + url: typeof source.url === "string" ? source.url : undefined, + finalUrl: typeof source.finalUrl === "string" && source.finalUrl !== source.url ? source.finalUrl : undefined, + status: typeof source.status === "number" || typeof source.status === "string" ? source.status : undefined, + title: sanitizeInline(source.title, 180), + contentType: typeof source.contentType === "string" ? source.contentType : undefined, + extractMode: typeof source.extractMode === "string" ? source.extractMode : undefined, + truncated: typeof source.truncated === "boolean" ? source.truncated : undefined, + length: typeof source.length === "number" ? source.length : undefined, + tookMs: typeof source.tookMs === "number" ? source.tookMs : undefined, + untrustedExternal: typeof source.externalContent === "object" && source.externalContent !== null + ? Boolean((source.externalContent as Record).untrusted) + : undefined, + excerpt: textExcerpt, + }; + + const cleaned = Object.fromEntries(Object.entries(compact).filter(([, value]) => typeof value !== "undefined")); + const nextText = JSON.stringify(cleaned, null, 2); + return { + text: trimChars(nextText, cfg.compressToolContextMaxChars), + details: cleaned, + }; +} + +function compressToolResultMessage(message: AgentMessage, cfg: Required): { message: AgentMessage; changed: boolean; savedChars: number } { + if (message.role !== "toolResult" || !cfg.compressToolContext) { + return { message, changed: false, savedChars: 0 }; + } + + const toolName = typeof message.toolName === "string" ? message.toolName : ""; + const enabled = TOOL_CONTEXT_COMPRESSIBLE.has(toolName) || (toolName === "read" && cfg.compressReadToolContext); + if (!enabled) { + return { message, changed: false, savedChars: 0 }; + } + + const next = cloneMessage(message); + const textBlocks = getTextBlocks(next); + const firstText = textBlocks[0]?.text; + const details = next.details && typeof next.details === "object" && !Array.isArray(next.details) + ? { ...(next.details as Record) } + : undefined; + + if (!firstText) { + return { message, changed: false, savedChars: 0 }; + } + + if (firstText.length <= cfg.compressToolContextAboveChars) { + return { message, changed: false, savedChars: 0 }; + } + + let nextText = firstText; + let nextDetails = details; + + if (toolName === "web_fetch" || toolName === "browser") { + const summarized = summarizeStructuredFetch(toolName, firstText, details, cfg); + if (summarized) { + nextText = summarized.text; + nextDetails = summarized.details; + } else { + nextText = trimChars(trimMiddle(firstText, cfg.compressToolContextHeadChars, cfg.compressToolContextTailChars), cfg.compressToolContextMaxChars); + nextDetails = compactDetails(toolName, details, cfg); + } + } else { + nextText = summarizeExecLikeText(firstText, toolName, details, cfg); + nextDetails = compactDetails(toolName, details, cfg); + } + + setSingleTextBlock(next, nextText); + if (nextDetails) { + next.details = nextDetails; + } else if (typeof next.details !== "undefined") { + delete next.details; + } + + const savedChars = Math.max(0, firstText.length - nextText.length); + return { message: next, changed: nextText !== firstText, savedChars }; +} + +function compressMessagesForContext(messages: AgentMessage[], cfg: Required, logger: Logger): AgentMessage[] { + if (!cfg.compressToolContext) { + return messages; + } + + let compressedCount = 0; + let savedChars = 0; + const nextMessages = messages.map((message) => { + const compressed = compressToolResultMessage(message, cfg); + if (compressed.changed) { + compressedCount += 1; + savedChars += compressed.savedChars; + return compressed.message; + } + return message; + }); + + if (compressedCount > 0) { + logger.info(`openviking: assemble compressed ${compressedCount} tool results, saved ~${savedChars} chars`); + } + + return nextMessages; } async function tryLegacyCompact(params: { @@ -136,30 +519,6 @@ function warnOrInfo(logger: Logger, message: string): void { logger.info(message); } -function md5Short(input: string): string { - return createHash("md5").update(input).digest("hex").slice(0, 12); -} - -const SAFE_SESSION_KEY_RE = /^[A-Za-z0-9_-]+$/; - -export function mapSessionKeyToOVSessionId(sessionKey: string): string { - const normalized = sessionKey.trim(); - if (!normalized) { - return "openclaw_session"; - } - if (SAFE_SESSION_KEY_RE.test(normalized)) { - return normalized; - } - - const readable = normalized - .replace(/[^A-Za-z0-9_-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 48); - const digest = md5Short(normalized); - return readable ? `openclaw_${readable}_${digest}` : `openclaw_session_${digest}`; -} - export function createMemoryOpenVikingContextEngine(params: { id: string; name: string; @@ -230,9 +589,10 @@ export function createMemoryOpenVikingContextEngine(params: { }, async assemble(assembleParams): Promise { + const messages = compressMessagesForContext(assembleParams.messages, cfg, logger); return { - messages: assembleParams.messages, - estimatedTokens: estimateTokens(assembleParams.messages), + messages, + estimatedTokens: estimateTokens(messages), }; }, diff --git a/examples/openclaw-plugin/openclaw.plugin.json b/examples/openclaw-plugin/openclaw.plugin.json index 1edf6c683..9a042b41e 100644 --- a/examples/openclaw-plugin/openclaw.plugin.json +++ b/examples/openclaw-plugin/openclaw.plugin.json @@ -106,6 +106,46 @@ "placeholder": "120", "help": "Minimum sanitized text length required before ingest reply assist can trigger.", "advanced": true + }, + "compressToolContext": { + "label": "Compress Tool Context", + "help": "Compress oversized tool results before they are assembled into the next model run.", + "advanced": true + }, + "compressReadToolContext": { + "label": "Compress Read Tool Context", + "help": "Also compress oversized read tool results. Off by default to avoid hiding useful file content.", + "advanced": true + }, + "compressToolContextAboveChars": { + "label": "Compress Tool Context Above Chars", + "placeholder": "1200", + "advanced": true, + "help": "Only tool results longer than this are compressed during context assembly." + }, + "compressToolContextMaxChars": { + "label": "Compress Tool Context Max Chars", + "placeholder": "1600", + "advanced": true, + "help": "Maximum chars kept for a compressed tool result in assembled context." + }, + "compressToolContextHeadChars": { + "label": "Compress Tool Context Head Chars", + "placeholder": "600", + "advanced": true, + "help": "Chars to preserve from the start of oversized tool results when no strong key lines are found." + }, + "compressToolContextTailChars": { + "label": "Compress Tool Context Tail Chars", + "placeholder": "400", + "advanced": true, + "help": "Chars to preserve from the end of oversized tool results." + }, + "compressToolContextMaxImportantLines": { + "label": "Compress Tool Context Max Important Lines", + "placeholder": "12", + "advanced": true, + "help": "Maximum number of error/warn/key lines preserved from oversized tool results." } }, "configSchema": { @@ -171,6 +211,27 @@ }, "ingestReplyAssistMinChars": { "type": "number" + }, + "compressToolContext": { + "type": "boolean" + }, + "compressReadToolContext": { + "type": "boolean" + }, + "compressToolContextAboveChars": { + "type": "number" + }, + "compressToolContextMaxChars": { + "type": "number" + }, + "compressToolContextHeadChars": { + "type": "number" + }, + "compressToolContextTailChars": { + "type": "number" + }, + "compressToolContextMaxImportantLines": { + "type": "number" } } }