Skip to content

Commit bf5f404

Browse files
committed
fix(active-memory): fast-fail stalled recall paths
1 parent ee8371d commit bf5f404

3 files changed

Lines changed: 470 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai
9494

9595
### Fixes
9696

97+
- Plugins/active-memory: fast-fail empty or unavailable Memory Search results, enforce the configured recall timeout budget, and discard embedded timeout boilerplate so stalled recalls no longer delay replies or inject timeout text. Thanks @codexGW.
9798
- fix: block workspace CLOUDSDK_PYTHON override and always set trusted interpreter for gcloud. (#74492) Thanks @pgondhi987.
9899
- Providers/Z.AI: move the bundled GLM catalog and auth env metadata into the plugin manifest, so `models list --all --provider zai` shows the full known catalog without duplicated runtime seed data. Thanks @shakkernerd.
99100
- Providers/Qianfan and Providers/Stepfun: declare setup auth metadata (`api-key` method, `QIANFAN_API_KEY`, `STEPFUN_API_KEY`) in the plugin manifest so onboarding and `models setup` surface the expected env var without falling back to legacy `providerAuthEnvVars` runtime seed data. Thanks @shakkernerd.

extensions/active-memory/index.test.ts

Lines changed: 218 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,189 @@ describe("active-memory plugin", () => {
15821582
]);
15831583
});
15841584

1585+
it("fast-fails empty recall when memory_search reports zero hits", async () => {
1586+
api.pluginConfig = {
1587+
agents: ["main"],
1588+
timeoutMs: 10_000,
1589+
logging: true,
1590+
};
1591+
plugin.register(api as unknown as OpenClawPluginApi);
1592+
const sessionKey = "agent:main:empty-search-fast-fail";
1593+
hoisted.sessionStore[sessionKey] = {
1594+
sessionId: "s-empty-search-fast-fail",
1595+
updatedAt: 0,
1596+
};
1597+
let aborted = false;
1598+
runEmbeddedPiAgent.mockImplementationOnce(
1599+
(params: { abortSignal?: AbortSignal; onToolResult?: (payload: unknown) => void }) => {
1600+
const pending = new Promise<never>((_resolve, reject) => {
1601+
params.abortSignal?.addEventListener(
1602+
"abort",
1603+
() => {
1604+
aborted = true;
1605+
const error = new Error("aborted by fast-fail");
1606+
error.name = "AbortError";
1607+
reject(error);
1608+
},
1609+
{ once: true },
1610+
);
1611+
});
1612+
params.onToolResult?.({
1613+
text: `🧠 Memory Search\n\`\`\`txt\n${JSON.stringify(
1614+
{
1615+
results: [],
1616+
debug: {
1617+
backend: "qmd",
1618+
configuredMode: "search",
1619+
effectiveMode: "query",
1620+
searchMs: 12,
1621+
hits: 0,
1622+
},
1623+
},
1624+
null,
1625+
2,
1626+
)}\n\`\`\``,
1627+
});
1628+
return pending;
1629+
},
1630+
);
1631+
1632+
const result = await hooks.before_prompt_build(
1633+
{ prompt: "what do you remember about my preferences?", messages: [] },
1634+
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
1635+
);
1636+
1637+
expect(result).toBeUndefined();
1638+
expect(aborted).toBe(true);
1639+
const lines = getActiveMemoryLines(sessionKey);
1640+
expect(lines).toEqual(
1641+
expect.arrayContaining([
1642+
expect.stringContaining("🧩 Active Memory: status=empty"),
1643+
expect.stringContaining("hits=0"),
1644+
]),
1645+
);
1646+
});
1647+
1648+
it("fast-fails unavailable recall when memory_search is denied by scope", async () => {
1649+
api.pluginConfig = {
1650+
agents: ["main"],
1651+
allowedChatTypes: ["channel"],
1652+
timeoutMs: 10_000,
1653+
logging: true,
1654+
};
1655+
plugin.register(api as unknown as OpenClawPluginApi);
1656+
const sessionKey = "agent:main:discord:channel:1488793123260862544";
1657+
hoisted.sessionStore[sessionKey] = {
1658+
sessionId: "s-scope-denied-fast-fail",
1659+
updatedAt: 0,
1660+
};
1661+
let aborted = false;
1662+
runEmbeddedPiAgent.mockImplementationOnce(
1663+
(params: { abortSignal?: AbortSignal; onToolResult?: (payload: unknown) => void }) => {
1664+
const pending = new Promise<never>((_resolve, reject) => {
1665+
params.abortSignal?.addEventListener(
1666+
"abort",
1667+
() => {
1668+
aborted = true;
1669+
const error = new Error("aborted by unavailable fast-fail");
1670+
error.name = "AbortError";
1671+
reject(error);
1672+
},
1673+
{ once: true },
1674+
);
1675+
});
1676+
params.onToolResult?.({
1677+
text: `🧠 Memory Search\n\`\`\`txt\n${JSON.stringify(
1678+
{
1679+
results: [],
1680+
disabled: true,
1681+
unavailable: true,
1682+
error: "qmd search denied by scope",
1683+
warning: "Memory search is unavailable due to an embedding/provider error.",
1684+
action: "Check embedding provider configuration and retry memory_search.",
1685+
debug: {
1686+
error: "qmd search denied by scope",
1687+
warning: "Memory search is unavailable due to an embedding/provider error.",
1688+
action: "Check embedding provider configuration and retry memory_search.",
1689+
},
1690+
},
1691+
null,
1692+
2,
1693+
)}\n\`\`\``,
1694+
});
1695+
return pending;
1696+
},
1697+
);
1698+
1699+
const result = await hooks.before_prompt_build(
1700+
{ prompt: "Testing 1, 2, 3", messages: [] },
1701+
{
1702+
agentId: "main",
1703+
trigger: "user",
1704+
sessionKey,
1705+
messageProvider: "discord",
1706+
channelId: "1488793123260862544",
1707+
},
1708+
);
1709+
1710+
expect(result).toBeUndefined();
1711+
expect(aborted).toBe(true);
1712+
const lines = getActiveMemoryLines(sessionKey);
1713+
expect(lines).toEqual(
1714+
expect.arrayContaining([
1715+
expect.stringContaining("🧩 Active Memory: status=unavailable"),
1716+
expect.stringContaining("Memory search is unavailable"),
1717+
]),
1718+
);
1719+
});
1720+
1721+
it("does not fast-fail unavailable output from memory_get", async () => {
1722+
api.pluginConfig = {
1723+
agents: ["main"],
1724+
timeoutMs: 10_000,
1725+
logging: true,
1726+
};
1727+
plugin.register(api as unknown as OpenClawPluginApi);
1728+
const sessionKey = "agent:main:memory-get-unavailable-not-terminal";
1729+
hoisted.sessionStore[sessionKey] = {
1730+
sessionId: "s-memory-get-unavailable-not-terminal",
1731+
updatedAt: 0,
1732+
};
1733+
let aborted = false;
1734+
runEmbeddedPiAgent.mockImplementationOnce(
1735+
async (params: { abortSignal?: AbortSignal; onToolResult?: (payload: unknown) => void }) => {
1736+
params.abortSignal?.addEventListener(
1737+
"abort",
1738+
() => {
1739+
aborted = true;
1740+
},
1741+
{ once: true },
1742+
);
1743+
params.onToolResult?.({
1744+
text: `📓 Memory Get\n\`\`\`txt\n${JSON.stringify(
1745+
{
1746+
disabled: true,
1747+
error: "wiki corpus result not found",
1748+
},
1749+
null,
1750+
2,
1751+
)}\n\`\`\``,
1752+
});
1753+
return { payloads: [{ text: "Useful search summary after memory_get miss." }] };
1754+
},
1755+
);
1756+
1757+
const result = await hooks.before_prompt_build(
1758+
{ prompt: "what should I remember?", messages: [] },
1759+
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
1760+
);
1761+
1762+
expect(aborted).toBe(false);
1763+
expect((result as { prependContext?: string } | undefined)?.prependContext).toContain(
1764+
"Useful search summary after memory_get miss.",
1765+
);
1766+
});
1767+
15851768
it("returns nothing when the subagent says none", async () => {
15861769
runEmbeddedPiAgent.mockResolvedValueOnce({
15871770
payloads: [{ text: "NONE" }],
@@ -1600,6 +1783,37 @@ describe("active-memory plugin", () => {
16001783
expect(result).toBeUndefined();
16011784
});
16021785

1786+
it("treats embedded timeout boilerplate as timeout instead of memory context", async () => {
1787+
api.pluginConfig = {
1788+
agents: ["main"],
1789+
timeoutMs: 10_000,
1790+
logging: true,
1791+
};
1792+
plugin.register(api as unknown as OpenClawPluginApi);
1793+
const sessionKey = "agent:main:timeout-boilerplate";
1794+
hoisted.sessionStore[sessionKey] = {
1795+
sessionId: "s-timeout-boilerplate",
1796+
updatedAt: 0,
1797+
};
1798+
runEmbeddedPiAgent.mockResolvedValueOnce({
1799+
payloads: [
1800+
{
1801+
text: "Request timed out before a response was generated. Please try again, or increase `agents.defaults.timeoutSeconds` in your config.",
1802+
},
1803+
],
1804+
});
1805+
1806+
const result = await hooks.before_prompt_build(
1807+
{ prompt: "what wings should i order? timeout boilerplate", messages: [] },
1808+
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
1809+
);
1810+
1811+
expect(result).toBeUndefined();
1812+
const lines = getActiveMemoryLines(sessionKey);
1813+
expect(lines).toEqual([expect.stringContaining("🧩 Active Memory: status=timeout")]);
1814+
expect(lines.join("\n")).not.toContain("Request timed out before a response was generated");
1815+
});
1816+
16031817
it("returns partial transcript text on timeout when the subagent has already written assistant output", async () => {
16041818
__testing.setMinimumTimeoutMsForTests(1);
16051819
__testing.setSetupGraceTimeoutMsForTests(0);
@@ -2175,7 +2389,7 @@ describe("active-memory plugin", () => {
21752389
).toBe(true);
21762390
});
21772391

2178-
it("does not spend the model timeout budget on active-memory subagent setup", async () => {
2392+
it("does not extend the user-visible recall budget with setup grace", async () => {
21792393
const CONFIGURED_TIMEOUT_MS = 10;
21802394
__testing.setMinimumTimeoutMsForTests(1);
21812395
__testing.setSetupGraceTimeoutMsForTests(100);
@@ -2200,19 +2414,19 @@ describe("active-memory plugin", () => {
22002414
},
22012415
);
22022416

2203-
expect(result?.prependContext).toContain("remember the ramen place");
2417+
expect(result).toBeUndefined();
22042418
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.timeoutMs).toBe(CONFIGURED_TIMEOUT_MS);
22052419
const infoLines = vi
22062420
.mocked(api.logger.info)
22072421
.mock.calls.map((call: unknown[]) => String(call[0]));
2208-
expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(false);
2422+
expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(true);
22092423
});
22102424

22112425
it("returns timeout within a hard deadline even when the subagent never checks the abort signal", async () => {
22122426
const CONFIGURED_TIMEOUT_MS = 200;
22132427
const HARD_DEADLINE_MARGIN_MS = 4_800;
22142428
__testing.setMinimumTimeoutMsForTests(1);
2215-
__testing.setSetupGraceTimeoutMsForTests(0);
2429+
__testing.setSetupGraceTimeoutMsForTests(5_000);
22162430
api.pluginConfig = {
22172431
agents: ["main"],
22182432
timeoutMs: CONFIGURED_TIMEOUT_MS,

0 commit comments

Comments
 (0)