mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:41:37 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user