fix(memory-core): harden request-scoped dreaming fallback (#64156)

* memory-core: harden request-scoped dreaming fallback

* memory-core: tighten request-scoped fallback classification
This commit is contained in:
Mariano
2026-04-10 12:11:57 +02:00
committed by GitHub
parent 948909b3fb
commit 46f8c4dfd5
6 changed files with 193 additions and 9 deletions

View File

@@ -136,6 +136,8 @@ Docs: https://docs.openclaw.ai
- Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical `*.test.ts` files stay blocked. (#63311) Thanks @altaywtf.
- Control UI/models: preserve provider-qualified refs for OpenRouter catalog models whose ids already contain slashes so picker selections submit allowlist-compatible model refs instead of dropping the `openrouter/` prefix. (#63416) Thanks @sallyom.
- Plugin SDK/command auth: split command status builders onto the lightweight `openclaw/plugin-sdk/command-status` subpath while preserving deprecated `command-auth` compatibility exports, so auth-only plugin imports no longer pull status/context warmup into CLI onboarding paths. (#63174) Thanks @hxy91819.
- Wizard/plugin config: coerce integer-typed plugin config fields from interactive text input so integer schema values persist as numbers instead of failing validation. (#63346) Thanks @jalehman.
- Dreaming/narrative: harden request-scoped diary fallback so scheduled dreaming only falls back on the dedicated subagent-runtime error, stop trusting spoofable raw error-code objects, and avoid leaking workspace paths when local fallback writes fail. (#64156) Thanks @mbelinky.
## 2026.4.8

View File

@@ -1,2 +1,2 @@
087dc7fe9759330c953a00130ea20242b3d7f460eaa530d631cfb2a9f96e0370 plugin-sdk-api-baseline.json
a84765a726e0493dc87d2799020fd454407b1fe2c4d3ad69e8c3cc3a0cde834b plugin-sdk-api-baseline.jsonl
268aca42eaae8b4dd37d7eddb7202d002db16a4a27830cd90d98b5c4413cbbe7 plugin-sdk-api-baseline.json
4fe4fc194bec72a58bdd5566c4b31c00b2c0a520941fdcdd0f42bdf02b683ea5 plugin-sdk-api-baseline.jsonl

View File

@@ -1,5 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
RequestScopedSubagentRuntimeError,
SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_CODE,
} from "openclaw/plugin-sdk/error-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
appendNarrativeEntry,
@@ -477,7 +481,11 @@ describe("generateAndAppendDreamNarrative", () => {
it("handles subagent error gracefully", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const subagent = createMockSubagent("");
subagent.run.mockRejectedValue(new Error("connection failed"));
subagent.run.mockRejectedValue(
new Error("connection failed", {
cause: new RequestScopedSubagentRuntimeError(),
}),
);
const logger = createMockLogger();
await generateAndAppendDreamNarrative({
@@ -489,6 +497,80 @@ describe("generateAndAppendDreamNarrative", () => {
// Should not throw.
expect(logger.warn).toHaveBeenCalled();
await expect(fs.access(path.join(workspaceDir, "DREAMS.md"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("falls back to a local narrative when subagent runtime is request-scoped", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const subagent = createMockSubagent("");
subagent.run.mockRejectedValue(new RequestScopedSubagentRuntimeError());
const logger = createMockLogger();
await generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: { phase: "light", snippets: ["API endpoints need authentication"] },
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
logger,
});
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).toContain("API endpoints need authentication");
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("request-scoped"));
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining(workspaceDir));
expect(subagent.deleteSession).toHaveBeenCalledOnce();
});
it("falls back when the request-scoped runtime error is detected by stable code", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const subagent = createMockSubagent("");
const crossBoundaryError = new Error("different wrapper text");
crossBoundaryError.name = "RequestScopedSubagentRuntimeError";
Object.assign(crossBoundaryError, {
code: SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_CODE,
});
subagent.run.mockRejectedValue(crossBoundaryError);
const logger = createMockLogger();
await generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: { phase: "deep", snippets: [], promotions: ["A durable candidate surfaced."] },
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
logger,
});
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).toContain("A durable candidate surfaced.");
});
it("does not fall back for non-Error objects that only spoof the stable code", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const subagent = createMockSubagent("");
subagent.run.mockRejectedValue({
code: SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_CODE,
name: "RequestScopedSubagentRuntimeError",
message: "spoofed",
});
const logger = createMockLogger();
await generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: { phase: "deep", snippets: ["should not persist"] },
logger,
});
await expect(fs.access(path.join(workspaceDir, "DREAMS.md"))).rejects.toMatchObject({
code: "ENOENT",
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("narrative generation failed"),
);
});
it("cleans up session even on failure", async () => {

View File

@@ -1,6 +1,12 @@
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
extractErrorCode,
formatErrorMessage,
RequestScopedSubagentRuntimeError,
readErrorName,
SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_CODE,
} from "openclaw/plugin-sdk/error-runtime";
// ── Types ──────────────────────────────────────────────────────────────
@@ -73,6 +79,80 @@ const DIARY_START_MARKER = "<!-- openclaw:dreaming:diary:start -->";
const DIARY_END_MARKER = "<!-- openclaw:dreaming:diary:end -->";
const BACKFILL_ENTRY_MARKER = "openclaw:dreaming:backfill-entry";
function isRequestScopedSubagentRuntimeError(err: unknown): boolean {
return (
err instanceof RequestScopedSubagentRuntimeError ||
(err instanceof Error &&
err.name === "RequestScopedSubagentRuntimeError" &&
extractErrorCode(err) === SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_CODE)
);
}
function formatFallbackWriteFailure(err: unknown): string {
const code = extractErrorCode(err);
const name = readErrorName(err);
if (code && name) {
return `code=${code} name=${name}`;
}
if (code) {
return `code=${code}`;
}
if (name) {
return `name=${name}`;
}
return "unknown error";
}
function buildRequestScopedFallbackNarrative(data: NarrativePhaseData): string {
return (
data.snippets.map((value) => value.trim()).find((value) => value.length > 0) ??
(data.promotions ?? []).map((value) => value.trim()).find((value) => value.length > 0) ??
"A memory trace surfaced, but details were unavailable in this run."
);
}
async function startNarrativeRunOrFallback(params: {
subagent: SubagentSurface;
sessionKey: string;
message: string;
data: NarrativePhaseData;
workspaceDir: string;
nowMs: number;
timezone?: string;
logger: Logger;
}): Promise<string | null> {
try {
const run = await params.subagent.run({
idempotencyKey: params.sessionKey,
sessionKey: params.sessionKey,
message: params.message,
extraSystemPrompt: NARRATIVE_SYSTEM_PROMPT,
deliver: false,
});
return run.runId;
} catch (runErr) {
if (!isRequestScopedSubagentRuntimeError(runErr)) {
throw runErr;
}
try {
await appendNarrativeEntry({
workspaceDir: params.workspaceDir,
narrative: buildRequestScopedFallbackNarrative(params.data),
nowMs: params.nowMs,
timezone: params.timezone,
});
params.logger.warn(
`memory-core: narrative generation used fallback for ${params.data.phase} phase because subagent runtime is request-scoped.`,
);
} catch (fallbackErr) {
params.logger.warn(
`memory-core: narrative fallback failed for ${params.data.phase} phase (${formatFallbackWriteFailure(fallbackErr)})`,
);
}
return null;
}
}
// ── Prompt building ────────────────────────────────────────────────────
export function buildNarrativePrompt(data: NarrativePhaseData): string {
@@ -449,13 +529,19 @@ export async function generateAndAppendDreamNarrative(params: {
const message = buildNarrativePrompt(params.data);
try {
const { runId } = await params.subagent.run({
idempotencyKey: sessionKey,
const runId = await startNarrativeRunOrFallback({
subagent: params.subagent,
sessionKey,
message,
extraSystemPrompt: NARRATIVE_SYSTEM_PROMPT,
deliver: false,
data: params.data,
workspaceDir: params.workspaceDir,
nowMs,
timezone: params.timezone,
logger: params.logger,
});
if (!runId) {
return;
}
const result = await params.subagent.waitForRun({
runId,

View File

@@ -1,5 +1,18 @@
// Shared error graph/format helpers without the full infra-runtime surface.
export const SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_CODE = "OPENCLAW_SUBAGENT_RUNTIME_REQUEST_SCOPE";
export const SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_MESSAGE =
"Plugin runtime subagent methods are only available during a gateway request.";
export class RequestScopedSubagentRuntimeError extends Error {
code = SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_CODE;
constructor(message = SUBAGENT_RUNTIME_REQUEST_SCOPE_ERROR_MESSAGE) {
super(message);
this.name = "RequestScopedSubagentRuntimeError";
}
}
export {
collectErrorGraphCandidates,
extractErrorCode,

View File

@@ -7,6 +7,7 @@ import {
generateMusic as generateRuntimeMusic,
listRuntimeMusicGenerationProviders,
} from "../../music-generation/runtime.js";
import { RequestScopedSubagentRuntimeError } from "../../plugin-sdk/error-runtime.js";
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
import {
createLazyRuntimeMethod,
@@ -119,7 +120,7 @@ function createRuntimeModelAuth(): PluginRuntime["modelAuth"] {
function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] {
const unavailable = () => {
throw new Error("Plugin runtime subagent methods are only available during a gateway request.");
throw new RequestScopedSubagentRuntimeError();
};
return {
run: unavailable,