fix(memory-core): register memory tools independently to prevent coupled failure (#52668)

Merged via admin squash because current required CI failures are inherited from base and match latest `main` failures outside this PR's `memory-core` surface.

Prepared head SHA: df7f968581
Co-authored-by: artwalker <44759507+artwalker@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
Frank Yang
2026-03-23 17:05:37 +08:00
committed by GitHub
parent a381e0d115
commit b186d9847c
4 changed files with 98 additions and 21 deletions

View File

@@ -107,6 +107,13 @@ Docs: https://docs.openclaw.ai
- Web tools/search provider lists: keep onboarding, configure, and docs provider lists alphabetical while preserving the separate runtime auto-detect precedence used for credential-based provider selection.
- Media/Windows security: block remote-host `file://` media URLs and UNC/network paths before local filesystem resolution in core media loading and adjacent prompt/sandbox attachment seams, so the next release no longer allows structured local-media inputs to trigger outbound SMB credential handshakes on Windows. Thanks @RacerZ-fighting for reporting.
- Gateway/discovery: fail closed on unresolved Bonjour and DNS-SD service endpoints in CLI discovery, onboarding, and `gateway status` so TXT-only hints can no longer steer routing or SSH auto-target selection. Thanks @nexrin for reporting.
- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey.
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
- Web tools/Exa: align the bundled Exa plugin with the current Exa API by supporting newer search types and richer `contents` options, while fixing the result-count cap to honor Exa's higher limit. Thanks @vincentkoc.
- Plugins/Matrix: move bundled plugin `KeyedAsyncQueue` imports onto the stable `plugin-sdk/core` surface so Matrix Docker/runtime builds do not depend on the brittle keyed-async-queue subpath. Thanks @ecohash-co and @vincentkoc.
- Nostr/security: enforce inbound DM policy before decrypt, route Nostr DMs through the standard reply pipeline, and add pre-crypto rate and size guards so unknown senders cannot bypass pairing or force unbounded crypto work. Thanks @kuranikaran.
- Synology Chat/security: keep reply delivery bound to stable numeric `user_id` by default, and gate mutable username/nickname recipient lookup behind `dangerouslyAllowNameMatching` with new regression coverage. Thanks @nexrin.
- Agents/default timeout: raise the shared default agent timeout from `600s` to `48h` so long-running ACP and agent sessions do not fail unless you configure a shorter limit.
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
- Gateway/startup: prewarm the configured primary model before channel startup and retry one transient provider-runtime miss so the first Telegram or Discord message after boot no longer fails with `Unknown model: openai-codex/gpt-5.4`. Thanks @vincentkoc.
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
@@ -310,6 +317,7 @@ Docs: https://docs.openclaw.ai
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
- Plugins/runtime state: share plugin-facing infra singleton state across duplicate module graphs and keep session-binding adapter ownership stable until the active owner unregisters. (#50725) thanks @huntharo.
- Discord/pickers: keep `/codex_resume --browse-projects` picker callbacks alive in Discord by sharing component callback state across duplicate module graphs, preserving callback fallbacks, and acknowledging matched plugin interactions before dispatch. (#51260) Thanks @huntharo.
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
## 2026.3.13

View File

@@ -1,23 +1,36 @@
import { describe, expect, it } from "vitest";
import { buildPromptSection } from "./index.js";
import { describe, expect, it, vi } from "vitest";
import plugin, { buildPromptSection } from "./index.js";
describe("buildPromptSection", () => {
it("returns empty when no memory tools are available", () => {
expect(buildPromptSection({ availableTools: new Set() })).toEqual([]);
});
it("returns Memory Recall section when memory_search is available", () => {
const result = buildPromptSection({ availableTools: new Set(["memory_search"]) });
it("describes the two-step flow when both memory tools are available", () => {
const result = buildPromptSection({
availableTools: new Set(["memory_search", "memory_get"]),
});
expect(result[0]).toBe("## Memory Recall");
expect(result[1]).toContain("run memory_search");
expect(result[1]).toContain("then use memory_get");
expect(result).toContain(
"Citations: include Source: <path#line> when it helps the user verify memory snippets.",
);
expect(result.at(-1)).toBe("");
});
it("returns Memory Recall section when memory_get is available", () => {
it("limits the guidance to memory_search when only search is available", () => {
const result = buildPromptSection({ availableTools: new Set(["memory_search"]) });
expect(result[0]).toBe("## Memory Recall");
expect(result[1]).toContain("run memory_search");
expect(result[1]).not.toContain("then use memory_get");
});
it("limits the guidance to memory_get when only get is available", () => {
const result = buildPromptSection({ availableTools: new Set(["memory_get"]) });
expect(result[0]).toBe("## Memory Recall");
expect(result[1]).toContain("run memory_get");
expect(result[1]).not.toContain("run memory_search");
});
it("includes citations-off instruction when citationsMode is off", () => {
@@ -30,3 +43,41 @@ describe("buildPromptSection", () => {
);
});
});
describe("plugin registration", () => {
it("registers memory tools independently so one unavailable tool does not suppress the other", () => {
const registerTool = vi.fn();
const registerMemoryPromptSection = vi.fn();
const registerCli = vi.fn();
const searchTool = { name: "memory_search" };
const getTool = null;
const api = {
registerTool,
registerMemoryPromptSection,
registerCli,
runtime: {
tools: {
createMemorySearchTool: vi.fn(() => searchTool),
createMemoryGetTool: vi.fn(() => getTool),
registerMemoryCli: vi.fn(),
},
},
};
plugin.register(api as never);
expect(registerMemoryPromptSection).toHaveBeenCalledWith(buildPromptSection);
expect(registerTool).toHaveBeenCalledTimes(2);
expect(registerTool.mock.calls[0]?.[1]).toEqual({ names: ["memory_search"] });
expect(registerTool.mock.calls[1]?.[1]).toEqual({ names: ["memory_get"] });
const searchFactory = registerTool.mock.calls[0]?.[0] as
| ((ctx: unknown) => unknown)
| undefined;
const getFactory = registerTool.mock.calls[1]?.[0] as ((ctx: unknown) => unknown) | undefined;
const ctx = { config: { plugins: {} }, sessionKey: "agent:main:slack:dm:u123" };
expect(searchFactory?.(ctx)).toBe(searchTool);
expect(getFactory?.(ctx)).toBeNull();
});
});

View File

@@ -5,13 +5,26 @@ export const buildPromptSection: MemoryPromptSectionBuilder = ({
availableTools,
citationsMode,
}) => {
if (!availableTools.has("memory_search") && !availableTools.has("memory_get")) {
const hasMemorySearch = availableTools.has("memory_search");
const hasMemoryGet = availableTools.has("memory_get");
if (!hasMemorySearch && !hasMemoryGet) {
return [];
}
const lines = [
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
];
let toolGuidance: string;
if (hasMemorySearch && hasMemoryGet) {
toolGuidance =
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.";
} else if (hasMemorySearch) {
toolGuidance =
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md and answer from the matching results. If low confidence after search, say you checked.";
} else {
toolGuidance =
"Before answering anything about prior work, decisions, dates, people, preferences, or todos that already point to a specific memory file or note: run memory_get to pull only the needed lines. If low confidence after reading them, say you checked.";
}
const lines = ["## Memory Recall", toolGuidance];
if (citationsMode === "off") {
lines.push(
"Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.",
@@ -34,21 +47,21 @@ export default definePluginEntry({
api.registerMemoryPromptSection(buildPromptSection);
api.registerTool(
(ctx) => {
const memorySearchTool = api.runtime.tools.createMemorySearchTool({
(ctx) =>
api.runtime.tools.createMemorySearchTool({
config: ctx.config,
agentSessionKey: ctx.sessionKey,
});
const memoryGetTool = api.runtime.tools.createMemoryGetTool({
}),
{ names: ["memory_search"] },
);
api.registerTool(
(ctx) =>
api.runtime.tools.createMemoryGetTool({
config: ctx.config,
agentSessionKey: ctx.sessionKey,
});
if (!memorySearchTool || !memoryGetTool) {
return null;
}
return [memorySearchTool, memoryGetTool];
},
{ names: ["memory_search", "memory_get"] },
}),
{ names: ["memory_get"] },
);
api.registerCli(

View File

@@ -109,6 +109,11 @@ export function resolvePluginTools(params: {
continue;
}
if (!resolved) {
if (entry.names.length > 0) {
log.debug(
`plugin tool factory returned null (${entry.pluginId}): [${entry.names.join(", ")}]`,
);
}
continue;
}
const listRaw = Array.isArray(resolved) ? resolved : [resolved];