From fdbb534271107d73b4ffe4be805d99b4d0b47c6e Mon Sep 17 00:00:00 2001 From: Mac Date: Sun, 15 Mar 2026 21:47:34 +0800 Subject: [PATCH 1/3] feat(openclaw-plugin): support multi-agent memory isolation via hook context agentId When OpenClaw gateway serves multiple agents, each agent's before_agent_start and agent_end hooks now carry the agent's ID in the second parameter (PluginHookAgentContext). The plugin dynamically switches the client's agentId before each recall/capture operation, ensuring memories are routed to the correct agent_space (md5(user_id + agent_id)[:12]). Changes: - client.ts: Add setAgentId()/getAgentId() to allow dynamic agent switching. Clears cached runtimeIdentity and resolvedSpaceByScope when switching to ensure correct space derivation. - index.ts: Extract agentId from hook ctx (2nd param) in both before_agent_start and agent_end handlers. This is backward compatible: if ctx.agentId is absent (single-agent setup), the plugin falls back to the static config agentId as before. --- examples/openclaw-memory-plugin/client.ts | 24 +++++++++++++++++++++-- examples/openclaw-memory-plugin/index.ts | 20 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/examples/openclaw-memory-plugin/client.ts b/examples/openclaw-memory-plugin/client.ts index 56489999..c48b2b69 100644 --- a/examples/openclaw-memory-plugin/client.ts +++ b/examples/openclaw-memory-plugin/client.ts @@ -47,16 +47,36 @@ export function isMemoryUri(uri: string): boolean { } export class OpenVikingClient { - private readonly resolvedSpaceByScope: Partial> = {}; + private resolvedSpaceByScope: Partial> = {}; private runtimeIdentity: RuntimeIdentity | null = null; constructor( private readonly baseUrl: string, private readonly apiKey: string, - private readonly agentId: string, + private agentId: string, private readonly timeoutMs: number, ) {} + /** + * Dynamically switch the agent identity for multi-agent memory isolation. + * When a shared client serves multiple agents (e.g. in OpenClaw multi-agent + * gateway), call this before each agent's recall/capture to route memories + * to the correct agent_space = md5(user_id + agent_id)[:12]. + * Clears cached space resolution so the next request re-derives agent_space. + */ + setAgentId(newAgentId: string): void { + if (newAgentId && newAgentId !== this.agentId) { + this.agentId = newAgentId; + // Clear cached identity and spaces — they depend on agentId + this.runtimeIdentity = null; + this.resolvedSpaceByScope = {}; + } + } + + getAgentId(): string { + return this.agentId; + } + private async request(path: string, init: RequestInit = {}): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); diff --git a/examples/openclaw-memory-plugin/index.ts b/examples/openclaw-memory-plugin/index.ts index 4a2a4499..06551a4a 100644 --- a/examples/openclaw-memory-plugin/index.ts +++ b/examples/openclaw-memory-plugin/index.ts @@ -354,7 +354,16 @@ const memoryPlugin = { ); if (cfg.autoRecall || cfg.ingestReplyAssist) { - api.on("before_agent_start", async (event: { messages?: unknown[]; prompt: string }) => { + api.on("before_agent_start", async (event: { messages?: unknown[]; prompt: string }, ctx?: { agentId?: string }) => { + // Dynamically switch agent identity for multi-agent memory isolation. + // In multi-agent gateway deployments, the hook context carries the current + // agent's ID so we route memory operations to the correct agent_space. + const hookAgentId = ctx?.agentId; + if (hookAgentId) { + const client = await getClient(); + client.setAgentId(hookAgentId); + api.logger.info?.(`memory-openviking: switched to agentId=${hookAgentId} for recall`); + } const queryText = extractLatestUserText(event.messages) || event.prompt.trim(); if (!queryText) { return; @@ -474,7 +483,14 @@ const memoryPlugin = { if (cfg.autoCapture) { let lastProcessedMsgCount = 0; - api.on("agent_end", async (event: { success?: boolean; messages?: unknown[] }) => { + api.on("agent_end", async (event: { success?: boolean; messages?: unknown[] }, ctx?: { agentId?: string }) => { + // Dynamically switch agent identity for multi-agent memory isolation + const hookAgentId = ctx?.agentId; + if (hookAgentId) { + const client = await getClient(); + client.setAgentId(hookAgentId); + api.logger.info?.(`memory-openviking: switched to agentId=${hookAgentId} for capture`); + } if (!event.success || !event.messages || event.messages.length === 0) { api.logger.info( `memory-openviking: auto-capture skipped (success=${String(event.success)}, messages=${event.messages?.length ?? 0})`, From 62ffcb3b4a691cffaf474ceef5bdae12d6017112 Mon Sep 17 00:00:00 2001 From: Mac Date: Mon, 16 Mar 2026 19:22:59 +0800 Subject: [PATCH 2/3] feat(openclaw-plugin): add captureMinLength config to reduce unnecessary VLM token consumption Problem: The auto-capture feature triggers VLM extraction (deepseek-chat) for almost every user message, including trivially short ones (as low as 4 CJK chars / 10 EN chars). In multi-agent setups with high interaction volume, this leads to excessive API calls and token consumption (observed 100K+ deepseek-chat calls in 2 days). Solution: Add a configurable `captureMinLength` option (default: 50 chars) that sets a minimum sanitized text length threshold for triggering auto-capture. Messages shorter than this threshold are skipped (reason: `length_out_of_range`), avoiding unnecessary VLM calls. The new threshold works as a floor: `Math.max(resolveCaptureMinLength(text), captureMinLength)`, preserving the existing CJK/EN-aware minimum while allowing users to set a higher bar. Changes: - config.ts: Add captureMinLength type, DEFAULT_CAPTURE_MIN_LENGTH=50, allowed key, resolve logic (clamped to 1..1000), and UI hint - text-utils.ts: Update getCaptureDecision signature to accept captureMinLength, use Math.max to combine with built-in minimum - index.ts: Pass cfg.captureMinLength to getCaptureDecision call Users can override via plugin config: { "captureMinLength": 100 } // skip messages under 100 chars --- examples/openclaw-memory-plugin/config.ts | 14 ++++++++++++++ examples/openclaw-memory-plugin/index.ts | 2 +- examples/openclaw-memory-plugin/text-utils.ts | 4 ++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/examples/openclaw-memory-plugin/config.ts b/examples/openclaw-memory-plugin/config.ts index 3fccd12e..40c73f03 100644 --- a/examples/openclaw-memory-plugin/config.ts +++ b/examples/openclaw-memory-plugin/config.ts @@ -16,6 +16,8 @@ export type MemoryOpenVikingConfig = { timeoutMs?: number; autoCapture?: boolean; captureMode?: "semantic" | "keyword"; + /** Minimum sanitized text length (chars) required to trigger auto-capture. Default 50. */ + captureMinLength?: number; captureMaxLength?: number; autoRecall?: boolean; recallLimit?: number; @@ -30,6 +32,7 @@ const DEFAULT_PORT = 1933; const DEFAULT_TARGET_URI = "viking://user/memories"; const DEFAULT_TIMEOUT_MS = 15000; const DEFAULT_CAPTURE_MODE = "semantic"; +const DEFAULT_CAPTURE_MIN_LENGTH = 50; const DEFAULT_CAPTURE_MAX_LENGTH = 24000; const DEFAULT_RECALL_LIMIT = 6; const DEFAULT_RECALL_SCORE_THRESHOLD = 0.01; @@ -105,6 +108,7 @@ export const memoryOpenVikingConfigSchema = { "timeoutMs", "autoCapture", "captureMode", + "captureMinLength", "captureMaxLength", "autoRecall", "recallLimit", @@ -153,6 +157,10 @@ export const memoryOpenVikingConfigSchema = { timeoutMs: Math.max(1000, Math.floor(toNumber(cfg.timeoutMs, DEFAULT_TIMEOUT_MS))), autoCapture: cfg.autoCapture !== false, captureMode: captureMode ?? DEFAULT_CAPTURE_MODE, + captureMinLength: Math.max( + 1, + Math.min(1000, Math.floor(toNumber(cfg.captureMinLength, DEFAULT_CAPTURE_MIN_LENGTH))), + ), captureMaxLength: Math.max( 200, Math.min(200_000, Math.floor(toNumber(cfg.captureMaxLength, DEFAULT_CAPTURE_MAX_LENGTH))), @@ -237,6 +245,12 @@ export const memoryOpenVikingConfigSchema = { advanced: true, help: '"semantic" captures all eligible user text and relies on OpenViking extraction; "keyword" uses trigger regex first.', }, + captureMinLength: { + label: "Capture Min Length", + placeholder: String(DEFAULT_CAPTURE_MIN_LENGTH), + advanced: true, + help: "Minimum sanitized text length (chars) required to trigger auto-capture. Shorter messages are skipped to save VLM tokens.", + }, captureMaxLength: { label: "Capture Max Length", placeholder: String(DEFAULT_CAPTURE_MAX_LENGTH), diff --git a/examples/openclaw-memory-plugin/index.ts b/examples/openclaw-memory-plugin/index.ts index 06551a4a..8eff483d 100644 --- a/examples/openclaw-memory-plugin/index.ts +++ b/examples/openclaw-memory-plugin/index.ts @@ -511,7 +511,7 @@ const memoryPlugin = { const turnText = newTexts.join("\n"); // 对合并文本做 capture decision(主要检查长度和命令过滤) - const decision = getCaptureDecision(turnText, cfg.captureMode, cfg.captureMaxLength); + const decision = getCaptureDecision(turnText, cfg.captureMode, cfg.captureMinLength, cfg.captureMaxLength); const preview = turnText.length > 80 ? `${turnText.slice(0, 80)}...` : turnText; api.logger.info( `memory-openviking: capture-check shouldCapture=${String(decision.shouldCapture)} reason=${decision.reason} newMsgCount=${newCount} text="${preview}"`, diff --git a/examples/openclaw-memory-plugin/text-utils.ts b/examples/openclaw-memory-plugin/text-utils.ts index 40f224a4..6dd18d87 100644 --- a/examples/openclaw-memory-plugin/text-utils.ts +++ b/examples/openclaw-memory-plugin/text-utils.ts @@ -206,7 +206,7 @@ export function pickRecentUniqueTexts(texts: string[], limit: number): string[] return picked.reverse(); } -export function getCaptureDecision(text: string, mode: CaptureMode, captureMaxLength: number): { +export function getCaptureDecision(text: string, mode: CaptureMode, captureMinLength: number, captureMaxLength: number): { shouldCapture: boolean; reason: string; normalizedText: string; @@ -223,7 +223,7 @@ export function getCaptureDecision(text: string, mode: CaptureMode, captureMaxLe } const compactText = normalizedText.replace(/\s+/g, ""); - const minLength = resolveCaptureMinLength(compactText); + const minLength = Math.max(resolveCaptureMinLength(compactText), captureMinLength); if (compactText.length < minLength || normalizedText.length > captureMaxLength) { return { shouldCapture: false, From 7fae4a37e4c89e5e374508a4e5f291c96184dbc1 Mon Sep 17 00:00:00 2001 From: Mac Date: Mon, 16 Mar 2026 23:29:16 +0800 Subject: [PATCH 3/3] fix(memory-plugin): use agentId-keyed Map for lastProcessedMsgCount to support multi-agent isolation --- examples/openclaw-memory-plugin/client.ts | 80 +++++++++++++---------- examples/openclaw-memory-plugin/index.ts | 10 +-- 2 files changed, 53 insertions(+), 37 deletions(-) diff --git a/examples/openclaw-memory-plugin/client.ts b/examples/openclaw-memory-plugin/client.ts index c48b2b69..b61db4e3 100644 --- a/examples/openclaw-memory-plugin/client.ts +++ b/examples/openclaw-memory-plugin/client.ts @@ -55,6 +55,7 @@ export class OpenVikingClient { private readonly apiKey: string, private agentId: string, private readonly timeoutMs: number, + private readonly maxRetries: number = 0, ) {} /** @@ -78,42 +79,55 @@ export class OpenVikingClient { } private async request(path: string, init: RequestInit = {}): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.timeoutMs); - try { - const headers = new Headers(init.headers ?? {}); - if (this.apiKey) { - headers.set("X-API-Key", this.apiKey); - } - if (this.agentId) { - headers.set("X-OpenViking-Agent", this.agentId); - } - if (init.body && !headers.has("Content-Type")) { - headers.set("Content-Type", "application/json"); - } + let lastError: Error | null = null; + const maxAttempts = 1 + Math.max(0, this.maxRetries); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const headers = new Headers(init.headers ?? {}); + if (this.apiKey) { + headers.set("X-API-Key", this.apiKey); + } + if (this.agentId) { + headers.set("X-OpenViking-Agent", this.agentId); + } + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } - const response = await fetch(`${this.baseUrl}${path}`, { - ...init, - headers, - signal: controller.signal, - }); - - const payload = (await response.json().catch(() => ({}))) as { - status?: string; - result?: T; - error?: { code?: string; message?: string }; - }; - - if (!response.ok || payload.status === "error") { - const code = payload.error?.code ? ` [${payload.error.code}]` : ""; - const message = payload.error?.message ?? `HTTP ${response.status}`; - throw new Error(`OpenViking request failed${code}: ${message}`); - } + const response = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers, + signal: controller.signal, + }); + + const payload = (await response.json().catch(() => ({}))) as { + status?: string; + result?: T; + error?: { code?: string; message?: string }; + }; + + if (!response.ok || payload.status === "error") { + const code = payload.error?.code ? ` [${payload.error.code}]` : ""; + const message = payload.error?.message ?? `HTTP ${response.status}`; + throw new Error(`OpenViking request failed${code}: ${message}`); + } - return (payload.result ?? payload) as T; - } finally { - clearTimeout(timer); + return (payload.result ?? payload) as T; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < maxAttempts) { + const delay = Math.min(1000 * 2 ** (attempt - 1), 5000); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + } finally { + clearTimeout(timer); + } } + throw lastError || new Error("Request failed after retries"); } async healthCheck(): Promise { diff --git a/examples/openclaw-memory-plugin/index.ts b/examples/openclaw-memory-plugin/index.ts index 8eff483d..66d710c0 100644 --- a/examples/openclaw-memory-plugin/index.ts +++ b/examples/openclaw-memory-plugin/index.ts @@ -81,7 +81,7 @@ const memoryPlugin = { }); } } else { - clientPromise = Promise.resolve(new OpenVikingClient(cfg.baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs)); + clientPromise = Promise.resolve(new OpenVikingClient(cfg.baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs, cfg.maxRetries)); } const getClient = (): Promise => clientPromise; @@ -481,9 +481,10 @@ const memoryPlugin = { } if (cfg.autoCapture) { - let lastProcessedMsgCount = 0; + const lastProcessedMsgCountMap = new Map(); api.on("agent_end", async (event: { success?: boolean; messages?: unknown[] }, ctx?: { agentId?: string }) => { + const agentKey = ctx?.agentId || "default"; // Dynamically switch agent identity for multi-agent memory isolation const hookAgentId = ctx?.agentId; if (hookAgentId) { @@ -499,8 +500,9 @@ const memoryPlugin = { } try { const messages = event.messages; + const lastProcessedMsgCount = lastProcessedMsgCountMap.get(agentKey) || 0; const { texts: newTexts, newCount } = extractNewTurnTexts(messages, lastProcessedMsgCount); - lastProcessedMsgCount = messages.length; + lastProcessedMsgCountMap.set(agentKey, messages.length); if (newTexts.length === 0) { api.logger.info("memory-openviking: auto-capture skipped (no new user/assistant messages)"); @@ -632,7 +634,7 @@ const memoryPlugin = { }); try { await waitForHealth(baseUrl, timeoutMs, intervalMs); - const client = new OpenVikingClient(baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs); + const client = new OpenVikingClient(baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs, cfg.maxRetries); localClientCache.set(localCacheKey, { client, process: child }); resolveLocalClient(client); rejectLocalClient = null;