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 <andy@Andys-MacBook-Pro-2.local>
This commit is contained in:
Peter Steinberger
2026-05-10 02:47:31 +01:00
parent 64b5b9796d
commit eda0316af3
4 changed files with 70 additions and 52 deletions

View File

@@ -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.

View File

@@ -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"]
```

View File

@@ -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.",
),

View File

@@ -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,
};