From 59449d7f19cca9a7775ea26a56b22318abfd1fdf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 07:26:50 +0100 Subject: [PATCH] fix(active-memory): make setup grace explicit --- CHANGELOG.md | 1 + docs/concepts/active-memory.md | 37 ++++++++++--------- extensions/active-memory/config.test.ts | 28 ++++++++++++++ extensions/active-memory/index.test.ts | 29 +++++++++++++-- extensions/active-memory/index.ts | 9 +++-- extensions/active-memory/openclaw.plugin.json | 5 +++ 6 files changed, 84 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 898b364edbb..48edd940802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Active Memory: use the configured recall timeout as the blocking prompt-build hook budget by default and move cold-start setup grace behind explicit `setupGraceTimeoutMs` config, so the plugin no longer silently extends 15000 ms configs to 45000 ms on the main lane. Fixes #75843. Thanks @vishutdhar. - Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan. - Agents/models: keep legacy CLI runtime model refs such as `claude-cli/*` in the configured allowlist after canonical runtime migration, so cron `payload.model` overrides keep working. Fixes #75753. Thanks @RyanSandoval. - Gateway/watch: keep colored subsystem log prefixes in the managed tmux pane even when the parent shell exports `NO_COLOR`, while preserving explicit `FORCE_COLOR=0` opt-out. Thanks @vincentkoc. diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index 1f5cc7a3676..de06d4a52fa 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -558,24 +558,25 @@ plugins.entries.active-memory The most important fields are: -| Key | Type | Meaning | -| --------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `enabled` | `boolean` | Enables the plugin itself | -| `config.agents` | `string[]` | Agent ids that may use active memory | -| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model | -| `config.allowedChatTypes` | `("direct" \| "group" \| "channel")[]` | Session types that may run Active Memory; defaults to direct-message style sessions | -| `config.allowedChatIds` | `string[]` | Optional per-conversation allowlist applied after `allowedChatTypes`; non-empty lists fail closed | -| `config.deniedChatIds` | `string[]` | Optional per-conversation denylist that overrides allowed session types and allowed ids | -| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees | -| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory | -| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive" \| "max"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed | -| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use | -| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt | -| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent, capped at 120000 ms | -| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary | -| `config.logging` | `boolean` | Emits active memory logs while tuning | -| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files | -| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder | +| Key | Type | Meaning | +| ---------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `enabled` | `boolean` | Enables the plugin itself | +| `config.agents` | `string[]` | Agent ids that may use active memory | +| `config.model` | `string` | Optional blocking memory sub-agent model ref; when unset, active memory uses the current session model | +| `config.allowedChatTypes` | `("direct" \| "group" \| "channel")[]` | Session types that may run Active Memory; defaults to direct-message style sessions | +| `config.allowedChatIds` | `string[]` | Optional per-conversation allowlist applied after `allowedChatTypes`; non-empty lists fail closed | +| `config.deniedChatIds` | `string[]` | Optional per-conversation denylist that overrides allowed session types and allowed ids | +| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory sub-agent sees | +| `config.promptStyle` | `"balanced" \| "strict" \| "contextual" \| "recall-heavy" \| "precision-heavy" \| "preference-only"` | Controls how eager or strict the blocking memory sub-agent is when deciding whether to return memory | +| `config.thinking` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh" \| "adaptive" \| "max"` | Advanced thinking override for the blocking memory sub-agent; default `off` for speed | +| `config.promptOverride` | `string` | Advanced full prompt replacement; not recommended for normal use | +| `config.promptAppend` | `string` | Advanced extra instructions appended to the default or overridden prompt | +| `config.timeoutMs` | `number` | Hard timeout for the blocking memory sub-agent, capped at 120000 ms | +| `config.setupGraceTimeoutMs` | `number` | Advanced extra setup budget before the recall timeout expires; defaults to 0 and is capped at 30000 ms | +| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary | +| `config.logging` | `boolean` | Emits active memory logs while tuning | +| `config.persistTranscripts` | `boolean` | Keeps blocking memory sub-agent transcripts on disk instead of deleting temp files | +| `config.transcriptDir` | `string` | Relative blocking memory sub-agent transcript directory under the agent sessions folder | Useful tuning fields: diff --git a/extensions/active-memory/config.test.ts b/extensions/active-memory/config.test.ts index b50f3b29620..1b9aa512ebd 100644 --- a/extensions/active-memory/config.test.ts +++ b/extensions/active-memory/config.test.ts @@ -36,6 +36,20 @@ describe("active-memory manifest config schema", () => { expect(result.ok).toBe(true); }); + it("accepts setupGraceTimeoutMs values at the runtime ceiling", () => { + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "active-memory.manifest.setup-grace-timeout-ceiling", + value: { + enabled: true, + agents: ["main"], + setupGraceTimeoutMs: 30_000, + }, + }); + + expect(result.ok).toBe(true); + }); + it("accepts explicit in allowedChatTypes", () => { const result = validateJsonSchemaValue({ schema: manifest.configSchema, @@ -64,6 +78,20 @@ describe("active-memory manifest config schema", () => { expect(result.ok).toBe(false); }); + it("rejects setupGraceTimeoutMs values above the runtime ceiling", () => { + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "active-memory.manifest.setup-grace-timeout-above-ceiling", + value: { + enabled: true, + agents: ["main"], + setupGraceTimeoutMs: 30_001, + }, + }); + + expect(result.ok).toBe(false); + }); + it("rejects unknown allowedChatTypes values", () => { const result = validateJsonSchemaValue({ schema: manifest.configSchema, diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 182efd637c3..88f0861d45b 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -185,18 +185,29 @@ describe("active-memory plugin", () => { it("registers a before_prompt_build hook", () => { expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function), { - timeoutMs: 45_000, + timeoutMs: 15_000, }); - expect(hookOptions.before_prompt_build?.timeoutMs).toBe(45_000); + expect(hookOptions.before_prompt_build?.timeoutMs).toBe(15_000); }); - it("registers before_prompt_build with the configured recall timeout plus setup grace", () => { + it("registers before_prompt_build with the configured recall timeout", () => { api.pluginConfig = { agents: ["main"], timeoutMs: 90_000, }; plugin.register(api as unknown as OpenClawPluginApi); + expect(hookOptions.before_prompt_build?.timeoutMs).toBe(90_000); + }); + + it("registers before_prompt_build with explicit setup grace when configured", () => { + api.pluginConfig = { + agents: ["main"], + timeoutMs: 90_000, + setupGraceTimeoutMs: 30_000, + }; + plugin.register(api as unknown as OpenClawPluginApi); + expect(hookOptions.before_prompt_build?.timeoutMs).toBe(120_000); }); @@ -2178,10 +2189,10 @@ describe("active-memory plugin", () => { it("does not spend the model timeout budget on active-memory subagent setup", async () => { const CONFIGURED_TIMEOUT_MS = 10; __testing.setMinimumTimeoutMsForTests(1); - __testing.setSetupGraceTimeoutMsForTests(100); api.pluginConfig = { agents: ["main"], timeoutMs: CONFIGURED_TIMEOUT_MS, + setupGraceTimeoutMs: 100, logging: true, }; plugin.register(api as unknown as OpenClawPluginApi); @@ -3242,6 +3253,16 @@ describe("active-memory plugin", () => { expect(config.circuitBreakerCooldownMs).toBe(60_000); }); + it("normalizes setup grace config with a zero default and bounded opt-in", () => { + expect(__testing.normalizePluginConfig({}).setupGraceTimeoutMs).toBe(0); + expect( + __testing.normalizePluginConfig({ setupGraceTimeoutMs: 30_001 }).setupGraceTimeoutMs, + ).toBe(30_000); + expect(__testing.normalizePluginConfig({ setupGraceTimeoutMs: -1 }).setupGraceTimeoutMs).toBe( + 0, + ); + }); + it("clamps circuit breaker config within valid ranges", () => { const config = __testing.normalizePluginConfig({ circuitBreakerMaxTimeouts: 0, diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 24639081981..3b494433856 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -35,7 +35,7 @@ const DEFAULT_CACHE_TTL_MS = 15_000; const DEFAULT_MAX_CACHE_ENTRIES = 1000; const CACHE_SWEEP_INTERVAL_MS = 1000; const DEFAULT_MIN_TIMEOUT_MS = 250; -const DEFAULT_SETUP_GRACE_TIMEOUT_MS = 30_000; +const DEFAULT_SETUP_GRACE_TIMEOUT_MS = 0; const DEFAULT_QUERY_MODE = "recent" as const; const DEFAULT_QMD_SEARCH_MODE = "search" as const; const DEFAULT_TRANSCRIPT_DIR = "active-memory"; @@ -91,6 +91,7 @@ type ActiveRecallPluginConfig = { promptOverride?: string; promptAppend?: string; timeoutMs?: number; + setupGraceTimeoutMs?: number; queryMode?: "message" | "recent" | "full"; maxSummaryChars?: number; recentUserTurns?: number; @@ -130,6 +131,7 @@ type ResolvedActiveRecallPluginConfig = { promptOverride?: string; promptAppend?: string; timeoutMs: number; + setupGraceTimeoutMs: number; queryMode: "message" | "recent" | "full"; maxSummaryChars: number; recentUserTurns: number; @@ -746,6 +748,7 @@ function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPlugi minimumTimeoutMs, 120_000, ), + setupGraceTimeoutMs: clampInt(raw.setupGraceTimeoutMs, setupGraceTimeoutMs, 0, 30_000), queryMode: raw.queryMode === "message" || raw.queryMode === "recent" || raw.queryMode === "full" ? raw.queryMode @@ -2280,7 +2283,7 @@ async function maybeResolveActiveRecall(params: { const controller = new AbortController(); const TIMEOUT_SENTINEL = Symbol("timeout"); let sessionFile: string | undefined; - const watchdogTimeoutMs = params.config.timeoutMs + setupGraceTimeoutMs; + const watchdogTimeoutMs = params.config.timeoutMs + params.config.setupGraceTimeoutMs; const timeoutId = setTimeout(() => { controller.abort(new Error(`active-memory timeout after ${watchdogTimeoutMs}ms`)); }, watchdogTimeoutMs); @@ -2535,7 +2538,7 @@ export default definePluginEntry({ }, }); - const beforePromptBuildTimeoutMs = config.timeoutMs + setupGraceTimeoutMs; + const beforePromptBuildTimeoutMs = config.timeoutMs + config.setupGraceTimeoutMs; api.on( "before_prompt_build", async (event, ctx) => { diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json index 29a3add6fdd..a19b28a9820 100644 --- a/extensions/active-memory/openclaw.plugin.json +++ b/extensions/active-memory/openclaw.plugin.json @@ -40,6 +40,7 @@ "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"] }, "timeoutMs": { "type": "integer", "minimum": 250, "maximum": 120000 }, + "setupGraceTimeoutMs": { "type": "integer", "minimum": 0, "maximum": 30000 }, "queryMode": { "type": "string", "enum": ["message", "recent", "full"] @@ -116,6 +117,10 @@ "timeoutMs": { "label": "Timeout (ms)" }, + "setupGraceTimeoutMs": { + "label": "Setup Grace Timeout (ms)", + "help": "Advanced: extra blocking budget for cold embedded-run setup before the recall timeout is considered exhausted. Defaults to 0 so timeoutMs remains the main-lane hook budget unless you opt in." + }, "queryMode": { "label": "Query Mode", "help": "Choose whether the blocking memory sub-agent sees only the latest user message, a small recent tail, or the full conversation."