Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
193 changes: 193 additions & 0 deletions test/per-agent-auto-recall.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading