From eb73e87f18d1e94ff240c419ffcff6776f551a3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 23:53:43 +0000 Subject: [PATCH] fix(session): prevent silent overflow on parent thread forks (#26912) Lands #26912 from @markshields-tl with configurable session.parentForkMaxTokens and docs/tests/changelog updates. Co-authored-by: Mark Shields <239231357+markshields-tl@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 4 + .../session-management-compaction.md | 1 + .../reply/agent-runner-execution.ts | 16 +++ src/auto-reply/reply/session.test.ts | 124 ++++++++++++++++++ src/auto-reply/reply/session.ts | 56 ++++++-- src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.base.ts | 6 + ...ema.session-maintenance-extensions.test.ts | 13 ++ src/config/zod-schema.session.ts | 1 + 11 files changed, 211 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2df44e3950..1bbd75dac04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. - Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting. - Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting. - Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 01ad82b6098..9d164fc4ea0 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1250,6 +1250,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden }, resetTriggers: ["/new", "/reset"], store: "~/.openclaw/agents/{agentId}/sessions/sessions.json", + parentForkMaxTokens: 100000, // skip parent-thread fork above this token count (0 disables) maintenance: { mode: "warn", // warn | enforce pruneAfter: "30d", @@ -1283,6 +1284,9 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing. - **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. - **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`. +- **`parentForkMaxTokens`**: max parent-session `totalTokens` allowed when creating a forked thread session (default `100000`). + - If parent `totalTokens` is above this value, OpenClaw starts a fresh thread session instead of inheriting parent transcript history. + - Set `0` to disable this guard and always allow parent forking. - **`mainKey`**: legacy field. Runtime now always uses `"main"` for the main direct-chat bucket. - **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins. - **`maintenance`**: session-store cleanup + retention controls. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index aff09a303e8..d258eeb6722 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -128,6 +128,7 @@ Rules of thumb: - **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`. - **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary. - **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins. +- **Thread parent fork guard** (`session.parentForkMaxTokens`, default `100000`) skips parent transcript forking when the parent session is already too large; the new thread starts fresh. Set `0` to disable. Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`. diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index eb8605ccfe1..32022f95453 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -572,6 +572,22 @@ export async function runAgentTurnWithFallback(params: { } } + // If the run completed but with an embedded context overflow error that + // wasn't recovered from (e.g. compaction reset already attempted), surface + // the error to the user instead of silently returning an empty response. + // See #26905: Slack DM sessions silently swallowed messages when context + // overflow errors were returned as embedded error payloads. + const finalEmbeddedError = runResult?.meta?.error; + const hasPayloadText = runResult?.payloads?.some((p) => p.text?.trim()); + if (finalEmbeddedError && isContextOverflowError(finalEmbeddedError.message) && !hasPayloadText) { + return { + kind: "final", + payload: { + text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.", + }, + }; + } + return { kind: "success", runId, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 8e9c99667b1..cdd8b5310c0 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -205,6 +205,130 @@ describe("initSessionState thread forking", () => { warn.mockRestore(); }); + it("skips fork and creates fresh session when parent tokens exceed threshold", async () => { + const root = await makeCaseDir("openclaw-thread-session-overflow-"); + const sessionsDir = path.join(root, "sessions"); + await fs.mkdir(sessionsDir); + + const parentSessionId = "parent-overflow"; + const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); + const header = { + type: "session", + version: 3, + id: parentSessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }; + const message = { + type: "message", + id: "m1", + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "Parent prompt" }, + }; + await fs.writeFile( + parentSessionFile, + `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`, + "utf-8", + ); + + const storePath = path.join(root, "sessions.json"); + const parentSessionKey = "agent:main:slack:channel:c1"; + // Set totalTokens well above PARENT_FORK_MAX_TOKENS (100_000) + await saveSessionStore(storePath, { + [parentSessionKey]: { + sessionId: parentSessionId, + sessionFile: parentSessionFile, + updatedAt: Date.now(), + totalTokens: 170_000, + }, + }); + + const cfg = { + session: { store: storePath }, + } as OpenClawConfig; + + const threadSessionKey = "agent:main:slack:channel:c1:thread:456"; + const result = await initSessionState({ + ctx: { + Body: "Thread reply", + SessionKey: threadSessionKey, + ParentSessionKey: parentSessionKey, + }, + cfg, + commandAuthorized: true, + }); + + // Should be marked as forked (to prevent re-attempts) but NOT actually forked from parent + expect(result.sessionEntry.forkedFromParent).toBe(true); + // Session ID should NOT match the parent — it should be a fresh UUID + expect(result.sessionEntry.sessionId).not.toBe(parentSessionId); + // Session file should NOT be the parent's file (it was not forked) + expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile); + }); + + it("respects session.parentForkMaxTokens override", async () => { + const root = await makeCaseDir("openclaw-thread-session-overflow-override-"); + const sessionsDir = path.join(root, "sessions"); + await fs.mkdir(sessionsDir); + + const parentSessionId = "parent-override"; + const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); + const header = { + type: "session", + version: 3, + id: parentSessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }; + const message = { + type: "message", + id: "m1", + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "Parent prompt" }, + }; + await fs.writeFile( + parentSessionFile, + `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`, + "utf-8", + ); + + const storePath = path.join(root, "sessions.json"); + const parentSessionKey = "agent:main:slack:channel:c1"; + await saveSessionStore(storePath, { + [parentSessionKey]: { + sessionId: parentSessionId, + sessionFile: parentSessionFile, + updatedAt: Date.now(), + totalTokens: 170_000, + }, + }); + + const cfg = { + session: { + store: storePath, + parentForkMaxTokens: 200_000, + }, + } as OpenClawConfig; + + const threadSessionKey = "agent:main:slack:channel:c1:thread:789"; + const result = await initSessionState({ + ctx: { + Body: "Thread reply", + SessionKey: threadSessionKey, + ParentSessionKey: parentSessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionEntry.forkedFromParent).toBe(true); + expect(result.sessionEntry.sessionFile).toBeTruthy(); + const forkedContent = await fs.readFile(result.sessionEntry.sessionFile ?? "", "utf-8"); + expect(forkedContent).toContain(parentSessionFile); + }); + it("records topic-specific session files when MessageThreadId is present", async () => { const root = await makeCaseDir("openclaw-topic-session-"); const storePath = path.join(root, "sessions.json"); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 6494192c58b..59b0c7ba379 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -105,6 +105,21 @@ export type SessionInitResult = { triggerBodyNormalized: string; }; +/** + * Default max parent token count beyond which thread/session parent forking is skipped. + * This prevents new thread sessions from inheriting near-full parent context. + * See #26905. + */ +const DEFAULT_PARENT_FORK_MAX_TOKENS = 100_000; + +function resolveParentForkMaxTokens(cfg: OpenClawConfig): number { + const configured = cfg.session?.parentForkMaxTokens; + if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) { + return Math.floor(configured); + } + return DEFAULT_PARENT_FORK_MAX_TOKENS; +} + function forkSessionFromParent(params: { parentEntry: SessionEntry; agentId: string; @@ -171,6 +186,7 @@ export async function initSessionState(params: { const resetTriggers = sessionCfg?.resetTriggers?.length ? sessionCfg.resetTriggers : DEFAULT_RESET_TRIGGERS; + const parentForkMaxTokens = resolveParentForkMaxTokens(cfg); const sessionScope = sessionCfg?.scope ?? "per-sender"; const storePath = resolveStorePath(sessionCfg?.store, { agentId }); @@ -399,21 +415,33 @@ export async function initSessionState(params: { sessionStore[parentSessionKey] && !alreadyForked ) { - log.warn( - `forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + - `parentTokens=${sessionStore[parentSessionKey].totalTokens ?? "?"}`, - ); - const forked = forkSessionFromParent({ - parentEntry: sessionStore[parentSessionKey], - agentId, - sessionsDir: path.dirname(storePath), - }); - if (forked) { - sessionId = forked.sessionId; - sessionEntry.sessionId = forked.sessionId; - sessionEntry.sessionFile = forked.sessionFile; + const parentTokens = sessionStore[parentSessionKey].totalTokens ?? 0; + if (parentForkMaxTokens > 0 && parentTokens > parentForkMaxTokens) { + // Parent context is too large — forking would create a thread session + // that immediately overflows the model's context window. Start fresh + // instead and mark as forked to prevent re-attempts. See #26905. + log.warn( + `skipping parent fork (parent too large): parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + + `parentTokens=${parentTokens} maxTokens=${parentForkMaxTokens}`, + ); sessionEntry.forkedFromParent = true; - log.warn(`forked session created: file=${forked.sessionFile}`); + } else { + log.warn( + `forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + + `parentTokens=${parentTokens}`, + ); + const forked = forkSessionFromParent({ + parentEntry: sessionStore[parentSessionKey], + agentId, + sessionsDir: path.dirname(storePath), + }); + if (forked) { + sessionId = forked.sessionId; + sessionEntry.sessionId = forked.sessionId; + sessionEntry.sessionFile = forked.sessionFile; + sessionEntry.forkedFromParent = true; + log.warn(`forked session created: file=${forked.sessionFile}`); + } } } const fallbackSessionFile = !sessionEntry.sessionFile diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index e5fcb3aa6b7..bf917461f56 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -973,6 +973,8 @@ export const FIELD_HELP: Record = { "Controls interval for repeated typing indicators while replies are being prepared in typing-capable channels. Increase to reduce chatty updates or decrease for more active typing feedback.", "session.typingMode": 'Controls typing behavior timing: "never", "instant", "thinking", or "message" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.', + "session.parentForkMaxTokens": + "Maximum parent-session token count allowed for thread/session inheritance forking. If the parent exceeds this, OpenClaw starts a fresh thread session instead of forking; set 0 to disable this protection.", "session.mainKey": 'Overrides the canonical main session key used for continuity when dmScope or routing logic points to "main". Use a stable value only if you intentionally need custom session anchoring.', "session.sendPolicy": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 7a12e9293ba..cd28b1fafb8 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -455,6 +455,7 @@ export const FIELD_LABELS: Record = { "session.store": "Session Store Path", "session.typingIntervalSeconds": "Session Typing Interval (seconds)", "session.typingMode": "Session Typing Mode", + "session.parentForkMaxTokens": "Session Parent Fork Max Tokens", "session.mainKey": "Session Main Key", "session.sendPolicy": "Session Send Policy", "session.sendPolicy.default": "Session Send Policy Default Action", diff --git a/src/config/types.base.ts b/src/config/types.base.ts index cb1b926b53f..676767fc901 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -112,6 +112,12 @@ export type SessionConfig = { store?: string; typingIntervalSeconds?: number; typingMode?: TypingMode; + /** + * Max parent transcript token count allowed for thread/session forking. + * If parent totalTokens is above this value, OpenClaw skips parent fork and + * starts a fresh thread session instead. Set to 0 to disable this guard. + */ + parentForkMaxTokens?: number; mainKey?: string; sendPolicy?: SessionSendPolicyConfig; agentToAgent?: { diff --git a/src/config/zod-schema.session-maintenance-extensions.test.ts b/src/config/zod-schema.session-maintenance-extensions.test.ts index 6efe8b39907..deb86999934 100644 --- a/src/config/zod-schema.session-maintenance-extensions.test.ts +++ b/src/config/zod-schema.session-maintenance-extensions.test.ts @@ -14,6 +14,19 @@ describe("SessionSchema maintenance extensions", () => { ).not.toThrow(); }); + it("accepts parentForkMaxTokens including 0 to disable the guard", () => { + expect(() => SessionSchema.parse({ parentForkMaxTokens: 100_000 })).not.toThrow(); + expect(() => SessionSchema.parse({ parentForkMaxTokens: 0 })).not.toThrow(); + }); + + it("rejects negative parentForkMaxTokens", () => { + expect(() => + SessionSchema.parse({ + parentForkMaxTokens: -1, + }), + ).toThrow(/parentForkMaxTokens/i); + }); + it("accepts disabling reset archive cleanup", () => { expect(() => SessionSchema.parse({ diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 5af707b2804..de23c50846e 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -52,6 +52,7 @@ export const SessionSchema = z store: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), typingMode: TypingModeSchema.optional(), + parentForkMaxTokens: z.number().int().nonnegative().optional(), mainKey: z.string().optional(), sendPolicy: SessionSendPolicySchema.optional(), agentToAgent: z