diff --git a/index.ts b/index.ts index ea05cd36..42fbc45d 100644 --- a/index.ts +++ b/index.ts @@ -113,6 +113,8 @@ interface PluginConfig { recallMode?: "full" | "summary" | "adaptive" | "off"; /** Agent IDs excluded from auto-recall injection. Useful for background agents (e.g. memory-distiller, cron workers) whose output should not be contaminated by injected memory context. */ autoRecallExcludeAgents?: string[]; + /** Agent IDs included in auto-recall injection (whitelist mode). When set, ONLY these agents receive auto-recall. Cannot be used together with autoRecallExcludeAgents. */ + autoRecallIncludeAgents?: string[]; captureAssistant?: boolean; retrieval?: { mode?: "hybrid" | "vector"; @@ -2309,18 +2311,28 @@ const memoryLanceDBProPlugin = { const AUTO_RECALL_TIMEOUT_MS = parsePositiveInt(config.autoRecallTimeoutMs) ?? 5_000; // configurable; default raised from 3s to 5s for remote embedding APIs behind proxies api.on("before_prompt_build", async (event: any, ctx: any) => { - // Per-agent exclusion: skip auto-recall for agents in the exclusion list. + // Per-agent inclusion/exclusion: autoRecallIncludeAgents takes precedence over autoRecallExcludeAgents. + // - If autoRecallIncludeAgents is set: ONLY these agents receive auto-recall + // - Else if autoRecallExcludeAgents is set: all agents EXCEPT these receive auto-recall const agentId = resolveHookAgentId(ctx?.agentId, (event as any).sessionKey); - if ( - Array.isArray(config.autoRecallExcludeAgents) && - config.autoRecallExcludeAgents.length > 0 && - agentId !== undefined && - config.autoRecallExcludeAgents.includes(agentId) - ) { - api.logger.debug?.( - `memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}'`, - ); - return; + if (agentId !== undefined) { + if (Array.isArray(config.autoRecallIncludeAgents) && config.autoRecallIncludeAgents.length > 0) { + if (!config.autoRecallIncludeAgents.includes(agentId)) { + api.logger.debug?.( + `memory-lancedb-pro: auto-recall skipped for agent '${agentId}' not in autoRecallIncludeAgents`, + ); + return; + } + } else if ( + Array.isArray(config.autoRecallExcludeAgents) && + config.autoRecallExcludeAgents.length > 0 && + config.autoRecallExcludeAgents.includes(agentId) + ) { + api.logger.debug?.( + `memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}'`, + ); + return; + } } // Manually increment turn counter for this session @@ -3969,6 +3981,9 @@ export function parsePluginConfig(value: unknown): PluginConfig { autoRecallExcludeAgents: Array.isArray(cfg.autoRecallExcludeAgents) ? cfg.autoRecallExcludeAgents.filter((id: unknown): id is string => typeof id === "string" && id.trim() !== "") : undefined, + autoRecallIncludeAgents: Array.isArray(cfg.autoRecallIncludeAgents) + ? cfg.autoRecallIncludeAgents.filter((id: unknown): id is string => typeof id === "string" && id.trim() !== "") + : undefined, captureAssistant: cfg.captureAssistant === true, retrieval: typeof cfg.retrieval === "object" && cfg.retrieval !== null diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 88cb4b53..81783823 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -164,6 +164,22 @@ "default": "full", "description": "Auto-recall depth mode. 'full': inject with configured per-item budget. 'summary': L0 abstracts only (compact). 'adaptive': analyze query intent to auto-select category and depth. 'off': disable auto-recall injection." }, + "autoRecallExcludeAgents": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Agent IDs excluded from auto-recall injection. When set, these agents will NOT receive auto-recalled memories. Useful for background agents (e.g. cron workers) whose output should not be contaminated by injected context. Cannot be used with autoRecallIncludeAgents." + }, + "autoRecallIncludeAgents": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Agent IDs included in auto-recall injection (whitelist mode). When set, ONLY these agents will receive auto-recall. Cannot be used with autoRecallExcludeAgents. Overrides autoRecallExcludeAgents if both are set." + }, "captureAssistant": { "type": "boolean" }, diff --git a/test/per-agent-auto-recall.test.mjs b/test/per-agent-auto-recall.test.mjs new file mode 100644 index 00000000..3a0fde86 --- /dev/null +++ b/test/per-agent-auto-recall.test.mjs @@ -0,0 +1,193 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import jitiFactory from "jiti"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs"); +const jiti = jitiFactory(import.meta.url, { + interopDefault: true, + alias: { + "openclaw/plugin-sdk": pluginSdkStubPath, + }, +}); +const { parsePluginConfig } = jiti("../index.ts"); + +function baseConfig() { + return { + embedding: { + apiKey: "test-api-key", + }, + }; +} + +describe("autoRecallExcludeAgents", () => { + it("defaults to undefined when not specified", () => { + const parsed = parsePluginConfig(baseConfig()); + assert.equal(parsed.autoRecallExcludeAgents, undefined); + }); + + it("parses a valid array of agent IDs", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallExcludeAgents: ["saffron", "maple", "matcha"], + }); + assert.deepEqual(parsed.autoRecallExcludeAgents, ["saffron", "maple", "matcha"]); + }); + + it("filters out non-string entries", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallExcludeAgents: ["saffron", null, 123, "maple", undefined, ""], + }); + assert.deepEqual(parsed.autoRecallExcludeAgents, ["saffron", "maple"]); + }); + + it("filters out whitespace-only strings", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallExcludeAgents: ["saffron", " ", "\t", "maple"], + }); + assert.deepEqual(parsed.autoRecallExcludeAgents, ["saffron", "maple"]); + }); + + it("returns empty array for empty array input (not undefined)", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallExcludeAgents: [], + }); + // Empty array stays as [] — falsy check via length is the right way to handle + assert.ok(Array.isArray(parsed.autoRecallExcludeAgents)); + assert.equal(parsed.autoRecallExcludeAgents.length, 0); + }); + + it("handles single agent ID", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallExcludeAgents: ["cron-worker"], + }); + assert.deepEqual(parsed.autoRecallExcludeAgents, ["cron-worker"]); + }); +}); + +describe("autoRecallIncludeAgents", () => { + it("defaults to undefined when not specified", () => { + const parsed = parsePluginConfig(baseConfig()); + assert.equal(parsed.autoRecallIncludeAgents, undefined); + }); + + it("parses a valid array of agent IDs", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallIncludeAgents: ["saffron", "maple"], + }); + assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron", "maple"]); + }); + + it("filters out non-string entries", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallIncludeAgents: ["saffron", null, 123, "maple"], + }); + assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron", "maple"]); + }); + + it("filters out whitespace-only strings", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallIncludeAgents: ["saffron", " ", "maple"], + }); + assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron", "maple"]); + }); + + it("returns empty array for empty array input (not undefined)", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallIncludeAgents: [], + }); + assert.ok(Array.isArray(parsed.autoRecallIncludeAgents)); + assert.equal(parsed.autoRecallIncludeAgents.length, 0); + }); + + it("handles single agent ID (whitelist mode)", () => { + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallIncludeAgents: ["sage"], + }); + assert.deepEqual(parsed.autoRecallIncludeAgents, ["sage"]); + }); + + it("include takes precedence over exclude in parsing (both specified)", () => { + // Note: logic precedence is handled at runtime in before_prompt_build, + // not in the config parser. Parser accepts both. + const parsed = parsePluginConfig({ + ...baseConfig(), + autoRecallIncludeAgents: ["saffron"], + autoRecallExcludeAgents: ["maple"], + }); + assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron"]); + assert.deepEqual(parsed.autoRecallExcludeAgents, ["maple"]); + }); +}); + +describe("mixed-agent scenarios", () => { + // Simulate the runtime logic for agent inclusion/exclusion + function shouldInjectMemory({ agentId, autoRecallIncludeAgents, autoRecallExcludeAgents }) { + if (agentId === undefined) return true; // no agent context, allow + + // autoRecallIncludeAgents takes precedence (whitelist mode) + if (Array.isArray(autoRecallIncludeAgents) && autoRecallIncludeAgents.length > 0) { + return autoRecallIncludeAgents.includes(agentId); + } + + // Fall back to exclude list (blacklist mode) + if (Array.isArray(autoRecallExcludeAgents) && autoRecallExcludeAgents.length > 0) { + return !autoRecallExcludeAgents.includes(agentId); + } + + return true; // no include/exclude configured, allow all + } + + it("whitelist mode: only included agents receive auto-recall", () => { + const cfg = { autoRecallIncludeAgents: ["saffron", "maple"] }; + assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), true); + assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), true); + assert.equal(shouldInjectMemory({ agentId: "matcha", ...cfg }), false); + assert.equal(shouldInjectMemory({ agentId: "cron-worker", ...cfg }), false); + }); + + it("blacklist mode: all agents except excluded receive auto-recall", () => { + const cfg = { autoRecallExcludeAgents: ["cron-worker", "matcha"] }; + assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), true); + assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), true); + assert.equal(shouldInjectMemory({ agentId: "cron-worker", ...cfg }), false); + assert.equal(shouldInjectMemory({ agentId: "matcha", ...cfg }), false); + }); + + it("whitelist takes precedence over blacklist when both set", () => { + const cfg = { autoRecallIncludeAgents: ["saffron"], autoRecallExcludeAgents: ["saffron", "maple"] }; + // Include wins — saffron is in include list + assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), true); + // Exclude is ignored because include is set + assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), false); + }); + + it("no include/exclude: all agents receive auto-recall", () => { + assert.equal(shouldInjectMemory({ agentId: "saffron" }), true); + assert.equal(shouldInjectMemory({ agentId: "maple" }), true); + assert.equal(shouldInjectMemory({ agentId: "matcha" }), true); + }); + + it("undefined agentId: allow auto-recall (no agent context)", () => { + const cfg = { autoRecallIncludeAgents: ["saffron"] }; + assert.equal(shouldInjectMemory({ agentId: undefined, ...cfg }), true); + }); + + it("empty include list treated as no include configured", () => { + const cfg = { autoRecallIncludeAgents: [], autoRecallExcludeAgents: ["saffron"] }; + // Empty include array = not configured, fall through to exclude + assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), false); + assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), true); + }); +});