diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 0a6d7e9a..b986357b 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -7,7 +7,7 @@ "onStartup": true }, "contracts": { - "tools": ["tdai_memory_search", "tdai_conversation_search"] + "tools": ["tdai_memory_search", "tdai_conversation_search", "tdai_offload_read"] }, "configSchema": { "type": "object", @@ -161,6 +161,10 @@ "mildOffloadRatio": { "type": "number", "default": 0.5, "description": "温和压缩触发比例(占 context window)" }, "aggressiveCompressRatio": { "type": "number", "default": 0.85, "description": "激进压缩触发比例" }, "mmdMaxTokenRatio": { "type": "number", "default": 0.2, "description": "MMD 注入 token 预算比例" }, + "inlineToolResultMaxTokens": { "type": "number", "default": 1200, "description": "单个工具结果超过该 token 估算值时,写入 refs 并在 prompt 中替换为 result_ref 摘要" }, + "summaryMaxTokens": { "type": "number", "default": 350, "description": "工具结果卸载摘要 token 预算" }, + "previewMaxChars": { "type": "number", "default": 1200, "description": "用于生成确定性摘要的预览字符数" }, + "readChunkMaxTokens": { "type": "number", "default": 1600, "description": "tdai_offload_read 单次返回 token 预算" }, "backendUrl": { "type": "string", "description": "后端服务 URL(如 https://offload-api.example.com),配置后 L1/L1.5/L2/L4 走后端" }, "backendApiKey": { "type": "string", "description": "后端 API 认证 token" }, "backendTimeoutMs": { "type": "number", "default": 10000, "description": "后端调用超时(毫秒)" } diff --git a/src/config.tool-offload.test.ts b/src/config.tool-offload.test.ts new file mode 100644 index 00000000..929b813b --- /dev/null +++ b/src/config.tool-offload.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { parseConfig } from "./config.js"; + +describe("tool-result offload config", () => { + it("provides bounded defaults and parses explicit limits", () => { + const defaults = parseConfig({}); + expect(defaults.offload.inlineToolResultMaxTokens).toBe(1200); + expect(defaults.offload.readChunkMaxTokens).toBe(1600); + + const configured = parseConfig({ + offload: { + inlineToolResultMaxTokens: 64, + summaryMaxTokens: 24, + previewMaxChars: 320, + readChunkMaxTokens: 512, + }, + }); + expect(configured.offload.inlineToolResultMaxTokens).toBe(64); + expect(configured.offload.summaryMaxTokens).toBe(24); + expect(configured.offload.previewMaxChars).toBe(320); + expect(configured.offload.readChunkMaxTokens).toBe(512); + }); +}); diff --git a/src/config.ts b/src/config.ts index ef614415..d8ba3395 100644 --- a/src/config.ts +++ b/src/config.ts @@ -260,6 +260,14 @@ export interface OffloadConfig { aggressiveCompressRatio: number; /** MMD injection token budget ratio (default: 0.2) */ mmdMaxTokenRatio: number; + /** Inline tool-result budget before front offload replaces the prompt payload (default: 1200) */ + inlineToolResultMaxTokens: number; + /** Summary token budget for offloaded tool-result stubs (default: 350) */ + summaryMaxTokens: number; + /** Preview character budget used for deterministic summaries (default: 1200) */ + previewMaxChars: number; + /** Max tokens returned by tdai_offload_read in one call (default: 1600) */ + readChunkMaxTokens: number; /** Backend service URL. When set, L1/L1.5/L2/L4 LLM calls go through the backend. */ backendUrl?: string; /** Backend API authentication token */ @@ -489,6 +497,10 @@ export function parseConfig(raw: Record | undefined): MemoryTda mildOffloadRatio: num(offloadGroup, "mildOffloadRatio") ?? 0.5, aggressiveCompressRatio: num(offloadGroup, "aggressiveCompressRatio") ?? 0.85, mmdMaxTokenRatio: num(offloadGroup, "mmdMaxTokenRatio") ?? 0.2, + inlineToolResultMaxTokens: num(offloadGroup, "inlineToolResultMaxTokens") ?? 1200, + summaryMaxTokens: num(offloadGroup, "summaryMaxTokens") ?? 350, + previewMaxChars: num(offloadGroup, "previewMaxChars") ?? 1200, + readChunkMaxTokens: num(offloadGroup, "readChunkMaxTokens") ?? 1600, backendUrl: optStr(offloadGroup, "backendUrl"), backendApiKey: optStr(offloadGroup, "backendApiKey"), backendTimeoutMs: num(offloadGroup, "backendTimeoutMs") ?? 120000, diff --git a/src/offload/hooks/after-tool-call.ts b/src/offload/hooks/after-tool-call.ts index 64e38395..48410151 100644 --- a/src/offload/hooks/after-tool-call.ts +++ b/src/offload/hooks/after-tool-call.ts @@ -7,7 +7,8 @@ import { nowChinaISO } from "../time-utils.js"; import { buildTiktokenContextSnapshot, type ContextSnapshot } from "../context-token-tracker.js"; import { traceOffloadDecision, traceMessagesSnapshot } from "../opik-tracer.js"; import { PLUGIN_DEFAULTS } from "../types.js"; -import { readOffloadEntries, markOffloadStatus, readMmd } from "../storage.js"; +import { readOffloadEntries, markOffloadStatus, readMmd, writeRefMd } from "../storage.js"; +import { normalizeToolResultForPrompt } from "../tool-result-normalizer.js"; import { createL3TokenCounter } from "../l3-token-counter.js"; import { normalizeToolCallIdForLookup, @@ -173,13 +174,62 @@ export function createAfterToolCallHandler( return; } + const rawResult = event.result; + const timestamp = nowChinaISO(); + let promptResult = rawResult; + let frontOffload: { resultRef?: string; contentHash?: string; originalTokens?: number } = {}; + try { + const normalized = await normalizeToolResultForPrompt({ + toolName: event.toolName, + toolCallId, + timestamp, + result: rawResult, + maxTokens: pluginConfig?.inlineToolResultMaxTokens ?? PLUGIN_DEFAULTS.inlineToolResultMaxTokens, + summaryMaxTokens: pluginConfig?.summaryMaxTokens ?? PLUGIN_DEFAULTS.summaryMaxTokens, + previewMaxChars: pluginConfig?.previewMaxChars ?? PLUGIN_DEFAULTS.previewMaxChars, + writeRef: async (content) => writeRefMd( + stateManager.ctx, + timestamp, + event.toolName, + content, + `${toolCallId}-${normalizedHashPrefix(content)}`, + stateManager.getLastSessionKey() ?? undefined, + ), + }); + if (normalized.offloaded) { + promptResult = normalized.promptResult; + frontOffload = { + resultRef: normalized.resultRef, + contentHash: normalized.contentHash, + originalTokens: normalized.originalTokens, + }; + if (applyFrontOffloadResult(event, toolCallId, promptResult)) { + logger.debug?.( + `[context-offload] front-offload: ${event.toolName} (${toolCallId}) ` + + `tokens=${normalized.originalTokens}, ref=${normalized.resultRef}`, + ); + } else { + frontOffload = {}; + logger.warn?.( + `[context-offload] front-offload skipped for ${event.toolName} (${toolCallId}): ` + + "prompt-facing tool result was not found; retaining the original result for normal L3 handling.", + ); + } + } + } catch (err) { + logger.warn?.(`[context-offload] front-offload failed for ${event.toolName} (${toolCallId}): ${String(err)}`); + } + const pair: ToolPair = { toolName: event.toolName, toolCallId, params: resolvedParams, - result: event.result, + result: rawResult, + result_ref: frontOffload.resultRef, + content_hash: frontOffload.contentHash, + original_tokens: frontOffload.originalTokens, error: event.error, - timestamp: nowChinaISO(), + timestamp, durationMs: event.durationMs, }; stateManager.addToolPair(pair); @@ -586,6 +636,81 @@ function _extractText(msg: any): string { return ""; } +function normalizedHashPrefix(content: string): string { + let hash = 0; + for (let i = 0; i < content.length; i++) { + hash = ((hash << 5) - hash + content.charCodeAt(i)) | 0; + } + return Math.abs(hash).toString(16); +} + +export function applyFrontOffloadResult(event: any, toolCallId: string, promptResult: unknown): boolean { + if (!replacePromptFacingToolResult(event?.messages, toolCallId, promptResult)) return false; + event.result = promptResult; + return true; +} + +function replacePromptFacingToolResult(messages: any, toolCallId: string, promptResult: unknown): boolean { + if (!Array.isArray(messages)) return false; + const target = normalizeToolCallIdForLookup(toolCallId); + for (const msg of messages) { + const id = extractPromptToolResultId(msg); + if (id && normalizeToolCallIdForLookup(id) === target) { + replaceMessageContent(msg, promptResult); + stripLargePromptFields(msg); + return true; + } + } + return false; +} + +function replaceMessageContent(msg: any, promptResult: unknown): void { + const text = typeof promptResult === "string" ? promptResult : JSON.stringify(promptResult, null, 2); + if (msg.type === "message" && msg.message) { + msg.message.content = [{ type: "text", text }]; + } else { + msg.content = [{ type: "text", text }]; + } +} + +function extractPromptToolResultId(msg: any): string | null { + const direct = extractToolCallId(msg) + ?? msg?.id + ?? msg?.tool_use_id + ?? msg?.message?.id + ?? msg?.message?.tool_use_id; + if (typeof direct === "string" && direct) return direct; + + const content = msg?.content ?? msg?.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + const id = block?.toolCallId ?? block?.tool_call_id ?? block?.tool_use_id ?? block?.id; + if (typeof id === "string" && id) return id; + } + } + return null; +} + +function stripLargePromptFields(msg: any): void { + const preserve = new Set([ + "role", "type", "name", "id", "toolCallId", "tool_call_id", "tool_use_id", + "content", "message", "status", "_offloaded", "_mmdContextMessage", + "_mmdInjection", "_contextOffloadProcessed", "_cachedTokens", "_tokenCount", + ]); + const stripObj = (obj: any) => { + if (!obj || typeof obj !== "object") return; + for (const key of Object.keys(obj)) { + if (preserve.has(key)) continue; + const value = obj[key]; + if (value == null) continue; + const serialized = typeof value === "string" ? value : JSON.stringify(value); + if (serialized && serialized.length > 500) delete obj[key]; + } + }; + stripObj(msg); + if (msg?.message && typeof msg.message === "object") stripObj(msg.message); +} + /** Dump all messages after MMD injection for diagnostics (debug-level only). */ function _dumpMessagesAfterMmd(messages: any[], action: string, logger: PluginLogger): void { const mmdCount = messages.filter((m: any) => m._mmdContextMessage || m._mmdInjection).length; diff --git a/src/offload/index.test.ts b/src/offload/index.test.ts new file mode 100644 index 00000000..9038590d --- /dev/null +++ b/src/offload/index.test.ts @@ -0,0 +1,86 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { registerOffload, isSafeRefPath, resolveReadMaxTokens, sliceRefContent } from "./index.js"; +import { createStorageContext, ensureDirs, writeRefMd } from "./storage.js"; + +function createToolApi() { + const tools: any[] = []; + const logger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; + return { + api: { + config: {}, + logger, + registerTool: (tool: any) => tools.push(tool), + }, + tools, + }; +} + +describe("sliceRefContent", () => { + it("returns the query hit from an oversized single-line ref payload", () => { + const before = Array.from({ length: 400 }, (_, i) => `TDAI_OFFLOAD_SENTINEL ${i} ${"X".repeat(80)}`).join("\\n"); + const hit = `TDAI_OFFLOAD_SENTINEL 400 ${"X".repeat(80)}`; + const after = Array.from({ length: 49 }, (_, i) => `TDAI_OFFLOAD_SENTINEL ${401 + i} ${"X".repeat(80)}`).join("\\n"); + const raw = `# Tool Result\n\n~~~json\n{"aggregated":"${before}\\n${hit}\\n${after}"}\n~~~`; + + const sliced = sliceRefContent(raw, { + query: "TDAI_OFFLOAD_SENTINEL 400", + maxTokens: 1200, + }); + + expect(sliced).toContain("TDAI_OFFLOAD_SENTINEL 400"); + expect(sliced).not.toBe("[truncated: max_tokens=1200]"); + }); +}); + +describe("isSafeRefPath", () => { + it("accepts a single refs filename and rejects traversal", () => { + expect(isSafeRefPath("refs/tool-result.md")).toBe(true); + expect(isSafeRefPath("refs/../secret.md")).toBe(false); + expect(isSafeRefPath("refs/nested/result.md")).toBe(false); + expect(isSafeRefPath("C:\\secret.md")).toBe(false); + }); +}); + +describe("tdai_offload_read", () => { + it("rejects calls that do not provide an explicit session key", async () => { + const dataRoot = await mkdtemp(join(tmpdir(), "tdai-offload-read-")); + try { + const { api, tools } = createToolApi(); + registerOffload(api, { mode: "collect", dataDir: dataRoot } as any); + const readTool = tools.find((tool) => tool.name === "tdai_offload_read"); + + await expect(readTool.execute("call-1", { result_ref: "refs/tool-result.md" }, {})) + .resolves.toBe("tdai_offload_read: an explicit sessionKey is required."); + + const sessionKey = "agent:main:session-1"; + const ctx = createStorageContext(dataRoot, "main", "session-1"); + await ensureDirs(ctx); + const ref = await writeRefMd( + ctx, + "2026-07-10T00:00:00.000Z", + "exec", + "owned session content", + "call-1", + sessionKey, + ); + + await expect(readTool.execute("call-2", { result_ref: ref }, { sessionKey })) + .resolves.toContain("owned session content"); + + await expect( + readTool.execute("call-3", { result_ref: ref }, { sessionKey: "agent:main:session-2" }), + ).resolves.toBe(`tdai_offload_read: result_ref not found: ${ref}`); + } finally { + await rm(dataRoot, { recursive: true, force: true }); + } + }); +}); + +describe("resolveReadMaxTokens", () => { + it("caps the requested read size at the configured hard limit", () => { + expect(resolveReadMaxTokens(10_000, 256)).toBe(256); + }); +}); diff --git a/src/offload/index.ts b/src/offload/index.ts index 35c18bb4..e86f2eda 100644 --- a/src/offload/index.ts +++ b/src/offload/index.ts @@ -20,6 +20,7 @@ import { readOffloadEntries, markOffloadStatus, DEFAULT_DATA_ROOT, + readOwnedRefMd, } from "./storage.js"; import { buildTiktokenContextSnapshot, configureTokenTracker, tiktokenCount, jsonReplacer } from "./context-token-tracker.js"; import { fastEstimateMessages } from "./fast-token-estimate.js"; @@ -96,6 +97,74 @@ let _sharedSessions: SessionRegistry | null = null; // ─── Helpers ───────────────────────────────────────────────────────────────── +export function isSafeRefPath(refPath: string): boolean { + return /^refs\/[^/\\]+\.md$/u.test(refPath) && + !refPath.includes("..") && + !refPath.includes(":") && + !refPath.startsWith("/") && + !refPath.startsWith("\\"); +} + +export function sliceRefContent( + raw: string, + options: { query?: string; startLine?: number; endLine?: number; maxTokens: number }, +): string { + if (options.query && options.query.trim()) { + const q = options.query.trim().toLowerCase(); + const rawLower = raw.toLowerCase(); + const matchIndex = rawLower.indexOf(q); + if (matchIndex < 0) return "(empty)"; + + const maxTokens = Math.max(80, Math.floor(options.maxTokens)); + const budgetChars = Math.max(400, maxTokens * 4); + let startOffset = Math.max(0, matchIndex - Math.floor(budgetChars / 2)); + let endOffset = Math.min(raw.length, startOffset + budgetChars); + if (endOffset - startOffset < budgetChars) { + startOffset = Math.max(0, endOffset - budgetChars); + } + let excerpt = raw.slice(startOffset, endOffset); + + while (tiktokenCount(excerpt) > maxTokens && excerpt.length > q.length) { + const excess = Math.max(100, Math.ceil(excerpt.length * 0.1)); + const matchInExcerpt = Math.max(0, matchIndex - startOffset); + const trimStart = matchInExcerpt > excerpt.length / 2; + if (trimStart) { + startOffset = Math.min(matchIndex, startOffset + excess); + } else { + endOffset = Math.max(matchIndex + options.query.trim().length, endOffset - excess); + } + excerpt = raw.slice(startOffset, endOffset); + } + + const prefix = startOffset > 0 ? "[truncated before]\n" : ""; + const suffix = endOffset < raw.length ? "\n[truncated after]" : ""; + return `${prefix}${excerpt}${suffix}`; + } + + const lines = raw.split(/\r?\n/u).map((line, idx) => ({ lineNo: idx + 1, text: line })); + const start = Math.max(1, Math.floor(options.startLine ?? 1)); + const end = Math.max(start, Math.floor(options.endLine ?? lines.length)); + const selectedLines = lines.filter((line) => line.lineNo >= start && line.lineNo <= end); + const maxTokens = Math.max(80, Math.floor(options.maxTokens)); + const output: string[] = []; + for (const item of selectedLines) { + const next = `${item.lineNo}: ${item.text}`; + const candidate = [...output, next].join("\n"); + if (tiktokenCount(candidate) > maxTokens) { + output.push(`[truncated: max_tokens=${maxTokens}]`); + break; + } + output.push(next); + } + return output.join("\n") || "(empty)"; +} + +export function resolveReadMaxTokens(requested: number | undefined, configuredLimit: number): number { + const hardLimit = Math.max(80, Math.floor(configuredLimit)); + if (requested == null || !Number.isFinite(requested)) return hardLimit; + return Math.min(hardLimit, Math.max(80, Math.floor(requested))); +} + function parseCreateSkillCommand( prompt: string, ): { mmdName: string | null; skillFocus: string | null } | null { @@ -298,6 +367,10 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void { mildOffloadRatio: offloadConfig.mildOffloadRatio, aggressiveCompressRatio: offloadConfig.aggressiveCompressRatio, mmdMaxTokenRatio: offloadConfig.mmdMaxTokenRatio, + inlineToolResultMaxTokens: offloadConfig.inlineToolResultMaxTokens, + summaryMaxTokens: offloadConfig.summaryMaxTokens, + previewMaxChars: offloadConfig.previewMaxChars, + readChunkMaxTokens: offloadConfig.readChunkMaxTokens, }; // Fix 4: Configure token tracker encoding to match plugin config (default: o200k_base) @@ -439,11 +512,22 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void { const refByToolCallId = new Map(); for (const p of pairs) { try { + if (p.result_ref) { + refByToolCallId.set(p.toolCallId, p.result_ref); + continue; + } const resultStr = typeof p.result === "string" ? sanitizeText(p.result) : sanitizeText(JSON.stringify(p.result, null, 2)); const content = `**Tool:** ${p.toolName}\n**Call ID:** ${p.toolCallId}\n\n**Result:**\n\`\`\`\n${resultStr}\n\`\`\``; - const refPath = await writeRefMd(stateManager.ctx, p.timestamp, p.toolName, content); + const refPath = await writeRefMd( + stateManager.ctx, + p.timestamp, + p.toolName, + content, + undefined, + stateManager.getLastSessionKey() ?? undefined, + ); refByToolCallId.set(p.toolCallId, refPath); } catch (err) { logger.error(`[context-offload] L1.1 ref write error (${p.toolCallId}): ${err}`); @@ -887,6 +971,52 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void { return entry.manager; }; + if (typeof api.registerTool === "function") { + api.registerTool( + { + name: "tdai_offload_read", + label: "Offload Read", + description: + "Read full tool results previously replaced by context offload. Use result_ref from an offloaded tool result stub. " + + "Only refs/... paths are allowed. Prefer query/start_line/end_line/max_tokens to read the smallest useful chunk.", + parameters: { + type: "object", + properties: { + result_ref: { type: "string", description: "Relative ref path, must start with refs/ and end with .md" }, + query: { type: "string", description: "Optional substring filter. Returns matching lines with line numbers." }, + start_line: { type: "number", description: "Optional 1-based inclusive start line" }, + end_line: { type: "number", description: "Optional 1-based inclusive end line" }, + max_tokens: { type: "number", description: "Optional max token budget for returned content" }, + }, + required: ["result_ref"], + }, + async execute(_toolCallId: string, params: Record, ctx?: any) { + const refPath = String(params?.result_ref ?? ""); + if (!isSafeRefPath(refPath)) { + return "tdai_offload_read: invalid result_ref. Only refs/*.md relative paths are allowed."; + } + if (typeof ctx?.sessionKey !== "string" || !ctx.sessionKey) { + return "tdai_offload_read: an explicit sessionKey is required."; + } + const mgr = await _resolveSession(ctx.sessionKey, ctx?.sessionId); + if (!mgr) return "tdai_offload_read: no active session is available."; + const raw = await readOwnedRefMd(mgr.ctx, refPath, ctx.sessionKey); + if (raw == null) return `tdai_offload_read: result_ref not found: ${refPath}`; + return sliceRefContent(raw, { + query: typeof params?.query === "string" ? params.query : undefined, + startLine: typeof params?.start_line === "number" ? params.start_line : undefined, + endLine: typeof params?.end_line === "number" ? params.end_line : undefined, + maxTokens: resolveReadMaxTokens( + typeof params?.max_tokens === "number" ? params.max_tokens : undefined, + pCfg.readChunkMaxTokens ?? PLUGIN_DEFAULTS.readChunkMaxTokens, + ), + }); + }, + }, + { name: "tdai_offload_read" }, + ); + } + // L2 Scheduler — uses module-level state (_l2Running, _l2PollHandle, _l2FirstNotifyAt) // Clean up any lingering poll timer from previous registerOffload() call if (_l2PollHandle !== null) { clearTimeout(_l2PollHandle); _l2PollHandle = null; } diff --git a/src/offload/l3-helpers.ts b/src/offload/l3-helpers.ts index d9d08f90..75222472 100644 --- a/src/offload/l3-helpers.ts +++ b/src/offload/l3-helpers.ts @@ -225,7 +225,8 @@ export function replaceWithSummary(msg: any, entry: OffloadEntry): { originalLen const summaryContent = [ `[Offloaded Tool Result | node: ${entry.node_id ?? "N/A"}]`, `Summary: ${entry.summary}`, - `result_ref: ${entry.result_ref} (read this file for full tool call and raw result)`, + `result_ref: ${entry.result_ref}`, + `instruction: call tdai_offload_read({ result_ref: "${entry.result_ref}" }) for full tool call and raw result`, ].join("\n"); // Measure original content length diff --git a/src/offload/storage.test.ts b/src/offload/storage.test.ts new file mode 100644 index 00000000..adfa2323 --- /dev/null +++ b/src/offload/storage.test.ts @@ -0,0 +1,32 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { + createStorageContext, + ensureDirs, + readRefMd, + writeRefMd, +} from "./storage.js"; + +describe("tool-result refs", () => { + it("writes collision-resistant refs and reads them through the session context", async () => { + const dataRoot = await mkdtemp(join(tmpdir(), "tdai-offload-")); + try { + const ctx = createStorageContext(dataRoot, "main", "session-1"); + await ensureDirs(ctx); + const ref = await writeRefMd( + ctx, + "2026-07-06T00:00:00.000Z", + "exec", + "TDAI_OFFLOAD_SENTINEL 400", + "tool-call-1", + ); + + expect(ref).toContain("tool-call-1"); + await expect(readRefMd(ctx, ref)).resolves.toContain("TDAI_OFFLOAD_SENTINEL 400"); + } finally { + await rm(dataRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/src/offload/storage.ts b/src/offload/storage.ts index 222a66b7..54d497b8 100644 --- a/src/offload/storage.ts +++ b/src/offload/storage.ts @@ -534,11 +534,17 @@ export async function writeRefMd( timestamp: string, toolName: string, content: string, + suffix?: string, + ownerSessionKey?: string, ): Promise { - const filename = `${isoToFilename(timestamp)}.md`; + const safeSuffix = suffix ? `-${sanitizePath(suffix).slice(0, 80)}` : ""; + const filename = `${isoToFilename(timestamp)}${safeSuffix}.md`; const filePath = join(ctx.refsDir, filename); const safeContent = (content ?? "").replace(UNSAFE_CHAR_RE, ""); - const header = `# Tool Result: ${toolName}\n\n**Timestamp:** ${timestamp}\n\n---\n\n`; + const ownerMarker = ownerSessionKey + ? `\n` + : ""; + const header = `${ownerMarker}# Tool Result: ${toolName}\n\n**Timestamp:** ${timestamp}\n\n---\n\n`; await writeFile(filePath, header + safeContent, "utf-8"); return `refs/${filename}`; } @@ -553,6 +559,23 @@ export async function readRefMd( return readFile(filePath, "utf-8"); } +/** Read a ref only when it was written by the requesting session. */ +export async function readOwnedRefMd( + ctx: StorageContext, + refPath: string, + sessionKey: string, +): Promise { + const raw = await readRefMd(ctx, refPath); + if (raw == null) return null; + const match = raw.match(/^$/m); + if (!match) return null; + try { + return decodeURIComponent(match[1]) === sessionKey ? raw : null; + } catch { + return null; + } +} + // ─── MMD (Mermaid) Operations ──────────────────────────────────────────────── /** A single replace block for patchMmd */ diff --git a/src/offload/tool-result-normalizer.test.ts b/src/offload/tool-result-normalizer.test.ts new file mode 100644 index 00000000..fc3f0301 --- /dev/null +++ b/src/offload/tool-result-normalizer.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { normalizeToolResultForPrompt, stableSerialize } from "./tool-result-normalizer.js"; +import { applyFrontOffloadResult } from "./hooks/after-tool-call.js"; + +describe("normalizeToolResultForPrompt", () => { + it("keeps small tool results inline", async () => { + const result = await normalizeToolResultForPrompt({ + toolName: "small", + toolCallId: "tc-small", + timestamp: "2026-07-06T00:00:00.000Z", + result: { ok: true }, + maxTokens: 100, + writeRef: async () => "refs/unused.md", + }); + + expect(result.offloaded).toBe(false); + expect(result.promptResult).toEqual({ ok: true }); + }); + + it("offloads large tool results with deterministic serialization and content hash", async () => { + const refs: string[] = []; + const result = await normalizeToolResultForPrompt({ + toolName: "read_file", + toolCallId: "tc-large", + timestamp: "2026-07-06T00:00:00.000Z", + result: { b: "x".repeat(1000), a: 1 }, + maxTokens: 20, + summaryMaxTokens: 20, + previewMaxChars: 60, + writeRef: async (content) => { + refs.push(content); + return "refs/tc-large.md"; + }, + }); + + expect(result.offloaded).toBe(true); + expect(refs).toHaveLength(1); + expect(refs[0]).toBe(stableSerialize({ a: 1, b: "x".repeat(1000) })); + expect(result.promptResult).toMatchObject({ + _tdai_offloaded: true, + result_ref: "refs/tc-large.md", + tool_call_id: "tc-large", + }); + expect(JSON.stringify(result.promptResult)).toContain(result.contentHash); + }); +}); + +describe("applyFrontOffloadResult", () => { + it("keeps the raw event result when prompt-facing history has no matching tool result", () => { + const rawResult = "X".repeat(4_000); + const event = { + result: rawResult, + messages: [{ role: "toolResult", toolCallId: "different-call", content: rawResult }], + }; + const promptResult = { _tdai_offloaded: true, result_ref: "refs/tool-result.md" }; + + expect(applyFrontOffloadResult(event, "call-1", promptResult)).toBe(false); + expect(event.result).toBe(rawResult); + expect(event.messages[0].content).toBe(rawResult); + }); +}); diff --git a/src/offload/tool-result-normalizer.ts b/src/offload/tool-result-normalizer.ts new file mode 100644 index 00000000..c56e9b43 --- /dev/null +++ b/src/offload/tool-result-normalizer.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { fastEstimateTokens } from "./fast-token-estimate.js"; + +export interface NormalizeToolResultInput { + toolName: string; + toolCallId: string; + timestamp: string; + result: unknown; + maxTokens: number; + summaryMaxTokens?: number; + previewMaxChars?: number; + writeRef: (content: string) => Promise; +} + +export interface NormalizeToolResultOutput { + offloaded: boolean; + promptResult: unknown; + resultRef?: string; + contentHash?: string; + originalTokens: number; +} + +export async function normalizeToolResultForPrompt(input: NormalizeToolResultInput): Promise { + const serialized = stableSerialize(input.result); + const originalTokens = fastEstimateTokens(serialized); + if (originalTokens <= Math.max(1, input.maxTokens)) { + return { offloaded: false, promptResult: input.result, originalTokens }; + } + + const contentHash = sha256(serialized); + const resultRef = await input.writeRef(serialized); + const summary = buildDeterministicSummary(serialized, input.previewMaxChars ?? 1200, input.summaryMaxTokens ?? 350); + return { + offloaded: true, + resultRef, + contentHash, + originalTokens, + promptResult: { + _tdai_offloaded: true, + tool_name: input.toolName, + tool_call_id: input.toolCallId, + result_ref: resultRef, + content_hash: contentHash, + original_tokens: originalTokens, + summary, + instruction: "需要完整工具结果时调用 tdai_offload_read({ result_ref }),可用 start_line/end_line/query/max_tokens 精确读取。", + }, + }; +} + +export function stableSerialize(value: unknown): string { + return JSON.stringify(sortForJson(value), null, 2) ?? String(value); +} + +function sortForJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortForJson); + if (value && typeof value === "object") { + const entries = Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => [k, sortForJson(v)]); + return Object.fromEntries(entries); + } + return value; +} + +function buildDeterministicSummary(serialized: string, previewMaxChars: number, summaryMaxTokens: number): string { + const maxChars = Math.max(80, Math.min(previewMaxChars, summaryMaxTokens * 4)); + const preview = Array.from(serialized).slice(0, maxChars).join("").trimEnd(); + return preview.length < serialized.length ? `${preview}\n[truncated]` : preview; +} + +function sha256(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} diff --git a/src/offload/types.ts b/src/offload/types.ts index fce1f319..a9087821 100644 --- a/src/offload/types.ts +++ b/src/offload/types.ts @@ -35,6 +35,9 @@ export interface ToolPair { toolCallId: string; params: Record | string; result: unknown; + result_ref?: string; + content_hash?: string; + original_tokens?: number; error?: string; timestamp: string; durationMs?: number; @@ -185,6 +188,14 @@ export interface PluginConfig { emergencyTargetRatio?: number; /** Max ratio of total tokens that injected MMDs may occupy. Default: 0.2 */ mmdMaxTokenRatio?: number; + /** Inline tool-result budget before front offload. Default: 1200 */ + inlineToolResultMaxTokens?: number; + /** Summary token budget for front offload stubs. Default: 350 */ + summaryMaxTokens?: number; + /** Preview character budget for deterministic summaries. Default: 1200 */ + previewMaxChars?: number; + /** Max tokens returned by tdai_offload_read. Default: 1600 */ + readChunkMaxTokens?: number; /** * L3 token counting: `tiktoken` uses js-tiktoken (exact BPE for chosen encoding); * `heuristic` uses 中文/1.7 + 其余/4. Default: tiktoken. @@ -243,6 +254,10 @@ export const PLUGIN_DEFAULTS = { /** Emergency target: delete until tokens <= contextWindow * 0.6 */ emergencyTargetRatio: 0.6, mmdMaxTokenRatio: 0.2, + inlineToolResultMaxTokens: 1200, + summaryMaxTokens: 350, + previewMaxChars: 1200, + readChunkMaxTokens: 1600, l3TokenCountMode: "tiktoken" as const, l3TiktokenEncoding: "cl100k_base" as const, defaultSystemOverheadRatio: 0.12,