UNPKG

@tencentdb-agent-memory/memory-tencentdb

Version:

Four-layer local memory system plugin for OpenClaw — auto-captures, structures, and profiles conversational knowledge using local LLM + SQLite vector search (L0→L1→L2→L3 pipeline)

406 lines (367 loc) 15.1 kB
/** * StandaloneLLMRunner — powered by Vercel AI SDK (`ai` + `@ai-sdk/openai`). * * This runner does NOT depend on OpenClaw's `runEmbeddedPiAgent`. It is designed * for the Hermes Gateway scenario where TDAI runs as an independent Node.js sidecar * without the OpenClaw host. * * Capabilities: * - `enableTools: false`: pure text output (L1 extraction, L1 dedup) * - `enableTools: true`: automatic tool-call loop with local file operations * (L2 scene, L3 persona) via AI SDK's `maxSteps` * * Tool sandbox: * When tools are enabled, three basic file operations are exposed: * `read`, `write`, `edit` — aligned with OpenClaw host tool names. * All file paths are resolved relative to `workspaceDir`, enforcing sandbox boundaries. */ import fsPromises from "node:fs/promises"; import path from "node:path"; import { generateText, tool, stepCountIs, jsonSchema } from "ai"; import { createOpenAI } from "@ai-sdk/openai"; import { report } from "../../core/report/reporter.js"; import type { LLMRunner, LLMRunParams, LLMRunnerFactory, LLMRunnerCreateOptions, Logger, } from "../../core/types.js"; import type { LLMUsage } from "../../core/report/metric-tracking-runner.js"; const TAG = "[memory-tdai] [standalone-runner]"; // Max iterations in the tool-call loop to prevent infinite loops const MAX_TOOL_ITERATIONS = 20; // ============================ // Configuration // ============================ export interface StandaloneLLMConfig { /** OpenAI-compatible API base URL (e.g. "https://api.openai.com/v1"). */ baseUrl: string; /** API key for authentication. */ apiKey: string; /** Default model name (e.g. "gpt-4o"). */ model: string; /** Default max output tokens. */ maxTokens?: number; /** Request timeout in milliseconds (default: 120_000). */ timeoutMs?: number; } // ============================ // Sandboxed tool execution helpers // ============================ function resolveSandboxedPath(workspaceDir: string, relativePath: string): string | null { const resolved = path.resolve(workspaceDir, relativePath); if (!resolved.startsWith(path.resolve(workspaceDir))) { return null; } return resolved; } // ============================ // Tool definitions (Vercel AI SDK `tool()` format) // ============================ function createSandboxedTools(workspaceDir: string, logger?: Logger) { return { read: tool({ description: "Read the contents of a file at the given relative path.", inputSchema: jsonSchema<{ path: string }>({ type: "object", properties: { path: { type: "string", description: "Relative file path to read." }, }, required: ["path"], }), execute: (async (args: { path: string }) => { const resolved = resolveSandboxedPath(workspaceDir, args.path); if (!resolved) return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` }); try { const content = await fsPromises.readFile(resolved, "utf-8"); logger?.debug?.(`${TAG} read: "${args.path}" → ${content.length} chars`); return content; } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger?.warn?.(`${TAG} read failed: ${msg}`); return JSON.stringify({ error: msg }); } }) as any, }), write: tool({ description: "Write content to a file at the given relative path. Creates or overwrites.", inputSchema: jsonSchema<{ path: string; content: string }>({ type: "object", properties: { path: { type: "string", description: "Relative file path to write." }, content: { type: "string", description: "Content to write." }, }, required: ["path", "content"], }), execute: (async (args: { path: string; content: string }) => { const resolved = resolveSandboxedPath(workspaceDir, args.path); if (!resolved) return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` }); try { await fsPromises.mkdir(path.dirname(resolved), { recursive: true }); await fsPromises.writeFile(resolved, args.content, "utf-8"); logger?.debug?.(`${TAG} write: "${args.path}" → ${args.content.length} chars`); return JSON.stringify({ success: true }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger?.warn?.(`${TAG} write failed: ${msg}`); return JSON.stringify({ error: msg }); } }) as any, }), edit: tool({ description: "Apply one or more text replacements to a file. Each edit replaces an exact substring.", inputSchema: jsonSchema<{ path: string; edits: Array<{ oldText: string; newText: string }> }>({ type: "object", properties: { path: { type: "string", description: "Relative file path." }, edits: { type: "array", description: "Array of replacements to apply sequentially.", items: { type: "object", properties: { oldText: { type: "string", description: "Exact string to find." }, newText: { type: "string", description: "Replacement string." }, }, required: ["oldText", "newText"], }, }, }, required: ["path", "edits"], }), execute: (async (args: { path: string; edits: Array<{ oldText: string; newText: string }> }) => { const resolved = resolveSandboxedPath(workspaceDir, args.path); if (!resolved) return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` }); if (!args.edits || args.edits.length === 0) return JSON.stringify({ error: "edits array cannot be empty." }); try { let content = await fsPromises.readFile(resolved, "utf-8"); for (const edit of args.edits) { if (!edit.oldText) return JSON.stringify({ error: "oldText cannot be empty." }); if (!content.includes(edit.oldText)) { return JSON.stringify({ error: `oldText not found in file "${args.path}": ${edit.oldText.slice(0, 80)}` }); } content = content.replace(edit.oldText, edit.newText); } await fsPromises.writeFile(resolved, content, "utf-8"); logger?.debug?.(`${TAG} edit: "${args.path}" → ${args.edits.length} replacement(s), ${content.length} chars`); return JSON.stringify({ success: true }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger?.warn?.(`${TAG} edit failed: ${msg}`); return JSON.stringify({ error: msg }); } }) as any, }), }; } /** Read-only tool subset — currently empty. * * Historically returned `{ read: all.read }` so the AI SDK wouldn't reject * an empty tools object. In practice this caused weak models (e.g. small * Doubao endpoints) to hallucinate calls like `read({"path":"."})` during * pure-text tasks (L1 extraction), triggering EISDIR on the sandbox dir * and burning a turn on a useless tool call. * * Modern AI SDK (v6) accepts an undefined `tools` field, so the runner now * skips the `tools`/`stopWhen` parameters entirely when tools are disabled * — see `generateText` invocation below. */ function createReadOnlyTools(_workspaceDir: string, _logger?: Logger) { return {}; } // ============================ // StandaloneLLMRunner // ============================ export class StandaloneLLMRunner implements LLMRunner { private config: StandaloneLLMConfig; private model: string; private enableTools: boolean; private logger?: Logger; /** * Side-channel: 最近一次 run() 调用的 token usage。 * 由 MetricTrackingRunner 装饰器读取,用于精确上报 credit。 * 不改变 LLMRunner 接口签名。 */ lastUsage?: LLMUsage; constructor(opts: { config: StandaloneLLMConfig; model?: string; enableTools?: boolean; logger?: Logger; }) { this.config = opts.config; this.model = opts.model ?? opts.config.model; this.enableTools = opts.enableTools ?? false; this.logger = opts.logger; } async run(params: LLMRunParams): Promise<string> { const runStartMs = Date.now(); const timeoutMs = params.timeoutMs ?? this.config.timeoutMs ?? 120_000; const maxTokens = params.maxTokens ?? this.config.maxTokens ?? 4096; const workspaceDir = params.workspaceDir ?? process.cwd(); this.logger?.debug?.( `${TAG} run() start: taskId=${params.taskId}, model=${this.model}, ` + `tools=${this.enableTools}, timeout=${timeoutMs}ms`, ); // Create OpenAI-compatible provider via AI SDK // Use "compatible" mode to call /chat/completions (not Responses API), // which works with all OpenAI-compatible backends (DeepSeek, Qwen, etc.) const provider = createOpenAI({ baseURL: this.config.baseUrl, apiKey: this.config.apiKey, compatibility: "compatible", }); // Select tools based on mode + storage // Service mode (COS): use storage-backed tools → LLM reads/writes via StorageAdapter // Standalone mode (local FS): use sandboxed FS tools → LLM reads/writes local files // enableTools=false: omit tools entirely so the model cannot hallucinate calls. let tools: Record<string, unknown> | undefined; if (this.enableTools && params.storage) { const { createStorageTools } = await import("./storage-tools.js"); tools = createStorageTools(params.storage, params.storagePrefix ?? "", this.logger); this.logger?.debug?.(`${TAG} Using storage-backed tools (prefix="${params.storagePrefix ?? ""}")`); } else if (this.enableTools) { tools = createSandboxedTools(workspaceDir, this.logger); } else { tools = undefined; // pure-text task — never expose any tool to the model } try { // H-11 Step 2: combine internal timeout with caller-provided abortSignal // (e.g. pipeline-worker lost its lock and wants the LLM call to bail out). // AbortSignal.any (Node 20+) aborts when ANY of the listed signals abort. const timeoutSignal = AbortSignal.timeout(timeoutMs); const combinedSignal = params.abortSignal ? AbortSignal.any([timeoutSignal, params.abortSignal]) : timeoutSignal; const result = await generateText({ model: provider.chat(this.model), system: params.systemPrompt, prompt: params.prompt, // Only attach tools when actually enabled — passing an empty object // (or even a tools-only-with-`read`) makes some OpenAI-compatible // backends emit spurious tool calls on pure-text tasks. ...(tools && Object.keys(tools).length > 0 ? { tools, stopWhen: stepCountIs(MAX_TOOL_ITERATIONS) } : {}), maxOutputTokens: maxTokens, abortSignal: combinedSignal, experimental_telemetry: { isEnabled: true, functionId: params.taskId, metadata: { instanceId: params.instanceId ?? "unknown" }, }, }); const text = (result.text ?? "").trim(); const totalMs = Date.now() - runStartMs; // 暴露 token usage 到 side-channel(供 MetricTrackingRunner 读取) if (result.usage) { this.lastUsage = { promptTokens: result.usage.promptTokens ?? 0, completionTokens: result.usage.completionTokens ?? 0, totalTokens: (result.usage.promptTokens ?? 0) + (result.usage.completionTokens ?? 0), }; } else { this.lastUsage = undefined; } this.logger?.debug?.( `${TAG} run() completed: ${totalMs}ms, steps=${result.steps.length}, output=${text.length} chars`, ); // Log each step's activity (tool calls + text output) for (const step of result.steps) { const calls = step.toolCalls ?? []; const textLen = step.text?.length ?? 0; if (calls.length > 0) { const callSummary = calls.map((tc) => `${tc.toolName}(${JSON.stringify(tc.input).slice(0, 120)})`, ).join(", "); this.logger?.debug?.( `${TAG} step[${step.stepNumber}] toolCalls: ${callSummary}`, ); } if (textLen > 0) { this.logger?.debug?.( `${TAG} step[${step.stepNumber}] text: ${textLen} chars, finishReason=${step.finishReason}`, ); } if (calls.length === 0 && textLen === 0) { this.logger?.debug?.( `${TAG} step[${step.stepNumber}] empty (no tools, no text), finishReason=${step.finishReason}`, ); } } // Metric if (params.instanceId) { report("llm_call", { taskId: params.taskId, provider: "standalone", model: this.model, inputLength: params.prompt.length, outputLength: text.length, totalDurationMs: totalMs, success: true, error: null, }); } return text; } catch (err) { const totalMs = Date.now() - runStartMs; const errMsg = err instanceof Error ? err.message : String(err); this.logger?.error(`${TAG} run() failed after ${totalMs}ms: ${errMsg}`); if (params.instanceId) { report("llm_call", { taskId: params.taskId, provider: "standalone", model: this.model, inputLength: params.prompt.length, outputLength: 0, totalDurationMs: totalMs, success: false, error: errMsg, }); } throw err; } } } // ============================ // StandaloneLLMRunnerFactory // ============================ export interface StandaloneLLMRunnerFactoryOptions { /** LLM API configuration. */ config: StandaloneLLMConfig; /** Logger instance. */ logger?: Logger; } /** * Factory that creates StandaloneLLMRunner instances. * * Used by the Gateway and Hermes host adapters. */ export class StandaloneLLMRunnerFactory implements LLMRunnerFactory { private config: StandaloneLLMConfig; private logger?: Logger; constructor(opts: StandaloneLLMRunnerFactoryOptions) { this.config = opts.config; this.logger = opts.logger; } createRunner(opts?: LLMRunnerCreateOptions): LLMRunner { const enableTools = opts?.enableTools ?? false; const modelRef = opts?.modelRef; // Parse "provider/model" → just use the model part for OpenAI-compatible API let model = this.config.model; if (modelRef) { const slashIdx = modelRef.indexOf("/"); model = slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef; } this.logger?.debug?.( `${TAG} Creating StandaloneLLMRunner: model=${model}, tools=${enableTools}`, ); return new StandaloneLLMRunner({ config: this.config, model, enableTools, logger: this.logger, }); } }