fix(memory-core): support dreaming model override

This commit is contained in:
Peter Steinberger
2026-04-27 11:08:15 +01:00
parent b8a9dc9d78
commit 4003e4389a
13 changed files with 178 additions and 14 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Memory-core/dreaming: add a supported `dreaming.model` knob for Dream Diary narrative subagents, wired through phase config and the existing plugin subagent model-override trust gate. Refs #65963. Thanks @esqandil and @mjamiv.
- Memory-core/dreaming: treat request-scoped narrative fallback as expected, skip session cleanup when no subagent run was created, and remove duplicate phase-level cleanup so fallback no longer emits warning noise. Fixes #67152. Thanks @jsompis.
- Agents/exec: apply configured `tools.exec.timeoutSec` to background and `yieldMs` commands when no per-call timeout is set, preventing auto-backgrounded commands from running indefinitely. Fixes #67600; supersedes #67603. Thanks @dlmpx and @kagura-agent.
- Config/doctor: stop masking unknown-key validation diagnostics such as `agents.defaults.llm`, and have `openclaw doctor --fix` remove the retired `agents.defaults.llm` timeout block. Thanks @aidiffuser.

View File

@@ -72,7 +72,7 @@ Dreaming can ingest redacted session transcripts into the dreaming corpus. When
## Dream Diary
Dreaming also keeps a narrative **Dream Diary** in `DREAMS.md`. After each phase has enough material, `memory-core` runs a best-effort background subagent turn (using the default runtime model) and appends a short diary entry.
Dreaming also keeps a narrative **Dream Diary** in `DREAMS.md`. After each phase has enough material, `memory-core` runs a best-effort background subagent turn and appends a short diary entry. It uses the default runtime model unless `dreaming.model` is configured.
<Note>
This diary is for human reading in the Dreams UI, not a promotion source. Dreaming-generated diary/report artifacts are excluded from short-term promotion. Only grounded memory snippets are eligible to promote into `MEMORY.md`.
@@ -112,9 +112,10 @@ When enabled, `memory-core` auto-manages one cron job for a full dreaming sweep.
Default cadence behavior:
| Setting | Default |
| -------------------- | ----------- |
| `dreaming.frequency` | `0 3 * * *` |
| Setting | Default |
| -------------------- | ------------- |
| `dreaming.frequency` | `0 3 * * *` |
| `dreaming.model` | default model |
## Quick start
@@ -210,6 +211,13 @@ All settings live under `plugins.entries.memory-core.config.dreaming`.
<ParamField path="frequency" type="string" default="0 3 * * *">
Cron cadence for the full dreaming sweep.
</ParamField>
<ParamField path="model" type="string">
Optional Dream Diary subagent model override. Use a canonical `provider/model` value when also setting a subagent `allowedModels` allowlist.
</ParamField>
<Warning>
`dreaming.model` requires `plugins.entries.memory-core.subagent.allowModelOverride: true`. To restrict it, also set `plugins.entries.memory-core.subagent.allowedModels`.
</Warning>
<Note>
Phase policy, thresholds, and storage behavior are internal implementation details (not user-facing config). See [Memory configuration reference](/reference/memory-config#dreaming) for the full key list.

View File

@@ -182,6 +182,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- `plugins.entries.memory-core.config.dreaming`: memory dreaming settings. See [Dreaming](/concepts/dreaming) for phases and thresholds.
- `enabled`: master dreaming switch (default `false`).
- `frequency`: cron cadence for each full dreaming sweep (`"0 3 * * *"` by default).
- `model`: optional Dream Diary subagent model override. Requires `plugins.entries.memory-core.subagent.allowModelOverride: true`; pair with `allowedModels` to restrict targets.
- phase policy and thresholds are implementation details (not user-facing config keys).
- Full memory config lives in [Memory configuration reference](/reference/memory-config):
- `agents.defaults.memorySearch.*`

View File

@@ -511,10 +511,11 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
### User settings
| Key | Type | Default | Description |
| ----------- | --------- | ----------- | ------------------------------------------------- |
| `enabled` | `boolean` | `false` | Enable or disable dreaming entirely |
| `frequency` | `string` | `0 3 * * *` | Optional cron cadence for the full dreaming sweep |
| Key | Type | Default | Description |
| ----------- | --------- | ------------- | ------------------------------------------------- |
| `enabled` | `boolean` | `false` | Enable or disable dreaming entirely |
| `frequency` | `string` | `0 3 * * *` | Optional cron cadence for the full dreaming sweep |
| `model` | `string` | default model | Optional Dream Diary subagent model override |
### Example
@@ -523,10 +524,15 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
plugins: {
entries: {
"memory-core": {
subagent: {
allowModelOverride: true,
allowedModels: ["anthropic/claude-sonnet-4-6"],
},
config: {
dreaming: {
enabled: true,
frequency: "0 3 * * *",
model: "anthropic/claude-sonnet-4-6",
},
},
},
@@ -538,6 +544,7 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
<Note>
- Dreaming writes machine state to `memory/.dreams/`.
- Dreaming writes human-readable narrative output to `DREAMS.md` (or existing `dreams.md`).
- `dreaming.model` uses the existing plugin subagent trust gate; set `plugins.entries.memory-core.subagent.allowModelOverride: true` before enabling it.
- The light/deep/REM phase policy and thresholds are internal behavior, not user-facing config.
</Note>

View File

@@ -16,6 +16,11 @@
"label": "Dreaming Frequency",
"placeholder": "0 3 * * *",
"help": "Optional cron cadence for the full dreaming sweep (light, REM, then deep)."
},
"dreaming.model": {
"label": "Dreaming Model",
"placeholder": "anthropic/claude-sonnet-4-6",
"help": "Optional provider/model override for Dream Diary narrative subagent runs. Requires plugins.entries.memory-core.subagent.allowModelOverride."
}
},
"configSchema": {
@@ -32,6 +37,9 @@
"frequency": {
"type": "string"
},
"model": {
"type": "string"
},
"timezone": {
"type": "string"
},
@@ -51,6 +59,21 @@
}
}
},
"execution": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaults": {
"type": "object",
"additionalProperties": false,
"properties": {
"model": {
"type": "string"
}
}
}
}
},
"phases": {
"type": "object",
"additionalProperties": false,
@@ -74,6 +97,15 @@
"type": "number",
"minimum": 0,
"maximum": 1
},
"execution": {
"type": "object",
"additionalProperties": false,
"properties": {
"model": {
"type": "string"
}
}
}
}
},
@@ -108,6 +140,15 @@
"maxAgeDays": {
"type": "integer",
"minimum": 1
},
"execution": {
"type": "object",
"additionalProperties": false,
"properties": {
"model": {
"type": "string"
}
}
}
}
},
@@ -130,6 +171,15 @@
"type": "number",
"minimum": 0,
"maximum": 1
},
"execution": {
"type": "object",
"additionalProperties": false,
"properties": {
"model": {
"type": "string"
}
}
}
}
}

View File

@@ -596,6 +596,7 @@ describe("generateAndAppendDreamNarrative", () => {
},
nowMs,
timezone: "UTC",
model: "anthropic/claude-sonnet-4-6",
logger,
});
@@ -606,6 +607,7 @@ describe("generateAndAppendDreamNarrative", () => {
lane: `dreaming-narrative:${expectedSessionKey}`,
lightContext: true,
deliver: false,
model: "anthropic/claude-sonnet-4-6",
});
expect(subagent.waitForRun).toHaveBeenCalledOnce();
expect(subagent.deleteSession).toHaveBeenCalledOnce();

View File

@@ -26,6 +26,7 @@ type SubagentSurface = {
idempotencyKey: string;
sessionKey: string;
message: string;
model?: string;
extraSystemPrompt?: string;
lane?: string;
lightContext?: boolean;
@@ -147,6 +148,7 @@ async function startNarrativeRunOrFallback(params: {
workspaceDir: string;
nowMs: number;
timezone?: string;
model?: string;
logger: Logger;
}): Promise<string | null> {
try {
@@ -154,6 +156,7 @@ async function startNarrativeRunOrFallback(params: {
idempotencyKey: params.sessionKey,
sessionKey: params.sessionKey,
message: params.message,
...(params.model ? { model: params.model } : {}),
extraSystemPrompt: NARRATIVE_SYSTEM_PROMPT,
lane: `dreaming-narrative:${params.sessionKey}`,
lightContext: true,
@@ -846,6 +849,7 @@ export async function generateAndAppendDreamNarrative(params: {
data: NarrativePhaseData;
nowMs?: number;
timezone?: string;
model?: string;
logger: Logger;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
@@ -871,6 +875,7 @@ export async function generateAndAppendDreamNarrative(params: {
workspaceDir: params.workspaceDir,
nowMs,
timezone: params.timezone,
model: params.model,
logger: params.logger,
});
if (!runId) {

View File

@@ -118,7 +118,7 @@ function createHarness(
}
function createMockNarrativeSubagent(response = "The archive hummed softly.") {
const run = vi.fn(async (_params: { sessionKey: string; message: string }) => ({
const run = vi.fn(async (_params: { sessionKey: string; message: string; model?: string }) => ({
runId: "dream-run-1",
}));
const waitForRun = vi.fn(async () => ({ status: "ok" }));
@@ -2321,7 +2321,33 @@ describe("memory-core dreaming phases", () => {
it("passes staged light-dreaming snippets into the narrative pipeline", async () => {
const workspaceDir = await createDreamingWorkspace();
const subagent = createMockNarrativeSubagent("The backup plan glowed like cold storage.");
const { beforeAgentReply } = createHarness(LIGHT_DREAMING_TEST_CONFIG, workspaceDir, subagent);
const { beforeAgentReply } = createHarness(
{
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
timezone: "UTC",
model: "anthropic/claude-sonnet-4-6",
storage: { mode: "inline", separateReports: false },
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 2,
},
},
},
},
},
},
},
},
workspaceDir,
subagent,
);
await withDreamingTestClock(async () => {
await writeDailyNote(workspaceDir, [
@@ -2338,6 +2364,7 @@ describe("memory-core dreaming phases", () => {
const firstRun = subagent.run.mock.calls[0]?.[0];
expect(firstRun?.message).toContain("Move backups to S3 Glacier.");
expect(firstRun?.message).toContain("Keep retention at 365 days.");
expect(firstRun?.model).toBe("anthropic/claude-sonnet-4-6");
await expect(fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8")).resolves.toContain(
"The backup plan glowed like cold storage.",
);
@@ -2354,12 +2381,20 @@ describe("memory-core dreaming phases", () => {
config: {
dreaming: {
enabled: true,
execution: {
defaults: {
model: "openai/gpt-5.4",
},
},
phases: {
rem: {
enabled: true,
limit: 10,
lookbackDays: 7,
minPatternStrength: 0,
execution: {
model: "xai/grok-4.1-fast",
},
},
},
},
@@ -2392,6 +2427,7 @@ describe("memory-core dreaming phases", () => {
const firstRun = subagent.run.mock.calls[0]?.[0];
expect(firstRun?.message).toContain("Move backups to S3 Glacier.");
expect(firstRun?.message).toContain("Keep retention at 365 days.");
expect(firstRun?.model).toBe("xai/grok-4.1-fast");
await expect(fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8")).resolves.toContain(
"The traces braided themselves into a map.",
);

View File

@@ -34,6 +34,7 @@ type DreamingHostConfig = unknown;
type DreamingPhaseStorageConfig = {
timezone?: string;
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
execution?: { model?: string };
};
type LightDreamingConfig = DreamingPhaseStorageConfig & {
enabled: boolean;
@@ -1580,6 +1581,7 @@ async function runLightDreaming(params: {
data,
nowMs,
timezone: params.config.timezone,
model: params.config.execution?.model,
logger: params.logger,
}).catch(() => undefined);
});
@@ -1590,6 +1592,7 @@ async function runLightDreaming(params: {
data,
nowMs,
timezone: params.config.timezone,
model: params.config.execution?.model,
logger: params.logger,
});
}
@@ -1676,6 +1679,7 @@ async function runRemDreaming(params: {
data,
nowMs,
timezone: params.config.timezone,
model: params.config.execution?.model,
logger: params.logger,
}).catch(() => undefined);
});
@@ -1686,6 +1690,7 @@ async function runRemDreaming(params: {
data,
nowMs,
timezone: params.config.timezone,
model: params.config.execution?.model,
logger: params.logger,
});
}

View File

@@ -220,6 +220,7 @@ describe("short-term dreaming config", () => {
timezone: "UTC",
verboseLogging: true,
frequency: "5 1 * * *",
model: "anthropic/claude-haiku-4-5",
phases: {
deep: {
limit: 7,
@@ -248,6 +249,9 @@ describe("short-term dreaming config", () => {
mode: "separate",
separateReports: false,
},
execution: {
model: "anthropic/claude-haiku-4-5",
},
});
});
@@ -1880,7 +1884,7 @@ describe("short-term dreaming trigger", () => {
});
const subagent = {
run: vi.fn(async () => ({ runId: "narrative-run-1" })),
run: vi.fn(async (_params: { model?: string }) => ({ runId: "narrative-run-1" })),
waitForRun: vi.fn(async () => ({ status: "ok" })),
getSessionMessages: vi.fn(async () => ({
messages: [{ role: "assistant", content: "A diary entry." }],
@@ -1901,6 +1905,9 @@ describe("short-term dreaming trigger", () => {
minUniqueQueries: 0,
recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
verboseLogging: false,
execution: {
model: "anthropic/claude-sonnet-4-6",
},
},
logger,
subagent,
@@ -1908,6 +1915,9 @@ describe("short-term dreaming trigger", () => {
expect(result?.handled).toBe(true);
expect(subagent.run).toHaveBeenCalled();
expect(subagent.run.mock.calls[0]?.[0]).toMatchObject({
model: "anthropic/claude-sonnet-4-6",
});
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Move backups to S3 Glacier.");
await vi.waitFor(async () => {

View File

@@ -116,6 +116,9 @@ export type ShortTermPromotionDreamingConfig = {
mode: "inline" | "separate" | "both";
separateReports: boolean;
};
execution?: {
model?: string;
};
};
type ReconcileResult =
@@ -391,6 +394,7 @@ export function resolveShortTermPromotionDreamingConfig(params: {
...(typeof resolved.maxAgeDays === "number" ? { maxAgeDays: resolved.maxAgeDays } : {}),
verboseLogging: resolved.verboseLogging,
storage: resolved.storage,
...(resolved.execution.model ? { execution: { model: resolved.execution.model } } : {}),
};
}
@@ -633,6 +637,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
model: params.config.execution?.model,
logger: params.logger,
}).catch(() => undefined);
});
@@ -643,6 +648,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
model: params.config.execution?.model,
logger: params.logger,
});
}

View File

@@ -28,6 +28,7 @@ describe("memory dreaming host helpers", () => {
enabled: true,
frequency: "0 */4 * * *",
timezone: "Europe/London",
model: " anthropic/claude-sonnet-4-6 ",
storage: {
mode: "both",
separateReports: true,
@@ -49,6 +50,10 @@ describe("memory dreaming host helpers", () => {
expect(resolved.enabled).toBe(true);
expect(resolved.frequency).toBe("0 */4 * * *");
expect(resolved.timezone).toBe("Europe/London");
expect(resolved.execution.defaults.model).toBe("anthropic/claude-sonnet-4-6");
expect(resolved.phases.light.execution.model).toBe("anthropic/claude-sonnet-4-6");
expect(resolved.phases.deep.execution.model).toBe("anthropic/claude-sonnet-4-6");
expect(resolved.phases.rem.execution.model).toBe("anthropic/claude-sonnet-4-6");
expect(resolved.storage).toEqual({
mode: "both",
separateReports: true,
@@ -64,6 +69,33 @@ describe("memory dreaming host helpers", () => {
});
});
it("lets execution defaults and phase execution override the top-level dreaming model", () => {
const resolved = resolveMemoryDreamingConfig({
pluginConfig: {
dreaming: {
model: "anthropic/claude-haiku-4-5",
execution: {
defaults: {
model: "openai/gpt-5.4",
},
},
phases: {
rem: {
execution: {
model: "xai/grok-4.1-fast",
},
},
},
},
},
});
expect(resolved.execution.defaults.model).toBe("openai/gpt-5.4");
expect(resolved.phases.light.execution.model).toBe("openai/gpt-5.4");
expect(resolved.phases.deep.execution.model).toBe("openai/gpt-5.4");
expect(resolved.phases.rem.execution.model).toBe("xai/grok-4.1-fast");
});
it("falls back to cfg timezone and deep defaults", () => {
const cfg = {
agents: {

View File

@@ -291,14 +291,13 @@ function resolveExecutionConfig(
typeof temperatureRaw === "number" && Number.isFinite(temperatureRaw) && temperatureRaw >= 0
? Math.min(2, temperatureRaw)
: undefined;
const model = normalizeTrimmedString(record?.model) ?? fallback.model;
return {
speed: normalizeSpeed(record?.speed) ?? fallback.speed,
thinking: normalizeThinking(record?.thinking) ?? fallback.thinking,
budget: normalizeBudget(record?.budget) ?? fallback.budget,
...(normalizeTrimmedString(record?.model)
? { model: normalizeTrimmedString(record?.model) }
: {}),
...(model ? { model } : {}),
...(typeof maxOutputTokens === "number" ? { maxOutputTokens } : {}),
...(typeof temperature === "number" ? { temperature } : {}),
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
@@ -359,11 +358,13 @@ export function resolveMemoryDreamingConfig(params: {
const storage = asNullableRecord(dreaming?.storage);
const execution = asNullableRecord(dreaming?.execution);
const phases = asNullableRecord(dreaming?.phases);
const topLevelModel = normalizeTrimmedString(dreaming?.model);
const defaultExecution = resolveExecutionConfig(execution?.defaults, {
speed: DEFAULT_MEMORY_DREAMING_SPEED,
thinking: DEFAULT_MEMORY_DREAMING_THINKING,
budget: DEFAULT_MEMORY_DREAMING_BUDGET,
...(topLevelModel ? { model: topLevelModel } : {}),
});
const light = asNullableRecord(phases?.light);