From eda0316af3e604d6feb22f8de7d4fb93c8587797 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 10 May 2026 02:47:31 +0100 Subject: [PATCH] fix: classify active memory no-relevant status (#80015) Recreated locally from PR #80015 because the contributor branch could not be updated by maintainers (maintainerCanModify=false). Fixes #79812. Co-authored-by: Andy Ye --- CHANGELOG.md | 1 + docs/concepts/active-memory.md | 2 +- extensions/active-memory/index.test.ts | 51 +++++++++---------- extensions/active-memory/index.ts | 68 ++++++++++++++++---------- 4 files changed, 70 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbfc127f6d..a7bf41e6278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - CLI: make parser, startup, config, guardrail, channel, agent, task, session, and MCP failures explain what happened and point to the next recovery command. - GitHub Copilot: refresh the model catalog from `${baseUrl}/models` so per-account entitlement and accurate context windows surface at runtime; static manifest catalog (now including `gpt-5.5`) remains the fallback when discovery is disabled or the API is unreachable. - 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"`. +- Active Memory: report normal `NONE` recall decisions as `status=no_relevant_memory`, keep unavailable and failed recall paths distinct, and avoid caching no-summary recall results so ordinary no-context turns no longer look like broken `status=empty` memory. Fixes #79812. (#80015) Thanks @TurboTheTurtle. - Telegram: share the grammY API throttler across polling and ad hoc send clients for the same bot token, so visible draft previews and CLI sends use one quota gate. Thanks @anagnorisis2peripeteia. - Feishu: resolve group policy/tool context from the trusted chat target for group turns while keeping the speaker in `From`, so @mention replies do not drop the configured group id. Fixes #79457. Thanks @greyxiong. - 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. diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index 8d3844e32cd..258b7ff65eb 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -327,7 +327,7 @@ The runtime shape is: flowchart LR U["User Message"] --> Q["Build Memory Query"] Q --> R["Active Memory Blocking Memory Sub-Agent"] - R -->|NONE or empty| M["Main Reply"] + R -->|NONE / no relevant memory| M["Main Reply"] R -->|relevant summary| I["Append Hidden active_memory_plugin System Context"] I --> M["Main Reply"] ``` diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 19d07eed668..7f1af7afbcc 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -2031,7 +2031,7 @@ describe("active-memory plugin", () => { { pluginId: "other-plugin", lines: ["Other Plugin: keep me"] }, { pluginId: "active-memory", - lines: [expect.stringContaining("🧩 Active Memory: status=empty")], + lines: [expect.stringContaining("🧩 Active Memory: status=no_relevant_memory")], }, ]); }); @@ -2073,8 +2073,7 @@ describe("active-memory plugin", () => { 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")]); - expect(lines.join("\n")).not.toContain("status=unavailable"); + expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=unavailable")]); }); it("skips missing memory tools when the allowlist error includes inherited sources", async () => { @@ -2099,7 +2098,7 @@ describe("active-memory plugin", () => { 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"), + expect.stringContaining("🧩 Active Memory: status=unavailable"), ]); }); @@ -2131,7 +2130,7 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); expect(hasDebugLine("no configured memory tools available")).toBe(true); expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=empty"), + expect.stringContaining("🧩 Active Memory: status=unavailable"), ]); }); @@ -2157,7 +2156,7 @@ describe("active-memory plugin", () => { 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"), + expect.stringContaining("🧩 Active Memory: status=unavailable"), ]); }); @@ -2185,7 +2184,7 @@ describe("active-memory plugin", () => { expect(hasDebugLine("no configured memory tools available")).toBe(false); expect(hasWarnLine(reason)).toBe(true); expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=empty"), + expect.stringContaining("🧩 Active Memory: status=failed"), ]); }, ); @@ -2521,7 +2520,7 @@ describe("active-memory plugin", () => { expect(result).toBeUndefined(); expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=empty"), + expect.stringContaining("🧩 Active Memory: status=failed"), ]); expect(getActiveMemoryLines(sessionKey).join("\n")).not.toContain( "must not be surfaced from generic errors", @@ -2625,7 +2624,7 @@ describe("active-memory plugin", () => { ).resolves.toMatchObject({ backend: "qmd", hits: 1 }); }); - it("caches ok and empty results but not timeout_partial results", () => { + it("caches ok summaries but not empty, no-relevant, or timeout_partial results", () => { expect( __testing.shouldCacheResult({ status: "timeout_partial", @@ -2647,10 +2646,17 @@ describe("active-memory plugin", () => { elapsedMs: 1, summary: null, }), - ).toBe(true); + ).toBe(false); + expect( + __testing.shouldCacheResult({ + status: "no_relevant_memory", + elapsedMs: 1, + summary: null, + }), + ).toBe(false); }); - it("caches empty recall results", async () => { + it("does not cache no-relevant-memory recall results", async () => { api.pluginConfig = { agents: ["main"], logging: true, @@ -2679,16 +2685,11 @@ describe("active-memory plugin", () => { }, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expect( - infoLines.some( - (line: string) => - line.includes(" cached status=empty ") || line.includes(" cached status=empty"), - ), - ).toBe(true); + expect(infoLines.some((line: string) => line.includes("cached status="))).toBe(false); }); it("surfaces timeout_partial summaries in status lines, metadata, and prompt prefixes", () => { @@ -2911,7 +2912,7 @@ describe("active-memory plugin", () => { expect(wallClockMs).toBeLessThan(CONFIGURED_TIMEOUT_MS + HARD_DEADLINE_MARGIN_MS); }); - it("fast-fails terminal zero-hit memory_search results without waiting for recall timeout", async () => { + it("does not fast-fail terminal zero-hit memory_search results as empty", async () => { const CONFIGURED_TIMEOUT_MS = 1_000; __testing.setMinimumTimeoutMsForTests(1); __testing.setSetupGraceTimeoutMsForTests(0); @@ -2947,10 +2948,10 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expectLinesToContain(infoLines, "done status=empty"); - expectLinesNotToContain(infoLines, "done status=timeout"); + expectLinesToContain(infoLines, "done status=timeout"); + expectLinesNotToContain(infoLines, "done status=empty"); expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=empty"), + expect.stringContaining("🧩 Active Memory: status=timeout"), expect.stringContaining("🔎 Active Memory Debug: backend=qmd searchMs=8 hits=0"), ]); }); @@ -3039,10 +3040,10 @@ describe("active-memory plugin", () => { const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); - expectLinesToContain(infoLines, "done status=empty"); + expectLinesToContain(infoLines, "done status=unavailable"); expectLinesNotToContain(infoLines, "done status=timeout"); expect(getActiveMemoryLines(sessionKey)).toEqual([ - expect.stringContaining("🧩 Active Memory: status=empty"), + expect.stringContaining("🧩 Active Memory: status=unavailable"), expect.stringContaining( "🔎 Active Memory Debug: Memory search is unavailable due to an embedding/provider error. Check the embedding provider configuration, then retry memory_search.", ), @@ -3301,7 +3302,7 @@ describe("active-memory plugin", () => { { pluginId: "active-memory", lines: [ - expect.stringContaining("🧩 Active Memory: status=empty"), + expect.stringContaining("🧩 Active Memory: status=unavailable"), expect.stringContaining( "🔎 Active Memory Debug: Memory search is unavailable because the embedding provider quota is exhausted. Top up or switch embedding provider, then retry memory_search.", ), diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 8bb40fcc138..71a0906ddcc 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -224,7 +224,7 @@ type ActiveMemorySearchDebug = { type ActiveRecallResult = | { - status: "empty" | "timeout" | "unavailable"; + status: "empty" | "failed" | "no_relevant_memory" | "timeout" | "unavailable"; elapsedMs: number; summary: string | null; searchDebug?: ActiveMemorySearchDebug; @@ -256,12 +256,13 @@ type TranscriptReadLimits = { type RecallSubagentResult = { rawReply: string; + resultStatus?: "failed" | "unavailable"; transcriptPath?: string; searchDebug?: ActiveMemorySearchDebug; }; type TerminalMemorySearchResult = { - status: "empty"; + status: "unavailable"; searchDebug?: ActiveMemorySearchDebug; }; @@ -1400,7 +1401,11 @@ function toSingleLineLogValue(value: unknown): string { } function shouldCacheResult(result: ActiveRecallResult): boolean { - return result.status === "ok" || result.status === "empty"; + return result.status === "ok" && result.summary.length > 0; +} + +function isUnavailableMemorySearchDebug(debug?: ActiveMemorySearchDebug): boolean { + return Boolean(debug?.error); } function resolveStatusUpdateAgentId(ctx: { agentId?: string; sessionKey?: string }): string { @@ -1741,15 +1746,10 @@ function extractTerminalMemorySearchResultFromSessionRecord( } const details = asRecord(message.details); const debug = extractActiveMemorySearchDebugFromSessionRecord(value); - const results = Array.isArray(details?.results) ? details.results : undefined; const disabled = details?.disabled === true; - const unavailable = - disabled || Boolean(debug?.warning) || Boolean(debug?.error) || Boolean(details?.error); - const debugHits = - typeof debug?.hits === "number" && Number.isFinite(debug.hits) ? debug.hits : undefined; - const zeroHitSearch = results !== undefined ? results.length === 0 : debugHits === 0; - if (unavailable || zeroHitSearch) { - return { status: "empty", searchDebug: debug }; + const unavailable = disabled || Boolean(debug?.error) || Boolean(details?.error); + if (unavailable) { + return { status: "unavailable", searchDebug: debug }; } return undefined; } @@ -2038,21 +2038,23 @@ async function buildTimeoutRecallResult(params: { normalizeActiveSummary(rawReply ?? "") ?? "", params.maxSummaryChars, ); + const searchDebug = + params.searchDebug ?? + subagentPartialData.searchDebug ?? + (params.sessionFile ? await readActiveMemorySearchDebug(params.sessionFile) : undefined); if (summary.length === 0) { return { status: "timeout", elapsedMs: params.elapsedMs, summary: null, + searchDebug, }; } return { status: "timeout_partial", elapsedMs: params.elapsedMs, summary, - searchDebug: - params.searchDebug ?? - subagentPartialData.searchDebug ?? - (params.sessionFile ? await readActiveMemorySearchDebug(params.sessionFile) : undefined), + searchDebug, }; } @@ -2564,7 +2566,7 @@ async function runRecallSubagent(params: { } catch (error) { if (params.abortSignal?.aborted) { const partialReply = await readPartialAssistantText(sessionFile); - const searchDebug = partialReply ? await readActiveMemorySearchDebug(sessionFile) : undefined; + const searchDebug = await readActiveMemorySearchDebug(sessionFile); attachPartialTimeoutData(error, partialReply, searchDebug); } if ( @@ -2574,14 +2576,14 @@ async function runRecallSubagent(params: { params.api.logger.debug?.( `active-memory: no configured memory tools available; skipping sub-agent`, ); - return { rawReply: "NONE" }; + return { rawReply: "NONE", resultStatus: "unavailable" }; } 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" }; + return { rawReply: "NONE", resultStatus: "failed" }; } throw error; } finally { @@ -2777,7 +2779,7 @@ async function maybeResolveActiveRecall(params: { return result; } - const { rawReply, transcriptPath, searchDebug } = raceResult; + const { rawReply, resultStatus, transcriptPath, searchDebug } = raceResult; const summary = truncateSummary( normalizeActiveSummary(rawReply) ?? "", params.config.maxSummaryChars, @@ -2794,12 +2796,26 @@ async function maybeResolveActiveRecall(params: { summary, searchDebug, } - : { - status: "empty", - elapsedMs: Date.now() - startedAt, - summary: null, - searchDebug, - }; + : resultStatus === "failed" + ? { + status: "failed", + elapsedMs: Date.now() - startedAt, + summary: null, + searchDebug, + } + : resultStatus === "unavailable" || isUnavailableMemorySearchDebug(searchDebug) + ? { + status: "unavailable", + elapsedMs: Date.now() - startedAt, + summary: null, + searchDebug, + } + : { + status: "no_relevant_memory", + elapsedMs: Date.now() - startedAt, + summary: null, + searchDebug, + }; if (params.config.logging) { params.api.logger.info?.( `${logPrefix} done status=${result.status} elapsedMs=${String(result.elapsedMs)} summaryChars=${String(result.summary?.length ?? 0)}`, @@ -2849,7 +2865,7 @@ async function maybeResolveActiveRecall(params: { params.api.logger.warn?.(`${logPrefix} failed error=${message}; skipping recall`); } const result: ActiveRecallResult = { - status: "empty", + status: "failed", elapsedMs: Date.now() - startedAt, summary: null, };