mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user