mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
* 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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user