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>
This commit is contained in:
Peter Steinberger
2026-02-25 23:53:43 +00:00
parent 8d1481cb4a
commit eb73e87f18
11 changed files with 211 additions and 14 deletions

View File

@@ -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