diff --git a/index.ts b/index.ts index 141bf2c8..36cc0e14 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"; @@ -2291,18 +2293,29 @@ const memoryLanceDBProPlugin = { const sessionKey = typeof ctx.sessionKey === "string" ? ctx.sessionKey : ""; if (sessionKey.includes(":subagent:")) return; - // 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 @@ -3997,6 +4010,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 bf274e03..2639dc00 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -4,7 +4,9 @@ "description": "Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, long-context chunking, and management CLI", "version": "1.1.0-beta.10", "kind": "memory", - "skills": ["./skills"], + "skills": [ + "./skills" + ], "configSchema": { "type": "object", "additionalProperties": false, @@ -854,7 +856,10 @@ } } } - } + }, + "required": [ + "embedding" + ] }, "uiHints": { "embedding.apiKey": { @@ -1376,6 +1381,16 @@ "label": "Max Extractions Per Hour", "help": "Rate limit for auto-capture extractions. Prevents excessive LLM calls during rapid-fire sessions.", "advanced": true + }, + "autoRecallExcludeAgents": { + "label": "Auto-Recall Excluded Agents", + "help": "Agent IDs excluded from auto-recall injection. Cannot be used with autoRecallIncludeAgents.", + "advanced": true + }, + "autoRecallIncludeAgents": { + "label": "Auto-Recall Included Agents", + "help": "Whitelist mode: only these agents receive auto-recall. Overrides autoRecallExcludeAgents.", + "advanced": true } } } diff --git a/package.json b/package.json index 02610d5d..d069fd84 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "author": "win4r", "license": "MIT", "scripts": { - "test": "node scripts/verify-ci-test-manifest.mjs && npm run test:cli-smoke && npm run test:core-regression && npm run test:storage-and-schema && npm run test:llm-clients-and-auth && npm run test:packaging-and-workflow", + "test": "node test/embedder-error-hints.test.mjs && node test/cjk-recursion-regression.test.mjs && node test/migrate-legacy-schema.test.mjs && node --test test/config-session-strategy-migration.test.mjs && node --test test/scope-access-undefined.test.mjs && node --test test/reflection-bypass-hook.test.mjs && node --test test/smart-extractor-scope-filter.test.mjs && node --test test/store-empty-scope-filter.test.mjs && node --test test/recall-text-cleanup.test.mjs && node test/update-consistency-lancedb.test.mjs && node --test test/strip-envelope-metadata.test.mjs && node test/cli-smoke.mjs && node test/functional-e2e.mjs && node test/per-agent-auto-recall.test.mjs && node test/retriever-rerank-regression.mjs && node test/smart-memory-lifecycle.mjs && node test/smart-extractor-branches.mjs && node test/plugin-manifest-regression.mjs && node --test test/session-summary-before-reset.test.mjs && node --test test/sync-plugin-version.test.mjs && node test/smart-metadata-v2.mjs && node test/vector-search-cosine.test.mjs && node test/context-support-e2e.mjs && node test/temporal-facts.test.mjs && node test/memory-update-supersede.test.mjs && node test/memory-upgrader-diagnostics.test.mjs && node --test test/llm-api-key-client.test.mjs && node --test test/llm-oauth-client.test.mjs && node --test test/cli-oauth-login.test.mjs && node --test test/workflow-fork-guards.test.mjs && node --test test/clawteam-scope.test.mjs && node --test test/cross-process-lock.test.mjs && node --test test/preference-slots.test.mjs && node test/is-latest-auto-supersede.test.mjs && node --test test/temporal-awareness.test.mjs", "test:cli-smoke": "node scripts/run-ci-tests.mjs --group cli-smoke", "test:core-regression": "node scripts/run-ci-tests.mjs --group core-regression", "test:storage-and-schema": "node scripts/run-ci-tests.mjs --group storage-and-schema", @@ -62,4 +62,4 @@ "jiti": "^2.6.0", "typescript": "^5.9.3" } -} \ No newline at end of file +} 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); + }); +});