Skip to content

Commit 6da96df

Browse files
author
Miso
committed
feat: add per-agent auto-recall control (autoRecallIncludeAgents)
Implement per-agent auto-recall control as suggested in #474: - Add autoRecallIncludeAgents config option (whitelist mode): when set, ONLY these agents receive auto-recall injection - autoRecallExcludeAgents was already implemented in logic but missing from the plugin schema — add it to configSchema so users can configure it - autoRecallIncludeAgents takes precedence over autoRecallExcludeAgents when both are set - Include schema definitions for both fields - Add unit tests covering parsing, filtering, and mixed-agent runtime logic - Tests: 19 passing
1 parent 263af0d commit 6da96df

3 files changed

Lines changed: 235 additions & 11 deletions

File tree

index.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ interface PluginConfig {
113113
recallMode?: "full" | "summary" | "adaptive" | "off";
114114
/** 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. */
115115
autoRecallExcludeAgents?: string[];
116+
/** Agent IDs included in auto-recall injection (whitelist mode). When set, ONLY these agents receive auto-recall. Cannot be used together with autoRecallExcludeAgents. */
117+
autoRecallIncludeAgents?: string[];
116118
captureAssistant?: boolean;
117119
retrieval?: {
118120
mode?: "hybrid" | "vector";
@@ -2309,18 +2311,28 @@ const memoryLanceDBProPlugin = {
23092311

23102312
const AUTO_RECALL_TIMEOUT_MS = parsePositiveInt(config.autoRecallTimeoutMs) ?? 5_000; // configurable; default raised from 3s to 5s for remote embedding APIs behind proxies
23112313
api.on("before_prompt_build", async (event: any, ctx: any) => {
2312-
// Per-agent exclusion: skip auto-recall for agents in the exclusion list.
2314+
// Per-agent inclusion/exclusion: autoRecallIncludeAgents takes precedence over autoRecallExcludeAgents.
2315+
// - If autoRecallIncludeAgents is set: ONLY these agents receive auto-recall
2316+
// - Else if autoRecallExcludeAgents is set: all agents EXCEPT these receive auto-recall
23132317
const agentId = resolveHookAgentId(ctx?.agentId, (event as any).sessionKey);
2314-
if (
2315-
Array.isArray(config.autoRecallExcludeAgents) &&
2316-
config.autoRecallExcludeAgents.length > 0 &&
2317-
agentId !== undefined &&
2318-
config.autoRecallExcludeAgents.includes(agentId)
2319-
) {
2320-
api.logger.debug?.(
2321-
`memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}'`,
2322-
);
2323-
return;
2318+
if (agentId !== undefined) {
2319+
if (Array.isArray(config.autoRecallIncludeAgents) && config.autoRecallIncludeAgents.length > 0) {
2320+
if (!config.autoRecallIncludeAgents.includes(agentId)) {
2321+
api.logger.debug?.(
2322+
`memory-lancedb-pro: auto-recall skipped for agent '${agentId}' not in autoRecallIncludeAgents`,
2323+
);
2324+
return;
2325+
}
2326+
} else if (
2327+
Array.isArray(config.autoRecallExcludeAgents) &&
2328+
config.autoRecallExcludeAgents.length > 0 &&
2329+
config.autoRecallExcludeAgents.includes(agentId)
2330+
) {
2331+
api.logger.debug?.(
2332+
`memory-lancedb-pro: auto-recall skipped for excluded agent '${agentId}'`,
2333+
);
2334+
return;
2335+
}
23242336
}
23252337

23262338
// Manually increment turn counter for this session
@@ -3969,6 +3981,9 @@ export function parsePluginConfig(value: unknown): PluginConfig {
39693981
autoRecallExcludeAgents: Array.isArray(cfg.autoRecallExcludeAgents)
39703982
? cfg.autoRecallExcludeAgents.filter((id: unknown): id is string => typeof id === "string" && id.trim() !== "")
39713983
: undefined,
3984+
autoRecallIncludeAgents: Array.isArray(cfg.autoRecallIncludeAgents)
3985+
? cfg.autoRecallIncludeAgents.filter((id: unknown): id is string => typeof id === "string" && id.trim() !== "")
3986+
: undefined,
39723987
captureAssistant: cfg.captureAssistant === true,
39733988
retrieval:
39743989
typeof cfg.retrieval === "object" && cfg.retrieval !== null

openclaw.plugin.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,22 @@
164164
"default": "full",
165165
"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."
166166
},
167+
"autoRecallExcludeAgents": {
168+
"type": "array",
169+
"items": {
170+
"type": "string"
171+
},
172+
"default": [],
173+
"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."
174+
},
175+
"autoRecallIncludeAgents": {
176+
"type": "array",
177+
"items": {
178+
"type": "string"
179+
},
180+
"default": [],
181+
"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."
182+
},
167183
"captureAssistant": {
168184
"type": "boolean"
169185
},
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
import jitiFactory from "jiti";
6+
7+
const testDir = path.dirname(fileURLToPath(import.meta.url));
8+
const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs");
9+
const jiti = jitiFactory(import.meta.url, {
10+
interopDefault: true,
11+
alias: {
12+
"openclaw/plugin-sdk": pluginSdkStubPath,
13+
},
14+
});
15+
const { parsePluginConfig } = jiti("../index.ts");
16+
17+
function baseConfig() {
18+
return {
19+
embedding: {
20+
apiKey: "test-api-key",
21+
},
22+
};
23+
}
24+
25+
describe("autoRecallExcludeAgents", () => {
26+
it("defaults to undefined when not specified", () => {
27+
const parsed = parsePluginConfig(baseConfig());
28+
assert.equal(parsed.autoRecallExcludeAgents, undefined);
29+
});
30+
31+
it("parses a valid array of agent IDs", () => {
32+
const parsed = parsePluginConfig({
33+
...baseConfig(),
34+
autoRecallExcludeAgents: ["saffron", "maple", "matcha"],
35+
});
36+
assert.deepEqual(parsed.autoRecallExcludeAgents, ["saffron", "maple", "matcha"]);
37+
});
38+
39+
it("filters out non-string entries", () => {
40+
const parsed = parsePluginConfig({
41+
...baseConfig(),
42+
autoRecallExcludeAgents: ["saffron", null, 123, "maple", undefined, ""],
43+
});
44+
assert.deepEqual(parsed.autoRecallExcludeAgents, ["saffron", "maple"]);
45+
});
46+
47+
it("filters out whitespace-only strings", () => {
48+
const parsed = parsePluginConfig({
49+
...baseConfig(),
50+
autoRecallExcludeAgents: ["saffron", " ", "\t", "maple"],
51+
});
52+
assert.deepEqual(parsed.autoRecallExcludeAgents, ["saffron", "maple"]);
53+
});
54+
55+
it("returns empty array for empty array input (not undefined)", () => {
56+
const parsed = parsePluginConfig({
57+
...baseConfig(),
58+
autoRecallExcludeAgents: [],
59+
});
60+
// Empty array stays as [] — falsy check via length is the right way to handle
61+
assert.ok(Array.isArray(parsed.autoRecallExcludeAgents));
62+
assert.equal(parsed.autoRecallExcludeAgents.length, 0);
63+
});
64+
65+
it("handles single agent ID", () => {
66+
const parsed = parsePluginConfig({
67+
...baseConfig(),
68+
autoRecallExcludeAgents: ["cron-worker"],
69+
});
70+
assert.deepEqual(parsed.autoRecallExcludeAgents, ["cron-worker"]);
71+
});
72+
});
73+
74+
describe("autoRecallIncludeAgents", () => {
75+
it("defaults to undefined when not specified", () => {
76+
const parsed = parsePluginConfig(baseConfig());
77+
assert.equal(parsed.autoRecallIncludeAgents, undefined);
78+
});
79+
80+
it("parses a valid array of agent IDs", () => {
81+
const parsed = parsePluginConfig({
82+
...baseConfig(),
83+
autoRecallIncludeAgents: ["saffron", "maple"],
84+
});
85+
assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron", "maple"]);
86+
});
87+
88+
it("filters out non-string entries", () => {
89+
const parsed = parsePluginConfig({
90+
...baseConfig(),
91+
autoRecallIncludeAgents: ["saffron", null, 123, "maple"],
92+
});
93+
assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron", "maple"]);
94+
});
95+
96+
it("filters out whitespace-only strings", () => {
97+
const parsed = parsePluginConfig({
98+
...baseConfig(),
99+
autoRecallIncludeAgents: ["saffron", " ", "maple"],
100+
});
101+
assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron", "maple"]);
102+
});
103+
104+
it("returns empty array for empty array input (not undefined)", () => {
105+
const parsed = parsePluginConfig({
106+
...baseConfig(),
107+
autoRecallIncludeAgents: [],
108+
});
109+
assert.ok(Array.isArray(parsed.autoRecallIncludeAgents));
110+
assert.equal(parsed.autoRecallIncludeAgents.length, 0);
111+
});
112+
113+
it("handles single agent ID (whitelist mode)", () => {
114+
const parsed = parsePluginConfig({
115+
...baseConfig(),
116+
autoRecallIncludeAgents: ["sage"],
117+
});
118+
assert.deepEqual(parsed.autoRecallIncludeAgents, ["sage"]);
119+
});
120+
121+
it("include takes precedence over exclude in parsing (both specified)", () => {
122+
// Note: logic precedence is handled at runtime in before_prompt_build,
123+
// not in the config parser. Parser accepts both.
124+
const parsed = parsePluginConfig({
125+
...baseConfig(),
126+
autoRecallIncludeAgents: ["saffron"],
127+
autoRecallExcludeAgents: ["maple"],
128+
});
129+
assert.deepEqual(parsed.autoRecallIncludeAgents, ["saffron"]);
130+
assert.deepEqual(parsed.autoRecallExcludeAgents, ["maple"]);
131+
});
132+
});
133+
134+
describe("mixed-agent scenarios", () => {
135+
// Simulate the runtime logic for agent inclusion/exclusion
136+
function shouldInjectMemory({ agentId, autoRecallIncludeAgents, autoRecallExcludeAgents }) {
137+
if (agentId === undefined) return true; // no agent context, allow
138+
139+
// autoRecallIncludeAgents takes precedence (whitelist mode)
140+
if (Array.isArray(autoRecallIncludeAgents) && autoRecallIncludeAgents.length > 0) {
141+
return autoRecallIncludeAgents.includes(agentId);
142+
}
143+
144+
// Fall back to exclude list (blacklist mode)
145+
if (Array.isArray(autoRecallExcludeAgents) && autoRecallExcludeAgents.length > 0) {
146+
return !autoRecallExcludeAgents.includes(agentId);
147+
}
148+
149+
return true; // no include/exclude configured, allow all
150+
}
151+
152+
it("whitelist mode: only included agents receive auto-recall", () => {
153+
const cfg = { autoRecallIncludeAgents: ["saffron", "maple"] };
154+
assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), true);
155+
assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), true);
156+
assert.equal(shouldInjectMemory({ agentId: "matcha", ...cfg }), false);
157+
assert.equal(shouldInjectMemory({ agentId: "cron-worker", ...cfg }), false);
158+
});
159+
160+
it("blacklist mode: all agents except excluded receive auto-recall", () => {
161+
const cfg = { autoRecallExcludeAgents: ["cron-worker", "matcha"] };
162+
assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), true);
163+
assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), true);
164+
assert.equal(shouldInjectMemory({ agentId: "cron-worker", ...cfg }), false);
165+
assert.equal(shouldInjectMemory({ agentId: "matcha", ...cfg }), false);
166+
});
167+
168+
it("whitelist takes precedence over blacklist when both set", () => {
169+
const cfg = { autoRecallIncludeAgents: ["saffron"], autoRecallExcludeAgents: ["saffron", "maple"] };
170+
// Include wins — saffron is in include list
171+
assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), true);
172+
// Exclude is ignored because include is set
173+
assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), false);
174+
});
175+
176+
it("no include/exclude: all agents receive auto-recall", () => {
177+
assert.equal(shouldInjectMemory({ agentId: "saffron" }), true);
178+
assert.equal(shouldInjectMemory({ agentId: "maple" }), true);
179+
assert.equal(shouldInjectMemory({ agentId: "matcha" }), true);
180+
});
181+
182+
it("undefined agentId: allow auto-recall (no agent context)", () => {
183+
const cfg = { autoRecallIncludeAgents: ["saffron"] };
184+
assert.equal(shouldInjectMemory({ agentId: undefined, ...cfg }), true);
185+
});
186+
187+
it("empty include list treated as no include configured", () => {
188+
const cfg = { autoRecallIncludeAgents: [], autoRecallExcludeAgents: ["saffron"] };
189+
// Empty include array = not configured, fall through to exclude
190+
assert.equal(shouldInjectMemory({ agentId: "saffron", ...cfg }), false);
191+
assert.equal(shouldInjectMemory({ agentId: "maple", ...cfg }), true);
192+
});
193+
});

0 commit comments

Comments
 (0)