feat: release v1.0.0

This commit is contained in:
chrishuan
2026-06-09 15:28:05 +08:00
parent bc1575a0da
commit 9b7dbdd7b2
83 changed files with 7737 additions and 335 deletions
+526
View File
@@ -0,0 +1,526 @@
/**
* offload-client — OffloadContextEngine.
* Occupies the Context Engine slot and delegates compression to the server.
*/
import type { OffloadClientConfig, RecentMessage, Logger } from "./types.js";
import type { OffloadApiClient } from "./offload-api-client.js";
import { estimateAllTokens, estimateMessageTokens } from "./token-estimator.js";
const DEFAULT_CONTEXT_WINDOW = 128000;
/** compact target: keep messages until total <= contextWindow * TARGET_RATIO */
const COMPACT_TARGET_RATIO = 0.5;
/** When truncating a single large tool_result, keep at most this many chars */
const TOOL_RESULT_TRUNCATE_CHARS = 2000;
// ─── Message role helpers (handle multiple formats) ─────────────────────────
function getMsgRole(msg: any): string {
return msg?.role ?? msg?.message?.role ?? msg?.type ?? "";
}
function isToolResult(msg: any): boolean {
const role = getMsgRole(msg);
if (role === "tool" || role === "toolResult" || role === "tool_result") return true;
// Anthropic: user message with tool_result content blocks
if (role === "user" && Array.isArray(msg?.content)) {
return msg.content.some((b: any) => b?.type === "tool_result");
}
return false;
}
function isAssistantWithToolUse(msg: any): boolean {
const role = getMsgRole(msg);
if (role !== "assistant") return false;
const content = msg?.type === "message" ? msg?.message?.content : msg?.content;
if (!Array.isArray(content)) return false;
return content.some((b: any) => b?.type === "tool_use" || b?.type === "toolCall");
}
/**
* Truncate tool_result content in-place, returning a shallow-cloned message.
*/
function truncateToolResult(msg: any, maxChars: number): any {
const clone = JSON.parse(JSON.stringify(msg));
const content = clone.type === "message" ? clone.message?.content : clone.content;
if (typeof content === "string" && content.length > maxChars) {
const truncated = content.slice(0, maxChars) + "\n...[truncated]";
if (clone.type === "message") clone.message.content = truncated;
else clone.content = truncated;
return clone;
}
if (Array.isArray(content)) {
for (const block of content) {
if (typeof block?.text === "string" && block.text.length > maxChars) {
block.text = block.text.slice(0, maxChars) + "\n...[truncated]";
}
if (typeof block?.content === "string" && block.content.length > maxChars) {
block.content = block.content.slice(0, maxChars) + "\n...[truncated]";
}
}
return clone;
}
return clone;
}
export class OffloadContextEngine {
/** Per-session state cache. */
private sessions = new Map<string, {
lastAccessMs: number; // ← NEW: track last access time for cleanup
lastKnownTotalTokens: number;
lastKnownMsgCount: number;
lastL15PromptHash: string;
cachedPrompt?: string;
cachedRecentMessages: RecentMessage[];
cachedRecentContext?: string;
}>();
/** Get or create per-session state. */
private getSession(sessionKey: string) {
let s = this.sessions.get(sessionKey);
if (!s) {
s = {
lastAccessMs: Date.now(), // ← NEW
lastKnownTotalTokens: 0,
lastKnownMsgCount: 0,
lastL15PromptHash: "",
cachedRecentMessages: [],
};
this.sessions.set(sessionKey, s);
} else {
s.lastAccessMs = Date.now(); // ← NEW: update on access
}
return s;
}
/**
* Reset session state (call when session is /new'd or destroyed).
*/
resetSession(sessionKey: string): void {
this.sessions.delete(sessionKey);
}
/**
* Clear all session states (emergency shutdown).
*/
clearAllSessions(): void {
const n = this.sessions.size;
this.sessions.clear();
if (n > 0) {
this.logger.info(`[offload-client] cleared ${n} session states`);
}
}
/**
* Get the cached context for a session (for after_tool_call hook to send with ingest).
*/
getContext(sessionKey?: string): { prompt?: string; recentMessages?: RecentMessage[] } | undefined {
if (!sessionKey) return undefined;
const s = this.sessions.get(sessionKey);
if (!s || (!s.cachedPrompt && s.cachedRecentMessages.length === 0)) return undefined;
return { prompt: s.cachedPrompt, recentMessages: s.cachedRecentMessages };
}
/**
* Get the cached formatted context string for a session (for legacy compatibility).
*/
getRecentContext(sessionKey?: string): string | undefined {
if (!sessionKey) return undefined;
return this.sessions.get(sessionKey)?.cachedRecentContext;
}
constructor(
private client: OffloadApiClient,
private config: OffloadClientConfig,
private logger: Logger,
) {}
get info() {
return {
id: "memory-tencentdb",
name: "Offload Client Context Engine",
version: "2.0.0",
ownsCompaction: true,
};
}
/**
* bootstrap — called when a new session starts (e.g. /new command).
* Resets per-session cached state.
*/
async bootstrap(params: { sessionKey?: string; sessionId?: string }) {
const sk = params.sessionKey ?? params.sessionId;
if (sk) this.resetSession(sk);
return { bootstrapped: true };
}
/**
* ingest — no-op for client mode (ingest is handled by after_tool_call hook).
* Required by the framework ContextEngine interface.
*/
async ingest(_params: {
sessionId: string;
sessionKey?: string;
message: any;
isHeartbeat?: boolean;
}) {
return { ingested: true };
}
/**
* compact — record the framework's authoritative token count for calibration,
* then defer actual compaction to assemble().
*/
async compact(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
tokenBudget?: number;
force?: boolean;
currentTokenCount?: number;
compactionTarget?: "budget" | "threshold";
customInstructions?: string;
runtimeContext?: any;
}) {
// Record framework token count for calibration
if (params.currentTokenCount && params.currentTokenCount > 0) {
const sk = params.sessionKey ?? params.sessionId;
const s = this.getSession(sk);
s.lastKnownTotalTokens = params.currentTokenCount;
this.logger.debug?.(
`[offload-client] compact: calibration updated, knownTokens=${s.lastKnownTotalTokens}`,
);
}
const contextWindow = params.tokenBudget ?? DEFAULT_CONTEXT_WINDOW;
const targetTokens = Math.floor(contextWindow * COMPACT_TARGET_RATIO);
this.logger.info(
`[offload-client] compact: sessionKey=${params.sessionKey ?? params.sessionId}, ` +
`budget=${contextWindow}, target=${targetTokens}, ` +
`currentTokens=${params.currentTokenCount}, force=${params.force}`,
);
return {
ok: true,
compacted: false,
reason: "offload-client: compaction deferred to assemble()",
};
}
/**
* Resolve the best known token total for calibration.
* Uses lastKnownTotalTokens only if message count hasn't changed significantly.
*/
private resolveCalibrationTokens(sessionKey: string, msgCount: number): number | undefined {
const s = this.sessions.get(sessionKey);
if (!s || s.lastKnownTotalTokens <= 0) return undefined;
// If message count changed by >20%, the cached total is stale — skip calibration
if (s.lastKnownMsgCount > 0 && Math.abs(msgCount - s.lastKnownMsgCount) / s.lastKnownMsgCount > 0.2) {
return undefined;
}
return s.lastKnownTotalTokens;
}
/**
* Local brute-force compaction: keep tail messages up to target budget,
* respecting tool pairs and truncating oversized tool_results.
* Used as fallback when server compaction is unavailable.
*/
private localCompact(messages: any[], contextWindow: number, sessionKey: string): any[] {
const targetTokens = Math.floor(contextWindow * COMPACT_TARGET_RATIO);
const knownTokens = this.resolveCalibrationTokens(sessionKey, messages.length);
const { perMessage } = estimateAllTokens(messages, knownTokens);
const n = messages.length;
// Step 1: scan from tail, find the cut index
let cumTokens = 0;
let cutIdx = n; // everything before cutIdx gets deleted
for (let i = n - 1; i >= 0; i--) {
cumTokens += perMessage[i];
if (cumTokens > targetTokens) {
cutIdx = i + 1;
break;
}
cutIdx = i;
}
// Never delete the very first user message (index 0)
if (cutIdx <= 0) cutIdx = 0;
// Step 2: expand cut boundary to respect tool pairs
// If cutIdx lands inside a tool pair, move it to include the full pair.
// 2a: If msg at cutIdx is a tool_result, its paired assistant+tool_use is
// before cutIdx (would be deleted). Move cutIdx back to include the pair.
while (cutIdx < n && isToolResult(messages[cutIdx])) {
cutIdx++;
}
// 2b: If msg at cutIdx-1 (last deleted) is assistant+tool_use, its tool_result
// at cutIdx would be orphaned. Pull cutIdx back to keep the pair.
while (cutIdx > 0 && cutIdx < n && isAssistantWithToolUse(messages[cutIdx - 1])) {
cutIdx--;
}
// Step 3: build retained array
const retained = messages.slice(cutIdx);
if (retained.length === 0) {
return [...messages]; // safety: don't delete everything
}
const deletedCount = cutIdx;
let retainedTokens = 0;
for (let i = cutIdx; i < n; i++) retainedTokens += perMessage[i];
this.logger.info(
`[offload-client] localCompact: deleted ${deletedCount}/${n} msgs, ` +
`retained ${retained.length} msgs, tokens=${retainedTokens}/${targetTokens} target`,
);
// Step 4: if still over target and there's a large tool_result, truncate it
if (retainedTokens > targetTokens) {
let maxTrIdx = -1;
let maxTrTokens = 0;
for (let i = 0; i < retained.length; i++) {
if (isToolResult(retained[i])) {
const t = estimateMessageTokens(retained[i]);
if (t > maxTrTokens) {
maxTrTokens = t;
maxTrIdx = i;
}
}
}
if (maxTrIdx >= 0 && maxTrTokens > TOOL_RESULT_TRUNCATE_CHARS / 4) {
retained[maxTrIdx] = truncateToolResult(retained[maxTrIdx], TOOL_RESULT_TRUNCATE_CHARS);
const newTokens = estimateMessageTokens(retained[maxTrIdx]);
retainedTokens = retainedTokens - maxTrTokens + newTokens;
this.logger.info(
`[offload-client] localCompact: truncated tool_result[${maxTrIdx}] ` +
`${maxTrTokens}${newTokens} tokens, total now=${retainedTokens}`,
);
}
}
return retained;
}
// ─── L1.5 Trigger ───────────────────────────────────────────────────────────
/**
* Trigger L1.5 task judgment via ingest API when a new user prompt is detected.
* Fire-and-forget — does not block the assemble flow.
*/
private triggerL15IfNeeded(prompt: string | undefined, messages: any[], sessionKey: string): void {
if (!prompt || typeof prompt !== "string" || prompt.length === 0) return;
// Skip system/internal prompts that are not user-initiated
if (this.isInternalPrompt(prompt)) {
this.logger.debug?.(`[offload-client] L1.5 skipped: internal prompt (${prompt.slice(0, 60)})`);
return;
}
// Always update cached context for after_tool_call hook (L1 needs it)
const recentMsgs = this.buildRecentMessages(prompt, messages);
const s = this.getSession(sessionKey);
s.cachedPrompt = prompt.slice(0, 500);
s.cachedRecentMessages = recentMsgs;
s.cachedRecentContext = this.formatContextForL1(prompt, recentMsgs);
// Dedup: skip L1.5 if same prompt as last trigger for this session
const hash = this.simpleHash(prompt);
if (s.lastL15PromptHash === hash) {
this.logger.debug?.(`[offload-client] L1.5 skipped: same prompt hash (${hash})`);
return;
}
s.lastL15PromptHash = hash;
this.logger.info(
`[offload-client] L1.5 triggered: promptHash=${hash}, recentMsgs=${recentMsgs.length}`,
);
// Fire-and-forget L1.5
this.client.ingestL15(sessionKey, prompt.slice(0, 500), recentMsgs).catch((err) => {
this.logger.warn(`[offload-client] L1.5 ingestL15 failed: ${err}`);
});
}
/**
* Detect internal/system prompts that should not trigger L1.5.
* These are framework-generated messages, not user-initiated conversations.
*/
private isInternalPrompt(prompt: string): boolean {
// Compaction flush prompts
if (prompt.startsWith("Pre-compaction")) return true;
// Inter-session routing messages
if (prompt.startsWith("[Inter-session message]")) return true;
// Heartbeat/keepalive
if (prompt.includes("HEARTBEAT") || prompt.includes("heartbeat")) return true;
return false;
}
/**
* Build structured RecentMessage[] for ingest API.
* Filters: user/assistant text only, no tool calls, no heartbeats.
* Max 5 recent turns, 400 chars per message.
*/
private buildRecentMessages(prompt: string, messages: any[]): RecentMessage[] {
const normalizedPrompt = prompt.trim().slice(0, 200).toLowerCase();
// Scan messages, collect user/assistant pairs
const pairs: RecentMessage[] = [];
for (const msg of messages) {
const role = getMsgRole(msg);
// Skip tool messages entirely
if (isToolResult(msg) || isAssistantWithToolUse(msg)) continue;
if (role === "tool" || role === "toolResult" || role === "tool_result") continue;
if (role === "user") {
const text = this.extractMsgText(msg);
if (!text || text.length <= 5) continue;
if (text.includes("HEARTBEAT") || text.includes("heartbeat")) continue;
const trimmed = text.slice(0, 400);
// Skip if it matches current prompt
const normalizedText = trimmed.slice(0, 200).toLowerCase();
if (normalizedPrompt && (normalizedText === normalizedPrompt || normalizedText.startsWith(normalizedPrompt) || normalizedPrompt.startsWith(normalizedText))) continue;
pairs.push({ role: "user", content: trimmed });
} else if (role === "assistant") {
const text = this.extractMsgText(msg);
if (!text || text.length <= 10) continue;
if (text.includes("HEARTBEAT") || text.includes("heartbeat")) continue;
pairs.push({ role: "assistant", content: text.slice(0, 400) });
}
}
// Keep last N messages (max 10 messages ≈ 5 turns)
const recent = pairs.slice(-10);
return recent;
}
/**
* Format context string for L1 executor (recent-context.txt).
*/
private formatContextForL1(prompt: string, recentMsgs: RecentMessage[]): string {
const parts: string[] = [];
if (recentMsgs.length > 0) {
parts.push("历史消息,可作为参考:");
for (const m of recentMsgs) {
parts.push(`[${m.role === "user" ? "User" : "Assistant"}]: ${m.content}`);
}
}
parts.push(`\n最新user message\n[User]: ${prompt.slice(0, 500)}`);
return parts.join("\n");
}
/**
* Extract text content from a message.
*/
private extractMsgText(msg: any): string {
const content = msg?.content ?? msg?.message?.content ?? "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((b: any) => (typeof b === "string" ? b : b?.text ?? ""))
.join("");
}
return "";
}
/**
* Simple string hash for prompt deduplication.
*/
private simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return hash.toString(36);
}
/**
* assemble — estimate ratio → call server compaction → fallback to localCompact.
* Framework calls this to build the model context for each turn.
*/
async assemble(params: {
sessionId: string;
sessionKey?: string;
messages?: any[];
tokenBudget?: number;
prompt?: string;
availableTools?: Set<string>;
citationsMode?: string;
model?: string;
}) {
const { messages, sessionKey, sessionId } = params;
if (!messages || messages.length === 0) {
return { messages: messages ? [...messages] : [], estimatedTokens: 0 };
}
const sk = sessionKey ?? sessionId ?? "unknown";
// ── L1.5 trigger: fire-and-forget on new user prompt ──
this.triggerL15IfNeeded(params.prompt, messages, sk);
const contextWindow = params.tokenBudget ?? DEFAULT_CONTEXT_WINDOW;
// Don't use framework's knownTokens for calibration — our tiktoken is already precise.
// Framework's currentTokenCount may use a different calculation method (e.g. chars/4).
const { total, perMessage } = estimateAllTokens(messages);
const ratio = total / contextWindow;
// Update calibration state for future calls
const s = this.getSession(sk);
s.lastKnownTotalTokens = total;
s.lastKnownMsgCount = messages.length;
// Below client threshold — skip compaction
if (ratio < this.config.compactionRatio) {
this.logger.debug?.(
`[offload-client] assemble: ratio=${(ratio * 100).toFixed(1)}% < ${(this.config.compactionRatio * 100).toFixed(0)}%, skip`,
);
return { messages: [...messages], estimatedTokens: total };
}
this.logger.info(
`[offload-client] assemble: ratio=${(ratio * 100).toFixed(1)}%, msgs=${messages.length}, calling compaction...`,
);
// Try server-side compaction first
const result = await this.client.compaction({
sessionId: sessionKey ?? sessionId ?? "unknown",
messages,
ratio,
contextWindow,
totalTokens: total,
messageTokens: perMessage,
});
if (result) {
const compactedTokens = result.messages.reduce(
(sum: number, msg: any) => sum + estimateMessageTokens(msg),
0,
);
this.logger.info(
`[offload-client] server compaction done: level=${result.report.resolvedLevel}, ` +
`${result.report.originalCount}${result.report.compactedCount} msgs, ` +
`mild=${result.report.mildReplacements}, agg=${result.report.aggressiveDeleted}, ` +
`em=${result.report.emergencyDeleted}, mmd=${result.report.mmdInjected}`,
);
return { messages: result.messages, estimatedTokens: compactedTokens };
}
// Fallback: local brute-force compaction
this.logger.warn("[offload-client] server compaction failed, falling back to local compact");
const compacted = this.localCompact(messages, contextWindow, sk);
const compactedTokens = compacted.reduce(
(sum: number, msg: any) => sum + estimateMessageTokens(msg),
0,
);
return { messages: compacted, estimatedTokens: compactedTokens };
}
/**
* afterTurn — no-op (ingest is handled by after_tool_call hook).
*/
async afterTurn() {}
}
@@ -0,0 +1,53 @@
/**
* offload-client — after_tool_call hook handler.
* Fire-and-forget: sends tool pair + context to ingest API for L1 processing.
*/
import type { OffloadApiClient } from "../offload-api-client.js";
import type { OffloadClientConfig, ToolPairPayload, RecentMessage, Logger } from "../types.js";
export interface AfterToolCallEvent {
toolName: string;
toolCallId: string;
params?: unknown;
result?: unknown;
error?: string;
durationMs?: number;
}
/**
* Create the after_tool_call hook handler.
* Sends each tool call result to the server for L1 processing.
*
* @param getContext Optional getter for { prompt, recentMessages } context per session.
*/
export function createAfterToolCallHandler(
client: OffloadApiClient,
config: OffloadClientConfig,
logger: Logger,
getContext?: (sessionKey: string) => { prompt?: string; recentMessages?: RecentMessage[] } | undefined,
) {
return (event: AfterToolCallEvent, ctx: { sessionKey?: string; sessionId?: string }) => {
const sessionId = ctx.sessionKey ?? ctx.sessionId;
logger.debug?.(
`[offload-client] after_tool_call: tool=${event.toolName}, session=${sessionId ?? "(none)"}, callId=${event.toolCallId ?? "(none)"}`,
);
if (!sessionId) return;
const toolPair: ToolPairPayload = {
toolName: event.toolName,
toolCallId: event.toolCallId,
params: event.params ?? {},
result: event.result,
error: event.error,
timestamp: new Date().toISOString(),
durationMs: event.durationMs,
};
const context = getContext?.(sessionId);
// Fire-and-forget — do not block the LLM flow
client.ingestWithContext(sessionId, [toolPair], context?.prompt, context?.recentMessages).catch((err) => {
logger.warn(`[offload-client] ingest fire-and-forget error: ${err}`);
});
};
}
+101
View File
@@ -0,0 +1,101 @@
/**
* offload-client — Plugin registration entry point.
* Stateless, server-delegated offload client.
* 1 hook (after_tool_call) + 1 Context Engine (assemble → compaction API).
*/
import type { OffloadClientConfig, Logger } from "./types.js";
import { defaultOffloadClientConfig } from "./types.js";
import { OffloadApiClient } from "./offload-api-client.js";
import { OffloadContextEngine } from "./context-engine.js";
import { createAfterToolCallHandler } from "./hooks/after-tool-call.js";
export interface OpenClawPluginApi {
on: (hookName: string, handler: (...args: any[]) => any) => void;
registerContextEngine: (id: string, factoryOrInstance: any) => any;
logger?: Logger;
}
/**
* Register the offload-client plugin.
* Call this from the main plugin's register() when offload-client config is enabled.
*/
export function registerOffloadClient(api: OpenClawPluginApi, userConfig: Partial<OffloadClientConfig>): void {
const config: OffloadClientConfig = { ...defaultOffloadClientConfig(), ...userConfig };
const logger: Logger = api.logger ?? { info: console.log, warn: console.warn, error: console.error, debug: console.debug };
if (!config.enabled) {
logger.info("[offload-client] disabled by config");
return;
}
if (!config.serverUrl || !config.apiKey || !config.serviceId) {
logger.error("[offload-client] missing required config: serverUrl, apiKey, or serviceId");
return;
}
const client = new OffloadApiClient(config, logger);
// Context Engine: occupies slot, assemble() calls compaction API
const engine = new OffloadContextEngine(client, config, logger);
// Hook: fire-and-forget ingest on every tool call (with context from engine per session)
const afterToolCallHandler = createAfterToolCallHandler(
client, config, logger,
(sessionKey) => engine.getContext(sessionKey),
config.agentName, // ← NEW: pass agentName for sessionId construction
);
api.on("after_tool_call", afterToolCallHandler);
// ── Memory management hooks ──
// agent_end: clear token cache after each agent turn
api.on("agent_end", (_event: any, ctx: { sessionKey?: string; sessionId?: string }) => {
const sk = ctx.sessionKey ?? ctx.sessionId;
if (sk) {
engine.resetSession(sk);
logger.debug?.(`[offload-client] reset session state: ${sk}`);
}
});
// gateway_stop: emergency cleanup on shutdown
api.on("gateway_stop", async () => {
engine.clearAllSessions();
logger.info("[offload-client] all session states cleared on gateway_stop");
});
try {
const result = api.registerContextEngine("memory-tencentdb", () => engine) as any;
if (result?.ok === false) {
logger.error(
`[offload-client] Context Engine slot occupied by "${result.existingOwner ?? "unknown"}". ` +
`Compaction disabled — only ingest will work.`,
);
} else {
logger.info("[offload-client] Context Engine registered");
}
} catch (err) {
logger.error(`[offload-client] registerContextEngine failed: ${err}. Compaction disabled.`);
}
// ── Health check (async, non-blocking) ──
client.checkHealth().then((ok) => {
if (!ok) {
logger.warn(
`[offload-client] ⚠️ Server ${config.serverUrl} unreachable! ` +
`Ingest calls will fail silently until server becomes available.`
);
} else {
logger.info(`[offload-client] Server health OK: ${config.serverUrl}`);
}
}).catch((_err) => {
// ignore
});
logger.info(`[offload-client] registered (server=${config.serverUrl})`);
}
export { OffloadApiClient } from "./offload-api-client.js";
export { OffloadContextEngine } from "./context-engine.js";
export { createAfterToolCallHandler } from "./hooks/after-tool-call.js";
export { estimateTokens, estimateMessageTokens, estimateAllTokens } from "./token-estimator.js";
export type { OffloadClientConfig, ToolPairPayload, CompactionResult, CompactionReport, Logger } from "./types.js";
+177
View File
@@ -0,0 +1,177 @@
/**
* offload-client — HTTP client for Offload Server v2 API.
*/
import type { OffloadClientConfig, ToolPairPayload, RecentMessage, CompactionResult, Logger } from "./types.js";
export class OffloadApiClient {
constructor(
private config: OffloadClientConfig,
private logger: Logger,
) {}
/** Health check: GET /v2/offload/health. Returns true if server is reachable (any HTTP response = reachable). */
async checkHealth(): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`${this.config.serverUrl}/v2/offload/health`, {
method: "GET",
headers: { Authorization: `Bearer ${this.config.apiKey}` },
signal: controller.signal,
});
clearTimeout(timeoutId);
// Any HTTP response (including 401/403) means server is reachable
return res.status < 500;
} catch {
return false;
}
}
/**
* Fire-and-forget: send tool pairs to ingest endpoint.
* Does not throw — failures are logged as warnings.
*/
async ingest(sessionId: string, toolPairs: ToolPairPayload[]): Promise<void> {
return this.ingestWithContext(sessionId, toolPairs, undefined, undefined);
}
/**
* Fire-and-forget: send tool pairs + optional context to ingest endpoint.
* When prompt/recentMessages are provided, server triggers L1 with context (skip L1.5).
*/
async ingestWithContext(
sessionId: string,
toolPairs: ToolPairPayload[],
prompt?: string,
recentMessages?: RecentMessage[],
): Promise<void> {
const url = `${this.config.serverUrl}/v2/offload/ingest`;
const payload: Record<string, unknown> = {
session_id: sessionId,
tool_pairs: toolPairs.map((tp) => ({
tool_name: tp.toolName,
tool_call_id: tp.toolCallId,
params: tp.params,
result: tp.result,
error: tp.error,
timestamp: tp.timestamp,
duration_ms: tp.durationMs,
})),
};
if (prompt) payload.prompt = prompt;
if (recentMessages && recentMessages.length > 0) payload.recent_messages = recentMessages;
const body = JSON.stringify(payload);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.config.ingestTimeoutMs);
await fetch(url, {
method: "POST",
headers: this.buildHeaders(),
body,
signal: controller.signal,
});
clearTimeout(timer);
} catch (err) {
this.logger.warn(`[offload-client] ingest failed: ${err}`);
}
}
/**
* Fire-and-forget: trigger L1.5 task judgment via ingest endpoint.
* Sends prompt + recentMessages (empty toolPairs) to activate the L1.5 path on the server.
*/
async ingestL15(sessionId: string, prompt: string, recentMessages?: RecentMessage[]): Promise<void> {
const url = `${this.config.serverUrl}/v2/offload/ingest`;
const payload: Record<string, unknown> = {
session_id: sessionId,
tool_pairs: [],
prompt,
};
if (recentMessages && recentMessages.length > 0) payload.recent_messages = recentMessages;
const body = JSON.stringify(payload);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.config.ingestTimeoutMs);
const response = await fetch(url, {
method: "POST",
headers: this.buildHeaders(),
body,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
this.logger.warn(`[offload-client] ingestL15 returned ${response.status}`);
}
} catch (err) {
this.logger.warn(`[offload-client] ingestL15 failed: ${err}`);
}
}
/**
* Synchronous compaction call. Returns compressed messages + report.
* Returns null on timeout/failure (caller should keep original messages).
*/
async compaction(req: {
sessionId: string;
messages: any[];
ratio: number;
contextWindow: number;
totalTokens: number;
messageTokens?: number[];
}): Promise<CompactionResult | null> {
const url = `${this.config.serverUrl}/v2/offload/compact`;
const body = JSON.stringify({
session_id: req.sessionId,
messages: req.messages,
ratio: req.ratio,
context_window: req.contextWindow,
total_tokens: req.totalTokens,
message_tokens: req.messageTokens,
});
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.config.compactionTimeoutMs);
const response = await fetch(url, {
method: "POST",
headers: this.buildHeaders(),
body,
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
this.logger.warn(`[offload-client] compaction returned ${response.status}`);
return null;
}
const json = (await response.json()) as any;
if (json.code !== 0 || !json.data) {
this.logger.warn(`[offload-client] compaction error: ${json.message ?? "unknown"}`);
return null;
}
return { messages: json.data.messages, report: json.data.report };
} catch (err) {
this.logger.warn(`[offload-client] compaction failed: ${err}`);
return null;
}
}
private buildHeaders(): Record<string, string> {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
"X-TDAI-Service-Id": this.config.serviceId,
};
}
}
+227
View File
@@ -0,0 +1,227 @@
/**
* offload-client — Token estimation using tiktoken (precise).
* Aligned with server-side preciseMessageTokens: only counts LLM-visible content.
*
* Strategy:
* - Primary: tiktoken BPE encoding (o200k_base) on role + content
* - Fallback: CJK-aware heuristic if tiktoken fails
*
* `estimateAllTokens` still supports optional calibration from framework-reported
* totalTokens, but with tiktoken the drift should be minimal.
*/
import { getEncoding, type Tiktoken } from "js-tiktoken";
// ─── Tiktoken Encoder (lazy singleton) ──────────────────────────────────────
let _encoder: Tiktoken | null = null;
function getEncoder(): Tiktoken {
if (!_encoder) _encoder = getEncoding("o200k_base");
return _encoder;
}
// ─── Calibration constants ──────────────────────────────────────────────────
/** If heuristic drifts > 15% from known total, apply linear scaling. */
const CALIBRATION_THRESHOLD = 0.15;
/** Clamp calibration factor to prevent extreme scaling from noisy estimates. */
const CALIBRATION_FACTOR_MIN = 0.5;
const CALIBRATION_FACTOR_MAX = 3.0;
// ─── LLM-visible text extraction (must match server-side extractLlmVisibleText) ──
/**
* Extract the LLM-visible portion of a message (role + content only).
* Matches server-side preciseMessageTokens logic exactly.
*/
function extractLlmVisibleText(msg: any): string {
const role: string = msg?.role ?? msg?.message?.role ?? "";
const rawContent = msg?.content ?? msg?.message?.content ?? "";
let contentStr: string;
if (typeof rawContent === "string") {
contentStr = rawContent;
} else if (Array.isArray(rawContent)) {
const parts: string[] = [];
for (const block of rawContent) {
if (typeof block === "string") {
parts.push(block);
} else if (block?.type === "text" && typeof block.text === "string") {
parts.push(block.text);
} else if (block?.type === "tool_use" || block?.type === "toolCall") {
parts.push(block.name ?? block.toolName ?? "");
if (block.arguments) {
parts.push(typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments));
}
if (block.input) {
parts.push(typeof block.input === "string" ? block.input : JSON.stringify(block.input));
}
} else if (block?.type === "tool_result") {
if (typeof block.content === "string") parts.push(block.content);
else if (block.content) parts.push(JSON.stringify(block.content));
} else {
parts.push(JSON.stringify(block));
}
}
contentStr = parts.join("\n");
} else {
contentStr = JSON.stringify(rawContent);
}
return `${role}\n${contentStr}`;
}
// ─── Per-message token cache (WeakMap — auto GC when msg object is released) ──
const _tokenCache = new WeakMap<object, number>();
/** Max messages to tiktoken precisely per call. Beyond this, old messages use heuristic. */
const PRECISE_BUDGET = 200;
// ─── Core estimation ────────────────────────────────────────────────────────
/**
* Estimate tokens for a text string using tiktoken.
* Falls back to CJK-aware heuristic on error.
*/
export function estimateTokens(text: string): number {
if (!text) return 0;
try {
return getEncoder().encode(text).length;
} catch {
return heuristicTokens(text);
}
}
/**
* Estimate tokens for a single message using tiktoken (with WeakMap cache).
* Counts only LLM-visible content (role + content), aligned with server.
*/
export function estimateMessageTokens(msg: any): number {
if (msg == null) return 0;
// Cache hit
if (typeof msg === "object" && _tokenCache.has(msg)) {
return _tokenCache.get(msg)!;
}
const tokens = _computeMessageTokens(msg);
// Cache store (only for objects)
if (typeof msg === "object" && msg !== null) {
_tokenCache.set(msg, tokens);
}
return tokens;
}
function _computeMessageTokens(msg: any): number {
try {
const text = extractLlmVisibleText(msg);
return getEncoder().encode(text).length + 4; // +4 for message framing overhead
} catch {
return heuristicTokens(extractLlmVisibleText(msg));
}
}
/**
* Fast heuristic estimate for a message (no tiktoken, ~0.01ms per msg).
* Used for old messages when total count exceeds PRECISE_BUDGET.
*/
function heuristicMessageTokens(msg: any): number {
if (msg == null) return 0;
return heuristicTokens(extractLlmVisibleText(msg));
}
// ─── Calibrated batch estimation ────────────────────────────────────────────
/**
* Estimate total tokens and per-message tokens for a message array.
*
* Performance strategy:
* - If messages.length <= PRECISE_BUDGET (200): tiktoken all (with cache)
* - If messages.length > PRECISE_BUDGET: heuristic for old, tiktoken for recent N
* Then calibrate old estimates using recent tiktoken as reference.
*
* @param knownTotalTokens Optional authoritative total from the framework.
*/
export function estimateAllTokens(
messages: any[],
knownTotalTokens?: number,
): { total: number; perMessage: number[] } {
const n = messages.length;
if (n === 0) return { total: 0, perMessage: [] };
let raw: number[];
if (n <= PRECISE_BUDGET) {
// Small batch: tiktoken all (cache makes repeat calls fast)
raw = messages.map((msg) => estimateMessageTokens(msg));
} else {
// Large batch: tiktoken recent, heuristic old, calibrate
const preciseStart = n - PRECISE_BUDGET;
raw = new Array(n);
// Recent messages: precise (also populates cache for next call)
let preciseSum = 0;
let heuristicSumForRecent = 0;
for (let i = preciseStart; i < n; i++) {
raw[i] = estimateMessageTokens(messages[i]);
preciseSum += raw[i];
heuristicSumForRecent += heuristicMessageTokens(messages[i]);
}
// Calibration factor from recent messages
const calibFactor = heuristicSumForRecent > 0 ? preciseSum / heuristicSumForRecent : 1;
// Old messages: heuristic × calibration factor (or cache hit if available)
for (let i = 0; i < preciseStart; i++) {
if (typeof messages[i] === "object" && _tokenCache.has(messages[i])) {
raw[i] = _tokenCache.get(messages[i])!;
} else {
raw[i] = Math.max(1, Math.round(heuristicMessageTokens(messages[i]) * calibFactor));
}
}
}
let rawTotal = raw.reduce((s, v) => s + v, 0);
// External calibration (from framework totalTokens)
if (!knownTotalTokens || knownTotalTokens <= 0 || rawTotal <= 0) {
return { total: rawTotal, perMessage: raw };
}
const drift = Math.abs(knownTotalTokens - rawTotal) / knownTotalTokens;
if (drift <= CALIBRATION_THRESHOLD) {
return { total: rawTotal, perMessage: raw };
}
// Apply linear calibration factor, clamped
const factor = Math.max(
CALIBRATION_FACTOR_MIN,
Math.min(CALIBRATION_FACTOR_MAX, knownTotalTokens / rawTotal),
);
const calibrated = raw.map((v) => Math.max(1, Math.round(v * factor)));
const calibratedTotal = calibrated.reduce((s, v) => s + v, 0);
return { total: calibratedTotal, perMessage: calibrated };
}
// ─── Heuristic fallback ─────────────────────────────────────────────────────
function countCjkChars(text: string): number {
let n = 0;
for (const ch of text) {
const c = ch.codePointAt(0)!;
if (
(c >= 0x4e00 && c <= 0x9fff) ||
(c >= 0x3400 && c <= 0x4dbf) ||
(c >= 0xf900 && c <= 0xfaff)
) {
n++;
}
}
return n;
}
function heuristicTokens(text: string): number {
if (!text) return 0;
const cjk = countCjkChars(text);
const rest = Math.max(0, text.length - cjk);
return Math.max(1, Math.ceil(cjk / 1.7 + rest / 4));
}
+80
View File
@@ -0,0 +1,80 @@
/**
* offload-client — Type definitions.
*/
// ─── Plugin Configuration ────────────────────────────────────────────────────
export interface OffloadClientConfig {
enabled: boolean;
/** Offload server base URL (e.g. "http://localhost:9100"). */
serverUrl: string;
/** Bearer token for Authorization header. */
apiKey: string;
/** X-TDAI-Service-Id header value. */
serviceId: string;
/** Agent name for sessionId construction. Default "default". */
agentName?: string;
/** Client-side threshold: skip compaction request when ratio < this value. Default 0.5. */
compactionRatio: number;
/** Ingest request timeout in ms. Default 5000. */
ingestTimeoutMs: number;
/** Compaction request timeout in ms. Default 30000. */
compactionTimeoutMs: number;
}
export function defaultOffloadClientConfig(): OffloadClientConfig {
return {
enabled: false,
serverUrl: "http://localhost:9100",
apiKey: "",
serviceId: "",
compactionRatio: 0.5,
ingestTimeoutMs: 5000,
compactionTimeoutMs: 30000,
};
}
// ─── Ingest Payload ──────────────────────────────────────────────────────────
export interface ToolPairPayload {
toolName: string;
toolCallId: string;
params: unknown;
result: unknown;
error?: string;
timestamp: string;
durationMs?: number;
}
export interface RecentMessage {
role: "user" | "assistant";
content: string;
}
// ─── Compaction Response ─────────────────────────────────────────────────────
export interface CompactionReport {
resolvedLevel: string;
originalCount: number;
compactedCount: number;
fastPathReplaced: number;
fastPathDeleted: number;
mildReplacements: number;
aggressiveDeleted: number;
emergencyDeleted: number;
mmdInjected: number;
}
export interface CompactionResult {
messages: any[];
report: CompactionReport;
}
// ─── Logger ──────────────────────────────────────────────────────────────────
export interface Logger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
debug?: (...args: unknown[]) => void;
}