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
@@ -0,0 +1,328 @@
/**
* L3 Compaction Handler — orchestrates fast-path, MMD injection, and compression.
* Synchronous API: client awaits the response.
*
* Uses compact-state.json (independent from state.json) to avoid lock contention
* with L1/L1.5/L2 executors that write state.json concurrently.
*/
import type http from "node:http";
import type { StorageAdapter } from "../../core/storage/adapter.js";
import type { OffloadEntry, OffloadState, OffloadExecutorConfig, CompactState } from "../types.js";
import { defaultOffloadState, defaultCompactState } from "../types.js";
import { parseJsonl } from "../parsers/json-utils.js";
import { CompactionRequestSchemaV2 } from "../schemas.js";
import { buildOffloadBasePath } from "../session-utils.js";
import { applyFastPath } from "./fast-path.js";
import { injectActiveMmd, injectHistoryMmds } from "./mmd-injector.js";
import { resolveLevel, mildCompress, aggressiveCompress, emergencyCompress } from "./compressor.js";
import { estimateMessageTokens, extractToolResultId } from "./helpers.js";
import type { Message } from "./helpers.js";
import { traceServerCompaction } from "../opik-tracer.js";
export interface CompactionDeps {
storage: StorageAdapter;
config: OffloadExecutorConfig;
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
}
export interface CompactionReport {
resolvedLevel: string;
originalCount: number;
compactedCount: number;
fastPathReplaced: number;
fastPathDeleted: number;
mildReplacements: number;
aggressiveDeleted: number;
emergencyDeleted: number;
mmdInjected: number;
}
/**
* Handle POST /v2/offload/compact.
*/
export async function handleCompaction(
req: http.IncomingMessage,
res: http.ServerResponse,
auth: { serviceId: string },
deps: CompactionDeps,
requestId: string,
parseJsonBody: <T>(req: http.IncomingMessage) => Promise<T>,
sendJson: (res: http.ServerResponse, status: number, body: unknown) => void,
successEnvelope: <T>(data: T, requestId: string) => unknown,
errorEnvelope: (code: number, message: string, requestId: string) => unknown,
): Promise<void> {
const body = await parseJsonBody(req);
const parsed = CompactionRequestSchemaV2.safeParse(body);
if (!parsed.success) {
sendJson(res, 400, errorEnvelope(400, parsed.error.message, requestId));
return;
}
const { session_id: sessionId, messages, ratio, context_window: contextWindow, message_tokens: messageTokens } = parsed.data;
let { total_tokens: totalTokens } = parsed.data;
const { storage, config } = deps;
const basePath = buildOffloadBasePath(sessionId);
const originalCount = messages.length;
const compactionStartMs = Date.now();
const { preciseMessageTokens } = await import("./compressor.js");
const originalTotalTokens = totalTokens;
// Compute fixed overhead: system prompt + tool schemas + message framing.
// These are included in clientTotalTokens but NOT in messages.
// overhead = clientTotal - sum(preciseMessageTokens for each message)
const messagesTokenSum = (messages as any[]).reduce(
(s: number, msg: any) => s + preciseMessageTokens(msg), 0,
);
const fixedOverhead = Math.max(0, totalTokens - messagesTokenSum);
deps.logger.info(
`[offload-server] compaction: clientTotal=${totalTokens}, msgsTokens=${messagesTokenSum}, ` +
`overhead=${fixedOverhead}, msgs=${originalCount}, ratio=${ratio.toFixed(2)}`,
);
// Read offload state (L1/L1.5/L2 managed, read-only here) and compact state (L3 owned)
const state = await readOffloadState(storage, basePath);
const compactState = await readCompactState(storage, basePath);
const entriesRaw = await storage.readFile(`${basePath}/entries.jsonl`);
const entries = entriesRaw
? parseJsonl<OffloadEntry>(entriesRaw, (line, err) => {
deps.logger.warn(`[offload-server] compaction: bad JSONL line: ${line}`, err);
})
: [];
// Merge node-mapping.jsonl into entries (L2 writes node_id to a separate file)
const nodeMappingRaw = await storage.readFile(`${basePath}/node-mapping.jsonl`);
if (nodeMappingRaw) {
const mappings = parseJsonl<{ tool_call_id: string; node_id: string }>(nodeMappingRaw);
const nodeMap = new Map(mappings.map((m) => [m.tool_call_id, m.node_id]));
for (const entry of entries) {
if (!entry.node_id && nodeMap.has(entry.tool_call_id)) {
entry.node_id = nodeMap.get(entry.tool_call_id)!;
}
}
}
// Token array: use tiktoken values directly (no calibration against clientTotal).
// clientTotal includes fixedOverhead which is not in messages, so calibration
// would inflate per-message tokens incorrectly.
const tokenArray = buildTokenArray(messages as Message[], messagesTokenSum, messageTokens);
const report: CompactionReport = {
resolvedLevel: "fastpath",
originalCount,
compactedCount: 0,
fastPathReplaced: 0,
fastPathDeleted: 0,
mildReplacements: 0,
aggressiveDeleted: 0,
emergencyDeleted: 0,
mmdInjected: 0,
};
// Step 1: Fast-path re-apply (uses compactState for confirmed/deleted IDs)
const fp = applyFastPath(messages, entries, compactState);
report.fastPathReplaced = fp.replacedCount;
report.fastPathDeleted = fp.deletedCount;
// Recalculate totalTokens after fast-path:
// totalTokens = tiktoken(remaining messages) + fixedOverhead
if (fp.deletedCount > 0) {
const postFpMsgsTokens = (messages as any[]).reduce(
(s: number, msg: any) => s + preciseMessageTokens(msg), 0,
);
totalTokens = postFpMsgsTokens + fixedOverhead;
// Rebuild tokenArray for remaining messages
tokenArray.length = 0;
tokenArray.push(...buildTokenArray(messages as Message[], postFpMsgsTokens, undefined));
}
const effectiveRatio = contextWindow > 0 ? totalTokens / contextWindow : ratio;
// Step 2: Resolve compression level using post-fast-path ratio
const level = resolveLevel(effectiveRatio, {
mildRatio: config.mildOffloadRatio,
aggressiveRatio: config.aggressiveCompressRatio,
emergencyRatio: config.emergencyCompressRatio,
});
report.resolvedLevel = level;
deps.logger.info(
`[offload-server] compaction: level=${level}, ratio=${effectiveRatio.toFixed(2)} (pre-fp=${ratio.toFixed(2)}), msgs=${messages.length}, entries=${entries.length}`,
);
// Step 3: Mild compression
if (level === "mild" || level === "aggressive" || level === "emergency") {
const mild = mildCompress(messages, entries);
report.mildReplacements = mild.replacedCount;
compactState.confirmedOffloadIds.push(...mild.confirmedIds);
// Sync tokenArray after mild replacements (content changed)
// Incremental update: only recalculate tokens for messages that were actually replaced
if (mild.replacedCount > 0) {
const confirmedSet = new Set(mild.confirmedIds);
let tokenDelta = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i] as any;
// Use extractToolResultId which handles all formats (OpenAI, Anthropic content blocks)
const tid = extractToolResultId(msg);
if (!tid || !confirmedSet.has(tid)) continue;
const newTokens = preciseMessageTokens(msg);
tokenDelta += newTokens - tokenArray[i];
tokenArray[i] = newTokens;
}
totalTokens += tokenDelta;
}
}
// Step 4: Aggressive compression
// Target: just below the aggressive trigger threshold (leave ~5% headroom)
let aggRemainingTokens = totalTokens; // track for emergency
if (level === "aggressive" || level === "emergency") {
const aggTargetTokens = Math.floor(contextWindow * (config.aggressiveCompressRatio - 0.05));
// Skip aggressive if mild already brought tokens below target
if (totalTokens <= aggTargetTokens) {
deps.logger.info(
`[offload-server] aggressive skipped: mild already reduced tokens to ${totalTokens} (target=${aggTargetTokens})`,
);
} else {
const agg = aggressiveCompress(messages, aggTargetTokens, tokenArray, totalTokens);
report.aggressiveDeleted = agg.deletedCount;
aggRemainingTokens = agg.remainingTokens;
compactState.deletedOffloadIds.push(...agg.deletedIds);
// Inject history MMDs for deleted entries
if (agg.deletedIds.length > 0) {
const mmdBudget = Math.floor(contextWindow * 0.1 / 4); // 10% of context, in estimated tokens
const hist = await injectHistoryMmds(
messages, agg.deletedIds, entries, state, storage, basePath, mmdBudget,
);
report.mmdInjected += hist.injectedCount;
// Sync tokenArray for any injected MMD messages (calibrate against messages-only tokens)
if (hist.injectedCount > 0) {
const postAggMsgsTokens = (messages as any[]).reduce(
(s: number, msg: any) => s + preciseMessageTokens(msg), 0,
);
tokenArray.length = 0;
tokenArray.push(...buildTokenArray(messages as Message[], postAggMsgsTokens, undefined));
}
}
}
}
// Step 6: Emergency compression
// Target: just below the aggressive threshold (so next turn won't immediately re-trigger)
if (level === "emergency") {
const emTargetTokens = Math.floor(contextWindow * (config.aggressiveCompressRatio - 0.10));
const em = emergencyCompress(messages, emTargetTokens, tokenArray, aggRemainingTokens);
report.emergencyDeleted = em.deletedCount;
aggRemainingTokens = em.remainingTokens;
compactState.deletedOffloadIds.push(...em.deletedIds);
}
// Step 7: Inject active MMD (after all compression, so position is correct)
const mmdResult = await injectActiveMmd(messages, state, storage, basePath);
report.mmdInjected += mmdResult.injectedCount;
// Step 8: Write compact-state.json (independent file, no lock needed)
report.compactedCount = messages.length;
compactState.lastCompactedAt = new Date().toISOString();
await writeCompactState(storage, basePath, compactState);
// Compute remaining tokens (tracked from aggressive/emergency, no full re-scan)
const remainingTokens = level === "fastpath" || level === "mild"
? totalTokens // no deletion happened
: aggRemainingTokens; // tracked through aggressive → emergency chain
const remainingRatio = contextWindow > 0 ? (remainingTokens / contextWindow).toFixed(2) : "N/A";
// Opik trace: compaction decision
traceServerCompaction({
sessionId,
level,
ratio,
contextWindow,
totalTokensBefore: originalTotalTokens,
totalTokensAfter: remainingTokens,
originalMsgCount: originalCount,
compactedMsgCount: messages.length,
report: report as unknown as Record<string, unknown>,
messages: messages as unknown[],
durationMs: Date.now() - compactionStartMs,
logger: deps.logger,
});
// Step 8: Return
sendJson(res, 200, successEnvelope({ messages, report }, requestId));
deps.logger.info(
`[offload-server] compaction done: ${originalCount}${messages.length} msgs, level=${level}, ` +
`tokens=${originalTotalTokens}${remainingTokens} (${remainingRatio}), ` +
`fp=${fp.replacedCount}r/${fp.deletedCount}d, mild=${report.mildReplacements}, ` +
`agg=${report.aggressiveDeleted}, em=${report.emergencyDeleted}, mmd=${report.mmdInjected}`,
);
}
// ─── Token Array Builder ─────────────────────────────────────────────────────
/** Calibration threshold: if heuristic estimate drifts >15% from API totalTokens, 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;
/**
* Build a pre-computed token array for all messages, with optional linear calibration.
* - If messageTokens[i] is available, use it directly (precise value).
* - Otherwise, use estimateMessageTokens (CJK-aware heuristic).
* - If totalTokens is provided and drift > 15%, apply a calibration factor to estimated items.
*/
export function buildTokenArray(
messages: Message[],
totalTokens: number,
messageTokens?: number[],
): number[] {
const raw = messages.map((msg, i) =>
messageTokens && i < messageTokens.length
? messageTokens[i]
: estimateMessageTokens(msg),
);
const rawTotal = raw.reduce((s, v) => s + v, 0);
if (rawTotal <= 0 || totalTokens <= 0) return raw;
const drift = Math.abs(totalTokens - rawTotal) / totalTokens;
if (drift <= CALIBRATION_THRESHOLD) return raw;
// Linear calibration: only scale estimated items, keep precise items unchanged
const factor = Math.max(CALIBRATION_FACTOR_MIN, Math.min(CALIBRATION_FACTOR_MAX, totalTokens / rawTotal));
return raw.map((v, i) =>
messageTokens && i < messageTokens.length
? v
: Math.max(1, Math.round(v * factor)),
);
}
// ─── State Helpers ───────────────────────────────────────────────────────────
async function readOffloadState(storage: StorageAdapter, basePath: string): Promise<OffloadState> {
const raw = await storage.readFile(`${basePath}/state.json`);
if (!raw) return defaultOffloadState();
try {
return { ...defaultOffloadState(), ...JSON.parse(raw) };
} catch {
return defaultOffloadState();
}
}
async function readCompactState(storage: StorageAdapter, basePath: string): Promise<CompactState> {
const raw = await storage.readFile(`${basePath}/compact-state.json`);
if (!raw) return defaultCompactState();
try {
return { ...defaultCompactState(), ...JSON.parse(raw) };
} catch {
return defaultCompactState();
}
}
async function writeCompactState(storage: StorageAdapter, basePath: string, state: CompactState): Promise<void> {
await storage.writeFile(`${basePath}/compact-state.json`, JSON.stringify(state));
}
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
/**
* L3 Fast-path — re-apply confirmed/deleted offload state to messages.
* Runs on every compaction call as the first step.
*/
import type { OffloadEntry, CompactState } from "../types.js";
import type { Message } from "./helpers.js";
import {
extractToolResultId,
isToolResultMessage,
isOnlyToolUseAssistant,
isAssistantWithToolUse,
extractAllToolUseIds,
replaceWithSummary,
buildOffloadMap,
} from "./helpers.js";
export interface FastPathResult {
replacedCount: number;
deletedCount: number;
}
/**
* Apply fast-path: replace confirmed tool results with summary,
* delete messages whose tool_call_id is in deletedOffloadIds,
* strip orphaned toolCall blocks from mixed assistant messages.
* Mutates messages in place.
*/
export function applyFastPath(
messages: Message[],
entries: OffloadEntry[],
compactState: CompactState,
): FastPathResult {
const offloadMap = buildOffloadMap(entries);
const confirmedSet = new Set(compactState.confirmedOffloadIds);
const deletedSet = new Set(compactState.deletedOffloadIds);
if (confirmedSet.size === 0 && deletedSet.size === 0) {
return { replacedCount: 0, deletedCount: 0 };
}
const indicesToDelete: number[] = [];
let replacedCount = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const tid = extractToolResultId(msg);
// 1. Deleted tool_result — handle Anthropic multi-block user messages carefully
if (tid && deletedSet.has(tid)) {
// Anthropic format: user message may have multiple tool_result blocks
if (msg.role === "user" && Array.isArray(msg.content)) {
const allToolResultIds = msg.content
.filter((b: any) => b?.type === "tool_result" && b.tool_use_id)
.map((b: any) => b.tool_use_id as string);
if (allToolResultIds.length > 1) {
// Multi-block: only remove the blocks that are in deletedSet, keep the rest
const allDeleted = allToolResultIds.every((id: string) => deletedSet.has(id));
if (allDeleted) {
indicesToDelete.push(i);
} else {
// Partial: strip only deleted tool_result blocks
for (let j = msg.content.length - 1; j >= 0; j--) {
const block = msg.content[j];
if (block?.type === "tool_result" && block.tool_use_id && deletedSet.has(block.tool_use_id)) {
msg.content.splice(j, 1);
}
}
}
continue;
}
}
// Single tool_result or non-Anthropic format: delete entire message
indicesToDelete.push(i);
continue;
}
// 2. Confirmed tool_result → replace with summary
if (tid && confirmedSet.has(tid) && isToolResultMessage(msg) && !msg._offloaded) {
const entry = offloadMap.get(tid);
if (entry) {
replaceWithSummary(msg, entry);
replacedCount++;
}
}
// 3. Pure toolCall assistant — all IDs deleted → mark for deletion
if (!tid && isOnlyToolUseAssistant(msg)) {
const tuIds = extractAllToolUseIds(msg);
if (tuIds.length > 0 && tuIds.every((id) => deletedSet.has(id))) {
indicesToDelete.push(i);
}
}
// 4. Mixed assistant (text + toolCall) — strip deleted toolCall blocks
// to prevent orphaned tool_use without matching tool_result (provider 400)
if (!tid && isAssistantWithToolUse(msg) && !isOnlyToolUseAssistant(msg)) {
const content = msg.type === "message" ? msg.message?.content : msg.content;
if (Array.isArray(content)) {
for (let j = content.length - 1; j >= 0; j--) {
const block = content[j];
if ((block?.type === "tool_use" || block?.type === "toolCall") && block.id) {
if (deletedSet.has(block.id)) {
content.splice(j, 1);
}
}
}
}
}
}
// Delete in reverse order to preserve indices
const uniqueIndices = [...new Set(indicesToDelete)].sort((a, b) => b - a);
for (const idx of uniqueIndices) {
messages.splice(idx, 1);
}
// Post-pass: ensure no orphaned tool_use or tool_result after deletion
// Collect all remaining tool_use IDs and tool_result IDs
const remainingToolUseIds = new Set<string>();
const remainingToolResultIds = new Set<string>();
for (const msg of messages) {
for (const id of extractAllToolUseIds(msg)) {
remainingToolUseIds.add(id);
}
if (msg.role === "user" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block?.type === "tool_result" && block.tool_use_id) {
remainingToolResultIds.add(block.tool_use_id);
}
}
}
const singleTid = extractToolResultId(msg);
if (singleTid) remainingToolResultIds.add(singleTid);
}
// Remove orphaned tool_use blocks from assistant messages (no matching tool_result)
for (const msg of messages) {
if (!isAssistantWithToolUse(msg)) continue;
const content = msg.type === "message" ? msg.message?.content : msg.content;
if (!Array.isArray(content)) continue;
for (let j = content.length - 1; j >= 0; j--) {
const block = content[j];
if ((block?.type === "tool_use" || block?.type === "toolCall") && block.id) {
if (!remainingToolResultIds.has(block.id)) {
content.splice(j, 1);
}
}
}
}
// Remove orphaned tool_result messages/blocks (no matching tool_use)
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "user" && Array.isArray(msg.content)) {
for (let j = msg.content.length - 1; j >= 0; j--) {
const block = msg.content[j];
if (block?.type === "tool_result" && block.tool_use_id && !remainingToolUseIds.has(block.tool_use_id)) {
msg.content.splice(j, 1);
}
}
// If user message is now empty, remove it
if (msg.content.length === 0) {
messages.splice(i, 1);
uniqueIndices.push(i); // count it
}
} else {
const singleTid = extractToolResultId(msg);
if (singleTid && !remainingToolUseIds.has(singleTid) && isToolResultMessage(msg)) {
messages.splice(i, 1);
uniqueIndices.push(i);
}
}
}
// Remove empty assistant messages (all tool_use blocks were stripped)
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
const role = msg.role ?? msg.message?.role ?? msg.type;
if (role !== "assistant") continue;
const content = msg.type === "message" ? msg.message?.content : msg.content;
if (Array.isArray(content) && content.length === 0) {
messages.splice(i, 1);
uniqueIndices.push(i);
}
}
return { replacedCount, deletedCount: uniqueIndices.length };
}
+297
View File
@@ -0,0 +1,297 @@
/**
* L3 Compaction — Message helper utilities.
* Handles multiple message formats (OpenAI, Anthropic, OpenClaw).
*
* ═══════════════════════════════════════════════════════════════════════════
* OpenClaw 消息格式说明(来源:@mariozechner/pi-ai 类型定义)
* ═══════════════════════════════════════════════════════════════════════════
*
* OpenClaw 内部使用统一的 AgentMessage 联合类型,包含以下几种消息:
*
* ── 1. UserMessage ──
* {
* role: "user",
* content: string | ContentBlock[], // string 或 [{type:"text",text:"..."}] 或含图片块
* timestamp: number,
* }
*
* ── 2. AssistantMessage(纯文本回复)──
* {
* role: "assistant",
* content: [{ type: "text", text: "..." }],
* model: "gpt-5.2",
* stopReason: "stop",
* timestamp: number,
* api: "messages" | "chat" | "responses",
* provider: "anthropic" | "openai" | "google",
* usage: { input, output, totalTokens, ... },
* }
*
* ── 3. AssistantMessage(含 tool_use / toolCall)──
* {
* role: "assistant",
* content: [
* { type: "text", text: "I'll read the file..." }, // 可选文本块
* { type: "toolCall", id: "call_abc123", name: "read_file", arguments: { path: "..." } },
* { type: "toolCall", id: "call_def456", name: "exec", arguments: { cmd: "..." } },
* ],
* stopReason: "toolUse",
* ...同上
* }
* 注: Anthropic 原生格式使用 { type: "tool_use", id, name, input }
* OpenClaw 内部统一为 { type: "toolCall", id, name, arguments }
* 但消息到达后端时**两种格式都可能出现**(取决于客户端是否已转换),
* 因此本模块所有判断都同时匹配 "tool_use" 和 "toolCall"。
*
* ── 4. ToolResultMessage ──
* {
* role: "toolResult",
* toolCallId: "call_abc123", // 对应 AssistantMessage 中 toolCall 的 id
* toolName: "read_file",
* content: [{ type: "text", text: "文件内容..." }],
* isError: false,
* timestamp: number,
* details?: any, // 可选的详细信息(不发给 LLM)
* }
*
* ── 5. 消息配对规则 ──
* - 每个 AssistantMessage 中的 toolCall 必须有对应的 ToolResultMessage
* - toolCallId 是配对的唯一标识
* - 删除 tool_result 时必须同时删除对应的 assistant toolCall(否则 provider 返回 400
* - 一个 assistant 消息可包含多个 toolCall(并行工具调用)
*
* ── 6. 转换到 LLM Provider 原生格式 ──
* OpenAI: toolResult → { role: "tool", tool_call_id: "...", content: "..." }
* Anthropic: toolResult → { role: "user", content: [{ type: "tool_result", tool_use_id: "...", content: "..." }] }
* 注: 本模块处理的是 **转换后** 的消息(发送给 provider 前的格式),
* 因此需要兼容所有上述格式。
*
* ── 7. 特殊标记字段(本模块使用)──
* - _offloaded: boolean — 已被 summary 替换的 tool_result
* - _mmdContextMessage: string — MMD 注入消息标记("active" | "history"
* - _mmdInjection: boolean — 历史 MMD 注入消息标记
* - _mmdVersion: string — MMD 内容哈希,用于版本去重
* ═══════════════════════════════════════════════════════════════════════════
*/
import type { OffloadEntry } from "../types.js";
// ─── Message type aliases ────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Message = Record<string, any>;
// ─── Tool Result ID Extraction ───────────────────────────────────────────────
/**
* Extract the linked tool_use_id from a tool-result message (supports multiple formats).
*/
export function extractToolResultId(msg: Message): string | null {
// OpenAI tool result: { role: "tool", tool_call_id: "..." }
if (msg.tool_call_id) return msg.tool_call_id;
// Anthropic tool_result: { type: "tool_result", tool_use_id: "..." }
if (msg.tool_use_id) return msg.tool_use_id;
// OpenClaw wrapped: { type: "message", message: { id: "...", role: "toolResult" } }
if (msg.type === "message" && msg.message?.id) return msg.message.id;
// Anthropic content block tool_result: { role: "user", content: [{type:"tool_result", tool_use_id}] }
if (msg.role === "user" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block?.type === "tool_result" && block.tool_use_id) return block.tool_use_id;
}
}
return null;
}
// ─── Message Type Checks ─────────────────────────────────────────────────────
/**
* Check if a message is a tool result (contains tool output).
*/
export function isToolResultMessage(msg: Message): boolean {
if (msg.role === "tool") return true;
if (msg.type === "tool_result") return true;
const innerRole = msg.message?.role ?? msg.type;
if (innerRole === "toolResult" || innerRole === "tool_result") return true;
// Anthropic user message with tool_result content blocks
if (msg.role === "user" && Array.isArray(msg.content)) {
return msg.content.some((b: any) => b?.type === "tool_result");
}
return false;
}
/**
* Check if a message is an assistant message containing only tool_use blocks.
*/
export function isOnlyToolUseAssistant(msg: Message): boolean {
const role = msg.role ?? msg.message?.role;
if (role !== "assistant") return false;
const content = msg.type === "message" ? msg.message?.content : msg.content;
if (!Array.isArray(content)) return false;
if (content.length === 0) return false;
return content.every((block: any) =>
block?.type === "tool_use" || block?.type === "toolCall",
);
}
/**
* Check if a message is an assistant message that contains tool_use (may also have text).
* Supports both Anthropic format (content blocks) and OpenAI format (tool_calls field).
*/
export function isAssistantWithToolUse(msg: Message): boolean {
const role = msg.role ?? msg.message?.role;
if (role !== "assistant") return false;
// OpenAI format: tool_calls field
if (msg.tool_calls && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) return true;
// Anthropic format: content blocks
const content = msg.type === "message" ? msg.message?.content : msg.content;
if (!Array.isArray(content)) return false;
return content.some((block: any) =>
block?.type === "tool_use" || block?.type === "toolCall",
);
}
/**
* Extract all tool_use IDs from an assistant message.
* Supports both Anthropic format (content blocks) and OpenAI format (tool_calls field).
*/
export function extractAllToolUseIds(msg: Message): string[] {
const ids: string[] = [];
// OpenAI format: tool_calls field
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
if (tc.id) ids.push(tc.id);
}
}
// Anthropic format: content blocks
const content = msg.type === "message" ? msg.message?.content : msg.content;
if (Array.isArray(content)) {
for (const block of content) {
if ((block?.type === "tool_use" || block?.type === "toolCall") && block.id) {
ids.push(block.id);
}
}
}
return ids;
}
// ─── Replace With Summary ────────────────────────────────────────────────────
/**
* Replace a tool result message's content with the offload summary.
*/
export function replaceWithSummary(msg: Message, entry: OffloadEntry): void {
const parts = [
`[Offloaded Tool Result | node: ${entry.node_id ?? "N/A"}]`,
`Summary: ${entry.summary}`,
];
if (entry.result_ref) {
parts.push(`原始工具结果已存档,如需查看完整内容请调用 tdai_read_cos(path="${entry.result_ref}")`);
}
const summaryContent = parts.join("\n");
if (msg.type === "message" && msg.message) {
if (Array.isArray(msg.message.content)) {
msg.message.content = [{ type: "text", text: summaryContent }];
} else {
msg.message.content = summaryContent;
}
} else if (msg.role === "user" && Array.isArray(msg.content)) {
// Anthropic tool_result in user content blocks
for (let i = 0; i < msg.content.length; i++) {
if (msg.content[i]?.type === "tool_result") {
msg.content[i].content = summaryContent;
break;
}
}
} else {
if (Array.isArray(msg.content)) {
msg.content = [{ type: "text", text: summaryContent }];
} else {
msg.content = summaryContent;
}
}
msg._offloaded = true;
}
// ─── Offload Map ─────────────────────────────────────────────────────────────
/**
* Build a lookup map from tool_call_id → OffloadEntry.
*/
export function buildOffloadMap(entries: OffloadEntry[]): Map<string, OffloadEntry> {
const map = new Map<string, OffloadEntry>();
for (const entry of entries) {
if (entry.tool_call_id) {
map.set(entry.tool_call_id, entry);
}
}
return map;
}
// ─── Token Estimation ────────────────────────────────────────────────────────
/**
* Count CJK characters (CJK Unified Ideographs + Extension A + Compatibility).
*/
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;
}
/**
* Estimate tokens for a text string using CJK-aware heuristic.
* CJK characters ≈ 1 token / 1.7 chars, non-CJK ≈ 1 token / 4 chars.
* Aligned with plugin-side estimateL3MixedTokensHeuristic.
*/
function estimateTextTokens(text: string): number {
const cjk = countCjkChars(text);
const rest = Math.max(0, text.length - cjk);
return Math.max(1, Math.ceil(cjk / 1.7 + rest / 4));
}
/**
* Estimate tokens for a message using CJK-aware heuristic (中文/1.7 + 非中文/4).
*/
export function estimateMessageTokens(msg: Message): number {
const content = msg.content ?? msg.message?.content ?? "";
let text: string;
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
text = content.map((b: any) => (typeof b === "string" ? b : b?.text ?? JSON.stringify(b) ?? "")).join("");
} else {
text = JSON.stringify(content);
}
// Include tool_calls arguments (OpenAI format)
const toolCalls = (msg as any).tool_calls;
if (toolCalls && Array.isArray(toolCalls)) {
for (const tc of toolCalls) {
if (tc.function?.name) text += tc.function.name;
if (tc.function?.arguments) text += tc.function.arguments;
}
}
return estimateTextTokens(text);
}
// ─── MMD Message Markers ─────────────────────────────────────────────────────
export const MMD_CONTEXT_MARKER = "_mmdContextMessage";
export const MMD_INJECTION_MARKER = "_mmdInjection";
/**
* Check if a message is a MMD-related message (should be preserved during compression).
*/
export function isMmdMessage(msg: Message): boolean {
return !!msg[MMD_CONTEXT_MARKER] || !!msg[MMD_INJECTION_MARKER];
}
+430
View File
@@ -0,0 +1,430 @@
/**
* L3 MMD Injector — inject active/history MMD into messages.
* Handles version dedup and insertion point calculation.
*/
import type { StorageAdapter } from "../../core/storage/adapter.js";
import type { OffloadEntry, OffloadState } from "../types.js";
import type { Message } from "./helpers.js";
import { MMD_CONTEXT_MARKER, MMD_INJECTION_MARKER, buildOffloadMap } from "./helpers.js";
// ─── Active MMD Injection ────────────────────────────────────────────────────
export interface MmdInjectionResult {
injectedCount: number;
mmdTokensEstimate: number;
}
/**
* Inject the active MMD into messages. Performs version dedup.
*/
export async function injectActiveMmd(
messages: Message[],
state: OffloadState,
storage: StorageAdapter,
basePath: string,
): Promise<MmdInjectionResult> {
// Resolve the MMD file to inject: use activeMmdFile, or fallback to the latest .mmd
let mmdFile = state.activeMmdFile;
if (!mmdFile) {
// Fallback: find the most recent .mmd file (sorted by prefix number, highest = latest)
const allMmds = await storage.readdirNames(`${basePath}/mmds/`, ".mmd");
if (allMmds.length > 0) {
// Sort descending by prefix number (e.g., "006-current-task.mmd" → 6)
allMmds.sort((a, b) => {
const numA = parseInt(a.split("-")[0], 10) || 0;
const numB = parseInt(b.split("-")[0], 10) || 0;
return numB - numA;
});
// Pick the first non-empty one
for (const candidate of allMmds) {
const content = await storage.readFile(`${basePath}/mmds/${candidate}`);
if (content && content.trim().length > 0) {
mmdFile = candidate;
break;
}
}
}
}
if (!mmdFile) {
removeMmdMessages(messages, "active");
return { injectedCount: 0, mmdTokensEstimate: 0 };
}
const mmdContent = await storage.readFile(`${basePath}/mmds/${mmdFile}`);
if (!mmdContent) {
removeMmdMessages(messages, "active");
return { injectedCount: 0, mmdTokensEstimate: 0 };
}
const newVersion = hashContent(mmdContent);
// Version dedup: check existing MMD message version and position
const existingIdx = messages.findIndex((m) => m[MMD_CONTEXT_MARKER] === "active");
if (existingIdx >= 0 && messages[existingIdx]._mmdVersion === newVersion) {
// Content unchanged — but still need to ensure correct position
// (aggressive compression may have shifted it)
const correctIdx = findActiveMmdInsertionPoint(
messages.filter((m) => m[MMD_CONTEXT_MARKER] !== "active"),
);
// Check if position is already correct (accounting for removal offset)
const effectiveCorrectIdx = correctIdx <= existingIdx ? correctIdx : correctIdx;
if (existingIdx === effectiveCorrectIdx) {
return { injectedCount: 0, mmdTokensEstimate: 0 };
}
// Reposition: remove and re-insert at correct point
const [mmdMsg] = messages.splice(existingIdx, 1);
const newInsertIdx = findActiveMmdInsertionPoint(messages);
messages.splice(newInsertIdx, 0, mmdMsg);
return { injectedCount: 0, mmdTokensEstimate: 0 };
}
// Remove old active MMD message
removeMmdMessages(messages, "active");
// Build MMD text
const mmdText = buildActiveMmdText(mmdFile, mmdContent);
const mmdMsg: Message = {
role: "user",
content: mmdText,
[MMD_CONTEXT_MARKER]: "active",
_mmdVersion: newVersion,
_mmdFilename: mmdFile,
};
// Insert at calculated point
const insertIdx = findActiveMmdInsertionPoint(messages);
messages.splice(insertIdx, 0, mmdMsg);
return { injectedCount: 1, mmdTokensEstimate: Math.ceil(mmdContent.length / 4) };
}
// ─── History MMD Injection ───────────────────────────────────────────────────
export interface HistoryMmdResult {
injectedCount: number;
mmdFiles: string[];
totalTokensEstimate: number;
}
/**
* Inject history MMD files for entries that were deleted during aggressive compression.
*/
export async function injectHistoryMmds(
messages: Message[],
deletedIds: string[],
entries: OffloadEntry[],
state: OffloadState,
storage: StorageAdapter,
basePath: string,
tokenBudget: number,
): Promise<HistoryMmdResult> {
if (deletedIds.length === 0) {
return { injectedCount: 0, mmdFiles: [], totalTokensEstimate: 0 };
}
// 1. Find MMD prefixes from deleted entries' node_ids
const offloadMap = buildOffloadMap(entries);
const mmdPrefixes = new Set<string>();
for (const id of deletedIds) {
const entry = offloadMap.get(id);
if (entry?.node_id) {
const prefix = entry.node_id.split("-")[0];
if (prefix) mmdPrefixes.add(prefix);
}
}
if (mmdPrefixes.size === 0) {
return { injectedCount: 0, mmdFiles: [], totalTokensEstimate: 0 };
}
// 2. Find matching history MMD files (exclude active)
const allMmds = await storage.readdirNames(`${basePath}/mmds/`, ".mmd");
const candidates = allMmds.filter((f) => {
const prefix = f.split("-")[0];
return mmdPrefixes.has(prefix) && f !== state.activeMmdFile;
});
if (candidates.length === 0) {
return { injectedCount: 0, mmdFiles: [], totalTokensEstimate: 0 };
}
// 3. Read and inject (respecting token budget), most recent first
candidates.reverse();
const injected: Message[] = [];
const mmdFiles: string[] = [];
let usedTokens = 0;
for (const filename of candidates) {
const content = await storage.readFile(`${basePath}/mmds/${filename}`);
if (!content) continue;
const text = buildHistoryMmdText(filename, content);
const tokens = Math.ceil(text.length / 4);
if (usedTokens + tokens > tokenBudget) continue;
injected.push({
role: "user",
content: text,
[MMD_INJECTION_MARKER]: true,
_mmdFilename: filename,
});
mmdFiles.push(filename);
usedTokens += tokens;
}
if (injected.length === 0) {
return { injectedCount: 0, mmdFiles: [], totalTokensEstimate: 0 };
}
// 4. Remove old history MMD injections and insert new ones
removeExistingMmdInjections(messages);
const insertIdx = findHistoryMmdInsertionPoint(messages);
// Reverse back to chronological order (oldest first)
injected.reverse();
mmdFiles.reverse();
messages.splice(insertIdx, 0, ...injected);
return { injectedCount: injected.length, mmdFiles, totalTokensEstimate: usedTokens };
}
// ─── Insertion Point Calculation ─────────────────────────────────────────────
/**
* Find insertion point for active MMD.
* Strategy: insert after the latest user message in the second half.
* Guard: don't split tool_call / tool_result pairs.
*/
export function findActiveMmdInsertionPoint(messages: Message[]): number {
if (messages.length <= 2) {
const idx = Math.min(1, messages.length);
return adjustForToolCallPair(messages, idx);
}
let latestUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
const role = messages[i].role ?? messages[i].message?.role ?? messages[i].type;
if (role === "user" && !messages[i][MMD_CONTEXT_MARKER] && !messages[i][MMD_INJECTION_MARKER]) {
latestUserIdx = i;
break;
}
}
let insertIdx: number;
if (latestUserIdx >= 0) {
// Insert relative to latest user message:
// - If it's the last message → insert before it (user prompt stays last)
// - Otherwise → insert after it (between user and tool loop, matching plugin behavior)
if (latestUserIdx === messages.length - 1) {
insertIdx = latestUserIdx;
} else {
insertIdx = latestUserIdx + 1;
}
} else {
// No user message found — fallback: before the trailing tool loop
let loopStart = messages.length;
for (let i = messages.length - 1; i >= 0; i--) {
const role = messages[i].role ?? messages[i].message?.role ?? messages[i].type;
if (messages[i][MMD_CONTEXT_MARKER] || messages[i][MMD_INJECTION_MARKER]) continue;
if (role === "toolResult" || role === "tool" || role === "assistant") {
loopStart = i;
} else {
break;
}
}
const maxDistFromTail = 30;
const minInsertIdx = Math.max(1, messages.length - maxDistFromTail);
insertIdx = Math.max(loopStart, minInsertIdx);
}
// Guard: don't insert between assistant(tool_use) and its tool_result
insertIdx = adjustForToolCallPair(messages, insertIdx);
// Hard guard: never insert before system message
if (insertIdx === 0 && messages.length > 0) {
const firstRole = messages[0].role ?? messages[0].message?.role ?? messages[0].type;
if (firstRole === "system") {
insertIdx = 1;
}
}
return insertIdx;
}
/**
* Find insertion point for history MMD (before active MMD).
*/
export function findHistoryMmdInsertionPoint(messages: Message[]): number {
for (let i = 0; i < messages.length; i++) {
if (messages[i][MMD_CONTEXT_MARKER] === "active") return i;
}
return findActiveMmdInsertionPoint(messages);
}
// ─── MMD Message Management ─────────────────────────────────────────────────
/**
* Remove MMD messages by type ("active", "history", or all).
*/
export function removeMmdMessages(messages: Message[], type?: "active" | "history"): number {
let removed = 0;
for (let i = messages.length - 1; i >= 0; i--) {
if (type === "active" && messages[i][MMD_CONTEXT_MARKER] === "active") {
messages.splice(i, 1);
removed++;
} else if (type === "history" && messages[i][MMD_INJECTION_MARKER]) {
messages.splice(i, 1);
removed++;
} else if (!type && (messages[i][MMD_CONTEXT_MARKER] || messages[i][MMD_INJECTION_MARKER])) {
messages.splice(i, 1);
removed++;
}
}
return removed;
}
/**
* Remove existing history MMD injection messages.
*/
export function removeExistingMmdInjections(messages: Message[]): number {
let removed = 0;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i][MMD_INJECTION_MARKER]) {
messages.splice(i, 1);
removed++;
}
}
return removed;
}
// ─── Text Builders ───────────────────────────────────────────────────────────
/**
* Build active MMD injection text.
*/
export function buildActiveMmdText(filename: string, mmdContent: string): string {
let taskGoal = "";
const metaMatch = mmdContent.match(/^%%\{\s*(.*?)\s*\}%%/);
if (metaMatch) {
try {
const meta = JSON.parse(`{${metaMatch[1]}}`);
taskGoal = meta.taskGoal || "";
} catch { /* ignore */ }
}
return [
`<current_task_context>`,
`【当前活跃任务的mermaid流程图】这是你最近正在执行的任务的阶段性记录。`,
taskGoal ? `**任务目标:** ${taskGoal}` : "",
`**任务文件:** ${filename}`,
"```mermaid",
mmdContent,
"```",
`标记为 "doing" 的节点是近期焦点,"done" 的已完成。请参考此保持方向感,避免重复已完成的工作。`,
`</current_task_context>`,
]
.filter((line) => line !== "")
.join("\n");
}
/**
* Build history MMD injection text.
*/
export function buildHistoryMmdText(filename: string, mmdContent: string): string {
let taskGoal = "";
const metaMatch = mmdContent.match(/^%%\{\s*(.*?)\s*\}%%/);
if (metaMatch) {
try {
const meta = JSON.parse(`{${metaMatch[1]}}`);
taskGoal = meta.taskGoal || "";
} catch { /* ignore */ }
}
return [
`<history_task_context>`,
`【历史任务记录】以下是此前完成的任务的概要。`,
taskGoal ? `**任务目标:** ${taskGoal}` : "",
`**任务文件:** ${filename}`,
"```mermaid",
mmdContent,
"```",
`</history_task_context>`,
]
.filter((line) => line !== "")
.join("\n");
}
// ─── Internal Helpers ────────────────────────────────────────────────────────
function hasToolResultContent(msg: Message): boolean {
const content = msg?.content;
if (!Array.isArray(content)) return false;
return content.some(
(block: any) =>
typeof block === "object" &&
block !== null &&
(block.type === "tool_result" || block.type === "toolResult"),
);
}
function hasToolUseContent(msg: Message): boolean {
const content = msg?.content;
if (!Array.isArray(content)) return false;
return content.some(
(block: any) =>
typeof block === "object" &&
block !== null &&
(block.type === "tool_use" || block.type === "toolUse"),
);
}
function adjustForToolCallPair(messages: Message[], insertIdx: number): number {
if (insertIdx <= 0 || insertIdx >= messages.length) return insertIdx;
const msgAtIdx = messages[insertIdx];
const role = msgAtIdx?.role ?? msgAtIdx?.message?.role ?? msgAtIdx?.type;
// Check both role-level (OpenAI: role="tool") and content-level (Anthropic: content[].type="tool_result")
const isToolResult =
role === "tool" ||
role === "toolResult" ||
role === "tool_result" ||
(role === "user" && hasToolResultContent(msgAtIdx));
if (isToolResult) {
// Walk back to before the assistant tool_use
let i = insertIdx - 1;
while (i >= 0) {
const r = messages[i].role ?? messages[i].message?.role ?? messages[i].type;
if (r === "assistant" && hasToolUseContent(messages[i])) {
return i;
}
if (r === "assistant") {
return i;
}
const prevIsToolResult =
r === "tool" ||
r === "toolResult" ||
r === "tool_result" ||
(r === "user" && hasToolResultContent(messages[i]));
if (!prevIsToolResult) break;
i--;
}
}
// Also check: don't insert right after an assistant with tool_use (before its tool_result)
if (insertIdx > 0) {
const prevMsg = messages[insertIdx - 1];
const prevRole = prevMsg?.role ?? prevMsg?.message?.role ?? prevMsg?.type;
if (prevRole === "assistant" && hasToolUseContent(prevMsg)) {
return insertIdx - 1;
}
}
return insertIdx;
}
function hashContent(s: string): string {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36).padStart(6, "0").slice(0, 6);
}
+262
View File
@@ -0,0 +1,262 @@
/**
* Offload Ingest Handler — fast path: COS write + task enqueue.
* Branch A: toolPairs only → write pending.jsonl, enqueue async L1
* Branch B: recentMessages only (no toolPairs) → L1.5 path
* Branch C: toolPairs + recentMessages → write pending + cache recentMessages, trigger L1 (skip L1.5)
*/
import type http from "node:http";
import type { StorageAdapter } from "../core/storage/adapter.js";
import type { IStateBackend, TaskPayload } from "../core/state/types.js";
import type { OffloadExecutorConfig, OffloadState } from "./types.js";
import { defaultOffloadState } from "./types.js";
import { IngestRequestSchema } from "./schemas.js";
import { serializeJsonl } from "./parsers/json-utils.js";
import { buildOffloadBasePath } from "./session-utils.js";
// ─── Per-session in-process mutex for COS append serialization ───
// Layer 1 (local): ensures at most one inflight appendFile per session within
// the same Node.js process (no I/O overhead, zero-latency queue).
// Layer 2 (distributed): stateBackend.acquireLock across multiple offload server
// instances prevents concurrent AppendObject calls to the same COS key.
const pendingMutexes = new Map<string, Promise<void>>();
async function withSessionMutex<T>(sessionKey: string, fn: () => Promise<T>): Promise<T> {
const prev = pendingMutexes.get(sessionKey) ?? Promise.resolve();
let resolve!: () => void;
const next = new Promise<void>((r) => { resolve = r; });
pendingMutexes.set(sessionKey, next);
try {
await prev;
return await fn();
} finally {
resolve();
if (pendingMutexes.get(sessionKey) === next) {
pendingMutexes.delete(sessionKey);
}
}
}
/** Max attempts to acquire the distributed lock. */
const APPEND_LOCK_MAX_ATTEMPTS = 10;
/** TTL for the distributed append lock (ms). Short-lived since append is fast. */
const APPEND_LOCK_TTL_MS = 5000;
/** Delay between lock acquisition retries (ms). Uses exponential backoff: base * 2^attempt. */
const APPEND_LOCK_RETRY_BASE_MS = 50;
export interface IngestDeps {
storage: StorageAdapter;
stateBackend?: IStateBackend;
config: OffloadExecutorConfig;
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void };
}
export async function handleIngest(
req: http.IncomingMessage,
res: http.ServerResponse,
auth: { serviceId: string },
deps: IngestDeps,
requestId: string,
parseJsonBody: <T>(req: http.IncomingMessage) => Promise<T>,
sendJson: (res: http.ServerResponse, status: number, body: unknown) => void,
successEnvelope: <T>(data: T, requestId: string) => unknown,
errorEnvelope: (code: number, message: string, requestId: string) => unknown,
): Promise<void> {
const body = await parseJsonBody(req);
const parsed = IngestRequestSchema.safeParse(body);
if (!parsed.success) {
sendJson(res, 400, errorEnvelope(400, parsed.error.message, requestId));
return;
}
const { session_id: sessionId, tool_pairs: toolPairs, prompt, recent_messages: recentMessages } = parsed.data;
const { storage, stateBackend, config } = deps;
const basePath = buildOffloadBasePath(sessionId);
// Build context string from structured prompt + recentMessages (for L1/L1.5)
let contextText: string | undefined;
if (prompt || (recentMessages && recentMessages.length > 0)) {
const parts: string[] = [];
if (recentMessages && recentMessages.length > 0) {
parts.push("历史消息,可作为参考:");
for (const m of recentMessages) {
parts.push(`[${m.role === "user" ? "User" : "Assistant"}]: ${m.content}`);
}
}
if (prompt) {
parts.push(`\n最新user message\n[User]: ${prompt}`);
}
contextText = parts.join("\n");
}
// ─── Session skip filter (shared by L1 and L1.5) ───
const INTERNAL_SESSION_RE = /memory-.*-session-\d+/;
const shouldSkipSession = INTERNAL_SESSION_RE.test(sessionId) || sessionId.includes("subagent");
// ─── Branch L1: toolPairs non-empty → write pending + trigger L1 ───
if (toolPairs.length > 0) {
if (shouldSkipSession) {
deps.logger.info(`[offload-server] ingest: L1 skipped (session=${sessionId})`);
sendJson(res, 200, successEnvelope({}, requestId));
return;
}
// Save context for L1 executor (if available)
if (contextText) {
await storage.writeFile(`${basePath}/recent-context.txt`, contextText);
}
const pendingPath = `${basePath}/pending.jsonl`;
// Map API snake_case → internal camelCase for JSONL storage
const camelPairs = toolPairs.map((tp: Record<string, unknown>) => ({
toolName: tp.tool_name,
toolCallId: tp.tool_call_id,
params: tp.params,
result: tp.result,
error: tp.error,
timestamp: tp.timestamp,
durationMs: tp.duration_ms,
}));
const lines = serializeJsonl(camelPairs);
// Two-layer serialization to prevent COS AppendPositionErr:
// Layer 1 (local mutex): queues concurrent requests within this process.
// Layer 2 (distributed lock via stateBackend): prevents races across server instances.
// If the lock cannot be acquired, return 409 so the client retries (NOT proceed without lock).
const lockAcquired = await withSessionMutex(pendingPath, async () => {
const lockKey = `offload-pending:${auth.serviceId}:${sessionId}`;
const lockOwner = requestId;
let locked = false;
if (stateBackend) {
for (let attempt = 0; attempt < APPEND_LOCK_MAX_ATTEMPTS; attempt++) {
locked = await stateBackend.acquireLock(lockKey, lockOwner, APPEND_LOCK_TTL_MS);
if (locked) break;
// Exponential backoff: 50, 100, 200, 400, 800, 1600... capped at 2000ms
const delay = Math.min(APPEND_LOCK_RETRY_BASE_MS * 2 ** attempt, 2000);
await new Promise((r) => setTimeout(r, delay));
}
if (!locked) {
deps.logger.warn(`[offload-server] ingest: append lock failed after ${APPEND_LOCK_MAX_ATTEMPTS} attempts (session=${sessionId}), returning 409`);
return false;
}
}
try {
await storage.appendFile(pendingPath, lines);
if (stateBackend) {
const raw = await storage.readFile(pendingPath);
const lineCount = raw ? raw.split("\n").filter(Boolean).length : 0;
if (lineCount >= config.forceTriggerThreshold) {
const task: TaskPayload = {
id: `offload-l1-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: "offload-l1" as TaskPayload["type"],
instanceId: auth.serviceId,
sessionId,
priority: 0,
data: { sessionId, instanceId: auth.serviceId },
createdAt: Date.now(),
};
await stateBackend.enqueueTask(task);
deps.logger.info(`[offload-server] ingest: ${toolPairs.length} pairs, triggered=true (lines=${lineCount})`);
} else {
await stateBackend.setTimerIfEarlier(
auth.serviceId,
`offload-l1:${auth.serviceId}:${sessionId}`,
Date.now() + config.pendingMaxAgeSeconds * 1000,
);
deps.logger.info(`[offload-server] ingest: ${toolPairs.length} pairs, lines=${lineCount}/${config.forceTriggerThreshold}, timer set`);
}
}
return true;
} finally {
if (locked && stateBackend) {
await stateBackend.releaseLock(lockKey, lockOwner);
}
}
});
if (!lockAcquired) {
sendJson(res, 409, errorEnvelope(409, "Concurrent write conflict, please retry", requestId));
return;
}
sendJson(res, 200, successEnvelope({}, requestId));
return;
}
// ─── Branch L1.5: toolPairs empty + prompt → task judgment ───
// Skip L1.5 for inter-session messages, internal sessions, and system prompts
const shouldSkipL15 = shouldSkipSession || !!(
prompt && (
prompt.startsWith("[Inter-session message]") ||
prompt.startsWith("Pre-compaction")
)
);
if (prompt && stateBackend && !shouldSkipL15) {
const lockKey = `offload-state:${auth.serviceId}:${sessionId}`;
const lockOwner = requestId;
let locked = false;
// Use exponential backoff, same policy as L1 append lock.
for (let attempt = 0; attempt < APPEND_LOCK_MAX_ATTEMPTS; attempt++) {
locked = await stateBackend.acquireLock(lockKey, lockOwner, APPEND_LOCK_TTL_MS);
if (locked) break;
const delay = Math.min(APPEND_LOCK_RETRY_BASE_MS * 2 ** attempt, 2000);
await new Promise((r) => setTimeout(r, delay));
}
if (!locked) {
// Do NOT proceed without the lock: a concurrent writer would overwrite state.json
// and silently drop this boundary, causing the L1.5 executor to skip the task
// (findIndex returns -1 → return without error).
deps.logger.warn(`[offload-server] ingest: L1.5 state lock failed after ${APPEND_LOCK_MAX_ATTEMPTS} attempts (session=${sessionId}), returning 409`);
sendJson(res, 409, errorEnvelope(409, "Concurrent write conflict, please retry", requestId));
return;
}
try {
const state = await readState(storage, basePath);
const boundaryTimestamp = new Date().toISOString();
state.boundaries.push({
targetMmd: "_pending",
timestamp: boundaryTimestamp,
});
await writeState(storage, basePath, state);
const task: TaskPayload = {
id: `offload-l15-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: "offload-l15" as TaskPayload["type"],
instanceId: auth.serviceId,
sessionId,
priority: 0,
data: { sessionId, recentMessages: contextText, boundaryTimestamp, instanceId: auth.serviceId },
createdAt: Date.now(),
};
await stateBackend.enqueueTask(task);
deps.logger.info(`[offload-server] ingest: L1.5 triggered, boundaryTs=${boundaryTimestamp}, prompt=${prompt.slice(0, 200)}`);
} finally {
await stateBackend.releaseLock(lockKey, lockOwner);
}
}
// ─── Fast return ───
sendJson(res, 200, successEnvelope({}, requestId));
}
// ─── State helpers (duplicated intentionally to keep ingest-handler self-contained) ──
async function readState(storage: StorageAdapter, basePath: string): Promise<OffloadState> {
const raw = await storage.readFile(`${basePath}/state.json`);
if (!raw) return defaultOffloadState();
try {
return { ...defaultOffloadState(), ...JSON.parse(raw) };
} catch {
return defaultOffloadState();
}
}
async function writeState(storage: StorageAdapter, basePath: string, state: OffloadState): Promise<void> {
await storage.writeFile(`${basePath}/state.json`, JSON.stringify(state));
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Offload MMD Query Handler — returns MMD files.
* Body params: sessionId (required), limit (optional, default=all)
* When limit=1, only returns the active MMD.
*/
import type http from "node:http";
import type { StorageAdapter } from "../core/storage/adapter.js";
import type { OffloadState } from "./types.js";
import { createHash } from "node:crypto";
import { buildOffloadBasePath } from "./session-utils.js";
function hashContent(content: string): string {
return createHash("md5").update(content).digest("hex").slice(0, 12);
}
export async function handleMmdQuery(
_req: http.IncomingMessage,
res: http.ServerResponse,
_auth: { serviceId: string },
storage: StorageAdapter,
requestId: string,
sendJson: (res: http.ServerResponse, status: number, body: unknown) => void,
successEnvelope: <T>(data: T, requestId: string) => unknown,
errorEnvelope: (code: number, message: string, requestId: string) => unknown,
sessionId: string,
limit?: number,
): Promise<void> {
if (!sessionId) {
sendJson(res, 400, errorEnvelope(400, "missing sessionId", requestId));
return;
}
const basePath = buildOffloadBasePath(sessionId);
// Read state
const stateRaw = await storage.readFile(`${basePath}/state.json`);
let state: Partial<OffloadState> = {};
if (stateRaw) {
try {
state = JSON.parse(stateRaw);
} catch {
// ignore
}
}
const mmdsPrefix = `${basePath}/mmds/`;
const mmds: Array<{
filename: string;
content: string;
version: string;
}> = [];
if (limit === 1 && state.activeMmdFile) {
// Fast path: only return the active MMD
const content = await storage.readFile(`${mmdsPrefix}${state.activeMmdFile}`) ?? "";
mmds.push({
filename: state.activeMmdFile,
content,
version: content ? hashContent(content) : "",
});
} else {
// Return all (or up to limit)
const mmdFiles = await storage.readdirNames(mmdsPrefix, ".mmd");
const filesToRead = limit && limit > 0 ? mmdFiles.slice(0, limit) : mmdFiles;
const readResults = await Promise.all(
filesToRead.map(async (filename) => {
const content = await storage.readFile(`${mmdsPrefix}${filename}`) ?? "";
return {
filename,
content,
version: content ? hashContent(content) : "",
};
}),
);
mmds.push(...readResults);
}
sendJson(
res,
200,
successEnvelope(
{
mmds,
currentMmd: state.activeMmdFile ?? null,
},
requestId,
),
);
}
+745
View File
@@ -0,0 +1,745 @@
/**
* Offload Task Executor — async L1/L1.5/L2 execution via PipelineWorker.
*/
import type { StorageAdapter } from "../core/storage/adapter.js";
import type { IStateBackend, TaskPayload } from "../core/state/types.js";
import type {
OffloadEntry,
OffloadState,
OffloadExecutorConfig,
L2ParsedResponse,
MmdMeta,
ToolPair,
} from "./types.js";
import { defaultOffloadState, defaultOffloadConfig } from "./types.js";
import { parseJsonl, serializeJsonl } from "./parsers/json-utils.js";
import { parseL1Response } from "./parsers/l1-parser.js";
import { parseL15Response } from "./parsers/l15-parser.js";
import { parseL2Response } from "./parsers/l2-parser.js";
import { L1_SYSTEM_PROMPT, buildL1UserPrompt } from "./prompts/l1-prompt.js";
import { L15_SYSTEM_PROMPT, buildL15UserPrompt } from "./prompts/l15-prompt.js";
import { L2_SYSTEM_PROMPT, buildL2UserPrompt } from "./prompts/l2-prompt.js";
import { handleTaskTransition, extractMmdMeta } from "./task-transition.js";
import { buildOffloadBasePath } from "./session-utils.js";
import { traceServerModelIo, traceServerTaskDecision } from "./opik-tracer.js";
// ─── LLM Client Interface ────────────────────────────────────────────────────
export interface LlmClient {
chat(params: {
model: string;
messages: Array<{ role: "system" | "user"; content: string }>;
temperature: number;
max_tokens: number;
timeoutMs?: number;
}): Promise<string>;
}
// ─── Logger Interface ────────────────────────────────────────────────────────
export interface ExecutorLogger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
// ─── Executor Deps ───────────────────────────────────────────────────────────
export interface OffloadExecutorDeps {
resolveStorage: (instanceId: string) => Promise<StorageAdapter | undefined>;
llmClient: LlmClient;
stateBackend: IStateBackend;
config?: OffloadExecutorConfig;
logger: ExecutorLogger;
}
// ─── Executor Class ──────────────────────────────────────────────────────────
export class OffloadTaskExecutor {
private deps: OffloadExecutorDeps;
private config: OffloadExecutorConfig;
constructor(deps: OffloadExecutorDeps) {
this.deps = deps;
this.config = deps.config ?? defaultOffloadConfig();
}
// ═══════════════════════════════════════════════════════════════════════════
// L1: Summarize pending ToolPairs → OffloadEntry[]
// ═══════════════════════════════════════════════════════════════════════════
async executeOffloadL1(task: TaskPayload, _signal?: AbortSignal): Promise<void> {
const startMs = Date.now();
const sessionId = this.extractSessionId(task);
if (!sessionId) return;
const storage = await this.resolveStorageOrThrow(task.instanceId);
const basePath = buildOffloadBasePath(sessionId);
const pendingPath = `${basePath}/pending.jsonl`;
// 1. Claim pending via rename (atomic: new ingest appends to fresh pending.jsonl)
const processingPath = `${basePath}/pending-processing-${task.id}.jsonl`;
const pendingRaw = await storage.readFile(pendingPath);
if (!pendingRaw || !pendingRaw.trim()) return;
try {
await storage.rename(pendingPath, processingPath);
} catch {
// rename failed (file gone — another L1 claimed it, or doesn't exist)
return;
}
// Re-read from processing file (rename succeeded, this is our exclusive copy)
const claimedRaw = await storage.readFile(processingPath);
if (!claimedRaw || !claimedRaw.trim()) {
await storage.unlink(processingPath);
return;
}
let toolPairs = parseJsonl<Record<string, unknown>>(claimedRaw, (line, err) => {
this.deps.logger.warn(`[offload-server] L1: bad JSONL line in pending: ${line}`, err);
});
if (toolPairs.length === 0) {
await storage.unlink(processingPath);
return;
}
// 2. Dedup: remove toolPairs whose toolCallId already exists in entries.jsonl
const entriesPath = `${basePath}/entries.jsonl`;
const existingEntriesRaw = await storage.readFile(entriesPath);
if (existingEntriesRaw) {
const existingIds = new Set<string>();
for (const e of parseJsonl<OffloadEntry>(existingEntriesRaw)) {
if (e.tool_call_id) existingIds.add(e.tool_call_id);
}
const before = toolPairs.length;
toolPairs = toolPairs.filter((tp) => {
const id = tp.toolCallId as string;
return !id || !existingIds.has(id);
});
if (toolPairs.length < before) {
this.deps.logger.info(`[offload-server] L1: dedup removed ${before - toolPairs.length}/${before} duplicate toolPairs`);
}
if (toolPairs.length === 0) {
await storage.unlink(processingPath);
return;
}
}
// 3. Build prompt & call LLM
const recentContext = await storage.readFile(`${basePath}/recent-context.txt`) ?? "";
const userPrompt = buildL1UserPrompt(recentContext, toolPairs as unknown as ToolPair[]);
let newEntries: OffloadEntry[];
const l1LlmStart = Date.now();
let l1RawResponse: string | undefined;
try {
const response = await this.deps.llmClient.chat({
model: this.config.l1Model,
messages: [
{ role: "system", content: L1_SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
temperature: this.config.l1Temperature,
max_tokens: this.config.l1MaxTokens,
timeoutMs: this.config.l1TimeoutMs,
});
l1RawResponse = response;
newEntries = parseL1Response(response);
traceServerModelIo({
sessionId,
stage: "L1",
model: this.config.l1Model,
systemPrompt: L1_SYSTEM_PROMPT,
userPrompt,
responseContent: response,
status: "ok",
durationMs: Date.now() - l1LlmStart,
logger: this.deps.logger,
});
} catch (err) {
traceServerModelIo({
sessionId,
stage: "L1",
model: this.config.l1Model,
systemPrompt: L1_SYSTEM_PROMPT,
userPrompt,
responseContent: l1RawResponse ?? "",
status: "error",
errorMessage: String(err),
durationMs: Date.now() - l1LlmStart,
logger: this.deps.logger,
});
this.deps.logger.error(`[offload-server] L1 LLM failed:`, err);
throw err; // Let Worker retry (processing file remains for re-claim)
}
// 4. Ensure all toolPairs are covered — fallback for missing entries
const parsedIds = new Set(newEntries.map((e) => e.tool_call_id));
// Build toolCallId → original timestamp map for reliable boundary matching
const tpTimestampMap = new Map<string, string>();
for (const tp of toolPairs) {
const id = tp.toolCallId as string;
if (id && tp.timestamp) tpTimestampMap.set(id, tp.timestamp as string);
}
// Overwrite entry timestamps with original tool pair timestamps (don't trust LLM output)
for (const entry of newEntries) {
const origTs = tpTimestampMap.get(entry.tool_call_id);
if (origTs) entry.timestamp = origTs;
}
for (const tp of toolPairs) {
const id = tp.toolCallId as string;
if (id && !parsedIds.has(id)) {
newEntries.push({
tool_call_id: id,
tool_call: (tp.toolName as string) ?? "",
summary: "[L1 parse incomplete]",
timestamp: (tp.timestamp as string) ?? new Date().toISOString(),
score: 2,
node_id: null,
});
}
}
// 5. Write refs: store original tool result content for each pair
for (const tp of toolPairs) {
const id = tp.toolCallId as string;
if (!id) continue;
const result = tp.result ?? tp.error ?? "";
const resultStr = typeof result === "string" ? result : JSON.stringify(result, null, 2);
if (!resultStr || resultStr.length < 20) continue; // skip trivially short results
const toolName = (tp.toolName as string) ?? "unknown";
const timestamp = (tp.timestamp as string) ?? new Date().toISOString();
const header = `# Tool Result: ${toolName}\n\n**tool_call_id:** ${id}\n**Timestamp:** ${timestamp}\n\n---\n\n`;
const refPath = `${basePath}/refs/${id}.md`;
await storage.writeFile(refPath, header + resultStr);
// Set result_ref on the matching entry (full relative path for tdai_read_cos)
const entry = newEntries.find((e) => e.tool_call_id === id);
if (entry) entry.result_ref = `${basePath}/refs/${id}.md`;
}
// 6. Write entries (atomic append) + delete processing file
await storage.appendFile(entriesPath, serializeJsonl(newEntries));
await storage.unlink(processingPath);
// 7. Check L2 trigger: group null entries by boundary targetMmd
const state = await this.readState(storage, basePath);
const allEntriesRaw = await storage.readFile(entriesPath);
if (allEntriesRaw && state.boundaries.length > 0) {
const allEntries = parseJsonl<OffloadEntry>(allEntriesRaw, (line, err) => {
this.deps.logger.warn(`[offload-server] L1: bad JSONL line in entries: ${line}`, err);
});
const nodeMapping = await this.readNodeMapping(storage, basePath);
// Group null entries by resolved targetMmd
const nullByMmd = new Map<string, number>();
for (const e of allEntries) {
if (this.getEffectiveNodeId(e, nodeMapping) !== null || !e.timestamp) continue;
const boundary = this.findBoundaryByTimestamp(state.boundaries, e.timestamp);
if (!boundary) continue; // no boundary → ignore
if (boundary.targetMmd === "_pending" || !boundary.targetMmd) continue; // pending → ignore
nullByMmd.set(boundary.targetMmd, (nullByMmd.get(boundary.targetMmd) ?? 0) + 1);
}
// Trigger L2 for each MMD that reached threshold
for (const [mmdFile, count] of nullByMmd) {
if (count >= this.config.l2NullThreshold) {
// Threshold met: short delay (1s) so concurrent L1s merge into one L2
await this.deps.stateBackend.setTimerIfEarlier(
task.instanceId,
`offload-l2:${task.instanceId}:${sessionId}:${mmdFile}`,
Date.now() + 1_000,
);
this.deps.logger.info(`[offload-server] L2 timer set (fast, mmd=${mmdFile}, nullCount=${count})`);
} else if (count > 0) {
await this.deps.stateBackend.setTimerIfEarlier(
task.instanceId,
`offload-l2:${task.instanceId}:${sessionId}:${mmdFile}`,
Date.now() + 30_000,
);
this.deps.logger.info(`[offload-server] L2 timer set (mmd=${mmdFile}, nullCount=${count})`);
}
}
}
this.deps.logger.info(
`[offload-server] L1 complete: ${newEntries.length} entries produced (${Date.now() - startMs}ms)`,
);
}
// ═══════════════════════════════════════════════════════════════════════════
// L1.5: Task judgment — triggered by user new message
// ═══════════════════════════════════════════════════════════════════════════
async executeOffloadL15(task: TaskPayload, _signal?: AbortSignal): Promise<void> {
const startMs = Date.now();
const { sessionId, recentMessages, boundaryTimestamp } = task.data as {
sessionId: string;
recentMessages?: string;
boundaryTimestamp: string;
};
const storage = await this.resolveStorageOrThrow(task.instanceId);
const basePath = buildOffloadBasePath(sessionId);
// ─── Phase 1: Read-only snapshot + LLM call (NO LOCK) ─────────────────
// Multiple L1.5 tasks can execute this phase concurrently. Each operates
// on its own boundary and the LLM call (3-10s) does not block others.
// 1. Read current active MMD (snapshot for prompt building)
const preState = await this.readState(storage, basePath);
let currentMmd: { filename: string; content: string } | null = null;
if (preState.activeMmdFile) {
const content = await storage.readFile(`${basePath}/mmds/${preState.activeMmdFile}`);
if (content) {
currentMmd = { filename: preState.activeMmdFile, content };
}
}
// 2. List available MMDs → extract metas
const mmdsPrefix = `${basePath}/mmds/`;
const mmdFiles = await storage.readdirNames(mmdsPrefix, ".mmd");
const metas: MmdMeta[] = [];
for (const f of mmdFiles) {
const content = await storage.readFile(`${mmdsPrefix}${f}`);
if (content) {
metas.push(extractMmdMeta(f, content));
}
}
// 3. Build prompt & call LLM (no lock held — this is the expensive part)
const userPrompt = buildL15UserPrompt(
recentMessages ?? "",
currentMmd,
metas,
);
let judgment;
let rawResponse: string | undefined;
const l15LlmStart = Date.now();
try {
const response = await this.deps.llmClient.chat({
model: this.config.l15Model,
messages: [
{ role: "system", content: L15_SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
temperature: this.config.l15Temperature,
max_tokens: this.config.l15MaxTokens,
timeoutMs: this.config.l15TimeoutMs,
});
rawResponse = response;
judgment = parseL15Response(response);
traceServerModelIo({
sessionId,
stage: "L1.5",
model: this.config.l15Model,
systemPrompt: L15_SYSTEM_PROMPT,
userPrompt,
responseContent: response,
status: "ok",
durationMs: Date.now() - l15LlmStart,
logger: this.deps.logger,
});
} catch (err) {
traceServerModelIo({
sessionId,
stage: "L1.5",
model: this.config.l15Model,
systemPrompt: L15_SYSTEM_PROMPT,
userPrompt,
responseContent: rawResponse ?? "",
status: "error",
errorMessage: String(err),
durationMs: Date.now() - l15LlmStart,
logger: this.deps.logger,
});
this.deps.logger.error(`[offload-server] L1.5 LLM failed:`, err);
throw err;
}
if (!judgment) {
this.deps.logger.warn(`[offload-server] L1.5: null response (parse failed), raw=${rawResponse?.slice(0, 500) ?? "(empty)"}`);
return;
}
this.deps.logger.info(
`[offload-server] L1.5: completed=${judgment.taskCompleted}, long=${judgment.isLongTask}, cont=${judgment.isContinuation}, label=${judgment.newTaskLabel ?? "none"} (LLM=${Date.now() - l15LlmStart}ms)`,
);
// Trace L1.5 decision
traceServerTaskDecision({
sessionId,
judgment: judgment as unknown as Record<string, unknown>,
durationMs: Date.now() - startMs,
logger: this.deps.logger,
});
// ─── Phase 2: Write phase (SHORT LOCK) ────────────────────────────────
// Only lock during state.json read-modify-write. Lock TTL is short (10s)
// because this phase only does file I/O (no LLM calls).
const lockKey = `offload-state:${task.instanceId}:${sessionId}`;
const lockOwner = task.id;
let locked = false;
for (let attempt = 0; attempt < 10; attempt++) {
locked = await this.deps.stateBackend.acquireLock(lockKey, lockOwner, 10_000);
if (locked) break;
await new Promise((r) => setTimeout(r, 100 + attempt * 50));
}
if (!locked) {
this.deps.logger.warn(`[offload-server] L1.5: failed to acquire write lock after 10 attempts (ts=${boundaryTimestamp}), skipping`);
return;
}
try {
// Re-read state (fresh, may have been modified by another L1.5 that finished earlier)
const state = await this.readState(storage, basePath);
// CAS check: find boundary by timestamp
const boundaryIdx = state.boundaries.findIndex((b) => b.timestamp === boundaryTimestamp);
if (boundaryIdx < 0) {
this.deps.logger.warn(`[offload-server] L1.5: boundary not found for ts=${boundaryTimestamp}, skipping`);
return;
}
// CAS check: already backfilled by another concurrent L1.5? Skip.
if (state.boundaries[boundaryIdx].targetMmd !== "_pending") {
this.deps.logger.info(`[offload-server] L1.5: boundary[ts=${boundaryTimestamp}] already backfilled, skipping`);
return;
}
// Apply task transition
await handleTaskTransition(state, judgment, storage, basePath);
// Backfill boundary targetMmd
state.boundaries[boundaryIdx].targetMmd = state.activeMmdFile;
// Update state
await this.writeState(storage, basePath, state);
// Check L2 trigger: scan ALL null entries grouped by targetMmd.
// This covers the case where L1 completed before L1.5 backfill —
// L1 skipped entries with _pending boundaries, so L1.5 must pick them up.
const entriesRaw = await storage.readFile(`${basePath}/entries.jsonl`);
if (entriesRaw) {
const allEntries = parseJsonl<OffloadEntry>(entriesRaw);
const l15NodeMapping = await this.readNodeMapping(storage, basePath);
// Group null entries by their resolved targetMmd (using all boundaries, not just current)
const nullByMmd = new Map<string, number>();
for (const e of allEntries) {
if (this.getEffectiveNodeId(e, l15NodeMapping) !== null || !e.timestamp) continue;
const boundary = this.findBoundaryByTimestamp(state.boundaries, e.timestamp);
if (!boundary) continue;
if (boundary.targetMmd === "_pending" || !boundary.targetMmd) continue;
nullByMmd.set(boundary.targetMmd, (nullByMmd.get(boundary.targetMmd) ?? 0) + 1);
}
for (const [mmdFile, nullCount] of nullByMmd) {
if (nullCount >= this.config.l2NullThreshold) {
await this.deps.stateBackend.setTimerIfEarlier(
task.instanceId,
`offload-l2:${task.instanceId}:${sessionId}:${mmdFile}`,
Date.now() + 1_000,
);
this.deps.logger.info(`[offload-server] L1.5: L2 timer set (fast, mmd=${mmdFile}, nullCount=${nullCount})`);
} else if (nullCount > 0) {
await this.deps.stateBackend.setTimerIfEarlier(
task.instanceId,
`offload-l2:${task.instanceId}:${sessionId}:${mmdFile}`,
Date.now() + 30_000,
);
this.deps.logger.info(`[offload-server] L1.5: L2 timer set after backfill (mmd=${mmdFile}, nullCount=${nullCount})`);
}
}
}
} finally {
if (locked) {
await this.deps.stateBackend.releaseLock(lockKey, lockOwner);
}
}
this.deps.logger.info(
`[offload-server] L1.5 complete: boundary[ts=${boundaryTimestamp}] (total=${Date.now() - startMs}ms)`,
);
}
// ═══════════════════════════════════════════════════════════════════════════
// L2: MMD generation / update + node_id backfill
// ═══════════════════════════════════════════════════════════════════════════
async executeOffloadL2(task: TaskPayload, _signal?: AbortSignal): Promise<void> {
const startMs = Date.now();
const sessionId = this.extractSessionId(task);
if (!sessionId) return;
let targetMmdFile: string | undefined;
const data = task.data as Record<string, unknown>;
targetMmdFile = data.targetMmdFile as string | undefined;
// Extract targetMmdFile from timer member if available
// Format: "offload-l2:{instanceId}:{sessionId}:{mmdFile}" → extract last segment ending in .mmd
if (!targetMmdFile && data.timerMember) {
const timerMember = data.timerMember as string;
const mmdMatch = timerMember.match(/(\d+-[^:]+\.mmd)$/);
if (mmdMatch) {
targetMmdFile = mmdMatch[1];
}
}
const storage = await this.resolveStorageOrThrow(task.instanceId);
const basePath = buildOffloadBasePath(sessionId);
const state = await this.readState(storage, basePath);
// Resolve targetMmdFile: from task data, timer member, or state (fallback)
if (!targetMmdFile) {
targetMmdFile = state.activeMmdFile ?? undefined;
}
if (!targetMmdFile) return;
// 1. Read all entries + node mapping
const entriesRaw = await storage.readFile(`${basePath}/entries.jsonl`);
if (!entriesRaw) return;
const allEntries = parseJsonl<OffloadEntry>(entriesRaw);
const nodeMapping = await this.readNodeMapping(storage, basePath);
// 2. Filter: entries belonging to targetMmdFile with null node_id (after join)
const relevantEntries = allEntries.filter((e) => {
if (this.getEffectiveNodeId(e, nodeMapping) !== null) return false;
if (!e.timestamp) return false;
const boundary = this.findBoundaryByTimestamp(state.boundaries, e.timestamp);
return boundary?.targetMmd === targetMmdFile;
});
if (relevantEntries.length === 0) return;
// Guard: if oldest null entry is older than 10 minutes, give up and assign fallback node_id
// to prevent infinite L2 retries when LLM consistently fails to map certain entries.
const L2_MAX_AGE_MS = 10 * 60 * 1000;
const oldestTs = relevantEntries.reduce((min, e) => {
const t = new Date(e.timestamp).getTime();
return t < min ? t : min;
}, Infinity);
if (Date.now() - oldestTs > L2_MAX_AGE_MS) {
this.deps.logger.warn(
`[offload-server] L2: ${relevantEntries.length} entries exceeded max age (10min), assigning fallback node_id`,
);
const fallbackMappings = relevantEntries.map((e) => ({
tool_call_id: e.tool_call_id,
node_id: `${targetMmdFile!.replace(/\.mmd$/, "")}-orphan`,
}));
const mappingPath = `${basePath}/node-mapping.jsonl`;
await storage.appendFile(mappingPath, serializeJsonl(fallbackMappings));
return;
}
// 3. Read existing MMD
const mmdPath = `${basePath}/mmds/${targetMmdFile}`;
const existingMmd = await storage.readFile(mmdPath);
// 4. Build prompt (include recent context for better MMD generation)
const recentHistory = await storage.readFile(`${basePath}/recent-context.txt`) ?? null;
const taskLabel = targetMmdFile.replace(/^\d+-/, "").replace(/\.mmd$/, "") || "task";
const prefixMatch = targetMmdFile.match(/^(\d+)-/);
const mmdPrefix = prefixMatch ? prefixMatch[1] : "000";
const charCount = existingMmd?.length ?? 0;
const userPrompt = buildL2UserPrompt({
existingMmd: existingMmd || null,
entries: relevantEntries,
recentHistory,
taskLabel,
mmdPrefix,
charCount,
});
// 5. Call LLM
let result: L2ParsedResponse | null;
let rawL2Response: string | undefined;
const l2LlmStart = Date.now();
try {
const response = await this.deps.llmClient.chat({
model: this.config.l2Model,
messages: [
{ role: "system", content: L2_SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
temperature: this.config.l2Temperature,
max_tokens: this.config.l2MaxTokens,
timeoutMs: this.config.l2TimeoutMs,
});
rawL2Response = response;
result = parseL2Response(response);
traceServerModelIo({
sessionId: sessionId!,
stage: "L2",
model: this.config.l2Model,
systemPrompt: L2_SYSTEM_PROMPT,
userPrompt,
responseContent: response,
status: "ok",
durationMs: Date.now() - l2LlmStart,
logger: this.deps.logger,
});
} catch (err) {
traceServerModelIo({
sessionId: sessionId!,
stage: "L2",
model: this.config.l2Model,
systemPrompt: L2_SYSTEM_PROMPT,
userPrompt,
responseContent: rawL2Response ?? "",
status: "error",
errorMessage: String(err),
durationMs: Date.now() - l2LlmStart,
logger: this.deps.logger,
});
this.deps.logger.error(`[offload-server] L2 LLM failed:`, err);
throw err;
}
if (!result) {
this.deps.logger.warn(`[offload-server] L2: parse failed, raw=${rawL2Response?.slice(0, 500) ?? "(empty)"}`);
// Schedule retry so remaining null entries are not orphaned
await this.deps.stateBackend.setTimerIfEarlier(
task.instanceId,
`offload-l2:${task.instanceId}:${sessionId}:${targetMmdFile}`,
Date.now() + 30_000,
);
return;
}
// 6. Apply MMD update
const updatedMmd = this.applyL2Result(existingMmd ?? "", result);
await storage.writeFile(mmdPath, updatedMmd);
// 7. Backfill: write node mappings to separate file (avoids overwriting entries.jsonl)
const mappingEntries = Object.entries(result.nodeMapping).map(([toolCallId, nodeId]) => ({
tool_call_id: toolCallId,
node_id: nodeId,
}));
if (mappingEntries.length > 0) {
const mappingPath = `${basePath}/node-mapping.jsonl`;
await storage.appendFile(mappingPath, serializeJsonl(mappingEntries));
}
// 8. Check remaining null entries for this MMD — re-read node-mapping for accurate count
// (another L2 may have concurrently written mappings, or LLM may have missed some)
const freshNodeMapping = await this.readNodeMapping(storage, basePath);
const remainingNull = relevantEntries.filter(
(e) => !freshNodeMapping.has(e.tool_call_id) && !result!.nodeMapping[e.tool_call_id],
).length;
if (remainingNull > 0) {
await this.deps.stateBackend.setTimerIfEarlier(
task.instanceId,
`offload-l2:${task.instanceId}:${sessionId}:${targetMmdFile}`,
Date.now() + 30_000,
);
this.deps.logger.info(`[offload-server] L2 retry timer set (mmd=${targetMmdFile}, remainingNull=${remainingNull})`);
}
this.deps.logger.info(
`[offload-server] L2 complete: ${Object.keys(result.nodeMapping).length} entries mapped, action=${result.fileAction} (${Date.now() - startMs}ms)`,
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
/**
* Extract sessionId from task data or task.sessionId.
*/
private extractSessionId(task: TaskPayload): string | undefined {
const data = task.data as Record<string, unknown> | undefined;
// Prefer explicit sessionId in data
if (data?.sessionId && typeof data.sessionId === "string") {
return data.sessionId;
}
// Fallback: task-level sessionId
if (task.sessionId) {
return task.sessionId;
}
return undefined;
}
private async resolveStorageOrThrow(instanceId: string): Promise<StorageAdapter> {
const storage = await this.deps.resolveStorage(instanceId);
if (!storage) throw new Error(`Storage unavailable for instance ${instanceId}`);
return storage;
}
private async readState(storage: StorageAdapter, basePath: string): Promise<OffloadState> {
const raw = await storage.readFile(`${basePath}/state.json`);
if (!raw) return defaultOffloadState();
try {
return { ...defaultOffloadState(), ...JSON.parse(raw) };
} catch {
return defaultOffloadState();
}
}
private async writeState(storage: StorageAdapter, basePath: string, state: OffloadState): Promise<void> {
await storage.writeFile(`${basePath}/state.json`, JSON.stringify(state));
}
private findBoundaryByTimestamp(
boundaries: OffloadState["boundaries"],
entryTimestamp: string,
): OffloadState["boundaries"][number] | null {
if (boundaries.length === 0) return null;
// Find the last boundary whose timestamp <= entryTimestamp
let result: OffloadState["boundaries"][number] | null = null;
for (const b of boundaries) {
if (b.timestamp <= entryTimestamp) result = b;
else break;
}
return result;
}
private applyL2Result(existingMmd: string, result: L2ParsedResponse): string {
if (result.fileAction === "write" && result.mmdContent) {
return result.mmdContent;
}
if (result.fileAction === "replace" && result.replaceBlocks?.length) {
const lines = existingMmd.split("\n");
// Sort blocks by startLine descending to avoid offset issues
const sorted = [...result.replaceBlocks]
.filter((block) => block.startLine >= 1 && block.endLine >= block.startLine && block.startLine <= lines.length)
.sort((a, b) => b.startLine - a.startLine);
for (const block of sorted) {
const start = block.startLine - 1; // 0-based
const end = Math.min(block.endLine, lines.length); // clamp endLine
const deleteCount = end - block.startLine + 1;
const newLines = block.content.split("\n");
lines.splice(start, deleteCount, ...newLines);
}
return lines.join("\n");
}
return existingMmd;
}
/**
* Read node-mapping.jsonl and build a tool_call_id → node_id map.
*/
private async readNodeMapping(storage: StorageAdapter, basePath: string): Promise<Map<string, string>> {
const raw = await storage.readFile(`${basePath}/node-mapping.jsonl`);
const map = new Map<string, string>();
if (!raw) return map;
const lines = parseJsonl<{ tool_call_id: string; node_id: string }>(raw);
for (const line of lines) {
if (line.tool_call_id && line.node_id) {
map.set(line.tool_call_id, line.node_id);
}
}
return map;
}
/**
* Get effective node_id for an entry: check node-mapping first, then entry's own field.
*/
private getEffectiveNodeId(entry: OffloadEntry, nodeMapping: Map<string, string>): string | null {
return nodeMapping.get(entry.tool_call_id) ?? entry.node_id;
}
}
+336
View File
@@ -0,0 +1,336 @@
/**
* Opik observability tracer for offload server.
* Wraps the opik npm package with graceful degradation when not installed.
* Configuration is read from environment variables (no OpenClaw plugin config).
*/
// Opik client types (minimal shape to avoid hard dependency)
interface OpikClient {
trace(params: Record<string, unknown>): OpikTrace;
flush(): Promise<void>;
}
interface OpikTrace {
update(params: Record<string, unknown>): void;
end(): void;
span(params: Record<string, unknown>): OpikSpan;
}
interface OpikSpan {
update(params: Record<string, unknown>): void;
end(): void;
}
// ─── Module State ────────────────────────────────────────────────────────────
let client: OpikClient | null = null;
let tracerEnabled = false;
let tracerInitTried = false;
// ─── Helpers ─────────────────────────────────────────────────────────────────
function extractLayerTag(stage: string): string {
const match = stage.match(/^(L\d+(?:\.\d+)?)/i);
if (!match) return "Lx-unknown";
return match[1].toUpperCase();
}
function durationBucketTag(ms: number): string {
if (typeof ms !== "number" || ms < 0) return "duration:unknown";
if (ms < 1000) return "duration:<1s";
if (ms < 5000) return "duration:1-5s";
if (ms < 15000) return "duration:5-15s";
if (ms < 30000) return "duration:15-30s";
return "duration:>30s";
}
function formatDuration(ms: number): string {
if (typeof ms !== "number" || ms < 0) return "?";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
// ─── Logger Interface ────────────────────────────────────────────────────────
export interface TracerLogger {
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug?: (message: string, ...args: unknown[]) => void;
}
// ─── Init ────────────────────────────────────────────────────────────────────
/**
* Initialize the offload server Opik tracer.
* Reads config from environment variables:
* OPIK_ENABLED — set to "true" to enable (default: disabled)
* OPIK_URL_OVERRIDE — Opik server URL
* OPIK_API_KEY — API key
* OPIK_WORKSPACE — workspace name (default: "default")
* OPIK_PROJECT_NAME — project name (default: "openclaw-offload-server")
*/
export async function initServerOpikTracer(logger: TracerLogger): Promise<void> {
if (tracerInitTried) return;
tracerInitTried = true;
try {
const enabled = process.env.OPIK_ENABLED === "true";
if (!enabled) {
logger.debug?.("[offload-server] Opik tracer disabled (OPIK_ENABLED != true)");
return;
}
const apiUrl = process.env.OPIK_URL_OVERRIDE;
const apiKey = process.env.OPIK_API_KEY;
const workspaceName = process.env.OPIK_WORKSPACE ?? "default";
const projectName = process.env.OPIK_PROJECT_NAME ?? "openclaw-offload-server";
// Dynamic import — graceful when opik is not installed
let OpikConstructor: new (params: Record<string, unknown>) => OpikClient;
let disableOpikLogger: (() => void) | undefined;
try {
const opikModule = await import("opik") as {
Opik: new (params: Record<string, unknown>) => OpikClient;
disableLogger?: () => void;
setLoggerLevel?: (level: string) => void;
};
OpikConstructor = opikModule.Opik;
disableOpikLogger = opikModule.disableLogger;
} catch {
logger.debug?.("[offload-server] opik package not available, tracer disabled");
return;
}
// Suppress opik internal logs (flush messages, ANSI color noise)
if (disableOpikLogger) {
disableOpikLogger();
}
client = new OpikConstructor({
...(apiKey ? { apiKey } : {}),
...(apiUrl ? { apiUrl } : {}),
workspaceName,
projectName,
});
tracerEnabled = true;
logger.info(
`[offload-server] Opik tracer enabled: project=${projectName}, workspace=${workspaceName}`,
);
} catch (err) {
tracerEnabled = false;
client = null;
logger.warn(`[offload-server] Opik tracer init failed: ${String(err)}`);
}
}
/**
* Check if the tracer is enabled and ready to trace.
*/
export function isTracerEnabled(): boolean {
return tracerEnabled && client !== null;
}
// ─── Trace: Model I/O (L1/L1.5/L2 LLM calls) ───────────────────────────────
/**
* Trace LLM model I/O for offload server L1/L1.5/L2 stages.
*/
export function traceServerModelIo(params: {
sessionId: string;
stage: string;
model: string;
systemPrompt: string;
userPrompt: string;
responseContent: string;
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number };
status: "ok" | "error";
errorMessage?: string;
durationMs: number;
logger?: TracerLogger;
}): void {
if (!tracerEnabled || !client) return;
try {
const layerTag = extractLayerTag(params.stage);
const threadId = params.sessionId || `offload-server-${Date.now()}`;
const dur = params.durationMs;
const durStr = formatDuration(dur);
const durBucket = durationBucketTag(dur);
const skTag = `session:${params.sessionId || "unknown"}`;
const trace = client.trace({
name: `${params.model} · offload-server · ${params.stage} · ${durStr}`,
threadId,
metadata: {
plugin: "openclaw-offload-server",
category: "llm",
stage: params.stage,
layer: layerTag,
model: params.model,
sessionId: params.sessionId,
durationMs: dur,
duration: durStr,
},
tags: ["offload-server", "llm", layerTag, durBucket, skTag],
});
const span = trace.span({
name: `${params.model} · ${params.stage} · ${durStr}`,
type: "llm",
model: params.model,
input: {
systemPrompt: params.systemPrompt,
userPrompt: params.userPrompt,
},
metadata: {
stage: params.stage,
layer: layerTag,
sessionId: params.sessionId,
durationMs: dur,
duration: durStr,
},
});
span.update({
output: {
responseContent: params.responseContent,
usage: params.usage,
durationMs: dur,
duration: durStr,
error: params.errorMessage,
},
metadata: {
status: params.status,
durationMs: dur,
},
});
span.end();
trace.end();
void client.flush().catch(() => undefined);
} catch (err) {
params.logger?.warn?.(`[offload-server] Opik model I/O trace failed: ${String(err)}`);
}
}
// ─── Trace: Compaction Decision ──────────────────────────────────────────────
/**
* Trace compaction (L3) decision and results.
*/
export function traceServerCompaction(params: {
sessionId: string;
level: string;
ratio: number;
contextWindow: number;
totalTokensBefore: number;
totalTokensAfter: number;
originalMsgCount: number;
compactedMsgCount: number;
report: Record<string, unknown>;
messages: unknown[];
durationMs: number;
logger?: TracerLogger;
}): void {
if (!tracerEnabled || !client) return;
try {
const threadId = params.sessionId || `offload-server-${Date.now()}`;
const dur = params.durationMs;
const durStr = formatDuration(dur);
const durBucket = durationBucketTag(dur);
const skTag = `session:${params.sessionId || "unknown"}`;
const trace = client.trace({
name: `compaction · L3 · ${params.level} · ${durStr} [${params.sessionId}]`,
threadId,
input: {
level: params.level,
ratio: params.ratio,
contextWindow: params.contextWindow,
totalTokensBefore: params.totalTokensBefore,
originalMsgCount: params.originalMsgCount,
},
metadata: {
plugin: "openclaw-offload-server",
category: "compaction",
stage: "L3",
layer: "L3",
level: params.level,
sessionId: params.sessionId,
durationMs: dur,
duration: durStr,
},
tags: ["offload-server", "compaction", "L3", `level:${params.level}`, durBucket, skTag],
});
// Serialize messages for full snapshot
const serializedMessages = params.messages.map((msg: any, i: number) => {
const role = msg.role ?? "unknown";
const content = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map((c: any) => {
if (c.type === "text") return c.text;
if (c.type === "tool_use") return `[tool_use: ${c.name} id=${c.id}]`;
if (c.type === "tool_result") return `[tool_result: id=${c.tool_use_id} content=${typeof c.content === "string" ? c.content.slice(0, 500) : JSON.stringify(c.content).slice(0, 500)}]`;
return `[${c.type ?? "unknown"}]`;
}).join("\n")
: "";
return { i, role, content, ...(msg._mmdContextMessage ? { mmdCtx: true } : {}), ...(msg._offloaded ? { offloaded: true } : {}) };
});
trace.update({
output: {
totalTokensAfter: params.totalTokensAfter,
compactedMsgCount: params.compactedMsgCount,
tokenReduction: params.totalTokensBefore - params.totalTokensAfter,
report: params.report,
messages: serializedMessages,
},
});
trace.end();
void client.flush().catch(() => undefined);
} catch (err) {
params.logger?.warn?.(`[offload-server] Opik compaction trace failed: ${String(err)}`);
}
}
// ─── Trace: Task Decision (L1.5 judgment result) ─────────────────────────────
/**
* Trace L1.5 task judgment decision.
*/
export function traceServerTaskDecision(params: {
sessionId: string;
judgment: Record<string, unknown>;
durationMs: number;
logger?: TracerLogger;
}): void {
if (!tracerEnabled || !client) return;
try {
const threadId = params.sessionId || `offload-server-${Date.now()}`;
const dur = params.durationMs;
const durStr = formatDuration(dur);
const skTag = `session:${params.sessionId || "unknown"}`;
const trace = client.trace({
name: `task-decision · L1.5 · ${durStr} [${params.sessionId}]`,
threadId,
input: {
stage: "L1.5",
sessionId: params.sessionId,
},
metadata: {
plugin: "openclaw-offload-server",
category: "decision",
stage: "L1.5",
layer: "L1.5",
sessionId: params.sessionId,
durationMs: dur,
duration: durStr,
},
tags: ["offload-server", "decision", "L1.5", skTag],
});
trace.update({ output: params.judgment });
trace.end();
void client.flush().catch(() => undefined);
} catch (err) {
params.logger?.warn?.(`[offload-server] Opik task decision trace failed: ${String(err)}`);
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* JSON / Mermaid extraction utilities for LLM response parsing.
*/
/**
* Extract JSON from raw LLM output. Tolerates markdown fences, extra text.
*/
export function extractJson<T>(raw: string): T | null {
if (!raw || typeof raw !== "string") return null;
const trimmed = raw.trim();
// 1. Direct parse
try {
return JSON.parse(trimmed) as T;
} catch {
// continue
}
// 2. Extract from ```json ... ``` fence
const jsonFenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
if (jsonFenceMatch) {
try {
return JSON.parse(jsonFenceMatch[1].trim()) as T;
} catch {
// continue
}
}
// 3. Extract first { ... } (greedy last })
const objStart = trimmed.indexOf("{");
const objEnd = trimmed.lastIndexOf("}");
if (objStart !== -1 && objEnd > objStart) {
try {
return JSON.parse(trimmed.slice(objStart, objEnd + 1)) as T;
} catch {
// continue
}
}
// 4. Extract first [ ... ] (greedy last ])
const arrStart = trimmed.indexOf("[");
const arrEnd = trimmed.lastIndexOf("]");
if (arrStart !== -1 && arrEnd > arrStart) {
try {
return JSON.parse(trimmed.slice(arrStart, arrEnd + 1)) as T;
} catch {
// continue
}
}
return null;
}
/**
* Extract mermaid content from a ```mermaid ... ``` code fence.
*/
export function extractMermaidFromFence(raw: string): string | null {
if (!raw) return null;
const match = raw.match(/```mermaid\s*\n?([\s\S]*?)```/);
return match ? match[1].trim() : null;
}
/**
* Parse JSONL (newline-delimited JSON) string into array.
* Corrupted lines are silently skipped (logged via optional callback).
*/
export function parseJsonl<T>(
content: string,
onBadLine?: (line: string, error: unknown) => void,
): T[] {
if (!content || !content.trim()) return [];
const results: T[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
try {
results.push(JSON.parse(trimmed) as T);
} catch (err) {
onBadLine?.(trimmed, err);
}
}
return results;
}
/**
* Serialize array to JSONL string (trailing newline).
*/
export function serializeJsonl<T>(items: T[]): string {
if (items.length === 0) return "";
return items.map((item) => JSON.stringify(item)).join("\n") + "\n";
}
+41
View File
@@ -0,0 +1,41 @@
/**
* L1 Response Parser — extracts OffloadEntry[] from LLM output.
*/
import { extractJson } from "./json-utils.js";
import type { OffloadEntry } from "../types.js";
interface RawL1Entry {
tool_call?: string;
summary?: string;
tool_call_id?: string;
timestamp?: string;
score?: number;
}
/**
* Parse L1 LLM response into OffloadEntry array.
* Tolerant of markdown wrapping, missing fields, etc.
*/
export function parseL1Response(raw: string): OffloadEntry[] {
const parsed = extractJson<RawL1Entry[]>(raw);
if (!parsed || !Array.isArray(parsed)) return [];
const entries: OffloadEntry[] = [];
for (const item of parsed) {
if (!item || typeof item !== "object") continue;
const toolCallId = item.tool_call_id ?? "";
if (!toolCallId) continue;
entries.push({
tool_call_id: toolCallId,
tool_call: item.tool_call ?? "",
summary: item.summary ?? "",
timestamp: item.timestamp ?? "",
score: typeof item.score === "number" ? Math.min(10, Math.max(0, item.score)) : 5,
node_id: null,
seq: -1,
});
}
return entries;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* L1.5 Response Parser — extracts TaskJudgment from LLM output.
*/
import { extractJson } from "./json-utils.js";
import type { TaskJudgment } from "../types.js";
interface RawL15Response {
taskCompleted?: boolean | null;
isContinuation?: boolean | null;
isLongTask?: boolean | null;
continuationMmdFile?: string | null;
newTaskLabel?: string | null;
}
/**
* Parse L1.5 LLM response into TaskJudgment.
* Returns null if completely unparseable or all-null (LLM unavailable).
*/
export function parseL15Response(raw: string): TaskJudgment | null {
const parsed = extractJson<RawL15Response>(raw);
if (!parsed || typeof parsed !== "object") return null;
if (
parsed.taskCompleted == null &&
parsed.isContinuation == null &&
parsed.isLongTask == null
) {
return null;
}
return {
taskCompleted: toBool(parsed.taskCompleted),
isContinuation: toBool(parsed.isContinuation),
isLongTask: toBool(parsed.isLongTask),
continuationMmdFile:
typeof parsed.continuationMmdFile === "string" && isSafeFilename(parsed.continuationMmdFile)
? parsed.continuationMmdFile
: undefined,
newTaskLabel:
typeof parsed.newTaskLabel === "string" ? parsed.newTaskLabel : undefined,
};
}
/** Safely coerce LLM value to boolean, handling string "false"/"0". */
function toBool(value: unknown): boolean {
if (typeof value === "string") {
return value.toLowerCase() !== "false" && value !== "0" && value !== "";
}
return Boolean(value);
}
/** Validate that a filename is safe (no path traversal or special chars). */
function isSafeFilename(name: string): boolean {
if (!name) return false;
if (name.includes("/") || name.includes("\\") || name.includes("..")) return false;
return /^[a-zA-Z0-9_.\-]+$/.test(name);
}
+76
View File
@@ -0,0 +1,76 @@
/**
* L2 Response Parser — extracts MMD generation results from LLM output.
*/
import { extractJson, extractMermaidFromFence } from "./json-utils.js";
import type { L2ParsedResponse } from "../types.js";
interface RawL2Response {
file_action?: string;
mmd_content?: string | null;
replace_blocks?: Array<{
start_line?: number | string;
end_line?: number | string;
content?: string;
}> | null;
node_mapping?: Record<string, string>;
}
/**
* Parse L2 LLM response into structured result.
* Returns null if parsing fails completely.
*/
export function parseL2Response(raw: string): L2ParsedResponse | null {
const parsed = extractJson<RawL2Response>(raw);
if (!parsed || typeof parsed !== "object") {
// Fallback: try extracting ```mermaid ... ``` code block
const mmd = extractMermaidFromFence(raw);
if (mmd) {
return { fileAction: "write", mmdContent: mmd, nodeMapping: {} };
}
return null;
}
const fileAction = parsed.file_action === "replace" ? "replace" : "write";
// Extract mmd_content (may be wrapped in code fence)
let mmdContent: string | undefined;
if (fileAction === "write") {
if (parsed.mmd_content) {
mmdContent =
extractMermaidFromFence(parsed.mmd_content) ?? parsed.mmd_content;
} else {
const fallbackMmd = extractMermaidFromFence(raw);
if (fallbackMmd) mmdContent = fallbackMmd;
}
}
// Parse replace_blocks
let replaceBlocks: L2ParsedResponse["replaceBlocks"] | undefined;
if (fileAction === "replace" && Array.isArray(parsed.replace_blocks)) {
replaceBlocks = [];
for (const block of parsed.replace_blocks) {
if (!block || typeof block !== "object") continue;
const startLine = Number(block.start_line);
const endLine = Number(block.end_line);
if (isNaN(startLine) || isNaN(endLine)) continue;
let content = block.content ?? "";
const extracted = extractMermaidFromFence(content);
if (extracted) content = extracted;
replaceBlocks.push({ startLine, endLine, content });
}
}
// Parse node_mapping
const nodeMapping: Record<string, string> = {};
if (parsed.node_mapping && typeof parsed.node_mapping === "object") {
for (const [key, value] of Object.entries(parsed.node_mapping)) {
if (typeof value === "string") {
nodeMapping[key] = value;
}
}
}
return { fileAction, mmdContent, replaceBlocks, nodeMapping };
}
+81
View File
@@ -0,0 +1,81 @@
/**
* L1 Summarization Prompt — converts ToolPairs into OffloadEntry summaries.
*/
import type { ToolPair } from "../types.js";
const PARAMS_MAX_LEN = 500;
const RESULT_MAX_LEN = 2000;
const COMPRESS_THRESHOLD = 200;
export const L1_SYSTEM_PROMPT = `你是一个专为 AI 编码助手提供支持的"工具结果摘要器"。你的核心任务是深度理解当前的对话上下文,并将繁杂的工具调用与执行结果(一对toolcall和tool result整合成一条summary输出),提炼为高信息密度的 JSON 数组。
在生成摘要前,请务必进行以下内部思考:
1. 任务对齐:结合最近的对话记录,识别用户当前的核心目标和最新意图。若上下文存在冲突,始终以最新的用户意图为准。
2. 价值过滤:忽略工具如何工作的冗余细节,直接提取"发现了什么关键线索"、"做了什么关键动作"、"修改了什么具体内容"或"遇到了什么具体报错"。
3. 影响评估:判断该结果对当前任务的实质性影响(例如:证实了某个假设、推进了哪一步、做出了什么决策,或因为什么报错导致了阻塞)。
【输出格式要求】
你必须且只能输出一个合法的 JSON 对象数组 [{...}],每个对象**必须**包含以下字段:
- "tool_call": 工具调用的简洁描述。处理规则如下:
· 如果输入中该 tool pair 标记了 [NEEDS_COMPRESS],你必须将工具名+关键参数压缩为一句简洁的描述(≤150字符),保留工具名、操作目标(如文件路径、命令意图),省略内联脚本/大段内容的细节。
示例:exec({"command":"python3 -c 'import csv; ...200行脚本...'"}) → "exec: 运行 Python xx/xx/xx.sh,标明具体路径和文件)脚本分析 sales_channels.csv 数据质量"
示例:write_file({"path":"/root/app.py","content":"...5000字符..."}) → "write_file: 写入 /root/app.py (Flask 应用主文件),大致内容是……"
· 如果未标记 [NEEDS_COMPRESS],直接简述工具与参数即可(系统会用原始值覆盖)。
- "summary": 融合上述思考的精炼总结(≤200个字符)。必须一针见血地说清楚结果的业务价值,以及它对任务的推进/阻塞作用。
- "tool_call_id": 原始的 tool_call_id(必须原样透传)。
- "timestamp": 原始的 ISO 8601 时间戳(必须原样透传)。
- "score"**必填**: 结合信息密度和任务目的分析summary对于原文的可替代性,范围在0-10之间,越接近10表示summary越能替代原文。
【严格规则】
只允许输出纯 JSON 数组,严禁输出思考过程或其他解释性文本。`;
/**
* Build the L1 user prompt for summarization.
*/
export function buildL1UserPrompt(
recentContext: string,
pairs: ToolPair[],
): string {
const parts: string[] = [];
parts.push("## 最近的对话上下文(用于理解当前任务):");
parts.push(recentContext || "(无可用上下文)");
parts.push("\n## Tool call/result pairs to summarize:");
for (let i = 0; i < pairs.length; i++) {
const p = pairs[i];
const paramsStr = truncate(stringify(p.params), PARAMS_MAX_LEN);
const resultStr = truncate(stringify(p.result), RESULT_MAX_LEN);
const canonical = `${p.toolName}(${stringify(p.params)})`;
const needsCompress = canonical.length > COMPRESS_THRESHOLD;
parts.push(`--- Tool Pair ${i + 1} ---`);
parts.push(`tool_call_id: ${p.toolCallId}`);
parts.push(`timestamp: ${p.timestamp}`);
if (needsCompress) {
parts.push(`Tool: ${p.toolName} [NEEDS_COMPRESS]`);
} else {
parts.push(`Tool: ${p.toolName}`);
}
parts.push(`Params: ${paramsStr}`);
parts.push(`Result: ${resultStr}\n`);
}
parts.push("Summarize each pair into the JSON array format described.");
return parts.join("\n");
}
function stringify(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function truncate(s: string, maxLen: number): string {
if (s.length <= maxLen) return s;
return s.slice(0, maxLen) + "...";
}
+81
View File
@@ -0,0 +1,81 @@
/**
* L1.5 Task Judgment Prompt — determines task lifecycle.
*/
import type { MmdMeta } from "../types.js";
export const L15_SYSTEM_PROMPT = `你是一个面向 AI 编码助手的"任务生命周期门神"。
你的职责是交叉分析提供的三个输入源,精准研判任务状态,并输出纯 JSON 对象。
【输入数据利用指南(必须遵循的思考链路)】
1. 第一步 - 剖析 recentMessages(识别意图):根据当前和历史对话,提取用户最新回复的核心诉求。判断是"继续排查"、"宣布完工(如:跑通了)"、"单轮闲聊问答"还是"开启全新需求"。
2. 第二步 - 对齐 currentMmd(评估当前基线):将用户的最新意图与 currentMmd 的完整 Mermaid 内容进行比对——关注 taskGoal、各节点的 statusdone/doing/todo)以及 summary。如果诉求完全超出了当前图表的范畴或目标已实现(所有节点 done 且无后续),则 taskCompleted 为 true。若仍在解决图表中的子问题(包括 doing 节点或修 bug),则为 false。(如果没有currentMmd,就只根据当前对话和历史对话来判断是否继续任务)
3. 第三步 - 检索 availableMmds(判断是否延续):如果判定要开启新任务(isLongTask=true 且 taskCompleted=true/当前无任务),必须扫描 availableMmds 的 taskGoal 和时间信息。若新诉求与列表中某个旧任务高度重合(如回到昨天没做完的模块),则是延续(isContinuation=true)。
【严格 JSON 输出格式】
务必输出合法的纯 JSON 对象,格式如下:
{
"taskCompleted": boolean, // 当前任务是否已结束(如果 currentMmd 为 none,这里必须填 true
"isLongTask": boolean, // 最新诉求是否是需要多步操作的复杂工程(普通技术问答、闲聊填 false)
"isContinuation": boolean, // 是否在延续 availableMmds 中的历史任务
"continuationMmdFile": "string|null", // 若延续旧任务,精确填入 availableMmds 中的文件名(不含路径前缀),否则为 null
"newTaskLabel": "string|null" // 若是全新长任务,生成简短标签(≤30字符,kebab-case,如 "refactor-api"),否则为 null
}
只输出纯 JSON 对象,绝不允许包含解释文字。`;
export interface L15CurrentMmd {
filename: string;
content: string;
}
/**
* Build the L1.5 user prompt for task judgment.
*/
export function buildL15UserPrompt(
recentMessages: string,
currentMmd: L15CurrentMmd | null,
metas: MmdMeta[],
): string {
const parts: string[] = [];
parts.push("## 1. 最近的对话上下文 (Recent messages):");
parts.push(recentMessages);
parts.push("\n## 2. 当前挂载的任务图 (Active Mermaid — 完整内容):");
if (currentMmd && currentMmd.filename) {
parts.push(`**File:** ${currentMmd.filename}`);
parts.push(`\n\`\`\`mermaid\n${currentMmd.content}\n\`\`\``);
} else {
parts.push("(none - 当前处于闲置状态,无活跃任务)");
}
parts.push("\n## 3. 历史可用的任务图 (Available Mermaid task files):");
if (metas.length === 0) {
parts.push("(none - 暂无历史长任务)");
} else {
for (const m of metas) {
const total = m.doneCount + m.doingCount + m.todoCount;
parts.push(`- **${m.filename}**`);
parts.push(` taskGoal: ${m.taskGoal}`);
parts.push(
` progress: ${m.doneCount}/${total} done, ${m.doingCount} doing, ${m.todoCount} todo`,
);
if (m.updatedTime) {
parts.push(` lastUpdated: ${m.updatedTime}`);
}
if (m.nodeSummaries && m.nodeSummaries.length > 0) {
parts.push(" recentNodes:");
for (const n of m.nodeSummaries) {
parts.push(` - [${n.nodeId}] (${n.status}) ${n.summary}`);
}
}
parts.push("");
}
}
parts.push(
"请严格根据系统指令的【三步思考链路】进行研判,并输出合法的 JSON 对象。",
);
return parts.join("\n");
}
+113
View File
@@ -0,0 +1,113 @@
/**
* L2 MMD Generation Prompt — generates/updates Mermaid flowcharts.
*/
import type { OffloadEntry } from "../types.js";
export const L2_SYSTEM_PROMPT = `你是一个究极实用主义的 AI 任务拓扑架构师与视觉叙事者。
你的核心逻辑是用尽量少的字符表达尽量多的信息,让LLM模型能看懂,不是为人类服务,尽量减少无用的视觉符号。任务是将底层工具调用记录,升维映射为一张高度语义化、表现力丰富且极度克制的 Mermaid (flowchart TD) 认知状态机。你要根据当前任务和意图,归纳"过去",要思考"未来"如何用这些已有的信息(你只需要记录已有信息,不需要写下一步规划)并标记"雷区"。保持图表的高度概括性。
【高阶认知与拓扑指南(你的自主权与极简原则)】
1. 弹性聚合:你拥有决定节点拆合的完全自主权。对于连续的、意图相同的常规动作(如连续查看多个文件以了解上下文),建议合并为一个宏观节点;,但保留关键转折点或重大发现为独立节点。图表必须保持宏观和克制,绝不事无巨细地记流水账。
2. 认知墓碑 (防重蹈覆辙):遇到彻底走不通的死胡同或引发严重报错的废弃方案,可以建立警示节点(status: blocked)(如果是价值不高的fail信息则不需要记录)。
3. 结论导向的摘要:节点的 summary(注意:尽量小于150字)应聚焦于"得出了什么结论"或"发生了什么实质改变",而非罗列琐碎的数据或参数,记得保持极简原则。
4. 要实事求是,你的任务是记录并归纳已经发生的事情,不是规划未来的具体操作,未发生的节点不要写,记录的已发生节点要有对应的消息来源(对应标注node_id)。
【符号即语义:高维认知字典(你的核心武器)】为了极致压缩 Token 并为你下一步推理提供"认知锚点",请自由使用不同的mmd形状来代表不同的节点逻辑。让形状替你说话,省略冗余的文字描述。
【高度自由的拓扑与极简法则】
1. 语义浓缩:既然形状已经表达了"领域",你的 summary 必须极其精简(≤150字),如"发现死锁"、"依赖冲突"、"已修复"。
2. 弹性拓扑:自主使用带标签的连线(-->|测试失败|)和虚线(-.->|参考|)来构建"依赖树"和"假设验证环"。不要记流水账。
3. 动态更新 (Token 极简)
- replace (增量微调):仅修改现有节点的状态、时间戳、短文本或追加极少节点时。
- write (全量重写):逻辑大洗牌、重构图表或初始化时。
注意:Existing Mermaid content 中每行开头都带有行号标记(如 "L1: ..."),这些行号仅供你在 replace 模式中引用,不是 MMD 内容的一部分。
【严格的工程底线】
1.节点标准格式:NodeID["阶段名: 宏观动作简述<br/>status: done|doing|paused|blocked <br/>summary: 核心结论摘要<br/>Timestamp: ISO8601"]
2. 全员归宿映射:输入的每一个新 tool_call_id,都必须在 node_mapping 中被分配到一个 Node ID;MMD里的每一个node都应该有源头的tool_call消息来源,不能乱编,绝对不允许遗漏!(Node_id和tool_call_id是一对多的关系)
3. 你可以通过各种整合方法,尽量把更新后mmd文件大小控制在4000字以内
【严格时间戳与元数据规则】
1. 顶部元数据(必填):%%{ "taskGoal": "一句话总结此次任务的目标(可动态更新)", "progress0-100": "进度百分比(严格点,几乎确认完成再打到90+)", createdTime": "ISO时间", "updatedTime": "ISO时间" }%%updatedTime为node中的最新时间)。
2. 节点内时间:如果合并了多个新条目,节点内的 Timestamp 必须取其中最新的 ISO 时间。
【严格 JSON 输出格式】
务必正确转义双引号。所有 Mermaid 代码(无论是 mmd_content 还是 replace_blocks 中的 content)都必须用 \`\`\`mermaid ... \`\`\` 代码块包裹起来。必须输出如下 JSON 结构:
{
"file_action": "replace 或 write",
"mmd_content": "完整的、带转义的 .mmd 代码,必须用 \`\`\`mermaid ... \`\`\` 包裹。(仅在 file_action 为 write 时填写,否则必须设为 null",
"replace_blocks": [
{
"start_line": "需要更新范围的起始行号(整数,对应 Existing Mermaid content 中的 L 标号)",
"end_line": "需要更新范围的结束行号(整数,包含该行)。要在某行之前插入新内容而不删除任何行,将 start_line 设为该行号,end_line 设为 start_line - 1",
"content": "替换后的新内容(不需要带行号前缀),必须用 \`\`\`mermaid ... \`\`\` 包裹"
}
],
"node_mapping": {
"tool_call_id_1": "001-N1",
"tool_call_id_2": "001-N1"
}
}
注意:node_mapping 中的 Node ID 必须是 MMD 中实际使用的完整 ID(包含 MMD prefix,如 "001-N1"),不能只写短 ID(如 "N1")。
仅输出纯 JSON 对象,绝不允许包含任何解释。`;
/**
* Build the L2 user prompt for MMD generation.
*/
export function buildL2UserPrompt(opts: {
existingMmd: string | null;
entries: OffloadEntry[];
recentHistory?: string | null;
currentTurn?: string | null;
taskLabel: string;
mmdPrefix: string;
charCount: number;
}): string {
const { existingMmd, entries, recentHistory, currentTurn, taskLabel, mmdPrefix, charCount } = opts;
const parts: string[] = [];
// History section
if (recentHistory) {
parts.push(`## 近期对话历史:\n${recentHistory}`);
} else {
parts.push("## 近期对话历史:\n(无可用历史)");
}
if (currentTurn) {
parts.push(`\n## 当前最新一轮:\n${currentTurn}`);
}
parts.push(`\n## MMD prefix: ${mmdPrefix}`);
parts.push(`(所有节点 ID 必须以此前缀开头,如 ${mmdPrefix}-N1, ${mmdPrefix}-N2...`);
parts.push(`\n## Current task label: ${taskLabel}`);
// Char count warning
if (charCount > 2500) {
parts.push(`\n## Current MMD size: ${charCount} chars (budget: 4000 chars)`);
parts.push("⚠ 接近上限,请积极合并节点、精简 summary,优先使用 replace 模式微调而非 write 全量重写。");
} else if (charCount > 2000) {
parts.push(`\n## Current MMD size: ${charCount} chars (budget: 4000 chars)`);
parts.push("注意控制增长,合并同类节点。");
}
// Existing MMD with line numbers
parts.push("\n## Existing Mermaid content:");
if (existingMmd) {
const lines = existingMmd.split("\n");
for (let i = 0; i < lines.length; i++) {
parts.push(`L${i + 1}: ${lines[i]}`);
}
} else {
parts.push("(empty — create new)");
}
// New entries
parts.push("\n## New offload entries to incorporate:");
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
parts.push(`${i + 1}. [${e.tool_call_id}] ${e.tool_call}${e.summary} (${e.timestamp})`);
}
parts.push("\n请根据系统指令生成/更新 Mermaid 流程图,并输出合法的 JSON 对象(含 node_mapping)。");
return parts.join("\n");
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Offload V2 Router — route registration and dispatch.
*/
import type http from "node:http";
import type { StorageAdapter } from "../core/storage/adapter.js";
import type { IStateBackend } from "../core/state/types.js";
import type { OffloadExecutorConfig } from "./types.js";
import { defaultOffloadConfig } from "./types.js";
import { parseV2Auth, successEnvelope, errorEnvelope, makeRequestId } from "../gateway/v2-router.js";
import { handleIngest } from "./ingest-handler.js";
import { handleMmdQuery } from "./mmd-handler.js";
import { handleCompaction } from "./compact/compaction-handler.js";
import { MmdQuerySchema } from "./schemas.js";
export interface OffloadV2Deps {
resolveStorage?: (instanceId: string) => Promise<StorageAdapter | undefined>;
getStorage: () => StorageAdapter | undefined;
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
stateBackend?: IStateBackend;
config?: OffloadExecutorConfig;
}
/**
* Handle offload V2 routes. Returns true if the request was handled.
*/
export async function handleOffloadV2Route(
req: http.IncomingMessage,
res: http.ServerResponse,
pathname: string,
method: string,
parseJsonBody: <T>(req: http.IncomingMessage) => Promise<T>,
sendJson: (res: http.ServerResponse, status: number, body: unknown) => void,
deps: OffloadV2Deps,
): Promise<boolean> {
if (!pathname.startsWith("/v2/offload/")) return false;
const requestId = makeRequestId();
// Auth
const auth = parseV2Auth(req, res, requestId, sendJson);
if (!auth) return true; // 401 already sent
// Resolve storage
const storage =
(await deps.resolveStorage?.(auth.serviceId)) ?? deps.getStorage();
if (!storage) {
sendJson(res, 503, errorEnvelope(503, "Storage unavailable", requestId));
return true;
}
const config = deps.config ?? defaultOffloadConfig();
// Normalize trailing slash for consistent route matching
const normalizedPath = pathname.endsWith("/") && pathname.length > 1 ? pathname.slice(0, -1) : pathname;
const route = `${method} ${normalizedPath}`;
switch (route) {
case "POST /v2/offload/ingest":
await handleIngest(req, res, auth, {
storage,
stateBackend: deps.stateBackend,
config,
logger: deps.logger,
}, requestId, parseJsonBody, sendJson, successEnvelope, errorEnvelope);
return true;
case "POST /v2/offload/query-mmd": {
const body = await parseJsonBody<{ session_id?: string; limit?: number }>(req);
const parsed = MmdQuerySchema.safeParse(body);
if (!parsed.success) {
sendJson(res, 400, errorEnvelope(400, "missing or invalid session_id in body", requestId));
return true;
}
await handleMmdQuery(req, res, auth, storage, requestId, sendJson, successEnvelope, errorEnvelope, parsed.data.session_id, parsed.data.limit);
return true;
}
case "POST /v2/offload/compact":
await handleCompaction(req, res, auth, {
storage,
config,
logger: deps.logger,
}, requestId, parseJsonBody, sendJson, successEnvelope, errorEnvelope);
return true;
default:
return false;
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Offload Server — Request validation schemas (Zod).
*/
import { z } from "zod";
/** Safe session ID: alphanumeric, underscore, hyphen, dot, colon allowed. No slashes or path traversal. Max 500 chars. */
const safeSessionId = z.string().min(1).max(500, {
message: "sessionId must not exceed 500 characters",
}).regex(/^[a-zA-Z0-9_.\-:]+$/, {
message: "Must only contain alphanumeric, underscore, hyphen, dot, or colon characters",
});
const ToolPairSchema = z.object({
tool_name: z.string(),
tool_call_id: z.string(),
params: z.unknown(),
result: z.unknown(),
error: z.string().optional(),
timestamp: z.string(),
duration_ms: z.number().optional(),
});
/** Recent message item: user/assistant text only (no tool_call/tool_result). */
const RecentMessageSchema = z.object({
role: z.enum(["user", "assistant"]),
content: z.string(),
});
export const IngestRequestSchema = z
.object({
session_id: safeSessionId,
tool_pairs: z.array(ToolPairSchema).default([]),
/** Current user prompt that triggers L1.5 task judgment. Must be non-empty (whitespace-only is rejected). */
prompt: z.string().trim().min(1, { message: "prompt must not be empty or whitespace-only" }).optional(),
/** Recent history messages (user/assistant only, no tool calls). */
recent_messages: z.array(RecentMessageSchema).optional(),
})
.refine(
(data) => data.tool_pairs.length > 0 || (data.prompt && data.prompt.length > 0),
{ message: "Either tool_pairs must be non-empty or prompt must be provided" },
);
export type IngestRequest = z.infer<typeof IngestRequestSchema>;
/**
* Each message must have non-empty `role` and `content` fields.
* Intentionally lenient: any non-empty string role is accepted to support
* OpenAI / Anthropic / OpenClaw-wrapped formats without over-specifying.
*/
const CompactionMessageSchema = z
.record(z.string(), z.unknown())
.refine(
(msg) => typeof msg["role"] === "string" && (msg["role"] as string).length > 0,
{ message: "Each message must have a non-empty 'role' field" },
);
export const CompactionRequestSchema = z.object({
session_id: safeSessionId,
messages: z.array(CompactionMessageSchema),
ratio: z.number().min(0).max(2),
});
export type CompactionRequest = z.infer<typeof CompactionRequestSchema>;
/** Extended compaction schema with token metadata for L3 compression. */
export const CompactionRequestSchemaV2 = z.object({
session_id: safeSessionId,
messages: z.array(CompactionMessageSchema),
ratio: z.number().min(0).max(2),
context_window: z.number().int().min(1),
total_tokens: z.number().int().min(0),
message_tokens: z.array(z.number()).optional(),
});
export type CompactionRequestV2 = z.infer<typeof CompactionRequestSchemaV2>;
export const MmdQuerySchema = z.object({
session_id: safeSessionId,
limit: z.number().int().min(1).optional(),
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Offload Session Utilities — unified sessionId sanitization.
*
* sessionId: opaque identifier from client (free format, e.g. "main:main", "coding:session-001")
* sanitizedSessionId: filesystem-safe version used for:
* - File/directory paths (offload/{sanitizedSessionId}/)
* - Distributed lock keys
* - Task queue session identifiers
*
* Conversion: replace all non-alphanumeric/dot/hyphen/underscore chars with "_"
*/
/**
* Convert a raw sessionId to a filesystem-safe string.
* Replaces colons and other unsafe characters with underscores.
*/
export function sanitizeSessionId(sessionId: string): string {
return sessionId.replace(/[^a-zA-Z0-9._\-]/g, "_");
}
/**
* Build the offload storage base path for a session.
*/
export function buildOffloadBasePath(sessionId: string): string {
return `offload/${sanitizeSessionId(sessionId)}`;
}
// Legacy compat: keep old name as alias (will remove after all callers migrated)
export const toSessionId = sanitizeSessionId;
+132
View File
@@ -0,0 +1,132 @@
/**
* Task Transition logic — manages MMD file creation/switching based on L1.5 judgment.
*/
import type { StorageAdapter } from "../core/storage/adapter.js";
import type { OffloadState, TaskJudgment, MmdMeta } from "./types.js";
/**
* Apply task transition based on L1.5 judgment.
* Mutates state in place; may create new MMD file in COS.
* @param basePath - Full storage base path, e.g. "offload/default/agent_main_tui-xxx"
*/
export async function handleTaskTransition(
state: OffloadState,
judgment: TaskJudgment,
storage: StorageAdapter,
basePath: string,
): Promise<void> {
if (judgment.taskCompleted && judgment.isLongTask && !judgment.isContinuation && judgment.newTaskLabel) {
// CASE 1: New long task → create new MMD file
const filename = await generateMmdFilename(storage, basePath, judgment.newTaskLabel);
await storage.writeFile(`${basePath}/mmds/${filename}`, "");
state.activeMmdFile = filename;
} else if (judgment.taskCompleted && judgment.isContinuation && judgment.continuationMmdFile) {
// CASE 2: Continue historical task → switch to that MMD
state.activeMmdFile = judgment.continuationMmdFile;
} else if (judgment.taskCompleted && !judgment.isLongTask) {
// CASE 3: Short task / casual chat → clear active MMD
state.activeMmdFile = null;
} else if (!judgment.taskCompleted && judgment.isLongTask && !state.activeMmdFile) {
// CASE 5: Task in progress but no active MMD yet → create one
const label = judgment.newTaskLabel || "current-task";
const filename = await generateMmdFilename(storage, basePath, label);
await storage.writeFile(`${basePath}/mmds/${filename}`, "");
state.activeMmdFile = filename;
}
// CASE 4: !taskCompleted + activeMmdFile already set → keep unchanged
}
/**
* Generate a new MMD filename with auto-incrementing sequence number.
* Format: "003-refactor-api.mmd"
*/
async function generateMmdFilename(
storage: StorageAdapter,
basePath: string,
label: string,
): Promise<string> {
const mmdsPrefix = `${basePath}/mmds/`;
const existingFiles = await storage.readdirNames(mmdsPrefix, ".mmd");
let maxSeq = 0;
for (const f of existingFiles) {
const match = f.match(/^(\d+)-/);
if (match) {
const seq = parseInt(match[1], 10);
if (seq > maxSeq) maxSeq = seq;
}
}
const nextSeq = String(maxSeq + 1).padStart(3, "0");
const safeLabel = label
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 30);
return `${nextSeq}-${safeLabel || "task"}.mmd`;
}
/**
* Extract MmdMeta from MMD file content (parses %%{ ... }%% header).
*/
export function extractMmdMeta(filename: string, content: string): MmdMeta {
const defaults: MmdMeta = {
filename,
taskGoal: "",
doneCount: 0,
doingCount: 0,
todoCount: 0,
updatedTime: null,
nodeSummaries: [],
};
if (!content) return defaults;
// Parse %%{ ... }%% metadata line
const metaMatch = content.match(/%%\{([\s\S]*?)\}%%/);
if (metaMatch) {
try {
const meta = JSON.parse(`{${metaMatch[1]}}`);
defaults.taskGoal = meta.taskGoal ?? "";
defaults.updatedTime = meta.updatedTime ?? null;
} catch {
// ignore parse errors
}
}
// Count node statuses and extract summaries
const nodeRegex = /(\w[\w-]*)\["([^"]*?)"\]/g;
let nodeMatch;
while ((nodeMatch = nodeRegex.exec(content)) !== null) {
const nodeId = nodeMatch[1];
const nodeContent = nodeMatch[2];
const statusM = nodeContent.match(/status:\s*(done|doing|todo|paused|blocked)/i);
const summaryM = nodeContent.match(/summary:\s*(.+?)(?:<br\/>|$)/i);
if (statusM) {
const s = statusM[1].toLowerCase();
if (s === "done") defaults.doneCount++;
else if (s === "doing") defaults.doingCount++;
else if (s === "todo") defaults.todoCount++;
defaults.nodeSummaries!.push({
nodeId,
status: s,
summary: summaryM?.[1]?.trim() ?? "",
});
}
}
// Fallback: if regex didn't catch statuses, try simple pattern
if (defaults.doneCount === 0 && defaults.doingCount === 0 && defaults.todoCount === 0) {
const statusMatches = content.matchAll(/status:\s*(done|doing|todo|paused|blocked)/gi);
for (const m of statusMatches) {
const s = m[1].toLowerCase();
if (s === "done") defaults.doneCount++;
else if (s === "doing") defaults.doingCount++;
else if (s === "todo") defaults.todoCount++;
}
}
return defaults;
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Offload Server — Independent type definitions.
* This module does NOT import from src/offload/.
*/
// ─── ToolPair (ingest 写入 pending.jsonl) ────────────────────────────────────
export interface ToolPair {
toolName: string;
toolCallId: string;
params: unknown;
result: unknown;
error?: string;
timestamp: string;
durationMs?: number;
}
// ─── OffloadEntry (L1 产出, entries.jsonl 每行) ──────────────────────────────
export interface OffloadEntry {
tool_call_id: string;
tool_call: string;
summary: string;
timestamp: string;
score: number;
node_id: string | null;
/** Full relative path to ref file storing original tool result (e.g. "offload/{sessionId}/refs/call_214.md"), readable via tdai_read_cos */
result_ref?: string;
}
// ─── TaskJudgment (L1.5 产出) ────────────────────────────────────────────────
export interface TaskJudgment {
taskCompleted: boolean;
isLongTask: boolean;
isContinuation: boolean;
continuationMmdFile?: string;
newTaskLabel?: string;
}
// ─── TaskBoundary (L1.5 写入 state.boundaries) ──────────────────────────────
export interface TaskBoundary {
targetMmd: string | null;
timestamp: string;
}
// ─── MmdMeta (从 MMD 文件首行 %%{...}%% 解析) ──────────────────────────────
export interface MmdMeta {
filename: string;
taskGoal: string;
doneCount: number;
doingCount: number;
todoCount: number;
updatedTime?: string | null;
nodeSummaries?: Array<{ nodeId: string; status: string; summary: string }>;
}
// ─── OffloadState (state.json) ───────────────────────────────────────────────
export interface OffloadState {
activeMmdFile: string | null;
boundaries: TaskBoundary[];
lastL15CreatedAt: number;
}
// ─── CompactState (compact-state.json) ───────────────────────────────────────
export interface CompactState {
confirmedOffloadIds: string[];
deletedOffloadIds: string[];
lastCompactedAt: string;
}
// ─── L2 Parsed Response ──────────────────────────────────────────────────────
export interface L2ParsedResponse {
fileAction: "write" | "replace";
mmdContent?: string;
replaceBlocks?: Array<{
startLine: number;
endLine: number;
content: string;
}>;
nodeMapping: Record<string, string>;
}
// ─── Configuration ───────────────────────────────────────────────────────────
export interface OffloadExecutorConfig {
forceTriggerThreshold: number;
pendingMaxAgeSeconds: number;
l1Model: string;
l1Temperature: number;
l1MaxTokens: number;
l1TimeoutMs: number;
l15Model: string;
l15Temperature: number;
l15MaxTokens: number;
l15TimeoutMs: number;
l2Model: string;
l2Temperature: number;
l2MaxTokens: number;
l2TimeoutMs: number;
l2NullThreshold: number;
mildOffloadRatio: number;
aggressiveCompressRatio: number;
emergencyCompressRatio: number;
maxRetries: number;
}
export function defaultOffloadConfig(): OffloadExecutorConfig {
return {
forceTriggerThreshold: 4,
pendingMaxAgeSeconds: 30,
l1Model: "", // uses gateway llm.model
l1Temperature: 0.3,
l1MaxTokens: 8000,
l1TimeoutMs: 120_000,
l15Model: "", // uses gateway llm.model
l15Temperature: 0.2,
l15MaxTokens: 3000,
l15TimeoutMs: 120_000,
l2Model: "", // uses gateway llm.model
l2Temperature: 0.4,
l2MaxTokens: 16000,
l2TimeoutMs: 120_000,
l2NullThreshold: 6,
mildOffloadRatio: 0.5,
aggressiveCompressRatio: 0.85,
emergencyCompressRatio: 0.95,
maxRetries: 3,
};
}
// ─── Default empty state ─────────────────────────────────────────────────────
export function defaultOffloadState(): OffloadState {
return {
activeMmdFile: null,
boundaries: [],
lastL15CreatedAt: 0,
};
}
export function defaultCompactState(): CompactState {
return {
confirmedOffloadIds: [],
deletedOffloadIds: [],
lastCompactedAt: "",
};
}