fix(active-memory): skip sub-agent gracefully when no memory tools registered (#77506) (#77515)

* fix(active-memory): skip sub-agent gracefully when no memory tools registered (#77506)

When memory-core and memory-lancedb are both absent, the embedded
memory sub-agent would throw 'No callable tools remain after resolving
explicit tool allowlist', which propagated as a noisy warning through
the before_prompt_build hook. Catch this specific error in
runActiveMemorySubAgent and return an empty NONE result so the
gateway log stays clean and the sub-agent run is skipped without
disrupting the parent session.

* fix(active-memory): skip missing memory-tool subagent runs

* fix(active-memory): match inherited missing memory tool errors

* fix(active-memory): preserve policy-filtered memory errors

---------

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
hcl
2026-05-05 05:47:38 +08:00
committed by GitHub
parent 1c52447f0b
commit d0cae0d950
3 changed files with 186 additions and 1 deletions

View File

@@ -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);

View File

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