Skip to content

Commit eb73e87

Browse files
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>
1 parent 8d1481c commit eb73e87

11 files changed

Lines changed: 211 additions & 14 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
### Fixes
1414

15+
- 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.
1516
- 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.
1617
- 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.
1718
- 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.

docs/gateway/configuration-reference.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
12501250
},
12511251
resetTriggers: ["/new", "/reset"],
12521252
store: "~/.openclaw/agents/{agentId}/sessions/sessions.json",
1253+
parentForkMaxTokens: 100000, // skip parent-thread fork above this token count (0 disables)
12531254
maintenance: {
12541255
mode: "warn", // warn | enforce
12551256
pruneAfter: "30d",
@@ -1283,6 +1284,9 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
12831284
- **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing.
12841285
- **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins.
12851286
- **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`.
1287+
- **`parentForkMaxTokens`**: max parent-session `totalTokens` allowed when creating a forked thread session (default `100000`).
1288+
- If parent `totalTokens` is above this value, OpenClaw starts a fresh thread session instead of inheriting parent transcript history.
1289+
- Set `0` to disable this guard and always allow parent forking.
12861290
- **`mainKey`**: legacy field. Runtime now always uses `"main"` for the main direct-chat bucket.
12871291
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.
12881292
- **`maintenance`**: session-store cleanup + retention controls.

docs/reference/session-management-compaction.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ Rules of thumb:
128128
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
129129
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
130130
- **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.
131+
- **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.
131132

132133
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
133134

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,22 @@ export async function runAgentTurnWithFallback(params: {
572572
}
573573
}
574574

575+
// If the run completed but with an embedded context overflow error that
576+
// wasn't recovered from (e.g. compaction reset already attempted), surface
577+
// the error to the user instead of silently returning an empty response.
578+
// See #26905: Slack DM sessions silently swallowed messages when context
579+
// overflow errors were returned as embedded error payloads.
580+
const finalEmbeddedError = runResult?.meta?.error;
581+
const hasPayloadText = runResult?.payloads?.some((p) => p.text?.trim());
582+
if (finalEmbeddedError && isContextOverflowError(finalEmbeddedError.message) && !hasPayloadText) {
583+
return {
584+
kind: "final",
585+
payload: {
586+
text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.",
587+
},
588+
};
589+
}
590+
575591
return {
576592
kind: "success",
577593
runId,

src/auto-reply/reply/session.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,130 @@ describe("initSessionState thread forking", () => {
205205
warn.mockRestore();
206206
});
207207

208+
it("skips fork and creates fresh session when parent tokens exceed threshold", async () => {
209+
const root = await makeCaseDir("openclaw-thread-session-overflow-");
210+
const sessionsDir = path.join(root, "sessions");
211+
await fs.mkdir(sessionsDir);
212+
213+
const parentSessionId = "parent-overflow";
214+
const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
215+
const header = {
216+
type: "session",
217+
version: 3,
218+
id: parentSessionId,
219+
timestamp: new Date().toISOString(),
220+
cwd: process.cwd(),
221+
};
222+
const message = {
223+
type: "message",
224+
id: "m1",
225+
parentId: null,
226+
timestamp: new Date().toISOString(),
227+
message: { role: "user", content: "Parent prompt" },
228+
};
229+
await fs.writeFile(
230+
parentSessionFile,
231+
`${JSON.stringify(header)}\n${JSON.stringify(message)}\n`,
232+
"utf-8",
233+
);
234+
235+
const storePath = path.join(root, "sessions.json");
236+
const parentSessionKey = "agent:main:slack:channel:c1";
237+
// Set totalTokens well above PARENT_FORK_MAX_TOKENS (100_000)
238+
await saveSessionStore(storePath, {
239+
[parentSessionKey]: {
240+
sessionId: parentSessionId,
241+
sessionFile: parentSessionFile,
242+
updatedAt: Date.now(),
243+
totalTokens: 170_000,
244+
},
245+
});
246+
247+
const cfg = {
248+
session: { store: storePath },
249+
} as OpenClawConfig;
250+
251+
const threadSessionKey = "agent:main:slack:channel:c1:thread:456";
252+
const result = await initSessionState({
253+
ctx: {
254+
Body: "Thread reply",
255+
SessionKey: threadSessionKey,
256+
ParentSessionKey: parentSessionKey,
257+
},
258+
cfg,
259+
commandAuthorized: true,
260+
});
261+
262+
// Should be marked as forked (to prevent re-attempts) but NOT actually forked from parent
263+
expect(result.sessionEntry.forkedFromParent).toBe(true);
264+
// Session ID should NOT match the parent — it should be a fresh UUID
265+
expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
266+
// Session file should NOT be the parent's file (it was not forked)
267+
expect(result.sessionEntry.sessionFile).not.toBe(parentSessionFile);
268+
});
269+
270+
it("respects session.parentForkMaxTokens override", async () => {
271+
const root = await makeCaseDir("openclaw-thread-session-overflow-override-");
272+
const sessionsDir = path.join(root, "sessions");
273+
await fs.mkdir(sessionsDir);
274+
275+
const parentSessionId = "parent-override";
276+
const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
277+
const header = {
278+
type: "session",
279+
version: 3,
280+
id: parentSessionId,
281+
timestamp: new Date().toISOString(),
282+
cwd: process.cwd(),
283+
};
284+
const message = {
285+
type: "message",
286+
id: "m1",
287+
parentId: null,
288+
timestamp: new Date().toISOString(),
289+
message: { role: "user", content: "Parent prompt" },
290+
};
291+
await fs.writeFile(
292+
parentSessionFile,
293+
`${JSON.stringify(header)}\n${JSON.stringify(message)}\n`,
294+
"utf-8",
295+
);
296+
297+
const storePath = path.join(root, "sessions.json");
298+
const parentSessionKey = "agent:main:slack:channel:c1";
299+
await saveSessionStore(storePath, {
300+
[parentSessionKey]: {
301+
sessionId: parentSessionId,
302+
sessionFile: parentSessionFile,
303+
updatedAt: Date.now(),
304+
totalTokens: 170_000,
305+
},
306+
});
307+
308+
const cfg = {
309+
session: {
310+
store: storePath,
311+
parentForkMaxTokens: 200_000,
312+
},
313+
} as OpenClawConfig;
314+
315+
const threadSessionKey = "agent:main:slack:channel:c1:thread:789";
316+
const result = await initSessionState({
317+
ctx: {
318+
Body: "Thread reply",
319+
SessionKey: threadSessionKey,
320+
ParentSessionKey: parentSessionKey,
321+
},
322+
cfg,
323+
commandAuthorized: true,
324+
});
325+
326+
expect(result.sessionEntry.forkedFromParent).toBe(true);
327+
expect(result.sessionEntry.sessionFile).toBeTruthy();
328+
const forkedContent = await fs.readFile(result.sessionEntry.sessionFile ?? "", "utf-8");
329+
expect(forkedContent).toContain(parentSessionFile);
330+
});
331+
208332
it("records topic-specific session files when MessageThreadId is present", async () => {
209333
const root = await makeCaseDir("openclaw-topic-session-");
210334
const storePath = path.join(root, "sessions.json");

src/auto-reply/reply/session.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,21 @@ export type SessionInitResult = {
105105
triggerBodyNormalized: string;
106106
};
107107

108+
/**
109+
* Default max parent token count beyond which thread/session parent forking is skipped.
110+
* This prevents new thread sessions from inheriting near-full parent context.
111+
* See #26905.
112+
*/
113+
const DEFAULT_PARENT_FORK_MAX_TOKENS = 100_000;
114+
115+
function resolveParentForkMaxTokens(cfg: OpenClawConfig): number {
116+
const configured = cfg.session?.parentForkMaxTokens;
117+
if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) {
118+
return Math.floor(configured);
119+
}
120+
return DEFAULT_PARENT_FORK_MAX_TOKENS;
121+
}
122+
108123
function forkSessionFromParent(params: {
109124
parentEntry: SessionEntry;
110125
agentId: string;
@@ -171,6 +186,7 @@ export async function initSessionState(params: {
171186
const resetTriggers = sessionCfg?.resetTriggers?.length
172187
? sessionCfg.resetTriggers
173188
: DEFAULT_RESET_TRIGGERS;
189+
const parentForkMaxTokens = resolveParentForkMaxTokens(cfg);
174190
const sessionScope = sessionCfg?.scope ?? "per-sender";
175191
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
176192

@@ -399,21 +415,33 @@ export async function initSessionState(params: {
399415
sessionStore[parentSessionKey] &&
400416
!alreadyForked
401417
) {
402-
log.warn(
403-
`forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
404-
`parentTokens=${sessionStore[parentSessionKey].totalTokens ?? "?"}`,
405-
);
406-
const forked = forkSessionFromParent({
407-
parentEntry: sessionStore[parentSessionKey],
408-
agentId,
409-
sessionsDir: path.dirname(storePath),
410-
});
411-
if (forked) {
412-
sessionId = forked.sessionId;
413-
sessionEntry.sessionId = forked.sessionId;
414-
sessionEntry.sessionFile = forked.sessionFile;
418+
const parentTokens = sessionStore[parentSessionKey].totalTokens ?? 0;
419+
if (parentForkMaxTokens > 0 && parentTokens > parentForkMaxTokens) {
420+
// Parent context is too large — forking would create a thread session
421+
// that immediately overflows the model's context window. Start fresh
422+
// instead and mark as forked to prevent re-attempts. See #26905.
423+
log.warn(
424+
`skipping parent fork (parent too large): parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
425+
`parentTokens=${parentTokens} maxTokens=${parentForkMaxTokens}`,
426+
);
415427
sessionEntry.forkedFromParent = true;
416-
log.warn(`forked session created: file=${forked.sessionFile}`);
428+
} else {
429+
log.warn(
430+
`forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
431+
`parentTokens=${parentTokens}`,
432+
);
433+
const forked = forkSessionFromParent({
434+
parentEntry: sessionStore[parentSessionKey],
435+
agentId,
436+
sessionsDir: path.dirname(storePath),
437+
});
438+
if (forked) {
439+
sessionId = forked.sessionId;
440+
sessionEntry.sessionId = forked.sessionId;
441+
sessionEntry.sessionFile = forked.sessionFile;
442+
sessionEntry.forkedFromParent = true;
443+
log.warn(`forked session created: file=${forked.sessionFile}`);
444+
}
417445
}
418446
}
419447
const fallbackSessionFile = !sessionEntry.sessionFile

src/config/schema.help.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,8 @@ export const FIELD_HELP: Record<string, string> = {
973973
"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.",
974974
"session.typingMode":
975975
'Controls typing behavior timing: "never", "instant", "thinking", or "message" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.',
976+
"session.parentForkMaxTokens":
977+
"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.",
976978
"session.mainKey":
977979
'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.',
978980
"session.sendPolicy":

src/config/schema.labels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ export const FIELD_LABELS: Record<string, string> = {
455455
"session.store": "Session Store Path",
456456
"session.typingIntervalSeconds": "Session Typing Interval (seconds)",
457457
"session.typingMode": "Session Typing Mode",
458+
"session.parentForkMaxTokens": "Session Parent Fork Max Tokens",
458459
"session.mainKey": "Session Main Key",
459460
"session.sendPolicy": "Session Send Policy",
460461
"session.sendPolicy.default": "Session Send Policy Default Action",

src/config/types.base.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ export type SessionConfig = {
112112
store?: string;
113113
typingIntervalSeconds?: number;
114114
typingMode?: TypingMode;
115+
/**
116+
* Max parent transcript token count allowed for thread/session forking.
117+
* If parent totalTokens is above this value, OpenClaw skips parent fork and
118+
* starts a fresh thread session instead. Set to 0 to disable this guard.
119+
*/
120+
parentForkMaxTokens?: number;
115121
mainKey?: string;
116122
sendPolicy?: SessionSendPolicyConfig;
117123
agentToAgent?: {

src/config/zod-schema.session-maintenance-extensions.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ describe("SessionSchema maintenance extensions", () => {
1414
).not.toThrow();
1515
});
1616

17+
it("accepts parentForkMaxTokens including 0 to disable the guard", () => {
18+
expect(() => SessionSchema.parse({ parentForkMaxTokens: 100_000 })).not.toThrow();
19+
expect(() => SessionSchema.parse({ parentForkMaxTokens: 0 })).not.toThrow();
20+
});
21+
22+
it("rejects negative parentForkMaxTokens", () => {
23+
expect(() =>
24+
SessionSchema.parse({
25+
parentForkMaxTokens: -1,
26+
}),
27+
).toThrow(/parentForkMaxTokens/i);
28+
});
29+
1730
it("accepts disabling reset archive cleanup", () => {
1831
expect(() =>
1932
SessionSchema.parse({

0 commit comments

Comments
 (0)