mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-11 04:44:29 +00:00
feat: release v1.0.0
This commit is contained in:
@@ -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}`);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user