fix(active-memory): allow active-memory to support custom recall tools (#77906)

* fix(active-memory): allow custom recall tools

* docs(active-memory): document custom recall tools

* docs(active-memory): note tools allowlist change

* fix(active-memory): constrain recall tool allowlist

* fix(active-memory): preserve lancedb recall defaults

* fix(active-memory): block non-memory recall tools

* fix(active-memory): satisfy bundled lint

* fix(active-memory): satisfy type-aware lint

* fix(tests): satisfy type-aware lint

* fix(tests): clear next type-aware lint batch

* fix(tests): clear lint and test type annotations

* docs(changelog): consolidate active memory entry

* docs(changelog): reclassify active memory tools entry
This commit is contained in:
Tak Hoffman
2026-05-08 17:12:48 -04:00
committed by GitHub
parent 2c7f2d3ac2
commit 2f26025085
17 changed files with 615 additions and 113 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Active Memory: support concrete `plugins.entries.active-memory.config.toolsAllow` recall tool names for custom memory plugins while keeping the built-in memory-core default on `memory_search`/`memory_get` and preserving `memory_recall` automatically for `plugins.slots.memory: "memory-lancedb"`.
- Telegram/Feishu: honor configured per-agent and global `reasoningDefault` values when deciding whether channel reasoning previews should stream or stay hidden, addressing the preview-default part of #73182. Thanks @anagnorisis2peripeteia.
- Docker: run the runtime image under `tini` so long-lived containers reap orphaned child processes and forward signals correctly. (#77885) Thanks @VintageAyu.
- Google/Gemini: normalize retired `google/gemini-3-pro-preview` and `google-gemini-cli/gemini-3-pro-preview` selections to `google/gemini-3.1-pro-preview` before they are written to model config.

View File

@@ -332,12 +332,16 @@ flowchart LR
I --> M["Main Reply"]
```
The blocking memory sub-agent can use only the available memory recall tools:
The blocking memory sub-agent can use only the configured memory recall tools.
By default that is:
- `memory_recall`
- `memory_search`
- `memory_get`
When `plugins.slots.memory` is `memory-lancedb`, the default is `memory_recall`
instead. Set `config.toolsAllow` when another memory provider exposes a
different recall tool contract.
If the connection is weak, it should return `NONE`.
## Query modes
@@ -462,6 +466,110 @@ skips recall for that turn.
`config.modelFallbackPolicy` is retained only as a deprecated compatibility
field for older configs. It no longer changes runtime behavior.
## Memory tools
By default Active Memory lets the blocking recall sub-agent call
`memory_search` and `memory_get`. That matches the built-in `memory-core`
contract. When `plugins.slots.memory` selects `memory-lancedb` and
`config.toolsAllow` is unset, Active Memory keeps the existing LanceDB behavior
and uses `memory_recall` instead.
If you use another memory plugin, set `config.toolsAllow` to the exact tool
names that plugin registers. Active Memory lists those tools in the recall
prompt and passes the same list to the embedded sub-agent. If none of the
configured tools are available, or the memory sub-agent fails, Active Memory
skips recall for that turn and the main reply continues without memory context.
`toolsAllow` only accepts concrete memory tool names. Wildcards, `group:*`
entries, and core agent tools such as `read`, `exec`, `message`, and
`web_search` are ignored before the hidden memory sub-agent starts.
Default-behavior note: Active Memory no longer includes `memory_recall` in the
memory-core default allowlist. Existing `memory-lancedb` setups keep working
when `plugins.slots.memory` is set to `memory-lancedb`. Explicit `toolsAllow`
always overrides the automatic default.
### Built-in memory-core
The default setup does not need an explicit `toolsAllow`:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
// Default: ["memory_search", "memory_get"]
},
},
},
},
}
```
### LanceDB memory
The bundled `memory-lancedb` plugin exposes `memory_recall`. Selecting the
memory slot is enough for Active Memory to use that recall tool:
```json5
{
plugins: {
slots: {
memory: "memory-lancedb",
},
entries: {
"memory-lancedb": {
enabled: true,
config: {
embedding: {
provider: "openai",
model: "text-embedding-3-small",
},
},
},
"active-memory": {
enabled: true,
config: {
agents: ["main"],
promptAppend: "Use memory_recall for long-term user preferences, past decisions, and previously discussed topics. If recall finds nothing useful, return NONE.",
},
},
},
},
}
```
### Lossless Claw
Lossless Claw is a context-engine plugin with its own recall tools. Install and
configure it as a context engine first; see [Context engine](/concepts/context-engine).
Then let Active Memory use the Lossless Claw recall tools:
```json5
{
plugins: {
entries: {
"lossless-claw": {
enabled: true,
},
"active-memory": {
enabled: true,
config: {
agents: ["main"],
toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"],
promptAppend: "Use lcm_grep first for compacted conversation recall. Use lcm_describe to inspect a specific summary. Use lcm_expand_query only when the latest user message needs exact details that may have been compacted away. Return NONE if the retrieved context is not clearly useful.",
},
},
},
},
}
```
Do not include `lcm_expand` in `toolsAllow` for the main Active Memory sub-agent.
Lossless Claw uses that as a lower-level delegated expansion tool.
## Advanced escape hatches
These options are intentionally not part of the recommended setup.
@@ -488,6 +596,9 @@ Memory prompt and before the conversation context:
promptAppend: "Prefer stable long-term preferences over one-off events."
```
Use `promptAppend` with custom `toolsAllow` when a non-core memory plugin needs
provider-specific tool order or query-shaping instructions.
`config.promptOverride` replaces the default Active Memory prompt. OpenClaw
still appends the conversation context afterward:
@@ -558,25 +669,26 @@ plugins.entries.active-memory
The most important fields are:
| Key | Type | Meaning |
| ---------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | Enables the plugin itself |
| `config.agents` | `string[]` | Agent ids that may use active memory |
| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model |
| `config.allowedChatTypes` | `("direct" \| "group" \| "channel")[]` | Session types that may run Active Memory; defaults to direct-message style sessions |
| `config.allowedChatIds` | `string[]` | Optional per-conversation allowlist applied after `allowedChatTypes`; non-empty lists fail closed |
| `config.deniedChatIds` | `string[]` | Optional per-conversation denylist that overrides allowed session types and allowed ids |
| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees |
| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory |
| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive" \| "max"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed |
| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use |
| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt |
| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent, capped at 120000 ms |
| `config.setupGraceTimeoutMs` | `number` | Advanced extra setup budget before the recall timeout expires; defaults to 0 and is capped at 30000 ms. See [Cold-start grace](#cold-start-grace) for v2026.4.x upgrade guidance |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.logging` | `boolean` | Emits active memory logs while tuning |
| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files |
| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder |
| Key | Type | Meaning |
| ---------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | Enables the plugin itself |
| `config.agents` | `string[]` | Agent ids that may use active memory |
| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model |
| `config.allowedChatTypes` | `("direct" \| "group" \| "channel")[]` | Session types that may run Active Memory; defaults to direct-message style sessions |
| `config.allowedChatIds` | `string[]` | Optional per-conversation allowlist applied after `allowedChatTypes`; non-empty lists fail closed |
| `config.deniedChatIds` | `string[]` | Optional per-conversation denylist that overrides allowed session types and allowed ids |
| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees |
| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory |
| `config.toolsAllow` | `string[]` | Concrete memory tool names the blocking memory sub-agent may call; defaults to `["memory_search", "memory_get"]`, or `["memory_recall"]` when `plugins.slots.memory` is `memory-lancedb`; wildcards, `group:*` entries, and core agent tools are ignored |
| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive" \| "max"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed |
| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use |
| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt |
| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent, capped at 120000 ms |
| `config.setupGraceTimeoutMs` | `number` | Advanced extra setup budget before the recall timeout expires; defaults to 0 and is capped at 30000 ms. See [Cold-start grace](#cold-start-grace) for v2026.4.x upgrade guidance |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.logging` | `boolean` | Emits active memory logs while tuning |
| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files |
| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder |
Useful tuning fields:
@@ -692,8 +804,9 @@ If active memory is too slow:
Active Memory rides on the configured memory plugin's recall pipeline, so most
recall surprises are embedding-provider problems, not Active Memory bugs. The
default `memory-core` path uses `memory_search`; `memory-lancedb` uses
`memory_recall`.
default `memory-core` path uses `memory_search` and `memory_get`; the
`memory-lancedb` slot uses `memory_recall`. If you use another memory plugin,
confirm `config.toolsAllow` names the tools that plugin actually registers.
<AccordionGroup>
<Accordion title="Embedding provider switched or stopped working">

View File

@@ -22,6 +22,34 @@ describe("active-memory manifest config schema", () => {
expect(result.ok).toBe(true);
});
it("accepts custom toolsAllow entries", () => {
const result = validateJsonSchemaValue({
schema: manifest.configSchema,
cacheKey: "active-memory.manifest.tools-allow",
value: {
enabled: true,
agents: ["main"],
toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"],
},
});
expect(result.ok).toBe(true);
});
it("rejects wildcard and group toolsAllow entries", () => {
const result = validateJsonSchemaValue({
schema: manifest.configSchema,
cacheKey: "active-memory.manifest.tools-allow.reserved",
value: {
enabled: true,
agents: ["main"],
toolsAllow: ["*", "group:plugins"],
},
});
expect(result.ok).toBe(false);
});
it("accepts timeoutMs values at the runtime ceiling", () => {
const result = validateJsonSchemaValue({
schema: manifest.configSchema,

View File

@@ -67,6 +67,19 @@ describe("active-memory plugin", () => {
},
};
};
const setMemorySlot = (memory: string) => {
const plugins = configFile.plugins as Record<string, unknown> | undefined;
configFile = {
...configFile,
plugins: {
...plugins,
slots: {
...(plugins?.slots as Record<string, unknown> | undefined),
memory,
},
},
};
};
const api: any = {
get pluginConfig() {
return pluginConfig;
@@ -147,7 +160,7 @@ describe("active-memory plugin", () => {
};
const makeMemoryToolAllowlistError = (
reason: string,
sources = "runtime toolsAllow: memory_recall, memory_search, memory_get",
sources = "runtime toolsAllow: memory_search, memory_get",
) =>
new Error(
`No callable tools remain after resolving explicit tool allowlist ` +
@@ -1285,16 +1298,17 @@ describe("active-memory plugin", () => {
);
expect(runParams?.prompt).toContain("Use only the available memory tools.");
expect(runParams?.prompt).toContain(
"Use the bounded search query as the memory_search or memory_recall query.",
"Use the bounded search query with the configured memory tools.",
);
expect(runParams?.prompt).toContain("Prefer memory_recall when available.");
expect(runParams?.prompt).toContain("Configured memory tools: memory_search, memory_get.");
expect(runParams?.prompt).toContain(
"If memory_recall is unavailable, use memory_search and memory_get.",
"If the available memory tools find nothing useful, reply with NONE.",
);
expect(runParams?.toolsAllow).toEqual(["memory_recall", "memory_search", "memory_get"]);
expect(runParams?.prompt).not.toContain("memory_recall");
expect(runParams?.toolsAllow).toEqual(["memory_search", "memory_get"]);
expect(runParams?.allowGatewaySubagentBinding).toBe(true);
expect(runParams?.prompt).toContain(
"When searching for preference or habit recall, use a permissive recall limit or memory_search threshold before deciding that no useful memory exists.",
"When searching for preference or habit recall, use permissive search limits or thresholds before deciding that no useful memory exists.",
);
expect(runParams?.prompt).toContain(
"If the user is directly asking about favorites, preferences, habits, routines, or personal facts, treat that as a strong recall signal.",
@@ -1318,6 +1332,187 @@ describe("active-memory plugin", () => {
);
});
it("passes custom configured memory tools and reflects them in the default prompt", async () => {
api.pluginConfig = {
agents: ["main"],
toolsAllow: [" lcm_grep ", "lcm_describe", "", "lcm_expand_query", "lcm_grep"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "What did we decide about active memory?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
expect(runParams?.toolsAllow).toEqual(["lcm_grep", "lcm_describe", "lcm_expand_query"]);
expect(runParams?.prompt).toContain(
"Configured memory tools: lcm_grep, lcm_describe, lcm_expand_query.",
);
expect(runParams?.prompt).not.toContain("Prefer memory_recall");
expect(runParams?.prompt).not.toContain("If memory_recall is unavailable");
});
it("uses memory_recall by default when the memory slot selects LanceDB", async () => {
setMemorySlot("memory-lancedb");
await hooks.before_prompt_build(
{
prompt: "What did we decide about active memory?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
expect(runParams?.toolsAllow).toEqual(["memory_recall"]);
expect(runParams?.prompt).toContain("Configured memory tools: memory_recall.");
});
it("keeps explicit custom memory tools authoritative when the memory slot selects LanceDB", async () => {
setMemorySlot("memory-lancedb");
api.pluginConfig = {
agents: ["main"],
toolsAllow: ["lcm_grep"],
};
await hooks.before_prompt_build(
{
prompt: "What did we decide about active memory?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
expect(runParams?.toolsAllow).toEqual(["lcm_grep"]);
expect(runParams?.prompt).toContain("Configured memory tools: lcm_grep.");
});
it("drops wildcard group and core tools from custom memory tools", async () => {
api.pluginConfig = {
agents: ["main"],
toolsAllow: [
"*",
"agents_list",
"apply_patch",
"canvas",
"cron",
"edit",
"gateway",
"heartbeat_respond",
"heartbeat_response",
"image",
"image_generate",
"music_generate",
"nodes",
"pdf",
"process",
"session_status",
"sessions_history",
"sessions_list",
"sessions_send",
"sessions_spawn",
"sessions_yield",
"tts",
"video_generate",
"group:plugins",
"read",
"exec",
"message",
"lcm_grep",
"web_search",
"lcm_describe",
],
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "What did we decide about active memory?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
expect(runParams?.toolsAllow).toEqual(["lcm_grep", "lcm_describe"]);
expect(runParams?.prompt).toContain("Configured memory tools: lcm_grep, lcm_describe.");
});
it("falls back to default memory tools when custom memory tools only contain reserved entries", async () => {
api.pluginConfig = {
agents: ["main"],
toolsAllow: ["*", "group:plugins", "read", "exec", "message", "web_search"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "What did we decide about active memory?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
expect(runParams?.toolsAllow).toEqual(["memory_search", "memory_get"]);
expect(runParams?.prompt).toContain("Configured memory tools: memory_search, memory_get.");
});
it("falls back to LanceDB compat tools when custom memory tools only contain reserved entries", async () => {
setMemorySlot("memory-lancedb");
api.pluginConfig = {
agents: ["main"],
toolsAllow: ["*", "group:plugins", "read", "exec", "message", "web_search"],
};
await hooks.before_prompt_build(
{
prompt: "What did we decide about active memory?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const runParams = runEmbeddedPiAgent.mock.calls.at(-1)?.[0];
expect(runParams?.toolsAllow).toEqual(["memory_recall"]);
expect(runParams?.prompt).toContain("Configured memory tools: memory_recall.");
});
it("defaults prompt style by query mode when no promptStyle is configured", async () => {
api.pluginConfig = {
agents: ["main"],
@@ -1871,7 +2066,7 @@ describe("active-memory plugin", () => {
);
expect(result).toBeUndefined();
expect(hasDebugLine("no memory tools registered")).toBe(true);
expect(hasDebugLine("no configured memory tools available")).toBe(true);
expect(hasWarnLine("No callable tools remain")).toBe(false);
const lines = getActiveMemoryLines(sessionKey);
expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=empty")]);
@@ -1886,7 +2081,7 @@ describe("active-memory plugin", () => {
};
const error = makeMemoryToolAllowlistError(
"no registered tools matched",
"tools.allow: *, lobster; runtime toolsAllow: memory_recall, memory_search, memory_get",
"tools.allow: *, lobster; runtime toolsAllow: memory_search, memory_get",
);
expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true);
runEmbeddedPiAgent.mockRejectedValueOnce(error);
@@ -1897,14 +2092,46 @@ describe("active-memory plugin", () => {
);
expect(result).toBeUndefined();
expect(hasDebugLine("no memory tools registered")).toBe(true);
expect(hasDebugLine("no configured memory tools available")).toBe(true);
expect(hasWarnLine("No callable tools remain")).toBe(false);
expect(getActiveMemoryLines(sessionKey)).toEqual([
expect.stringContaining("🧩 Active Memory: status=empty"),
]);
});
it("keeps memory-tool allowlist errors visible when upstream policy can filter memory tools", async () => {
it("skips missing custom memory tools using the resolved custom allowlist", async () => {
api.pluginConfig = {
agents: ["main"],
toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
const sessionKey = "agent:main:missing-custom-memory-tools";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-missing-custom-memory-tools",
updatedAt: 0,
};
const toolsAllow = ["lcm_grep", "lcm_describe", "lcm_expand_query"];
const error = makeMemoryToolAllowlistError(
"no registered tools matched",
`runtime toolsAllow: ${toolsAllow.join(", ")}`,
);
expect(__testing.isMissingRegisteredMemoryToolsError(error, toolsAllow)).toBe(true);
runEmbeddedPiAgent.mockRejectedValueOnce(error);
const result = await hooks.before_prompt_build(
{ prompt: "what did we decide? missing custom memory tools", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
expect(result).toBeUndefined();
expect(hasDebugLine("no configured memory tools available")).toBe(true);
expect(getActiveMemoryLines(sessionKey)).toEqual([
expect.stringContaining("🧩 Active Memory: status=empty"),
]);
});
it("skips memory-tool allowlist errors when upstream policy filters memory tools", async () => {
const sessionKey = "agent:main:memory-tools-filtered-by-policy";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-memory-tools-filtered-by-policy",
@@ -1912,9 +2139,9 @@ describe("active-memory plugin", () => {
};
const error = makeMemoryToolAllowlistError(
"no registered tools matched",
"tools.allow: read, exec; runtime toolsAllow: memory_recall, memory_search, memory_get",
"tools.allow: read, exec; runtime toolsAllow: memory_search, memory_get",
);
expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false);
expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true);
runEmbeddedPiAgent.mockRejectedValueOnce(error);
const result = await hooks.before_prompt_build(
@@ -1923,38 +2150,41 @@ describe("active-memory plugin", () => {
);
expect(result).toBeUndefined();
expect(hasDebugLine("no memory tools registered")).toBe(false);
expect(hasWarnLine("No callable tools remain")).toBe(true);
expect(hasDebugLine("no configured memory tools available")).toBe(true);
expect(hasWarnLine("No callable tools remain")).toBe(false);
expect(getActiveMemoryLines(sessionKey)).toEqual([
expect.stringContaining("🧩 Active Memory: status=unavailable"),
expect.stringContaining("🧩 Active Memory: status=empty"),
]);
});
it.each([
["disabled tools", "tools are disabled for this run"],
["models without tool support", "the selected model does not support tools"],
])("keeps allowlist errors for %s visible", async (_label, reason) => {
const sessionKey = `agent:main:${reason.replace(/\W+/g, "-")}`;
hoisted.sessionStore[sessionKey] = {
sessionId: `s-${reason.replace(/\W+/g, "-")}`,
updatedAt: 0,
};
const error = makeMemoryToolAllowlistError(reason);
expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false);
runEmbeddedPiAgent.mockRejectedValueOnce(error);
])(
"skips allowlist errors for %s without surfacing to the main thread",
async (_label, reason) => {
const sessionKey = `agent:main:${reason.replace(/\W+/g, "-")}`;
hoisted.sessionStore[sessionKey] = {
sessionId: `s-${reason.replace(/\W+/g, "-")}`,
updatedAt: 0,
};
const error = makeMemoryToolAllowlistError(reason);
expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false);
runEmbeddedPiAgent.mockRejectedValueOnce(error);
const result = await hooks.before_prompt_build(
{ prompt: `what wings should i order? ${reason}`, messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const result = await hooks.before_prompt_build(
{ prompt: `what wings should i order? ${reason}`, messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
expect(result).toBeUndefined();
expect(hasDebugLine("no memory tools registered")).toBe(false);
expect(hasWarnLine(reason)).toBe(true);
expect(getActiveMemoryLines(sessionKey)).toEqual([
expect.stringContaining("🧩 Active Memory: status=unavailable"),
]);
});
expect(result).toBeUndefined();
expect(hasDebugLine("no configured memory tools available")).toBe(false);
expect(hasWarnLine(reason)).toBe(true);
expect(getActiveMemoryLines(sessionKey)).toEqual([
expect.stringContaining("🧩 Active Memory: status=empty"),
]);
},
);
it("does not skip missing memory-tool allowlist errors after abort", async () => {
const sessionKey = "agent:main:missing-memory-tools-after-abort";
@@ -1976,7 +2206,7 @@ describe("active-memory plugin", () => {
);
expect(result).toBeUndefined();
expect(hasDebugLine("no memory tools registered")).toBe(false);
expect(hasDebugLine("no configured memory tools available")).toBe(false);
expect(getActiveMemoryLines(sessionKey)).toEqual([
expect.stringContaining("🧩 Active Memory: status=timeout"),
]);
@@ -2258,7 +2488,7 @@ describe("active-memory plugin", () => {
expect(getActiveMemoryLines(sessionKey).join("\n")).not.toContain("partial abort summary");
});
it("keeps generic subagent errors unavailable without using partial transcript output", async () => {
it("skips generic subagent errors without using partial transcript output", async () => {
api.pluginConfig = {
agents: ["main"],
persistTranscripts: true,
@@ -2287,7 +2517,7 @@ describe("active-memory plugin", () => {
expect(result).toBeUndefined();
expect(getActiveMemoryLines(sessionKey)).toEqual([
expect.stringContaining("🧩 Active Memory: status=unavailable"),
expect.stringContaining("🧩 Active Memory: status=empty"),
]);
expect(getActiveMemoryLines(sessionKey).join("\n")).not.toContain(
"must not be surfaced from generic errors",

View File

@@ -42,7 +42,43 @@ const DEFAULT_QMD_SEARCH_MODE = "search" as const;
const DEFAULT_TRANSCRIPT_DIR = "active-memory";
const DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS = 3;
const DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000;
const ACTIVE_MEMORY_TOOL_ALLOWLIST = ["memory_recall", "memory_search", "memory_get"] as const;
const DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW = ["memory_search", "memory_get"] as const;
const LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW = ["memory_recall"] as const;
const MAX_ACTIVE_MEMORY_TOOLS_ALLOW = 32;
const ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW = new Set([
"*",
"agents_list",
"apply_patch",
"browser",
"canvas",
"cron",
"edit",
"exec",
"gateway",
"heartbeat_respond",
"heartbeat_response",
"image",
"image_generate",
"message",
"music_generate",
"nodes",
"pdf",
"process",
"read",
"session_status",
"sessions_history",
"sessions_list",
"sessions_send",
"sessions_spawn",
"sessions_yield",
"subagents",
"tts",
"update_plan",
"video_generate",
"web_fetch",
"web_search",
"write",
]);
const TOGGLE_STATE_FILE = "session-toggles.json";
const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000;
const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000;
@@ -101,6 +137,7 @@ type ActiveRecallPluginConfig = {
| "recall-heavy"
| "precision-heavy"
| "preference-only";
toolsAllow?: string[];
promptOverride?: string;
promptAppend?: string;
timeoutMs?: number;
@@ -141,6 +178,7 @@ type ResolvedActiveRecallPluginConfig = {
| "recall-heavy"
| "precision-heavy"
| "preference-only";
toolsAllow: string[];
promptOverride?: string;
promptAppend?: string;
timeoutMs: number;
@@ -399,6 +437,46 @@ function normalizeChatIdList(value: unknown): string[] {
return out;
}
function normalizeConfiguredToolsAllow(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const seen = new Set<string>();
const out: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim();
if (!trimmed || isReservedActiveMemoryToolsAllowEntry(trimmed) || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
out.push(trimmed);
if (out.length >= MAX_ACTIVE_MEMORY_TOOLS_ALLOW) {
break;
}
}
return out.length > 0 ? out : undefined;
}
function isReservedActiveMemoryToolsAllowEntry(value: string): boolean {
const normalized = value.trim().toLowerCase();
return normalized.startsWith("group:") || ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW.has(normalized);
}
function resolveDefaultToolsAllow(cfg: OpenClawConfig | undefined): string[] {
return cfg?.plugins?.slots?.memory === "memory-lancedb"
? [...LANCEDB_ACTIVE_MEMORY_TOOLS_ALLOW]
: [...DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW];
}
function resolveToolsAllow(params: { pluginToolsAllow: unknown; cfg?: OpenClawConfig }): string[] {
return (
normalizeConfiguredToolsAllow(params.pluginToolsAllow) ?? resolveDefaultToolsAllow(params.cfg)
);
}
function normalizePromptConfigText(value: unknown): string | undefined {
const text = typeof value === "string" ? value.trim() : "";
return text ? text : undefined;
@@ -445,6 +523,13 @@ function resolvePersistentTranscriptBaseDir(api: OpenClawPluginApi, agentId: str
);
}
function requireTransientWorkspaceDir(tempDir: string | undefined): string {
if (!tempDir) {
throw new Error("Active memory transient workspace was not initialized.");
}
return tempDir;
}
function resolveCanonicalSessionKeyFromSessionId(params: {
api: OpenClawPluginApi;
agentId: string;
@@ -497,7 +582,14 @@ function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function isMissingRegisteredMemoryToolsError(error: unknown): boolean {
function formatRuntimeToolsAllowSource(toolsAllow: readonly string[]): string {
return `runtime toolsAllow: ${toolsAllow.join(", ")}`;
}
function isMissingRegisteredMemoryToolsError(
error: unknown,
toolsAllow: readonly string[] = DEFAULT_ACTIVE_MEMORY_TOOLS_ALLOW,
): boolean {
if (!(error instanceof Error)) {
return false;
}
@@ -509,24 +601,12 @@ function isMissingRegisteredMemoryToolsError(error: unknown): boolean {
return false;
}
const sources = message.slice(prefix.length, -suffix.length);
const runtimeSource = `runtime toolsAllow: ${ACTIVE_MEMORY_TOOL_ALLOWLIST.join(", ")}`;
const runtimeSource = formatRuntimeToolsAllowSource(toolsAllow);
const sourceParts = sources
.split(";")
.map((source) => source.trim())
.filter(Boolean);
if (!sourceParts.includes(runtimeSource)) {
return false;
}
return sourceParts.every((source) => {
if (source === runtimeSource) {
return true;
}
const entries = source
.slice(source.indexOf(":") + 1)
.split(",")
.map((entry) => entry.trim());
return entries.includes("*");
});
return sourceParts.includes(runtimeSource);
}
function resolveRecallRunChannelContext(params: {
@@ -791,7 +871,10 @@ function requiresAdminToMutateActiveMemoryGlobal(gatewayClientScopes?: readonly
const ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT =
"⚠️ /active-memory global enable/disable changes require operator.admin for gateway clients.";
function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPluginConfig {
function normalizePluginConfig(
pluginConfig: unknown,
cfg?: OpenClawConfig,
): ResolvedActiveRecallPluginConfig {
const raw = (
pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {}
) as ActiveRecallPluginConfig;
@@ -819,6 +902,7 @@ function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPlugi
deniedChatIds: normalizeChatIdList(raw.deniedChatIds),
thinking: resolveThinkingLevel(raw.thinking),
promptStyle: resolvePromptStyle(raw.promptStyle, raw.queryMode),
toolsAllow: resolveToolsAllow({ pluginToolsAllow: raw.toolsAllow, cfg }),
promptOverride: normalizePromptConfigText(raw.promptOverride),
promptAppend: normalizePromptConfigText(raw.promptAppend),
timeoutMs: clampInt(
@@ -990,11 +1074,11 @@ function buildRecallPrompt(params: {
"Your job is to search memory and return only the most relevant memory context for that model.",
"You receive a bounded search query plus conversation context, including the user's latest message.",
"Use only the available memory tools.",
"Use the bounded search query as the memory_search or memory_recall query.",
"Use the bounded search query with the configured memory tools.",
`Configured memory tools: ${params.config.toolsAllow.join(", ")}.`,
"Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.",
"Prefer memory_recall when available.",
"If memory_recall is unavailable, use memory_search and memory_get.",
"When searching for preference or habit recall, use a permissive recall limit or memory_search threshold before deciding that no useful memory exists.",
"If the available memory tools find nothing useful, reply with NONE.",
"When searching for preference or habit recall, use permissive search limits or thresholds before deciding that no useful memory exists.",
"Do not answer the user directly.",
`Prompt style: ${params.config.promptStyle}.`,
...buildPromptStyleLines(params.config.promptStyle),
@@ -2398,9 +2482,10 @@ async function runRecallSubagent(params: {
params.config.transcriptDir,
)
: undefined;
const sessionFile = params.config.persistTranscripts
? path.join(persistedDir!, `${subagentSessionId}.jsonl`)
: path.join(tempDir!, "session.jsonl");
const sessionFile =
persistedDir !== undefined
? path.join(persistedDir, `${subagentSessionId}.jsonl`)
: path.join(requireTransientWorkspaceDir(tempDir), "session.jsonl");
params.onSessionFile?.(sessionFile);
if (persistedDir) {
await fs.mkdir(persistedDir, { recursive: true, mode: 0o700 });
@@ -2439,7 +2524,7 @@ async function runRecallSubagent(params: {
timeoutMs: embeddedTimeoutMs,
runId: subagentSessionId,
trigger: "manual",
toolsAllow: [...ACTIVE_MEMORY_TOOL_ALLOWLIST],
toolsAllow: [...params.config.toolsAllow],
disableMessageTool: true,
allowGatewaySubagentBinding: true,
bootstrapContextMode: "lightweight",
@@ -2482,9 +2567,19 @@ async function runRecallSubagent(params: {
const searchDebug = partialReply ? await readActiveMemorySearchDebug(sessionFile) : undefined;
attachPartialTimeoutData(error, partialReply, searchDebug);
}
if (!params.abortSignal?.aborted && isMissingRegisteredMemoryToolsError(error)) {
if (
!params.abortSignal?.aborted &&
isMissingRegisteredMemoryToolsError(error, params.config.toolsAllow)
) {
params.api.logger.debug?.(
`active-memory: no memory tools registered (memory-core or memory-lancedb required); skipping sub-agent`,
`active-memory: no configured memory tools available; skipping sub-agent`,
);
return { rawReply: "NONE" };
}
if (!params.abortSignal?.aborted) {
const message = toSingleLineLogValue(error instanceof Error ? error.message : String(error));
params.api.logger.warn?.(
`active-memory: memory sub-agent failed, skipping recall: ${message}`,
);
return { rawReply: "NONE" };
}
@@ -2751,10 +2846,10 @@ async function maybeResolveActiveRecall(params: {
}
const message = toSingleLineLogValue(error instanceof Error ? error.message : String(error));
if (params.config.logging) {
params.api.logger.warn?.(`${logPrefix} failed error=${message}`);
params.api.logger.warn?.(`${logPrefix} failed error=${message}; skipping recall`);
}
const result: ActiveRecallResult = {
status: "unavailable",
status: "empty",
elapsedMs: Date.now() - startedAt,
summary: null,
};
@@ -2777,7 +2872,17 @@ export default definePluginEntry({
name: "Active Memory",
description: "Proactively surfaces relevant memory before eligible conversational replies.",
register(api: OpenClawPluginApi) {
let config = normalizePluginConfig(api.pluginConfig);
const readCurrentConfig = (): OpenClawConfig | undefined => {
try {
return (
(api.runtime.config?.current?.() as OpenClawConfig | undefined) ??
(api.config as OpenClawConfig | undefined)
);
} catch {
return api.config as OpenClawConfig | undefined;
}
};
let config = normalizePluginConfig(api.pluginConfig, readCurrentConfig());
const warnDeprecatedModelFallbackPolicy = (pluginConfig: unknown) => {
if (hasDeprecatedModelFallbackPolicy(pluginConfig)) {
// Wording matters here: the previous text ("set config.modelFallback
@@ -2805,7 +2910,7 @@ export default definePluginEntry({
"active-memory",
api.pluginConfig as Record<string, unknown>,
);
config = normalizePluginConfig(livePluginConfig ?? { enabled: false });
config = normalizePluginConfig(livePluginConfig ?? { enabled: false }, readCurrentConfig());
if (livePluginConfig) {
warnDeprecatedModelFallbackPolicy(livePluginConfig);
}

View File

@@ -56,6 +56,14 @@
"preference-only"
]
},
"toolsAllow": {
"type": "array",
"items": {
"type": "string",
"pattern": "^(?!\\*$)(?![Gg][Rr][Oo][Uu][Pp]:).+"
},
"maxItems": 32
},
"promptOverride": { "type": "string" },
"promptAppend": { "type": "string" },
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
@@ -129,6 +137,10 @@
"label": "Prompt Style",
"help": "Choose how eager or strict the blocking memory sub-agent should be when deciding whether to return memory."
},
"toolsAllow": {
"label": "Allowed Memory Tools",
"help": "Advanced: tool names the blocking memory sub-agent may use. Defaults to memory_search and memory_get, or memory_recall when plugins.slots.memory selects memory-lancedb; configure this for other non-core memory providers. Wildcards, group entries, and core agent tools are ignored."
},
"thinking": {
"label": "Thinking Override",
"help": "Advanced: optional thinking level for the blocking memory sub-agent. Defaults to off for speed."

View File

@@ -151,7 +151,7 @@ describe("discord channel message adapter", () => {
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "discordMessageAdapter",
adapter: adapter,
adapter,
proofs: {
text: proveText,
media: proveMedia,

View File

@@ -44,8 +44,9 @@ describe("matrix channel message adapter", () => {
it("backs declared durable-final capabilities with runtime outbound proofs", async () => {
const adapter = matrixPlugin.message;
if (adapter?.send?.text === undefined || adapter.send.media === undefined) {
throw new Error("expected matrix text and media message adapter");
expect(adapter).toBeDefined();
if (!adapter?.send?.text || !adapter.send.media) {
throw new Error("Expected Matrix message adapter send capabilities.");
}
const sendText = adapter.send.text;
const sendMedia = adapter.send.media;
@@ -93,7 +94,7 @@ describe("matrix channel message adapter", () => {
const proveReplyThread = async () => {
mocks.sendMessageMatrix.mockClear();
const result = await adapter.send!.text!({
const result = await adapter.send.text({
cfg,
to: "room:!room:example",
text: "threaded",
@@ -116,14 +117,14 @@ describe("matrix channel message adapter", () => {
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "matrixMessageAdapter",
adapter: adapter,
adapter,
proofs: {
text: proveText,
media: proveMedia,
replyTo: proveReplyThread,
thread: proveReplyThread,
messageSendingHooks: () => {
expect(adapter.send!.text).toBeTypeOf("function");
expect(adapter.send?.text).toBeTypeOf("function");
},
},
});

View File

@@ -132,7 +132,7 @@ describe("mattermost channel message adapter", () => {
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "mattermostMessageAdapter",
adapter: adapter,
adapter,
proofs: {
text: proveText,
media: proveMedia,

View File

@@ -114,7 +114,7 @@ describe("tlon channel message adapter", () => {
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "tlonMessageAdapter",
adapter: adapter,
adapter,
proofs: {
text: proveText,
media: proveMedia,

View File

@@ -754,6 +754,9 @@ describe("web auto-reply connection", () => {
timestamp: 1735689600000,
spies,
});
if (!capturedOnMessage) {
throw new Error("Expected WhatsApp web runtime to register onMessage.");
}
await sendWebDirectInboundMessage({
onMessage: capturedOnMessage,
body: "second",

View File

@@ -509,8 +509,9 @@ describe("CLI attempt execution", () => {
embeddedAssistantGapFill: true,
});
const sessionFile = updatedFirst?.sessionFile;
expect(sessionFile).toBeTruthy();
if (!sessionFile) {
throw new Error("expected embedded gap-fill persistence to create a session file");
throw new Error("Expected CLI transcript session file.");
}
await appendSessionTranscriptMessage({

View File

@@ -113,8 +113,9 @@ const createCompactionHandler = () => {
}),
} as unknown as ExtensionAPI;
compactionSafeguardExtension(mockApi);
expect(compactionHandler).toBeDefined();
if (!compactionHandler) {
throw new Error("expected compaction safeguard handler");
throw new Error("Expected compaction safeguard to register a handler.");
}
return compactionHandler;
};

View File

@@ -66,8 +66,13 @@ describe("createHostWorkspaceEditTool host access mapping", () => {
// library replaces any access error with a misleading "File not found".
// By resolving silently the subsequent readFile call surfaces the real
// "Path escapes workspace root" / "outside-workspace" error instead.
const operations = mocks.operations;
expect(operations).toBeDefined();
if (!operations) {
throw new Error("Expected workspace edit operations to be registered.");
}
await expect(
mocks.operations.access(path.join(workspaceDir, "escape", "secret.txt")),
operations.access(path.join(workspaceDir, "escape", "secret.txt")),
).resolves.toBeUndefined();
},
);

View File

@@ -210,10 +210,10 @@ describe("sessions_spawn tool", () => {
},
});
const schema = tool.parameters as {
properties?: {
thread?: { description?: string; enum?: string[]; type?: string };
mode?: { description?: string; enum?: string[]; type?: string };
};
properties?: Record<
string,
{ description?: string; enum?: string[]; type?: string } | undefined
>;
};
expect(schema.properties?.thread).toBeUndefined();
@@ -236,10 +236,10 @@ describe("sessions_spawn tool", () => {
},
});
const schema = tool.parameters as {
properties?: {
thread?: { description?: string; enum?: string[]; type?: string };
mode?: { description?: string; enum?: string[]; type?: string };
};
properties?: Record<
string,
{ description?: string; enum?: string[]; type?: string } | undefined
>;
};
const thread = requireSchemaProperty(schema.properties, "thread");

View File

@@ -236,8 +236,10 @@ describe("backup commands", () => {
const stateAsset = result.assets.find((asset) => asset.kind === "state");
const workspaceAsset = result.assets.find((asset) => asset.kind === "workspace");
expect(stateAsset).toBeDefined();
expect(workspaceAsset).toBeDefined();
if (!stateAsset || !workspaceAsset) {
throw new Error("expected state and workspace backup assets");
throw new Error("Expected backup assets to include state and workspace entries.");
}
expect(capturedEntryPaths).toHaveLength(result.assets.length + 1);

View File

@@ -141,7 +141,7 @@ function findSseEvent(events: SseEvent[], eventName: string): SseEvent {
}
function parseSseData(event: SseEvent): unknown {
return JSON.parse(event.data);
return JSON.parse(event.data) as unknown;
}
function requireSessionKey(value: string | undefined, label: string): string {