diff --git a/CHANGELOG.md b/CHANGELOG.md index 34256c64fef..e359d8987a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. - Claude CLI: honor non-off `/think` levels by passing Claude Code's session-scoped `--effort` flag through the CLI backend seam, so chat bridges no longer show an inert thinking control. Fixes #77303. Thanks @Petr1t. - Agents/subagents: refresh deferred final-delivery payloads when same-session completion output changes, so retried parent notifications use the final child summary instead of stale progress text. Thanks @vincentkoc. +- active-memory: skip the memory sub-agent gracefully instead of logging a confusing allowlist error when no memory plugin (`memory-core` or `memory-lancedb`) is loaded, so active-memory with no memory backend no longer produces misleading "No callable tools remain" warnings in the gateway log. Fixes #77506. Thanks @hclsys. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. - Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval. diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index d1e56d193fb..fcb711eb33b 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -125,6 +125,23 @@ describe("active-memory plugin", () => { "utf8", ); }; + const makeMemoryToolAllowlistError = ( + reason: string, + sources = "runtime toolsAllow: memory_recall, memory_search, memory_get", + ) => + new Error( + `No callable tools remain after resolving explicit tool allowlist ` + + `(${sources}); ${reason}. ` + + `Fix the allowlist or enable the plugin that registers the requested tool.`, + ); + const hasDebugLine = (needle: string) => + vi + .mocked(api.logger.debug) + .mock.calls.some((call: unknown[]) => String(call[0]).includes(needle)); + const hasWarnLine = (needle: string) => + vi + .mocked(api.logger.warn) + .mock.calls.some((call: unknown[]) => String(call[0]).includes(needle)); beforeEach(async () => { vi.clearAllMocks(); @@ -1646,6 +1663,133 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); }); + it("skips the recall subagent when no registered memory tools match", async () => { + const sessionKey = "agent:main:missing-memory-tools"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-memory-tools", + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError("no registered tools matched"); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? missing memory tools", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(true); + expect(hasWarnLine("No callable tools remain")).toBe(false); + const lines = getActiveMemoryLines(sessionKey); + expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=empty")]); + expect(lines.join("\n")).not.toContain("status=unavailable"); + }); + + it("skips missing memory tools when the allowlist error includes inherited sources", async () => { + const sessionKey = "agent:main:missing-memory-tools-with-policy-source"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-memory-tools-with-policy-source", + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError( + "no registered tools matched", + "tools.allow: *, lobster; runtime toolsAllow: memory_recall, memory_search, memory_get", + ); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(true); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? missing memory tools with policy", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).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 () => { + const sessionKey = "agent:main:memory-tools-filtered-by-policy"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-memory-tools-filtered-by-policy", + updatedAt: 0, + }; + const error = makeMemoryToolAllowlistError( + "no registered tools matched", + "tools.allow: read, exec; runtime toolsAllow: memory_recall, memory_search, memory_get", + ); + expect(__testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); + runEmbeddedPiAgent.mockRejectedValueOnce(error); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? memory tools filtered by policy", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(hasWarnLine("No callable tools remain")).toBe(true); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=unavailable"), + ]); + }); + + 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); + + 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"), + ]); + }); + + it("does not skip missing memory-tool allowlist errors after abort", async () => { + const sessionKey = "agent:main:missing-memory-tools-after-abort"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-missing-memory-tools-after-abort", + updatedAt: 0, + }; + runEmbeddedPiAgent.mockImplementationOnce(async (params: { abortSignal?: AbortSignal }) => { + Object.defineProperty(params.abortSignal as AbortSignal, "aborted", { + configurable: true, + value: true, + }); + throw makeMemoryToolAllowlistError("no registered tools matched"); + }); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? missing memory tools after abort", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + expect(hasDebugLine("no memory tools registered")).toBe(false); + expect(getActiveMemoryLines(sessionKey)).toEqual([ + expect.stringContaining("🧩 Active Memory: status=timeout"), + ]); + }); + it("returns partial transcript text on timeout when the subagent has already written assistant output", async () => { __testing.setMinimumTimeoutMsForTests(1); __testing.setSetupGraceTimeoutMsForTests(0); diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index b6f80208ae5..10d891405be 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -41,6 +41,7 @@ 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 TOGGLE_STATE_FILE = "session-toggles.json"; const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000; const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000; @@ -494,6 +495,38 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function isMissingRegisteredMemoryToolsError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const message = error.message.trim(); + const prefix = "No callable tools remain after resolving explicit tool allowlist ("; + const suffix = + "); no registered tools matched. Fix the allowlist or enable the plugin that registers the requested tool."; + if (!message.startsWith(prefix) || !message.endsWith(suffix)) { + return false; + } + const sources = message.slice(prefix.length, -suffix.length); + const runtimeSource = `runtime toolsAllow: ${ACTIVE_MEMORY_TOOL_ALLOWLIST.join(", ")}`; + 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("*"); + }); +} + function resolveRecallRunChannelContext(params: { api: OpenClawPluginApi; agentId: string; @@ -2394,7 +2427,7 @@ async function runRecallSubagent(params: { timeoutMs: embeddedTimeoutMs, runId: subagentSessionId, trigger: "manual", - toolsAllow: ["memory_recall", "memory_search", "memory_get"], + toolsAllow: [...ACTIVE_MEMORY_TOOL_ALLOWLIST], disableMessageTool: true, allowGatewaySubagentBinding: true, bootstrapContextMode: "lightweight", @@ -2437,6 +2470,12 @@ async function runRecallSubagent(params: { const searchDebug = partialReply ? await readActiveMemorySearchDebug(sessionFile) : undefined; attachPartialTimeoutData(error, partialReply, searchDebug); } + if (!params.abortSignal?.aborted && isMissingRegisteredMemoryToolsError(error)) { + params.api.logger.debug?.( + `active-memory: no memory tools registered (memory-core or memory-lancedb required); skipping sub-agent`, + ); + return { rawReply: "NONE" }; + } throw error; } finally { if (tempDir) { @@ -2959,6 +2998,7 @@ const testing = { buildPromptPrefix, getCachedResult, isCircuitBreakerOpen, + isMissingRegisteredMemoryToolsError, normalizePluginConfig, readActiveMemorySearchDebug, readPartialAssistantText,