diff --git a/CHANGELOG.md b/CHANGELOG.md index db95b3dcbb3..f875fddf882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5808,7 +5808,7 @@ Docs: https://docs.openclaw.ai - Slack/Threading: when `replyToMode="all"` auto-threads top-level Slack DMs, seed the thread session key from the message `ts` so the initial message and later replies share the same isolated `:thread:` session instead of falling back to base DM context. (#26849) Thanks @calder-sandy. - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156). -- 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. +- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and share the PI parent-fork fallback between channel threads and subagents. The legacy `session.parentForkMaxTokens` compatibility guard remains available but is no longer the preferred tuning surface. (#26912) Thanks @markshields-tl. - Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky. - Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin. - Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 64a475b3e21..ebc365c18de 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -d0b1fc318d2f737c91c21ffffae2fe12197b4ba6d49859c4786ecbc586cf5a82 config-baseline.json -3f9c52903905d82d4b4ca9dbda530cac2e059870b08c69965099ebcd09a270a3 config-baseline.core.json +f40d6dea3b81c42d5af9e340f091d34b9e39321d171fade6115da594ba90522f config-baseline.json +40b1f91714d6f17e2718ea5d34c135550f5a39fe47f781cddc153cf3a59fe2e7 config-baseline.core.json f42329d45c095881bd226bdb192c235980658fd250606d0c0badc2b12f12f5d3 config-baseline.channel.json af71b84b2411d8ccabcc6e09de0ee41f8212ff9869a6677698b6e7e3afdfaa47 config-baseline.plugin.json diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 0f0627c9072..27695bcdc80 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1174,7 +1174,6 @@ 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", @@ -1211,9 +1210,10 @@ 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. Dock commands such as `/dock_discord` use the same map to switch the active session's reply route to another linked channel peer; see [Channel docking](/concepts/channel-docking). - **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. Daily reset freshness uses the session row's `sessionStartedAt`; idle reset freshness uses `lastInteractionAt`. Background/system-event writes such as heartbeat, cron wakeups, exec notifications, and gateway bookkeeping can update `updatedAt`, but they do not keep daily/idle sessions fresh. - **`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. +- **`parentForkMaxTokens`**: deprecated compatibility guard for the historical parent fork ceiling (default `100000`). + - Channel thread sessions and subagent `context="fork"` now use the same parent fork decision path. + - When the active parent branch is too large, OpenClaw starts with isolated context instead of failing or inheriting unusable history. + - Set `0` only if you intentionally want to disable this guard. - **`mainKey`**: legacy field. Runtime always uses `"main"` for the main direct-chat bucket. - **`agentToAgent.maxPingPongTurns`**: maximum reply-back turns between agents during agent-to-agent exchanges (integer, range: `0`–`5`). `0` disables ping-pong chaining. - **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 32b92e2b334..68ce3c3505d 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -148,7 +148,7 @@ Rules of thumb: - **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. - **System events** (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may mutate the session row but do not extend daily/idle reset freshness. Reset rollover discards queued system-event notices for the previous session before the fresh prompt is built. -- **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. +- **Parent fork policy** uses PI's active branch when creating a thread or subagent fork. If that branch is too large, OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. The legacy `session.parentForkMaxTokens` key remains as a deprecated compatibility guard; set `0` only if you intentionally want to disable it. Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`. diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index a5590104bde..59e64538e74 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -194,7 +194,10 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { compact: async () => ({ ok: true, compacted: false }), ingest: async () => ({ ingested: false }), }), - resolveParentForkMaxTokens: () => 100_000, + resolveParentForkDecision: async () => ({ + status: "fork", + maxTokens: 100_000, + }), forkSessionFromParent: async () => ({ sessionId: "forked-session-id", sessionFile: "/tmp/forked-session.jsonl", diff --git a/src/agents/subagent-spawn.context.test.ts b/src/agents/subagent-spawn.context.test.ts index ad15e52ff51..713a5abedf6 100644 --- a/src/agents/subagent-spawn.context.test.ts +++ b/src/agents/subagent-spawn.context.test.ts @@ -115,6 +115,37 @@ describe("sessions_spawn context modes", () => { ); }); + it("falls back to isolated context when requested fork is too large", async () => { + const store: SessionStore = { + main: { + sessionId: "parent-session-id", + sessionFile: "/tmp/parent-session.jsonl", + updatedAt: 1, + totalTokens: 170_000, + }, + }; + usePersistentStoreMock(store); + const prepareSubagentSpawn = vi.fn(async () => undefined); + resolveContextEngineMock.mockResolvedValue({ prepareSubagentSpawn }); + + const result = await spawnSubagentDirect( + { task: "inspect the current thread", context: "fork" }, + { agentSessionKey: "main" }, + ); + + expect(result).toMatchObject({ status: "accepted", runId: "run-1" }); + expect(result.note).toContain("Parent context is too large to fork"); + expect(forkSessionFromParentMock).not.toHaveBeenCalled(); + expect(prepareSubagentSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionKey: "main", + childSessionKey: result.childSessionKey, + contextMode: "isolated", + parentSessionId: "parent-session-id", + }), + ); + }); + it("forks by default for thread-bound subagent sessions", async () => { const store: SessionStore = { main: { diff --git a/src/agents/subagent-spawn.runtime.ts b/src/agents/subagent-spawn.runtime.ts index 4c1364078c2..a6def7dd68e 100644 --- a/src/agents/subagent-spawn.runtime.ts +++ b/src/agents/subagent-spawn.runtime.ts @@ -6,7 +6,8 @@ export { getRuntimeConfig } from "../config/config.js"; export { mergeSessionEntry, updateSessionStore } from "../config/sessions.js"; export { forkSessionFromParent, - resolveParentForkMaxTokens, + resolveParentForkDecision, + type ParentForkDecision, } from "../auto-reply/reply/session-fork.js"; export { ensureContextEnginesInitialized } from "../context-engine/init.js"; export { resolveContextEngine } from "../context-engine/registry.js"; diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index df9854eae7d..28074f3d540 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -121,7 +121,7 @@ export async function loadSubagentSpawnModuleForTest(params: { updateSessionStoreMock?: MockFn; forkSessionFromParentMock?: MockFn; resolveContextEngineMock?: MockFn; - resolveParentForkMaxTokensMock?: MockFn; + resolveParentForkDecisionMock?: MockFn; pruneLegacyStoreKeysMock?: MockFn; registerSubagentRunMock?: MockFn; emitSessionLifecycleEventMock?: MockFn; @@ -182,7 +182,37 @@ export async function loadSubagentSpawnModuleForTest(params: { ensureContextEnginesInitialized: params.ensureContextEnginesInitializedMock ?? (() => undefined), resolveContextEngine: params.resolveContextEngineMock ?? (async () => ({})), - resolveParentForkMaxTokens: params.resolveParentForkMaxTokensMock ?? (() => 100_000), + resolveParentForkDecision: + params.resolveParentForkDecisionMock ?? + (async (forkParams: { + cfg?: { session?: { parentForkMaxTokens?: unknown } }; + parentEntry?: { totalTokens?: unknown }; + }) => { + const configured = forkParams.cfg?.session?.parentForkMaxTokens; + const maxTokens = + typeof configured === "number" && Number.isFinite(configured) && configured >= 0 + ? Math.floor(configured) + : 100_000; + const parentTokens = + typeof forkParams.parentEntry?.totalTokens === "number" && + Number.isFinite(forkParams.parentEntry.totalTokens) + ? Math.floor(forkParams.parentEntry.totalTokens) + : undefined; + if (maxTokens > 0 && typeof parentTokens === "number" && parentTokens > maxTokens) { + return { + status: "skip", + reason: "parent-too-large", + maxTokens, + parentTokens, + message: `Parent context is too large to fork (${parentTokens}/${maxTokens} tokens); starting with isolated context instead.`, + }; + } + return { + status: "fork", + maxTokens, + ...(typeof parentTokens === "number" ? { parentTokens } : {}), + }; + }), mergeSessionEntry: ( current: Record | undefined, next: Record, diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index c5654676658..3e6f7525245 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -56,13 +56,13 @@ import { normalizeDeliveryContext, pruneLegacyStoreKeys, ensureContextEnginesInitialized, + resolveParentForkDecision, resolveAgentConfig, resolveContextEngine, resolveDisplaySessionKey, resolveGatewaySessionStoreTarget, resolveInternalSessionKey, resolveMainSessionAlias, - resolveParentForkMaxTokens, resolveSandboxRuntimeStatus, updateSessionStore, isAdminOnlyMethod, @@ -96,7 +96,7 @@ type SubagentSpawnDeps = { getRuntimeConfig: typeof getRuntimeConfig; ensureContextEnginesInitialized: typeof ensureContextEnginesInitialized; resolveContextEngine: typeof resolveContextEngine; - resolveParentForkMaxTokens: typeof resolveParentForkMaxTokens; + resolveParentForkDecision: typeof resolveParentForkDecision; updateSessionStore: typeof updateSessionStore; }; @@ -107,7 +107,7 @@ const defaultSubagentSpawnDeps: SubagentSpawnDeps = { getRuntimeConfig, ensureContextEnginesInitialized, resolveContextEngine, - resolveParentForkMaxTokens, + resolveParentForkDecision, updateSessionStore, }; @@ -306,13 +306,20 @@ function resolveStoreEntryByKeys( } type PreparedSpawnContext = - | { status: "ok"; mode: "isolated"; parentEntry?: SessionEntry; childEntry?: SessionEntry } + | { + status: "ok"; + mode: "isolated"; + parentEntry?: SessionEntry; + childEntry?: SessionEntry; + forkFallbackNote?: string; + } | { status: "ok"; mode: "fork"; parentEntry: SessionEntry; childEntry?: SessionEntry; forked: { sessionId: string; sessionFile: string }; + forkFallbackNote?: never; } | { status: "error"; error: string }; @@ -338,7 +345,7 @@ async function prepareSubagentSessionContext(params: { let parentEntry: SessionEntry | undefined; let childEntry: SessionEntry | undefined; - const forkMaxTokens = subagentSpawnDeps.resolveParentForkMaxTokens(params.cfg); + let forkFallbackNote: string | undefined; const sessionsDir = path.dirname(parentTarget.storePath); try { @@ -356,14 +363,14 @@ async function prepareSubagentSessionContext(params: { 'context="fork" requested but the requester session transcript is not available.', ); } - const parentTokens = - typeof parentEntry.totalTokens === "number" && Number.isFinite(parentEntry.totalTokens) - ? parentEntry.totalTokens - : 0; - if (forkMaxTokens > 0 && parentTokens > forkMaxTokens) { - throw new Error( - `context="fork" requested but requester context is too large to fork (${parentTokens}/${forkMaxTokens} tokens). Use context="isolated" or compact first.`, - ); + const forkDecision = await subagentSpawnDeps.resolveParentForkDecision({ + cfg: params.cfg, + parentEntry, + storePath: parentTarget.storePath, + }); + if (forkDecision.status === "skip") { + forkFallbackNote = forkDecision.message; + return null; } const fork = await subagentSpawnDeps.forkSessionFromParent({ @@ -392,6 +399,15 @@ async function prepareSubagentSessionContext(params: { if (params.contextMode === "fork") { if (!parentEntry || !forked) { + if (forkFallbackNote) { + return { + status: "ok", + mode: "isolated", + parentEntry, + childEntry, + forkFallbackNote, + }; + } return { status: "error", error: 'context="fork" requested but OpenClaw could not prepare forked context.', @@ -405,7 +421,13 @@ async function prepareSubagentSessionContext(params: { forked, }; } - return { status: "ok", mode: "isolated", parentEntry, childEntry }; + return { + status: "ok", + mode: "isolated", + parentEntry, + childEntry, + ...(forkFallbackNote ? { forkFallbackNote } : {}), + }; } catch (err) { return { status: "error", error: summarizeError(err) }; } @@ -1276,15 +1298,18 @@ export async function spawnSubagentDirect( label: label || undefined, }); + const acceptedNote = resolveSubagentSpawnAcceptedNote({ + spawnMode, + agentSessionKey: ctx.agentSessionKey, + }); return { status: "accepted", childSessionKey, runId: childRunId, mode: spawnMode, - note: resolveSubagentSpawnAcceptedNote({ - spawnMode, - agentSessionKey: ctx.agentSessionKey, - }), + note: preparedSpawnContext.forkFallbackNote + ? `${acceptedNote} ${preparedSpawnContext.forkFallbackNote}` + : acceptedNote, modelApplied: resolvedModel ? modelApplied : undefined, attachments: attachmentsReceipt, }; diff --git a/src/auto-reply/reply/session-fork.ts b/src/auto-reply/reply/session-fork.ts index cdd3ecaf30a..737d9a1ad6b 100644 --- a/src/auto-reply/reply/session-fork.ts +++ b/src/auto-reply/reply/session-fork.ts @@ -9,11 +9,30 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; const DEFAULT_PARENT_FORK_MAX_TOKENS = 100_000; let sessionForkRuntimePromise: Promise | null = null; +export type ParentForkDecision = + | { + status: "fork"; + maxTokens: number; + parentTokens?: number; + } + | { + status: "skip"; + reason: "parent-too-large"; + maxTokens: number; + parentTokens: number; + message: string; + }; + function loadSessionForkRuntime(): Promise { sessionForkRuntimePromise ??= import("./session-fork.runtime.js"); return sessionForkRuntimePromise; } +/** + * Deprecated compatibility guard for deployments that explicitly tuned the + * historical thread fork ceiling. New behavior should use the shared parent + * fork decision helper so channel threads and subagents degrade the same way. + */ export function resolveParentForkMaxTokens(cfg: OpenClawConfig): number { const configured = cfg.session?.parentForkMaxTokens; if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) { @@ -22,6 +41,45 @@ export function resolveParentForkMaxTokens(cfg: OpenClawConfig): number { return DEFAULT_PARENT_FORK_MAX_TOKENS; } +export function formatParentForkTooLargeMessage(params: { + parentTokens: number; + maxTokens: number; +}): string { + return ( + `Parent context is too large to fork (${params.parentTokens}/${params.maxTokens} tokens); ` + + "starting with isolated context instead." + ); +} + +export async function resolveParentForkDecision(params: { + cfg: OpenClawConfig; + parentEntry: SessionEntry; + storePath: string; +}): Promise { + const maxTokens = resolveParentForkMaxTokens(params.cfg); + if (maxTokens <= 0) { + return { status: "fork", maxTokens }; + } + const parentTokens = await resolveParentForkTokenCount({ + parentEntry: params.parentEntry, + storePath: params.storePath, + }); + if (typeof parentTokens === "number" && parentTokens > maxTokens) { + return { + status: "skip", + reason: "parent-too-large", + maxTokens, + parentTokens, + message: formatParentForkTooLargeMessage({ parentTokens, maxTokens }), + }; + } + return { + status: "fork", + maxTokens, + ...(typeof parentTokens === "number" ? { parentTokens } : {}), + }; +} + export async function forkSessionFromParent(params: { parentEntry: SessionEntry; agentId: string; diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 25f8a04a3fe..d342a6f633c 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -52,11 +52,37 @@ vi.mock("./session-fork.js", () => ({ sessionForkMocks.forkSessionFromParent(...args), resolveParentForkTokenCount: (...args: [{ parentEntry: SessionEntry; storePath: string }]) => sessionForkMocks.resolveParentForkTokenCount(...args), - resolveParentForkMaxTokens: (cfg: { session?: { parentForkMaxTokens?: unknown } }) => { - const configured = cfg.session?.parentForkMaxTokens; - return typeof configured === "number" && Number.isFinite(configured) && configured >= 0 - ? Math.floor(configured) - : 100_000; + resolveParentForkDecision: async (params: { + cfg: { session?: { parentForkMaxTokens?: unknown } }; + parentEntry: SessionEntry; + storePath: string; + }) => { + const configured = params.cfg.session?.parentForkMaxTokens; + const maxTokens = + typeof configured === "number" && Number.isFinite(configured) && configured >= 0 + ? Math.floor(configured) + : 100_000; + if (maxTokens <= 0) { + return { status: "fork", maxTokens }; + } + const parentTokens = await sessionForkMocks.resolveParentForkTokenCount({ + parentEntry: params.parentEntry, + storePath: params.storePath, + }); + if (typeof parentTokens === "number" && parentTokens > maxTokens) { + return { + status: "skip", + reason: "parent-too-large", + maxTokens, + parentTokens, + message: `Parent context is too large to fork (${parentTokens}/${maxTokens} tokens); starting with isolated context instead.`, + }; + } + return { + status: "fork", + maxTokens, + ...(typeof parentTokens === "number" ? { parentTokens } : {}), + }; }, })); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index cb7730bfea3..f12cf6f8298 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -60,11 +60,7 @@ import { resolveLastChannelRaw, resolveLastToRaw, } from "./session-delivery.js"; -import { - forkSessionFromParent, - resolveParentForkMaxTokens, - resolveParentForkTokenCount, -} from "./session-fork.js"; +import { forkSessionFromParent, resolveParentForkDecision } from "./session-fork.js"; import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./session-hooks.js"; import { clearSessionResetRuntimeState } from "./session-reset-cleanup.js"; @@ -274,7 +270,6 @@ 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 }); const ingressTimingEnabled = process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1"; @@ -705,30 +700,23 @@ export async function initSessionState(params: { !alreadyForked ) { const parentEntry = sessionStore[parentSessionKey]; - const parentTokens = - parentForkMaxTokens > 0 - ? await resolveParentForkTokenCount({ - parentEntry, - storePath, - }) - : undefined; - if ( - parentForkMaxTokens > 0 && - typeof parentTokens === "number" && - 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. + const forkDecision = await resolveParentForkDecision({ + cfg, + parentEntry, + storePath, + }); + if (forkDecision.status === "skip") { + // The parent branch is too large to inherit usefully. Start fresh and + // mark as handled so the thread does not retry this decision every turn. log.warn( `skipping parent fork (parent too large): parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + - `parentTokens=${parentTokens} maxTokens=${parentForkMaxTokens}`, + `parentTokens=${forkDecision.parentTokens} maxTokens=${forkDecision.maxTokens}`, ); sessionEntry.forkedFromParent = true; } else { log.warn( `forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` + - `parentTokens=${parentTokens ?? "unknown"}`, + `parentTokens=${forkDecision.parentTokens ?? "unknown"}`, ); const forked = await forkSessionFromParent({ parentEntry, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 537e85535cd..a82b02c21b0 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -20696,9 +20696,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "integer", minimum: 0, maximum: 9007199254740991, - title: "Session Parent Fork Max Tokens", + title: "Deprecated Session Parent Fork Max Tokens", description: - "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.", + "Deprecated compatibility guard for the historical parent-session fork ceiling. OpenClaw now shares the parent fork decision between channel threads and subagents and falls back to isolated context when the active parent branch is too large; set 0 only to disable that guard intentionally.", }, mainKey: { type: "string", @@ -27842,8 +27842,8 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { tags: ["storage"], }, "session.parentForkMaxTokens": { - label: "Session Parent Fork Max Tokens", - help: "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.", + label: "Deprecated Session Parent Fork Max Tokens", + help: "Deprecated compatibility guard for the historical parent-session fork ceiling. OpenClaw now shares the parent fork decision between channel threads and subagents and falls back to isolated context when the active parent branch is too large; set 0 only to disable that guard intentionally.", tags: ["security", "auth", "performance", "storage"], }, "session.mainKey": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 232455c81d2..d14b6ad6db0 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1428,7 +1428,7 @@ export const FIELD_HELP: Record = { "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.", + "Deprecated compatibility guard for the historical parent-session fork ceiling. OpenClaw now shares the parent fork decision between channel threads and subagents and falls back to isolated context when the active parent branch is too large; set 0 only to disable that guard intentionally.", "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 9e32c8e0cf7..412f2448c7c 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -706,7 +706,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.parentForkMaxTokens": "Deprecated 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 91b562110da..31e05d802a9 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -182,9 +182,11 @@ export type SessionConfig = { 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. + * @deprecated Compatibility guard for the historical parent fork ceiling. + * OpenClaw now uses a shared parent fork decision path for channel threads + * and subagents, and falls back to isolated context when the active parent + * branch is too large. Set to 0 only if you intentionally want to disable + * that compatibility guard. */ parentForkMaxTokens?: number; mainKey?: string; diff --git a/src/config/zod-schema.session-maintenance-extensions.test.ts b/src/config/zod-schema.session-maintenance-extensions.test.ts index deb86999934..9a26d07525d 100644 --- a/src/config/zod-schema.session-maintenance-extensions.test.ts +++ b/src/config/zod-schema.session-maintenance-extensions.test.ts @@ -14,7 +14,7 @@ describe("SessionSchema maintenance extensions", () => { ).not.toThrow(); }); - it("accepts parentForkMaxTokens including 0 to disable the guard", () => { + it("accepts deprecated parentForkMaxTokens including 0 to disable the guard", () => { expect(() => SessionSchema.parse({ parentForkMaxTokens: 100_000 })).not.toThrow(); expect(() => SessionSchema.parse({ parentForkMaxTokens: 0 })).not.toThrow(); }); diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 7b5f7c53b9c..7ef92ee1ddf 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -53,6 +53,7 @@ export const SessionSchema = z store: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), typingMode: TypingModeSchema.optional(), + /** @deprecated Compatibility guard for the historical parent fork ceiling. */ parentForkMaxTokens: z.number().int().nonnegative().optional(), mainKey: z.string().optional(), sendPolicy: SessionSendPolicySchema.optional(),