mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 20:34:30 +00:00
feat: release v0.3.4 — offload local LLM, CLI restore, bugfix scripts
This commit is contained in:
@@ -494,7 +494,8 @@ export function compressByScoreCascade(
|
||||
for (const tuId of c.allToolUseIds) {
|
||||
replacedIds.add(tuId);
|
||||
replacedToolCallIdList.push(tuId);
|
||||
const tuEntry = c.allOffloadEntries.find((e: OffloadEntry) => e.tool_call_id === tuId);
|
||||
const tuIdNorm = normalizeToolCallIdForLookup(tuId);
|
||||
const tuEntry = c.allOffloadEntries.find((e: OffloadEntry) => e.tool_call_id === tuId || e.tool_call_id === tuIdNorm || normalizeToolCallIdForLookup(e.tool_call_id) === tuIdNorm);
|
||||
replacedDetails.push({ toolCallId: tuId, score: c.score, summaryPreview: (tuEntry?.summary ?? "").slice(0, 120) });
|
||||
}
|
||||
for (let ei = 0; ei < c.allToolUseIds.length; ei++) {
|
||||
|
||||
+91
-34
@@ -50,6 +50,7 @@ import { findHistoryMmdInsertionPoint } from "./mmd-injector.js";
|
||||
import type { OffloadConfig } from "../config.js";
|
||||
import type { PluginConfig, PluginLogger } from "./types.js";
|
||||
import { BackendClient } from "./backend-client.js";
|
||||
import { LocalLlmClient } from "./local-llm/index.js";
|
||||
import type { L1Request, L15Request, L2Request } from "./backend-client.js";
|
||||
import { parseMmdMeta } from "./mmd-meta.js";
|
||||
import { sanitizeText, writeRefMd } from "./storage.js";
|
||||
@@ -299,36 +300,91 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
}
|
||||
const sessions = _sharedSessions;
|
||||
|
||||
// Resolve LLM Configuration — backend mode only
|
||||
// Backend Client (required for L1/L1.5/L2/L4 — but context engine still registers for L3)
|
||||
// Resolve LLM Configuration — mode-based selection
|
||||
// - "backend": use remote backend service (requires backendUrl)
|
||||
// - "local": call LLM directly via AI SDK (uses offload.model or main agent model)
|
||||
//
|
||||
// User identity: prefer offloadConfig.userId; fall back to the host's
|
||||
// primary non-loopback IPv4 address. The backend `/offload/v1/store`
|
||||
// endpoint upserts state keyed by this value (as Mongo `_id`).
|
||||
// primary non-loopback IPv4 address.
|
||||
const _resolvedUserId = resolveUserId(offloadConfig.userId ?? null);
|
||||
logger.debug?.(
|
||||
`[context-offload] user-id resolved: "${_resolvedUserId}" (source=${getUserIdSource() ?? "?"})`,
|
||||
);
|
||||
|
||||
const backendClient = offloadConfig.backendUrl
|
||||
? new BackendClient(
|
||||
let backendClient: BackendClient | LocalLlmClient | null = null;
|
||||
|
||||
if (offloadConfig.mode === "backend") {
|
||||
// Remote backend mode
|
||||
if (!offloadConfig.backendUrl) {
|
||||
logger.error("[context-offload] mode=backend but backendUrl not configured. L1/L1.5/L2/L4 disabled.");
|
||||
} else {
|
||||
backendClient = new BackendClient(
|
||||
offloadConfig.backendUrl,
|
||||
logger,
|
||||
offloadConfig.backendApiKey,
|
||||
offloadConfig.backendTimeoutMs,
|
||||
() => _lastActiveSessionKey,
|
||||
() => _resolvedUserId,
|
||||
() => _lastActiveSessionKey, // use sessionKey as task scope — coarse but stable
|
||||
)
|
||||
: null;
|
||||
() => _lastActiveSessionKey,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Local LLM mode — resolve model from offload.model or fall back to agents.defaults.model
|
||||
let resolvedModelRef = offloadConfig.model;
|
||||
if (!resolvedModelRef) {
|
||||
// Fallback: use main agent model from openclaw.json agents.defaults.model
|
||||
const mainConfig = api.config as Record<string, unknown> | undefined;
|
||||
const agents = mainConfig?.agents as Record<string, unknown> | undefined;
|
||||
const defaults = agents?.defaults as Record<string, unknown> | undefined;
|
||||
const modelCfg = defaults?.model;
|
||||
if (typeof modelCfg === "string" && modelCfg.includes("/")) {
|
||||
resolvedModelRef = modelCfg;
|
||||
} else if (modelCfg && typeof modelCfg === "object") {
|
||||
const primary = (modelCfg as Record<string, unknown>).primary;
|
||||
if (typeof primary === "string" && primary.includes("/")) {
|
||||
resolvedModelRef = primary;
|
||||
}
|
||||
}
|
||||
if (resolvedModelRef) {
|
||||
logger.info(`[context-offload] offload.model not set, using main agent model: ${resolvedModelRef}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedModelRef) {
|
||||
const modelParts = resolvedModelRef.split("/", 2);
|
||||
const providerKey = modelParts[0];
|
||||
const modelId = modelParts[1] ?? resolvedModelRef;
|
||||
const models = (api.config as any)?.models;
|
||||
const providerCfg = models?.providers?.[providerKey];
|
||||
const baseUrl = providerCfg?.baseUrl ?? providerCfg?.baseURL;
|
||||
const apiKey = providerCfg?.apiKey;
|
||||
|
||||
if (baseUrl && apiKey) {
|
||||
backendClient = new LocalLlmClient(
|
||||
{ baseUrl, apiKey, model: modelId, temperature: offloadConfig.temperature, timeoutMs: offloadConfig.backendTimeoutMs },
|
||||
logger,
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`[context-offload] Local LLM mode failed: provider "${providerKey}" not found or missing baseUrl/apiKey in models.providers. ` +
|
||||
`L1/L1.5/L2 disabled.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn("[context-offload] No model resolved (offload.model not set, agents.defaults.model not found). L1/L1.5/L2 disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
// Track last active session key for BackendClient header
|
||||
let _lastActiveSessionKey: string | null = null;
|
||||
|
||||
if (backendClient) {
|
||||
logger.debug?.(`[context-offload] Backend mode: ${offloadConfig.backendUrl}`);
|
||||
if (backendClient && offloadConfig.mode === "backend") {
|
||||
logger.debug?.(`[context-offload] LLM mode: backend (${offloadConfig.backendUrl})`);
|
||||
} else if (backendClient) {
|
||||
logger.debug?.(`[context-offload] LLM mode: local (${offloadConfig.model ?? "main-agent-model"})`);
|
||||
|
||||
} else {
|
||||
logger.warn("[context-offload] backendUrl not configured. L1/L1.5/L2/L4 disabled (L3 compression still active).");
|
||||
logger.warn("[context-offload] LLM client not available. L1/L1.5/L2/L4 disabled (L3 compression still active).");
|
||||
}
|
||||
|
||||
// ─── Fault tolerance constants ──────────────────────────────────────────────
|
||||
@@ -361,7 +417,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
const beforeFilter = takenPairs.length;
|
||||
const pairs = takenPairs.filter((p) => !isHeartbeat(p));
|
||||
if (beforeFilter > pairs.length) {
|
||||
logger.info(`[context-offload] Backend L1: filtered ${beforeFilter - pairs.length} heartbeat pair(s)`);
|
||||
logger.info(`[context-offload] L1: filtered ${beforeFilter - pairs.length} heartbeat pair(s)`);
|
||||
}
|
||||
if (pairs.length === 0) return;
|
||||
|
||||
@@ -376,7 +432,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
const refPath = await writeRefMd(stateManager.ctx, p.timestamp, p.toolName, content);
|
||||
refByToolCallId.set(p.toolCallId, refPath);
|
||||
} catch (err) {
|
||||
logger.error(`[context-offload] Backend L1.1 ref write error (${p.toolCallId}): ${err}`);
|
||||
logger.error(`[context-offload] L1.1 ref write error (${p.toolCallId}): ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +441,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
for (let i = 0; i < pairs.length; i += L1_BATCH_SIZE) {
|
||||
batches.push(pairs.slice(i, i + L1_BATCH_SIZE));
|
||||
}
|
||||
logger.info(`[context-offload] Backend L1 (${triggerSource}): ${pairs.length} pairs → ${batches.length} batch(es) of ≤${L1_BATCH_SIZE}`);
|
||||
logger.info(`[context-offload] L1 (${triggerSource}): ${pairs.length} pairs → ${batches.length} batch(es) of ≤${L1_BATCH_SIZE}`);
|
||||
|
||||
const recentMessages = _buildL1RecentContext(stateManager);
|
||||
logger.info(`[context-offload] L1 recentMessages (${recentMessages.length} chars):\n${recentMessages}`);
|
||||
@@ -417,15 +473,15 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
}
|
||||
await appendOffloadEntries(stateManager.ctx, resp.entries, undefined, logger);
|
||||
stateManager.entryCounter += resp.entries.length;
|
||||
logger.info(`[context-offload] Backend L1 batch OK: ${resp.entries.length} entries from ${chunk.length} pairs (entryCounter=${stateManager.entryCounter})`);
|
||||
logger.info(`[context-offload] L1 batch OK: ${resp.entries.length} entries from ${chunk.length} pairs (entryCounter=${stateManager.entryCounter})`);
|
||||
}
|
||||
} catch (err) {
|
||||
const newFails = prevFails + 1;
|
||||
logger.warn(`[context-offload] Backend L1 batch FAILED (${chunkKey}, attempt ${newFails}/${MAX_L1_CHUNK_RETRIES}): ${err}`);
|
||||
logger.warn(`[context-offload] L1 batch FAILED (${chunkKey}, attempt ${newFails}/${MAX_L1_CHUNK_RETRIES}): ${err}`);
|
||||
|
||||
if (newFails >= MAX_L1_CHUNK_RETRIES) {
|
||||
// Exceeded retry limit — generate local fallback entries (no LLM summary)
|
||||
logger.warn(`[context-offload] Backend L1 batch DEGRADED: ${chunk.length} pairs → fallback entries (no LLM summary)`);
|
||||
logger.warn(`[context-offload] L1 batch DEGRADED: ${chunk.length} pairs → fallback entries (no LLM summary)`);
|
||||
stateManager._l1ChunkFailCounts.delete(chunkKey);
|
||||
const fallbackEntries: import("./types.js").OffloadEntry[] = [];
|
||||
for (const p of chunk) {
|
||||
@@ -446,7 +502,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
}
|
||||
await appendOffloadEntries(stateManager.ctx, fallbackEntries, undefined, logger);
|
||||
stateManager.entryCounter += fallbackEntries.length;
|
||||
logger.info(`[context-offload] Backend L1 fallback: wrote ${fallbackEntries.length} degraded entries`);
|
||||
logger.info(`[context-offload] L1 fallback: wrote ${fallbackEntries.length} degraded entries`);
|
||||
} else {
|
||||
// Under retry limit — re-enqueue this chunk for next flush
|
||||
stateManager._l1ChunkFailCounts.set(chunkKey, newFails);
|
||||
@@ -454,7 +510,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
stateManager.processedToolCallIds.delete(p.toolCallId);
|
||||
stateManager.pendingToolPairs.push(p as any);
|
||||
}
|
||||
logger.info(`[context-offload] Backend L1 batch: re-enqueued ${chunk.length} pairs (retry ${newFails}/${MAX_L1_CHUNK_RETRIES})`);
|
||||
logger.info(`[context-offload] L1 batch: re-enqueued ${chunk.length} pairs (retry ${newFails}/${MAX_L1_CHUNK_RETRIES})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,13 +568,13 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
// Normalize backend response (handles null fields from fallback)
|
||||
const judgment = normalizeJudgment(resp as unknown as Record<string, unknown>);
|
||||
if (!judgment) {
|
||||
logger.warn("[context-offload] Backend L1.5: all-null response (backend LLM unavailable)");
|
||||
logger.warn("[context-offload] L1.5: all-null response (backend LLM unavailable)");
|
||||
return false; // trigger retry
|
||||
}
|
||||
|
||||
// Success
|
||||
logger.info(
|
||||
`[context-offload] Backend L1.5: completed=${judgment.taskCompleted}, continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, label=${judgment.newTaskLabel ?? "none"}, contFile=${judgment.continuationMmdFile ?? "none"}`,
|
||||
`[context-offload] L1.5: completed=${judgment.taskCompleted}, continuation=${judgment.isContinuation}, longTask=${judgment.isLongTask}, label=${judgment.newTaskLabel ?? "none"}, contFile=${judgment.continuationMmdFile ?? "none"}`,
|
||||
);
|
||||
|
||||
// ── Flush residual null entries for the OLD mmd before task transition ──
|
||||
@@ -578,10 +634,10 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
await stateManager.save();
|
||||
stateManager.setMmdInjectionReady(true);
|
||||
stateManager.l15Settled = true;
|
||||
logger.info("[context-offload] Backend L1.5: settled, MMD injection ready");
|
||||
logger.info("[context-offload] L1.5: settled, MMD injection ready");
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(`[context-offload] Backend L1.5 attempt failed: ${err}`);
|
||||
logger.warn(`[context-offload] L1.5 attempt failed: ${err}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -634,7 +690,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
for (let i = 0; i < mmdEntries.length; i += L2_BATCH_SIZE) {
|
||||
batches.push(mmdEntries.slice(i, i + L2_BATCH_SIZE));
|
||||
}
|
||||
logger.info(`[context-offload] Backend L2 (${triggerSource}): mmd=${mmdFile}, ${mmdEntries.length} entries → ${batches.length} batch(es) of ≤${L2_BATCH_SIZE}`);
|
||||
logger.info(`[context-offload] L2 (${triggerSource}): mmd=${mmdFile}, ${mmdEntries.length} entries → ${batches.length} batch(es) of ≤${L2_BATCH_SIZE}`);
|
||||
|
||||
for (let bIdx = 0; bIdx < batches.length; bIdx++) {
|
||||
const batch = batches[bIdx];
|
||||
@@ -678,7 +734,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
|
||||
// Handle backend degraded response (empty fileAction = LLM unavailable)
|
||||
if (!resp.fileAction) {
|
||||
logger.warn(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: degraded response, applying fallback backfill`);
|
||||
logger.warn(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: degraded response, applying fallback backfill`);
|
||||
await backfillNodeIds(stateManager.ctx, resp.nodeMapping ?? {}, batchWaitIds, logger, {
|
||||
mmdFallbackText: existingMmd ?? "",
|
||||
mmdPrefix,
|
||||
@@ -689,14 +745,14 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
// Apply MMD file changes
|
||||
if (resp.fileAction === "replace" && resp.replaceBlocks && resp.replaceBlocks.length > 0) {
|
||||
const patchOk = await patchMmd(stateManager.ctx, mmdFile, resp.replaceBlocks);
|
||||
logger.info(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: patchMmd: ${patchOk ? "ok" : "FAILED"} (${resp.replaceBlocks.length} blocks)`);
|
||||
logger.info(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: patchMmd: ${patchOk ? "ok" : "FAILED"} (${resp.replaceBlocks.length} blocks)`);
|
||||
if (!patchOk && resp.mmdContent) {
|
||||
await writeMmd(stateManager.ctx, mmdFile, resp.mmdContent);
|
||||
logger.info(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: fallback writeMmd: ${resp.mmdContent.length} chars`);
|
||||
logger.info(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: fallback writeMmd: ${resp.mmdContent.length} chars`);
|
||||
}
|
||||
} else if (resp.mmdContent) {
|
||||
await writeMmd(stateManager.ctx, mmdFile, resp.mmdContent);
|
||||
logger.info(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: writeMmd: ${resp.mmdContent.length} chars`);
|
||||
logger.info(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length}: writeMmd: ${resp.mmdContent.length} chars`);
|
||||
}
|
||||
|
||||
// Backfill node_ids
|
||||
@@ -712,15 +768,15 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
mmdPrefix,
|
||||
});
|
||||
|
||||
logger.info(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length} (${triggerSource}): applied, action=${resp.fileAction}, mapping=${Object.keys(resp.nodeMapping ?? {}).length}`);
|
||||
logger.info(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length} (${triggerSource}): applied, action=${resp.fileAction}, mapping=${Object.keys(resp.nodeMapping ?? {}).length}`);
|
||||
} catch (err) {
|
||||
logger.error(`[context-offload] Backend L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length} failed: ${err}`);
|
||||
logger.error(`[context-offload] L2 [${mmdFile}] batch ${bIdx + 1}/${batches.length} failed: ${err}`);
|
||||
// Continue with remaining batches — failed entries stay as "wait" for retry
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[context-offload] Backend L2 failed: ${err}`);
|
||||
logger.error(`[context-offload] L2 failed: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -745,12 +801,13 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
nodeIds.add(match[1]);
|
||||
}
|
||||
const filtered = allEntries.filter((e) => e.node_id && nodeIds.has(e.node_id));
|
||||
const resp = await backendClient.l4Generate({
|
||||
const resp = await (backendClient as any).l4Generate({
|
||||
mmdFilename,
|
||||
mmdContent,
|
||||
offloadEntries: filtered,
|
||||
skillFocus: skillCommand.skillFocus,
|
||||
});
|
||||
if (!resp) return null;
|
||||
// Write skill file locally
|
||||
const { mkdir, writeFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
@@ -960,7 +1017,7 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
|
||||
logger.warn(`[context-offload] <<< after_tool_call SKIP: no session manager (${Date.now() - _atcStart}ms)`);
|
||||
return;
|
||||
}
|
||||
const afterToolCallHandler = createAfterToolCallHandler(_mgr, logger, getContextWindow, pCfg, backendClient);
|
||||
const afterToolCallHandler = createAfterToolCallHandler(_mgr, logger, getContextWindow, pCfg, backendClient as any);
|
||||
await afterToolCallHandler(event, ctx);
|
||||
const _handlerDone = Date.now();
|
||||
logger.info(`[context-offload] after_tool_call handler done: ${_handlerDone - _atcStart}ms`);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* LocalLlmClient — local-mode offload LLM client.
|
||||
*
|
||||
* Implements the same interface as BackendClient (l1Summarize, l15Judge, l2Generate)
|
||||
* but calls the LLM directly via AI SDK instead of routing through a remote backend.
|
||||
*
|
||||
* Used when `offload.model` is configured and `offload.backendUrl` is not set.
|
||||
*/
|
||||
import { callLlm, type LlmCallerConfig } from "./llm-caller.js";
|
||||
import { L1_SYSTEM_PROMPT, buildL1UserPrompt, type L1ToolPair } from "./prompts/l1-prompt.js";
|
||||
import { L15_SYSTEM_PROMPT, buildL15UserPrompt, type L15CurrentMmd, type L15MmdMeta } from "./prompts/l15-prompt.js";
|
||||
import { L2_SYSTEM_PROMPT, buildL2UserPrompt, type L2NewEntry } from "./prompts/l2-prompt.js";
|
||||
import { parseL1Response } from "./parsers/l1-parser.js";
|
||||
import { parseL15Response } from "./parsers/l15-parser.js";
|
||||
import { parseL2Response, type L2ParsedResponse } from "./parsers/l2-parser.js";
|
||||
import type { OffloadEntry, TaskJudgment, PluginLogger } from "../types.js";
|
||||
import type { L1Request, L1Response, L15Request, L15Response, L2Request, L2Response } from "../backend-client.js";
|
||||
|
||||
const TAG = "[context-offload] [local-llm]";
|
||||
|
||||
export interface LocalLlmClientConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class LocalLlmClient {
|
||||
private config: LlmCallerConfig;
|
||||
private logger?: PluginLogger;
|
||||
|
||||
constructor(cfg: LocalLlmClientConfig, logger?: PluginLogger) {
|
||||
this.config = {
|
||||
baseUrl: cfg.baseUrl,
|
||||
apiKey: cfg.apiKey,
|
||||
model: cfg.model,
|
||||
temperature: cfg.temperature ?? 0.2,
|
||||
timeoutMs: cfg.timeoutMs ?? 120_000,
|
||||
};
|
||||
this.logger = logger;
|
||||
logger?.info?.(`${TAG} Initialized: model=${cfg.model}, baseUrl=${cfg.baseUrl}`);
|
||||
}
|
||||
|
||||
// ─── L1 Summarize ──────────────────────────────────────────────────────────
|
||||
|
||||
async l1Summarize(req: L1Request): Promise<L1Response> {
|
||||
const pairs: L1ToolPair[] = req.toolPairs.map((p) => ({
|
||||
toolName: p.toolName,
|
||||
toolCallId: p.toolCallId,
|
||||
params: p.params,
|
||||
result: p.result,
|
||||
timestamp: p.timestamp,
|
||||
}));
|
||||
|
||||
const userPrompt = buildL1UserPrompt(req.recentMessages, pairs);
|
||||
|
||||
const raw = await callLlm(this.config, {
|
||||
systemPrompt: L1_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
label: "L1",
|
||||
}, this.logger);
|
||||
|
||||
const entries = parseL1Response(raw);
|
||||
if (entries.length === 0) {
|
||||
this.logger?.warn?.(`${TAG} L1: parsed 0 entries from LLM response (${raw.length} chars)`);
|
||||
}
|
||||
|
||||
return { entries };
|
||||
}
|
||||
|
||||
// ─── L1.5 Judge ────────────────────────────────────────────────────────────
|
||||
|
||||
async l15Judge(req: L15Request): Promise<L15Response> {
|
||||
const currentMmd: L15CurrentMmd | null = req.currentMmd
|
||||
? { filename: req.currentMmd.filename, content: req.currentMmd.content, path: req.currentMmd.path }
|
||||
: null;
|
||||
|
||||
const metas: L15MmdMeta[] = req.availableMmdMetas.map((m) => ({
|
||||
filename: m.filename,
|
||||
path: m.path,
|
||||
taskGoal: m.taskGoal,
|
||||
doneCount: m.doneCount,
|
||||
doingCount: m.doingCount,
|
||||
todoCount: m.todoCount,
|
||||
updatedTime: m.updatedTime,
|
||||
nodeSummaries: m.nodeSummaries?.map((n) => ({
|
||||
nodeId: n.nodeId,
|
||||
status: n.status,
|
||||
summary: n.summary,
|
||||
})),
|
||||
}));
|
||||
|
||||
const userPrompt = buildL15UserPrompt(req.recentMessages, currentMmd, metas);
|
||||
|
||||
const raw = await callLlm(this.config, {
|
||||
systemPrompt: L15_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
label: "L1.5",
|
||||
}, this.logger);
|
||||
|
||||
const result = parseL15Response(raw);
|
||||
if (!result) {
|
||||
this.logger?.warn?.(`${TAG} L1.5: failed to parse judgment from LLM response (${raw.length} chars)`);
|
||||
// Return all-null to trigger normalizeJudgment's "LLM unavailable" path
|
||||
return {
|
||||
taskCompleted: false,
|
||||
isContinuation: false,
|
||||
isLongTask: false,
|
||||
} as L15Response;
|
||||
}
|
||||
|
||||
return result as L15Response;
|
||||
}
|
||||
|
||||
// ─── L2 Generate ───────────────────────────────────────────────────────────
|
||||
|
||||
async l2Generate(req: L2Request): Promise<L2Response> {
|
||||
const entries: L2NewEntry[] = req.newEntries.map((e) => ({
|
||||
toolCallId: e.tool_call_id,
|
||||
toolCall: e.tool_call,
|
||||
summary: e.summary,
|
||||
timestamp: e.timestamp,
|
||||
}));
|
||||
|
||||
const userPrompt = buildL2UserPrompt({
|
||||
existingMmd: req.existingMmd,
|
||||
entries,
|
||||
recentHistory: req.recentHistory,
|
||||
currentTurn: req.currentTurn,
|
||||
taskLabel: req.taskLabel,
|
||||
mmdPrefix: req.mmdPrefix,
|
||||
charCount: req.mmdCharCount,
|
||||
});
|
||||
|
||||
const raw = await callLlm(this.config, {
|
||||
systemPrompt: L2_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
label: "L2",
|
||||
timeoutMs: 120_000, // L2 may take longer due to complex prompts
|
||||
}, this.logger);
|
||||
|
||||
const result = parseL2Response(raw);
|
||||
if (!result) {
|
||||
this.logger?.error?.(`${TAG} L2: failed to parse response (${raw.length} chars)`);
|
||||
throw new Error("L2 response parsing failed");
|
||||
}
|
||||
|
||||
return {
|
||||
fileAction: result.fileAction,
|
||||
mmdContent: result.mmdContent,
|
||||
replaceBlocks: result.replaceBlocks?.map((b) => ({
|
||||
startLine: b.startLine,
|
||||
endLine: b.endLine,
|
||||
content: b.content,
|
||||
})),
|
||||
nodeMapping: result.nodeMapping,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Stubs (not applicable in local mode) ──────────────────────────────────
|
||||
|
||||
/** No-op in local mode — state reporting requires a remote backend. */
|
||||
async storeState(_payload: unknown): Promise<void> {}
|
||||
|
||||
/** L4 Skill generation is not supported in local mode. */
|
||||
async l4Generate(_req: unknown): Promise<unknown> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Unified LLM caller for offload local mode.
|
||||
*
|
||||
* Uses Vercel AI SDK (`ai` + `@ai-sdk/openai`) with "compatible" mode
|
||||
* to support any OpenAI-compatible backend.
|
||||
*/
|
||||
import { generateText } from "ai";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import type { PluginLogger } from "../types.js";
|
||||
|
||||
const TAG = "[context-offload] [local-llm]";
|
||||
|
||||
export interface LlmCallerConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface CallLlmOpts {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
/** Override temperature for this call */
|
||||
temperature?: number;
|
||||
/** Override timeout for this call */
|
||||
timeoutMs?: number;
|
||||
/** Label for logging (e.g. "L1", "L1.5", "L2") */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call LLM with the given prompts and return the text response.
|
||||
* Throws on timeout or API errors.
|
||||
*/
|
||||
export async function callLlm(
|
||||
config: LlmCallerConfig,
|
||||
opts: CallLlmOpts,
|
||||
logger?: PluginLogger,
|
||||
): Promise<string> {
|
||||
const startMs = Date.now();
|
||||
const label = opts.label ?? "call";
|
||||
const temperature = opts.temperature ?? config.temperature;
|
||||
const timeoutMs = opts.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
logger?.info?.(
|
||||
`${TAG} ${label} >>> model=${config.model}, temp=${temperature}, timeout=${timeoutMs}ms, ` +
|
||||
`systemLen=${opts.systemPrompt.length}, userLen=${opts.userPrompt.length}`,
|
||||
);
|
||||
|
||||
const provider = createOpenAI({
|
||||
baseURL: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
compatibility: "compatible",
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: provider.chat(config.model),
|
||||
system: opts.systemPrompt,
|
||||
prompt: opts.userPrompt,
|
||||
temperature,
|
||||
abortSignal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const text = result.text.trim();
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
|
||||
logger?.info?.(
|
||||
`${TAG} ${label} <<< ${elapsedMs}ms, output=${text.length} chars`,
|
||||
);
|
||||
|
||||
return text;
|
||||
} catch (err) {
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
logger?.error?.(`${TAG} ${label} FAILED (${elapsedMs}ms): ${errMsg}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tolerant JSON parsing utilities for LLM responses.
|
||||
*
|
||||
* LLMs often wrap JSON in markdown code fences, include trailing commas,
|
||||
* or prepend explanatory text. These utilities handle common deviations.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract JSON from LLM output — handles code fences, prefix text, etc.
|
||||
* Returns the parsed object/array, or null if parsing fails.
|
||||
*/
|
||||
export function extractJson<T = unknown>(raw: string): T | null {
|
||||
if (!raw || typeof raw !== "string") return null;
|
||||
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Strategy 1: Direct parse (ideal case)
|
||||
const direct = tryParse<T>(trimmed);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
// Strategy 2: Extract from markdown code fence (```json ... ``` or ``` ... ```)
|
||||
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
|
||||
if (fenceMatch) {
|
||||
const inner = fenceMatch[1].trim();
|
||||
const parsed = tryParse<T>(inner);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
|
||||
// Strategy 3: Find first { to last } (or first [ to last ])
|
||||
const firstBrace = trimmed.indexOf("{");
|
||||
const lastBrace = trimmed.lastIndexOf("}");
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
const candidate = trimmed.slice(firstBrace, lastBrace + 1);
|
||||
const parsed = tryParse<T>(candidate);
|
||||
if (parsed !== null) return parsed;
|
||||
|
||||
// Try with trailing comma fix
|
||||
const fixed = fixTrailingCommas(candidate);
|
||||
const parsedFixed = tryParse<T>(fixed);
|
||||
if (parsedFixed !== null) return parsedFixed;
|
||||
}
|
||||
|
||||
const firstBracket = trimmed.indexOf("[");
|
||||
const lastBracket = trimmed.lastIndexOf("]");
|
||||
if (firstBracket >= 0 && lastBracket > firstBracket) {
|
||||
const candidate = trimmed.slice(firstBracket, lastBracket + 1);
|
||||
const parsed = tryParse<T>(candidate);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
|
||||
// Strategy 4: Try fixing the entire string
|
||||
const fixed = fixTrailingCommas(trimmed);
|
||||
const parsedFixed = tryParse<T>(fixed);
|
||||
if (parsedFixed !== null) return parsedFixed;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract mermaid content from a code fence.
|
||||
* Returns the raw mermaid text (without fence markers).
|
||||
*/
|
||||
export function extractMermaidFromFence(text: string): string | null {
|
||||
if (!text) return null;
|
||||
const match = text.match(/```mermaid\s*\n?([\s\S]*?)```/);
|
||||
if (match) return match[1].trim();
|
||||
// Fallback: if no fence, return as-is (might already be raw mermaid)
|
||||
if (text.includes("flowchart") || text.includes("graph")) return text.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function tryParse<T>(s: string): T | null {
|
||||
try {
|
||||
return JSON.parse(s) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fixTrailingCommas(s: string): string {
|
||||
// Remove trailing commas before } or ]
|
||||
return s.replace(/,\s*([}\]])/g, "$1");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* L1 Response Parser — extracts summarization results 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; // tool_call_id is required
|
||||
|
||||
entries.push({
|
||||
tool_call_id: toolCallId,
|
||||
tool_call: item.tool_call ?? "",
|
||||
summary: item.summary ?? "",
|
||||
timestamp: item.timestamp ?? "",
|
||||
score: typeof item.score === "number" ? item.score : 5,
|
||||
node_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* L1.5 Response Parser — extracts task judgment 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 the response is completely unparseable or all-null (backend unavailable).
|
||||
*/
|
||||
export function parseL15Response(raw: string): TaskJudgment | null {
|
||||
const parsed = extractJson<RawL15Response>(raw);
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
|
||||
// All-null check (mirrors normalizeJudgment logic)
|
||||
if (parsed.taskCompleted == null && parsed.isContinuation == null && parsed.isLongTask == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
taskCompleted: Boolean(parsed.taskCompleted),
|
||||
isContinuation: Boolean(parsed.isContinuation),
|
||||
isLongTask: Boolean(parsed.isLongTask),
|
||||
continuationMmdFile:
|
||||
typeof parsed.continuationMmdFile === "string" ? parsed.continuationMmdFile : undefined,
|
||||
newTaskLabel:
|
||||
typeof parsed.newTaskLabel === "string" ? parsed.newTaskLabel : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* L2 Response Parser — extracts MMD generation results from LLM output.
|
||||
*/
|
||||
import { extractJson, extractMermaidFromFence } from "./json-utils.js";
|
||||
|
||||
export interface L2ParsedResponse {
|
||||
fileAction: "write" | "replace";
|
||||
mmdContent?: string;
|
||||
replaceBlocks?: Array<{
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
content: string;
|
||||
}>;
|
||||
nodeMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
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 L2 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 (same as Go backend)
|
||||
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 {
|
||||
// mmd_content missing in write mode — try extracting from raw response
|
||||
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 ?? "";
|
||||
// Extract mermaid from fence if present
|
||||
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,98 @@
|
||||
/**
|
||||
* L1 Summarization Prompt — migrated from context-offload-server.
|
||||
*
|
||||
* Converts tool call/result pairs into high-density JSON summaries.
|
||||
*/
|
||||
|
||||
// ─── System Prompt ───────────────────────────────────────────────────────────
|
||||
|
||||
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": 原始的中国标准时间(+08:00)ISO 8601 时间戳(必须原样透传)。
|
||||
- "score"(**必填**): 结合信息密度和任务目的分析summary对于原文的可替代性,范围在0-10之间,越接近10表示summary越能替代原文。
|
||||
|
||||
【严格规则】
|
||||
只允许输出纯 JSON 数组,严禁输出思考过程或其他解释性文本。`;
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PARAMS_MAX_LEN = 500;
|
||||
const RESULT_MAX_LEN = 2000;
|
||||
const COMPRESS_THRESHOLD = 200;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface L1ToolPair {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
params: unknown;
|
||||
result: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ─── User Prompt Builder ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the L1 user prompt for summarization.
|
||||
* Mirrors context-offload-server/internal/service/prompt/BuildL1UserPrompt.
|
||||
*/
|
||||
export function buildL1UserPrompt(recentMessages: string, pairs: L1ToolPair[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push("## 最近的对话上下文(用于理解当前任务):");
|
||||
parts.push(recentMessages);
|
||||
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");
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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,101 @@
|
||||
/**
|
||||
* L1.5 Task Judgment Prompt — migrated from context-offload-server.
|
||||
*
|
||||
* Determines task lifecycle: completion, continuation, new task detection.
|
||||
*/
|
||||
|
||||
// ─── System Prompt ───────────────────────────────────────────────────────────
|
||||
|
||||
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 对象,绝不允许包含解释文字。`;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface L15CurrentMmd {
|
||||
filename: string;
|
||||
content: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface L15MmdMeta {
|
||||
filename: string;
|
||||
path: string;
|
||||
taskGoal: string;
|
||||
doneCount: number;
|
||||
doingCount: number;
|
||||
todoCount: number;
|
||||
updatedTime?: string | null;
|
||||
nodeSummaries?: Array<{ nodeId: string; status: string; summary: string }>;
|
||||
}
|
||||
|
||||
// ─── User Prompt Builder ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the L1.5 user prompt for task judgment.
|
||||
* Mirrors context-offload-server/internal/service/prompt/BuildL15UserPrompt.
|
||||
*/
|
||||
export function buildL15UserPrompt(
|
||||
recentMessages: string,
|
||||
currentMmd: L15CurrentMmd | null,
|
||||
metas: L15MmdMeta[],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push("## 1. 最近的对话上下文 (Recent 6 messages):");
|
||||
parts.push(recentMessages);
|
||||
parts.push("\n## 2. 当前挂载的任务图 (Active Mermaid — 完整内容):");
|
||||
|
||||
if (currentMmd && currentMmd.filename) {
|
||||
parts.push(`**File:** ${currentMmd.filename}`);
|
||||
if (currentMmd.path) {
|
||||
parts.push(`**Path:** \`${currentMmd.path}\``);
|
||||
}
|
||||
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) {
|
||||
parts.push(`- **${m.filename}**`);
|
||||
parts.push(` path: \`${m.path}\``);
|
||||
parts.push(` taskGoal: ${m.taskGoal}`);
|
||||
const total = m.doneCount + m.doingCount + m.todoCount;
|
||||
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,127 @@
|
||||
/**
|
||||
* L2 MMD Generation Prompt — migrated from context-offload-server.
|
||||
*
|
||||
* Generates/updates Mermaid flowchart diagrams from offload entries.
|
||||
*/
|
||||
|
||||
// ─── System Prompt ───────────────────────────────────────────────────────────
|
||||
|
||||
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": "N1",
|
||||
"tool_call_id_2": "N1"
|
||||
}
|
||||
}
|
||||
|
||||
仅输出纯 JSON 对象,绝不允许包含任何解释。`;
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface L2NewEntry {
|
||||
toolCallId: string;
|
||||
toolCall: string;
|
||||
summary: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ─── User Prompt Builder ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the L2 user prompt for MMD generation.
|
||||
* Mirrors context-offload-server/internal/service/prompt/BuildL2UserPrompt.
|
||||
*/
|
||||
export function buildL2UserPrompt(opts: {
|
||||
existingMmd: string | null;
|
||||
entries: L2NewEntry[];
|
||||
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.toolCallId}] ${e.toolCall} → ${e.summary} (${e.timestamp})`);
|
||||
}
|
||||
|
||||
parts.push("\n请根据系统指令生成/更新 Mermaid 流程图,并输出合法的 JSON 对象(含 node_mapping)。");
|
||||
return parts.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user