mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:40:42 +00:00
refactor: share parent fork policy
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown> | undefined,
|
||||
next: Record<string, unknown>,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -9,11 +9,30 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
const DEFAULT_PARENT_FORK_MAX_TOKENS = 100_000;
|
||||
let sessionForkRuntimePromise: Promise<typeof import("./session-fork.runtime.js")> | 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<typeof import("./session-fork.runtime.js")> {
|
||||
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<ParentForkDecision> {
|
||||
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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1428,7 +1428,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -706,7 +706,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user