Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": "后端调用超时(毫秒)" }
Expand Down
23 changes: 23 additions & 0 deletions src/config.tool-offload.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -489,6 +497,10 @@ export function parseConfig(raw: Record<string, unknown> | 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,
Expand Down
131 changes: 128 additions & 3 deletions src/offload/hooks/after-tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions src/offload/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading