diff --git a/CHANGELOG.md b/CHANGELOG.md index 124e6d17311..066395a0e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index df0aa726150..d8080c2ac26 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/memory-core/src/dreaming-narrative.test.ts b/extensions/memory-core/src/dreaming-narrative.test.ts index b08b712b7b4..c3f0899ae21 100644 --- a/extensions/memory-core/src/dreaming-narrative.test.ts +++ b/extensions/memory-core/src/dreaming-narrative.test.ts @@ -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 () => { diff --git a/extensions/memory-core/src/dreaming-narrative.ts b/extensions/memory-core/src/dreaming-narrative.ts index 85633cad364..ba6021d5eb1 100644 --- a/extensions/memory-core/src/dreaming-narrative.ts +++ b/extensions/memory-core/src/dreaming-narrative.ts @@ -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 = ""; const DIARY_END_MARKER = ""; 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 { + 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, diff --git a/src/plugin-sdk/error-runtime.ts b/src/plugin-sdk/error-runtime.ts index 2fe70e21eb3..0a0972d9d3a 100644 --- a/src/plugin-sdk/error-runtime.ts +++ b/src/plugin-sdk/error-runtime.ts @@ -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, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index cea7adb95fd..bd6b04ce684 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -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,