mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-25 11:44:30 +00:00
feat: release v1.0.0
This commit is contained in:
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) + "...";
|
||||
}
|
||||
@@ -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、各节点的 status(done/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");
|
||||
}
|
||||
@@ -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": "一句话总结此次任务的目标(可动态更新)", "progress(0-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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user