From 2f26025085112c182796711a4e55f5cfbe38fe61 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 8 May 2026 17:12:48 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + docs/concepts/active-memory.md | 159 +++++++-- extensions/active-memory/config.test.ts | 28 ++ extensions/active-memory/index.test.ts | 306 +++++++++++++++--- extensions/active-memory/index.ts | 167 ++++++++-- extensions/active-memory/openclaw.plugin.json | 12 + .../src/channel.message-adapter.test.ts | 2 +- .../src/channel.message-adapter.test.ts | 11 +- .../src/channel.message-adapter.test.ts | 2 +- .../tlon/src/channel.message-adapter.test.ts | 2 +- ...o-reply.connection-and-logging.e2e.test.ts | 3 + .../command/attempt-execution.cli.test.ts | 3 +- .../pi-hooks/compaction-safeguard.test.ts | 3 +- .../pi-tools.read.host-edit-access.test.ts | 7 +- src/agents/tools/sessions-spawn-tool.test.ts | 16 +- src/commands/backup.test.ts | 4 +- src/gateway/openresponses-http.test.ts | 2 +- 17 files changed, 615 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeac2f8b820..7fe061bbfb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index ded9c3bb3aa..8d3844e32cd 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -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. diff --git a/extensions/active-memory/config.test.ts b/extensions/active-memory/config.test.ts index 1b9aa512ebd..eabbb7e42f7 100644 --- a/extensions/active-memory/config.test.ts +++ b/extensions/active-memory/config.test.ts @@ -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, diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 98c3d1b82be..07ba5d7213f 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -67,6 +67,19 @@ describe("active-memory plugin", () => { }, }; }; + const setMemorySlot = (memory: string) => { + const plugins = configFile.plugins as Record | undefined; + configFile = { + ...configFile, + plugins: { + ...plugins, + slots: { + ...(plugins?.slots as Record | 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", diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index ec14a051546..8bb40fcc138 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -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(); + 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, ); - config = normalizePluginConfig(livePluginConfig ?? { enabled: false }); + config = normalizePluginConfig(livePluginConfig ?? { enabled: false }, readCurrentConfig()); if (livePluginConfig) { warnDeprecatedModelFallbackPolicy(livePluginConfig); } diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json index a19b28a9820..cfcc47b1de3 100644 --- a/extensions/active-memory/openclaw.plugin.json +++ b/extensions/active-memory/openclaw.plugin.json @@ -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." diff --git a/extensions/discord/src/channel.message-adapter.test.ts b/extensions/discord/src/channel.message-adapter.test.ts index cbe9914ebcb..273f398b41d 100644 --- a/extensions/discord/src/channel.message-adapter.test.ts +++ b/extensions/discord/src/channel.message-adapter.test.ts @@ -151,7 +151,7 @@ describe("discord channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "discordMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, diff --git a/extensions/matrix/src/channel.message-adapter.test.ts b/extensions/matrix/src/channel.message-adapter.test.ts index 5e5c8156245..0e549d395ce 100644 --- a/extensions/matrix/src/channel.message-adapter.test.ts +++ b/extensions/matrix/src/channel.message-adapter.test.ts @@ -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"); }, }, }); diff --git a/extensions/mattermost/src/channel.message-adapter.test.ts b/extensions/mattermost/src/channel.message-adapter.test.ts index 51edfa1576b..2df75528e0d 100644 --- a/extensions/mattermost/src/channel.message-adapter.test.ts +++ b/extensions/mattermost/src/channel.message-adapter.test.ts @@ -132,7 +132,7 @@ describe("mattermost channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "mattermostMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, diff --git a/extensions/tlon/src/channel.message-adapter.test.ts b/extensions/tlon/src/channel.message-adapter.test.ts index 7c79e3716bf..d9b84a13f67 100644 --- a/extensions/tlon/src/channel.message-adapter.test.ts +++ b/extensions/tlon/src/channel.message-adapter.test.ts @@ -114,7 +114,7 @@ describe("tlon channel message adapter", () => { await verifyChannelMessageAdapterCapabilityProofs({ adapterName: "tlonMessageAdapter", - adapter: adapter, + adapter, proofs: { text: proveText, media: proveMedia, diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 667ffab64ae..2b9c0493d10 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -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", diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 7c0c134a825..4d92fbe49c4 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -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({ diff --git a/src/agents/pi-hooks/compaction-safeguard.test.ts b/src/agents/pi-hooks/compaction-safeguard.test.ts index 931531423aa..fa1e0c65298 100644 --- a/src/agents/pi-hooks/compaction-safeguard.test.ts +++ b/src/agents/pi-hooks/compaction-safeguard.test.ts @@ -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; }; diff --git a/src/agents/pi-tools.read.host-edit-access.test.ts b/src/agents/pi-tools.read.host-edit-access.test.ts index 6e807b716a3..f227137f4a4 100644 --- a/src/agents/pi-tools.read.host-edit-access.test.ts +++ b/src/agents/pi-tools.read.host-edit-access.test.ts @@ -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(); }, ); diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 2e0c1461cf9..841d3872005 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -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"); diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 89b71f1599a..8b7842d9bb3 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -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); diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index cb0b595e2e7..91e2707b0aa 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -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 {