mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
refactor: add embedded run session target seam (#90439)
This commit is contained in:
@@ -14,9 +14,7 @@ import type {
|
||||
ContextEngineRuntimeSettings,
|
||||
} from "../../context-engine/types.js";
|
||||
import {
|
||||
captureCompactionCheckpointSnapshotAsync,
|
||||
cleanupCompactionCheckpointSnapshot,
|
||||
persistSessionCompactionCheckpoint,
|
||||
createFileBackedCompactionCheckpointStore,
|
||||
readSessionLeafStateFromTranscriptAsync,
|
||||
resolveCompactionCheckpointTranscriptPosition,
|
||||
resolveSessionCompactionCheckpointReason,
|
||||
@@ -63,6 +61,8 @@ import { resolveModelAsync } from "./model.js";
|
||||
import type { EmbeddedAgentCompactResult } from "./types.js";
|
||||
import { normalizeContextTokenBudget } from "./utils.js";
|
||||
|
||||
const compactionCheckpointStore = createFileBackedCompactionCheckpointStore();
|
||||
|
||||
function shouldFallbackAfterHarnessCompaction(
|
||||
result: EmbeddedAgentCompactResult | undefined,
|
||||
): boolean {
|
||||
@@ -352,7 +352,7 @@ export async function compactEmbeddedAgentSession(
|
||||
// are notified regardless of which engine is active.
|
||||
const engineOwnsCompaction = contextEngine.info.ownsCompaction === true;
|
||||
checkpointSnapshot = engineOwnsCompaction
|
||||
? await captureCompactionCheckpointSnapshotAsync({
|
||||
? await compactionCheckpointStore.captureSnapshot({
|
||||
sessionFile: params.sessionFile,
|
||||
})
|
||||
: null;
|
||||
@@ -478,7 +478,7 @@ export async function compactEmbeddedAgentSession(
|
||||
preferredLeafId: postCompactionLeafId,
|
||||
transcriptState,
|
||||
});
|
||||
const storedCheckpoint = await persistSessionCompactionCheckpoint({
|
||||
const storedCheckpoint = await compactionCheckpointStore.persistCheckpoint({
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: postCompactionSessionId,
|
||||
@@ -620,7 +620,7 @@ export async function compactEmbeddedAgentSession(
|
||||
};
|
||||
} finally {
|
||||
if (!checkpointSnapshotRetained) {
|
||||
await cleanupCompactionCheckpointSnapshot(checkpointSnapshot);
|
||||
await compactionCheckpointStore.cleanupSnapshot(checkpointSnapshot);
|
||||
}
|
||||
await contextEngine.dispose?.();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Types for the lazy embedded-agent compaction runtime boundary.
|
||||
*/
|
||||
import type { CompactEmbeddedAgentSessionParams } from "./compact.types.js";
|
||||
import type { CompactEmbeddedAgentSessionRuntimeParams } from "./compact.types.js";
|
||||
import type { EmbeddedAgentCompactResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* Lazy-runtime signature for direct embedded session compaction.
|
||||
*/
|
||||
export type CompactEmbeddedAgentSessionDirect = (
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
params: CompactEmbeddedAgentSessionRuntimeParams,
|
||||
) => Promise<EmbeddedAgentCompactResult>;
|
||||
|
||||
@@ -8,9 +8,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import { resolveAgentModelFallbackValues } from "../../config/model-input.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
captureCompactionCheckpointSnapshotAsync,
|
||||
cleanupCompactionCheckpointSnapshot,
|
||||
persistSessionCompactionCheckpoint,
|
||||
createFileBackedCompactionCheckpointStore,
|
||||
readSessionLeafStateFromTranscriptAsync,
|
||||
resolveCompactionCheckpointTranscriptPosition,
|
||||
resolveSessionCompactionCheckpointReason,
|
||||
@@ -107,6 +105,10 @@ import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js";
|
||||
import { resolveAgentPromptSurfaceForSessionKey } from "../prompt-surface.js";
|
||||
import { applyPreparedRuntimeAuthToModel } from "../provider-request-config.js";
|
||||
import { registerProviderStreamForModel } from "../provider-stream.js";
|
||||
import {
|
||||
applyAgentRunSessionTargetIdentity,
|
||||
resolveAgentRunSessionTarget,
|
||||
} from "../run-session-target.js";
|
||||
import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js";
|
||||
import { buildAgentRuntimePlan } from "../runtime-plan/build.js";
|
||||
import type { AgentRuntimePlan } from "../runtime-plan/types.js";
|
||||
@@ -135,6 +137,7 @@ import {
|
||||
} from "./compact-reasons.js";
|
||||
import type {
|
||||
CompactEmbeddedAgentSessionParams,
|
||||
CompactEmbeddedAgentSessionRuntimeParams,
|
||||
CompactionMessageMetrics,
|
||||
} from "./compact.types.js";
|
||||
import { dedupeDuplicateUserMessagesForCompaction } from "./compaction-duplicate-user-messages.js";
|
||||
@@ -192,6 +195,11 @@ import { mapThinkingLevel, normalizeContextTokenBudget } from "./utils.js";
|
||||
import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js";
|
||||
export type { CompactEmbeddedAgentSessionParams } from "./compact.types.js";
|
||||
|
||||
const compactionCheckpointStore = createFileBackedCompactionCheckpointStore();
|
||||
type CompactEmbeddedAgentSessionParamsWithSessionFile = CompactEmbeddedAgentSessionRuntimeParams & {
|
||||
sessionFile: string;
|
||||
};
|
||||
|
||||
function hasRealConversationContent(
|
||||
msg: AgentMessage,
|
||||
messages: AgentMessage[],
|
||||
@@ -464,8 +472,17 @@ function fallbackFailureToCompactionResult(err: unknown): EmbeddedAgentCompactRe
|
||||
* Use this when already inside a session/global lane to avoid deadlocks.
|
||||
*/
|
||||
export async function compactEmbeddedAgentSessionDirect(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
paramsInput: CompactEmbeddedAgentSessionRuntimeParams,
|
||||
): Promise<EmbeddedAgentCompactResult> {
|
||||
const paramsBase = applyAgentRunSessionTargetIdentity(paramsInput);
|
||||
const runSessionTarget = await resolveAgentRunSessionTarget(paramsBase);
|
||||
const params: CompactEmbeddedAgentSessionParamsWithSessionFile = {
|
||||
...paramsBase,
|
||||
agentId: paramsBase.agentId ?? runSessionTarget.agentId,
|
||||
sessionId: runSessionTarget.sessionId,
|
||||
sessionKey: paramsBase.sessionKey ?? runSessionTarget.sessionKey,
|
||||
sessionFile: runSessionTarget.sessionFile,
|
||||
};
|
||||
if (hasExplicitCompactionModel(params) || !hasCompactionModelFallbackCandidates(params)) {
|
||||
return await compactEmbeddedAgentSessionDirectOnce(params);
|
||||
}
|
||||
@@ -530,7 +547,7 @@ export async function compactEmbeddedAgentSessionDirect(
|
||||
}
|
||||
|
||||
async function compactEmbeddedAgentSessionDirectOnce(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
params: CompactEmbeddedAgentSessionParamsWithSessionFile,
|
||||
): Promise<EmbeddedAgentCompactResult> {
|
||||
const startedAt = Date.now();
|
||||
const diagId = params.diagId?.trim() || createCompactionDiagId();
|
||||
@@ -1190,7 +1207,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
: undefined,
|
||||
allowedToolNames,
|
||||
});
|
||||
checkpointSnapshot = await captureCompactionCheckpointSnapshotAsync({
|
||||
checkpointSnapshot = await compactionCheckpointStore.captureSnapshot({
|
||||
sessionManager,
|
||||
sessionFile: params.sessionFile,
|
||||
});
|
||||
@@ -1546,7 +1563,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
preferredLeafId: activePostLeafId,
|
||||
transcriptState,
|
||||
});
|
||||
const storedCheckpoint = await persistSessionCompactionCheckpoint({
|
||||
const storedCheckpoint = await compactionCheckpointStore.persistCheckpoint({
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: activeSessionId,
|
||||
@@ -1670,7 +1687,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
return fail(reason, err);
|
||||
} finally {
|
||||
if (!checkpointSnapshotRetained) {
|
||||
await cleanupCompactionCheckpointSnapshot(checkpointSnapshot);
|
||||
await compactionCheckpointStore.cleanupSnapshot(checkpointSnapshot);
|
||||
}
|
||||
restoreSkillEnv?.();
|
||||
}
|
||||
|
||||
@@ -9,12 +9,15 @@ import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-e
|
||||
import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js";
|
||||
import type { SkillSnapshot } from "../../skills/types.js";
|
||||
import type { ExecElevatedDefaults, ExecToolDefaults } from "../bash-tools.exec-types.js";
|
||||
import type { AgentRunSessionTarget } from "../run-session-target.js";
|
||||
import type { AgentRuntimePlan } from "../runtime-plan/types.js";
|
||||
|
||||
export type CompactEmbeddedAgentSessionParams = {
|
||||
sessionId: string;
|
||||
runId?: string;
|
||||
sessionKey?: string;
|
||||
/** Storage-neutral transcript/session target. Defaults to sessionId/sessionKey/agentId. */
|
||||
sessionTarget?: AgentRunSessionTarget;
|
||||
/** Caller-resolved owner agent for global session aliases. */
|
||||
agentId?: string;
|
||||
/** Session key used only for runtime policy/sandbox resolution. Defaults to sessionKey. */
|
||||
@@ -106,6 +109,14 @@ export type CompactEmbeddedAgentSessionParams = {
|
||||
oneShotCliRun?: boolean;
|
||||
};
|
||||
|
||||
export type CompactEmbeddedAgentSessionRuntimeParams = Omit<
|
||||
CompactEmbeddedAgentSessionParams,
|
||||
"sessionFile"
|
||||
> & {
|
||||
/** Deprecated file-backed artifact target. Prefer sessionTarget for new callers. */
|
||||
sessionFile?: string;
|
||||
};
|
||||
|
||||
export type CompactionMessageMetrics = {
|
||||
messages: number;
|
||||
historyTextChars: number;
|
||||
|
||||
@@ -125,6 +125,10 @@ import {
|
||||
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
|
||||
import { hasOnlyAssistantReasoningContent } from "../replay-turn-classification.js";
|
||||
import { runAgentCleanupStep } from "../run-cleanup-timeout.js";
|
||||
import {
|
||||
applyAgentRunSessionTargetIdentity,
|
||||
resolveAgentRunSessionTarget,
|
||||
} from "../run-session-target.js";
|
||||
import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
|
||||
import { buildAgentRuntimePlan } from "../runtime-plan/build.js";
|
||||
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
|
||||
@@ -255,6 +259,7 @@ const BEFORE_AGENT_FINALIZE_RETRY_PROMPT_PREFIX =
|
||||
"Before accepting the previous final answer, apply this revision request and produce the revised final answer. Do not repeat completed work or rerun tools unless the request explicitly requires it.";
|
||||
const MAX_BEFORE_AGENT_FINALIZE_REVISIONS = 3;
|
||||
type EmbeddedRunAttemptForRunner = Awaited<ReturnType<typeof runEmbeddedAttemptWithBackend>>;
|
||||
type RunEmbeddedAgentParamsWithSessionFile = RunEmbeddedAgentParams & { sessionFile: string };
|
||||
|
||||
function isNoRealConversationCompactionNoop(params: {
|
||||
ok?: boolean;
|
||||
@@ -617,20 +622,28 @@ export function runEmbeddedAgent(
|
||||
async function runEmbeddedAgentInternal(
|
||||
paramsInput: RunEmbeddedAgentParams,
|
||||
): Promise<EmbeddedAgentRunResult> {
|
||||
let params = paramsInput;
|
||||
let lifecycleGeneration = params.lifecycleGeneration!;
|
||||
const paramsBase = applyAgentRunSessionTargetIdentity(paramsInput);
|
||||
let lifecycleGeneration = paramsBase.lifecycleGeneration!;
|
||||
const queuedLifecycleGeneration = getAgentEventLifecycleGeneration();
|
||||
// Resolve sessionKey early so all downstream consumers (hooks, LCM, compaction)
|
||||
// receive a non-null key even when callers omit it. See #60552.
|
||||
const effectiveSessionKey = backfillSessionKey({
|
||||
config: params.config,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: paramsBase.config,
|
||||
sessionId: paramsBase.sessionId,
|
||||
sessionKey: paramsBase.sessionKey,
|
||||
agentId: paramsBase.agentId,
|
||||
});
|
||||
if (effectiveSessionKey !== params.sessionKey) {
|
||||
params = { ...params, sessionKey: effectiveSessionKey };
|
||||
}
|
||||
const runSessionTarget = await resolveAgentRunSessionTarget({
|
||||
...paramsBase,
|
||||
sessionKey: effectiveSessionKey,
|
||||
});
|
||||
let params: RunEmbeddedAgentParamsWithSessionFile = {
|
||||
...paramsBase,
|
||||
agentId: paramsBase.agentId ?? runSessionTarget.agentId,
|
||||
sessionId: runSessionTarget.sessionId,
|
||||
sessionKey: effectiveSessionKey ?? runSessionTarget.sessionKey,
|
||||
sessionFile: runSessionTarget.sessionFile,
|
||||
};
|
||||
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
|
||||
const globalLane = resolveGlobalLane(params.lane);
|
||||
// Outer fallback attempts defer session suspension only while another
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
} from "../../embedded-agent-subscribe.shared-types.js";
|
||||
import type { FastModeAutoProgressState } from "../../fast-mode.js";
|
||||
import type { AgentInternalEvent } from "../../internal-events.js";
|
||||
import type { AgentRunSessionTarget } from "../../run-session-target.js";
|
||||
import type { AgentMessage } from "../../runtime/index.js";
|
||||
import type { SilentReplyPromptMode } from "../../system-prompt.types.js";
|
||||
import type { PromptMode } from "../../system-prompt.types.js";
|
||||
@@ -47,6 +48,8 @@ export type CurrentInboundPromptContext = {
|
||||
export type RunEmbeddedAgentParams = {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
/** Storage-neutral transcript/session target. Defaults to sessionId/sessionKey/agentId. */
|
||||
sessionTarget?: AgentRunSessionTarget;
|
||||
/** Immutable gateway lifecycle ownership captured when this execution was admitted. */
|
||||
lifecycleGeneration?: string;
|
||||
/** Provider prompt-cache affinity key; distinct from transcript/session identity. */
|
||||
@@ -122,7 +125,8 @@ export type RunEmbeddedAgentParams = {
|
||||
forceHeartbeatTool?: boolean;
|
||||
/** Allow runtime plugins for this run to late-bind the gateway subagent. */
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
sessionFile: string;
|
||||
/** @deprecated Use sessionTarget plus sessionId/sessionKey/agentId for runtime identity. */
|
||||
sessionFile?: string;
|
||||
workspaceDir: string;
|
||||
/** Task working directory for tool/runtime execution. Defaults to workspaceDir. */
|
||||
cwd?: string;
|
||||
|
||||
@@ -40,6 +40,7 @@ type EmbeddedRunAttemptBase = Omit<
|
||||
| "fastMode"
|
||||
| "lane"
|
||||
| "enqueue"
|
||||
| "sessionFile"
|
||||
>;
|
||||
|
||||
export type EmbeddedRunContextWindowInfo = {
|
||||
@@ -51,6 +52,8 @@ export type EmbeddedRunContextWindowInfo = {
|
||||
export type EmbeddedRunFastModeParam = boolean | (() => boolean | undefined);
|
||||
|
||||
export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & {
|
||||
/** Active file-backed artifact target resolved by the run/session target seam. */
|
||||
sessionFile: string;
|
||||
initialReplayState?: EmbeddedRunReplayState;
|
||||
/** Pluggable context engine for ingest/assemble/compact lifecycle. */
|
||||
contextEngine?: ContextEngine;
|
||||
|
||||
@@ -206,13 +206,26 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
|
||||
compact: async () => ({ ok: true, compacted: false }),
|
||||
ingest: async () => ({ ingested: false }),
|
||||
}),
|
||||
resolveParentForkDecision: async () => ({
|
||||
status: "fork",
|
||||
maxTokens: 100_000,
|
||||
}),
|
||||
forkSessionFromParent: async () => ({
|
||||
sessionId: "forked-session-id",
|
||||
sessionFile: "/tmp/forked-session.jsonl",
|
||||
forkSessionEntryFromParent: async () => ({
|
||||
status: "forked",
|
||||
fork: {
|
||||
sessionId: "forked-session-id",
|
||||
sessionFile: "/tmp/forked-session.jsonl",
|
||||
},
|
||||
parentEntry: {
|
||||
sessionId: "parent-session-id",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "forked-session-id",
|
||||
sessionFile: "/tmp/forked-session.jsonl",
|
||||
forkedFromParent: true,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
decision: {
|
||||
status: "fork",
|
||||
maxTokens: 100_000,
|
||||
},
|
||||
}),
|
||||
updateSessionStore: async (_storePath, mutator) => mutator({}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadSessionStore } from "../config/sessions/store.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveAgentRunSessionTarget } from "./run-session-target.js";
|
||||
|
||||
describe("agent run session target", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-run-session-target-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolves runtime identity through the run config store", async () => {
|
||||
const storePath = path.join(tempDir, "custom-sessions", "sessions.json");
|
||||
const sessionKey = "agent:helper:commitments:test-run";
|
||||
|
||||
const target = await resolveAgentRunSessionTarget({
|
||||
agentId: "helper",
|
||||
config: { session: { store: storePath } } as OpenClawConfig,
|
||||
sessionId: "test-run",
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
expect(target).toMatchObject({
|
||||
agentId: "helper",
|
||||
sessionId: "test-run",
|
||||
sessionKey,
|
||||
});
|
||||
expect(path.dirname(target.sessionFile)).toBe(path.dirname(storePath));
|
||||
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]?.sessionFile).toBe(
|
||||
target.sessionFile,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the agent from an agent-scoped session key when agentId is omitted", async () => {
|
||||
const storeRoot = path.join(tempDir, "agents", "{agentId}", "sessions.json");
|
||||
const sessionKey = "agent:helper:main";
|
||||
|
||||
const target = await resolveAgentRunSessionTarget({
|
||||
config: { session: { store: storeRoot } } as OpenClawConfig,
|
||||
sessionId: "helper-session",
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
const helperStorePath = path.join(tempDir, "agents", "helper", "sessions.json");
|
||||
expect(target.agentId).toBe("helper");
|
||||
expect(path.dirname(target.sessionFile)).toBe(path.dirname(helperStorePath));
|
||||
expect(loadSessionStore(helperStorePath, { skipCache: true })[sessionKey]?.sessionFile).toBe(
|
||||
target.sessionFile,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import {
|
||||
resolveSessionTranscriptRuntimeTarget,
|
||||
type SessionTranscriptRuntimeTarget,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
|
||||
/** Identifies a run transcript target without naming the current storage artifact. */
|
||||
export type AgentRunSessionTarget = {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
/** File-backed target resolved from the storage-neutral run identity. */
|
||||
export type ResolvedAgentRunSessionTarget = SessionTranscriptRuntimeTarget;
|
||||
|
||||
/** Resolves the active file-backed target used by current run/session internals. */
|
||||
export async function resolveAgentRunSessionTarget(params: {
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
sessionTarget?: AgentRunSessionTarget;
|
||||
}): Promise<ResolvedAgentRunSessionTarget> {
|
||||
const sessionTarget = params.sessionTarget;
|
||||
const agentId = normalizeOptionalString(sessionTarget?.agentId) ?? params.agentId;
|
||||
const sessionId = normalizeOptionalString(sessionTarget?.sessionId) ?? params.sessionId;
|
||||
const sessionKey = normalizeOptionalString(sessionTarget?.sessionKey) ?? params.sessionKey;
|
||||
const effectiveAgentId = agentId ?? resolveAgentIdFromSessionKey(sessionKey);
|
||||
const sessionFile = normalizeOptionalString(params.sessionFile);
|
||||
if (sessionFile) {
|
||||
return {
|
||||
agentId: effectiveAgentId ?? "",
|
||||
sessionFile,
|
||||
sessionId,
|
||||
sessionKey: sessionKey ?? "",
|
||||
};
|
||||
}
|
||||
if (!sessionKey) {
|
||||
throw new Error(`Cannot resolve run session target without a session key: ${sessionId}`);
|
||||
}
|
||||
const storePath =
|
||||
normalizeOptionalString(sessionTarget?.storePath) ??
|
||||
resolveStorePath(params.config?.session?.store, { agentId: effectiveAgentId });
|
||||
return await resolveSessionTranscriptRuntimeTarget({
|
||||
...(effectiveAgentId ? { agentId: effectiveAgentId } : {}),
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
...(sessionTarget?.threadId !== undefined ? { threadId: sessionTarget.threadId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Applies identity fields from the explicit target before legacy backfills run. */
|
||||
export function applyAgentRunSessionTargetIdentity<
|
||||
T extends {
|
||||
agentId?: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
sessionTarget?: AgentRunSessionTarget;
|
||||
},
|
||||
>(params: T): T {
|
||||
const target = params.sessionTarget;
|
||||
if (!target) {
|
||||
return params;
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
agentId: normalizeOptionalString(target.agentId) ?? params.agentId,
|
||||
sessionId: normalizeOptionalString(target.sessionId) ?? params.sessionId,
|
||||
sessionKey: normalizeOptionalString(target.sessionKey) ?? params.sessionKey,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Subagent spawn context tests cover isolated, forked, lightweight, and
|
||||
// thread-bound bootstrap context preparation for child sessions.
|
||||
import path from "node:path";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -14,7 +12,9 @@ type GatewayRequest = { method?: string; params?: Record<string, unknown> };
|
||||
describe("sessions_spawn context modes", () => {
|
||||
const storePath = "/tmp/subagent-context-session-store.json";
|
||||
const callGatewayMock = vi.fn();
|
||||
const loadSessionStoreMock = vi.fn();
|
||||
const updateSessionStoreMock = vi.fn();
|
||||
const forkSessionEntryFromParentMock = vi.fn();
|
||||
const forkSessionFromParentMock = vi.fn();
|
||||
const ensureContextEnginesInitializedMock = vi.fn();
|
||||
const resolveContextEngineMock = vi.fn();
|
||||
@@ -25,7 +25,9 @@ describe("sessions_spawn context modes", () => {
|
||||
beforeAll(async () => {
|
||||
({ spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
|
||||
callGatewayMock,
|
||||
loadSessionStoreMock,
|
||||
updateSessionStoreMock,
|
||||
forkSessionEntryFromParentMock,
|
||||
forkSessionFromParentMock,
|
||||
ensureContextEnginesInitializedMock,
|
||||
resolveContextEngineMock,
|
||||
@@ -35,7 +37,9 @@ describe("sessions_spawn context modes", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockReset();
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
forkSessionEntryFromParentMock.mockReset();
|
||||
forkSessionFromParentMock.mockReset();
|
||||
ensureContextEnginesInitializedMock.mockReset();
|
||||
resolveContextEngineMock.mockReset();
|
||||
@@ -44,14 +48,78 @@ describe("sessions_spawn context modes", () => {
|
||||
});
|
||||
|
||||
function usePersistentStoreMock(store: SessionStore) {
|
||||
// The spawn path mutates the session store in-place; this mock keeps that
|
||||
// contract visible without touching disk.
|
||||
loadSessionStoreMock.mockReturnValue(store);
|
||||
updateSessionStoreMock.mockImplementation(async (_storePath: unknown, mutator: unknown) => {
|
||||
if (typeof mutator !== "function") {
|
||||
throw new Error("missing session store mutator");
|
||||
}
|
||||
return await mutator(store);
|
||||
});
|
||||
forkSessionEntryFromParentMock.mockImplementation(
|
||||
async (params: {
|
||||
agentId: string;
|
||||
fallbackEntry?: Record<string, unknown>;
|
||||
parentStoreKeys?: string[];
|
||||
sessionKey: string;
|
||||
sessionsDir?: string;
|
||||
}) => {
|
||||
const parentEntry = params.parentStoreKeys
|
||||
?.map((key) => store[key])
|
||||
.find((entry): entry is Record<string, unknown> => Boolean(entry));
|
||||
const maxTokens = 100_000;
|
||||
const parentTokens = parentEntry?.totalTokens;
|
||||
if (
|
||||
typeof parentTokens === "number" &&
|
||||
Number.isFinite(parentTokens) &&
|
||||
parentTokens > maxTokens
|
||||
) {
|
||||
const sessionEntry = {
|
||||
...params.fallbackEntry,
|
||||
...store[params.sessionKey],
|
||||
};
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "decision-skip",
|
||||
parentEntry,
|
||||
sessionEntry,
|
||||
decision: {
|
||||
status: "skip",
|
||||
reason: "parent-too-large",
|
||||
maxTokens,
|
||||
parentTokens,
|
||||
message: `Parent context is too large to fork (${parentTokens}/${maxTokens} tokens); starting with isolated context instead.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const fork = await forkSessionFromParentMock({
|
||||
parentEntry,
|
||||
agentId: params.agentId,
|
||||
sessionsDir: params.sessionsDir,
|
||||
});
|
||||
if (!fork) {
|
||||
return { status: "failed" };
|
||||
}
|
||||
const sessionEntry = {
|
||||
...params.fallbackEntry,
|
||||
...store[params.sessionKey],
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
forkedFromParent: true,
|
||||
};
|
||||
store[params.sessionKey] = sessionEntry;
|
||||
return {
|
||||
status: "forked",
|
||||
fork,
|
||||
parentEntry,
|
||||
sessionEntry,
|
||||
decision: {
|
||||
status: "fork",
|
||||
maxTokens,
|
||||
...(typeof parentTokens === "number" ? { parentTokens } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function requireAcceptedResult(result: Awaited<ReturnType<typeof spawnSubagentDirect>>) {
|
||||
@@ -202,8 +270,6 @@ describe("sessions_spawn context modes", () => {
|
||||
});
|
||||
|
||||
it("falls back to isolated context when requested fork is too large", async () => {
|
||||
// Forking very large transcripts would create expensive child context, so
|
||||
// the accepted run records the downgrade in its note.
|
||||
const store: SessionStore = {
|
||||
main: {
|
||||
sessionId: "parent-session-id",
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
export { getRuntimeConfig } from "../config/config.js";
|
||||
export { loadSessionStore, mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
|
||||
export {
|
||||
forkSessionEntryFromParent,
|
||||
forkSessionFromParent,
|
||||
resolveParentForkDecision,
|
||||
type ParentForkDecision,
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function loadSubagentSpawnModuleForTest(params: {
|
||||
loadSessionStoreMock?: MockFn;
|
||||
ensureContextEnginesInitializedMock?: MockFn;
|
||||
updateSessionStoreMock?: MockFn;
|
||||
forkSessionEntryFromParentMock?: MockFn;
|
||||
forkSessionFromParentMock?: MockFn;
|
||||
resolveContextEngineMock?: MockFn;
|
||||
resolveParentForkDecisionMock?: MockFn;
|
||||
@@ -215,6 +216,36 @@ export async function loadSubagentSpawnModuleForTest(params: {
|
||||
params.dispatchGatewayMethodInProcessMock?.(...args),
|
||||
hasInProcessGatewayContext: () => Boolean(params.hasInProcessGatewayContextMock?.()),
|
||||
buildSubagentSystemPrompt: () => "system-prompt",
|
||||
forkSessionEntryFromParent:
|
||||
params.forkSessionEntryFromParentMock ??
|
||||
(async () => {
|
||||
const fork = (
|
||||
params.forkSessionFromParentMock
|
||||
? await params.forkSessionFromParentMock()
|
||||
: { sessionId: "forked-session-id", sessionFile: "/tmp/forked-session.jsonl" }
|
||||
) as { sessionId: string; sessionFile: string } | null;
|
||||
if (!fork) {
|
||||
return { status: "failed" };
|
||||
}
|
||||
return {
|
||||
status: "forked",
|
||||
fork,
|
||||
parentEntry: {
|
||||
sessionId: "parent-session-id",
|
||||
sessionFile: "/tmp/parent-session.jsonl",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
forkedFromParent: true,
|
||||
},
|
||||
decision: {
|
||||
status: "fork",
|
||||
maxTokens: 100_000,
|
||||
},
|
||||
};
|
||||
}),
|
||||
forkSessionFromParent:
|
||||
params.forkSessionFromParentMock ??
|
||||
(async () => ({ sessionId: "forked-session-id", sessionFile: "/tmp/forked-session.jsonl" })),
|
||||
|
||||
@@ -88,7 +88,7 @@ import {
|
||||
callGateway,
|
||||
dispatchGatewayMethodInProcess,
|
||||
emitSessionLifecycleEvent,
|
||||
forkSessionFromParent,
|
||||
forkSessionEntryFromParent,
|
||||
getGlobalHookRunner,
|
||||
getSessionBindingService,
|
||||
getRuntimeConfig,
|
||||
@@ -99,7 +99,6 @@ import {
|
||||
normalizeDeliveryContext,
|
||||
pruneLegacyStoreKeys,
|
||||
ensureContextEnginesInitialized,
|
||||
resolveParentForkDecision,
|
||||
resolveAgentConfig,
|
||||
resolveContextEngine,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
@@ -133,26 +132,24 @@ function resolveConfiguredAgentIds(cfg: OpenClawConfig): string[] {
|
||||
type SubagentSpawnDeps = {
|
||||
callGateway: typeof callGateway;
|
||||
dispatchGatewayMethodInProcess: typeof dispatchGatewayMethodInProcess;
|
||||
forkSessionFromParent: typeof forkSessionFromParent;
|
||||
forkSessionEntryFromParent: typeof forkSessionEntryFromParent;
|
||||
getGlobalHookRunner: () => SubagentLifecycleHookRunner | null;
|
||||
getRuntimeConfig: typeof getRuntimeConfig;
|
||||
hasInProcessGatewayContext: typeof hasInProcessGatewayContext;
|
||||
ensureContextEnginesInitialized: typeof ensureContextEnginesInitialized;
|
||||
resolveContextEngine: typeof resolveContextEngine;
|
||||
resolveParentForkDecision: typeof resolveParentForkDecision;
|
||||
updateSessionStore: typeof updateSessionStore;
|
||||
};
|
||||
|
||||
const defaultSubagentSpawnDeps: SubagentSpawnDeps = {
|
||||
callGateway,
|
||||
dispatchGatewayMethodInProcess,
|
||||
forkSessionFromParent,
|
||||
forkSessionEntryFromParent,
|
||||
getGlobalHookRunner,
|
||||
getRuntimeConfig,
|
||||
hasInProcessGatewayContext,
|
||||
ensureContextEnginesInitialized,
|
||||
resolveContextEngine,
|
||||
resolveParentForkDecision,
|
||||
updateSessionStore,
|
||||
};
|
||||
|
||||
@@ -510,52 +507,45 @@ async function prepareSubagentSessionContext(params: {
|
||||
const sessionsDir = path.dirname(parentTarget.storePath);
|
||||
|
||||
try {
|
||||
const forked = (await updateSubagentSessionStore(childTarget.storePath, async (store) => {
|
||||
parentEntry = resolveStoreEntryByKeys(store, parentTarget.storeKeys);
|
||||
childEntry = resolveStoreEntryByKeys(store, childTarget.storeKeys);
|
||||
if (params.targetAgentId !== params.requesterAgentId) {
|
||||
throw new Error(
|
||||
'context="fork" currently requires the same target agent as the requester; use context="isolated" for cross-agent spawns.',
|
||||
);
|
||||
}
|
||||
|
||||
if (params.targetAgentId !== params.requesterAgentId) {
|
||||
throw new Error(
|
||||
'context="fork" currently requires the same target agent as the requester; use context="isolated" for cross-agent spawns.',
|
||||
);
|
||||
}
|
||||
if (!parentEntry?.sessionId) {
|
||||
throw new Error(
|
||||
'context="fork" requested but the requester session transcript is not available.',
|
||||
);
|
||||
}
|
||||
const forkDecision = await subagentSpawnDeps.resolveParentForkDecision({
|
||||
parentEntry,
|
||||
storePath: parentTarget.storePath,
|
||||
});
|
||||
if (forkDecision.status === "skip") {
|
||||
forkFallbackNote = forkDecision.message;
|
||||
return null;
|
||||
}
|
||||
|
||||
const fork = await subagentSpawnDeps.forkSessionFromParent({
|
||||
parentEntry,
|
||||
agentId: params.requesterAgentId,
|
||||
sessionsDir,
|
||||
});
|
||||
if (!fork) {
|
||||
throw new Error(
|
||||
'context="fork" requested but OpenClaw could not fork the requester transcript.',
|
||||
);
|
||||
}
|
||||
pruneLegacyStoreKeys({
|
||||
store,
|
||||
canonicalKey: childTarget.canonicalKey,
|
||||
candidates: childTarget.storeKeys,
|
||||
});
|
||||
store[childTarget.canonicalKey] = mergeSessionEntry(store[childTarget.canonicalKey], {
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
forkedFromParent: true,
|
||||
});
|
||||
childEntry = store[childTarget.canonicalKey];
|
||||
return fork;
|
||||
})) as { sessionId: string; sessionFile: string } | null;
|
||||
const forkedResult = await subagentSpawnDeps.forkSessionEntryFromParent({
|
||||
storePath: childTarget.storePath,
|
||||
parentSessionKey: parentTarget.canonicalKey,
|
||||
parentStoreKeys: parentTarget.storeKeys,
|
||||
sessionKey: childTarget.canonicalKey,
|
||||
sessionStoreKeys: childTarget.storeKeys,
|
||||
fallbackEntry: { sessionId: "", updatedAt: Date.now() },
|
||||
agentId: params.requesterAgentId,
|
||||
sessionsDir,
|
||||
});
|
||||
if (forkedResult.status === "missing-parent") {
|
||||
throw new Error(
|
||||
'context="fork" requested but the requester session transcript is not available.',
|
||||
);
|
||||
}
|
||||
if (forkedResult.status === "failed" || forkedResult.status === "missing-entry") {
|
||||
throw new Error(
|
||||
'context="fork" requested but OpenClaw could not fork the requester transcript.',
|
||||
);
|
||||
}
|
||||
parentEntry = forkedResult.parentEntry;
|
||||
childEntry = forkedResult.sessionEntry;
|
||||
if (forkedResult.status === "skipped") {
|
||||
forkFallbackNote =
|
||||
forkedResult.decision?.status === "skip" ? forkedResult.decision.message : undefined;
|
||||
}
|
||||
const forked =
|
||||
forkedResult.status === "forked"
|
||||
? {
|
||||
sessionId: forkedResult.fork.sessionId,
|
||||
sessionFile: forkedResult.fork.sessionFile,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (params.contextMode === "fork") {
|
||||
if (!parentEntry || !forked) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Tests parent-session fork facade storage-boundary behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { forkSessionEntryFromParent } from "./session-fork.js";
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
forkSessionFromParentRuntime: vi.fn(),
|
||||
resolveParentForkTokenCountRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./session-fork.runtime.js", () => runtimeMocks);
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
async function makeRoot(prefix: string): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
runtimeMocks.forkSessionFromParentRuntime.mockReset();
|
||||
runtimeMocks.resolveParentForkTokenCountRuntime.mockReset();
|
||||
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("forkSessionEntryFromParent", () => {
|
||||
it("forks transcripts in the directory for the store being mutated", async () => {
|
||||
const root = await makeRoot("openclaw-session-fork-boundary-");
|
||||
const activeStoreDir = path.join(root, "active-store");
|
||||
const configStoreDir = path.join(root, "config-store");
|
||||
await fs.mkdir(activeStoreDir, { recursive: true });
|
||||
await fs.mkdir(configStoreDir, { recursive: true });
|
||||
const storePath = path.join(activeStoreDir, "sessions.json");
|
||||
const configStorePath = path.join(configStoreDir, "sessions.json");
|
||||
const parentSessionKey = "agent:main:main";
|
||||
const sessionKey = "agent:main:subagent:child";
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[parentSessionKey]: {
|
||||
sessionId: "parent-session",
|
||||
sessionFile: path.join(activeStoreDir, "parent.jsonl"),
|
||||
updatedAt: 1,
|
||||
},
|
||||
[sessionKey]: { sessionId: "", updatedAt: 2 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
runtimeMocks.resolveParentForkTokenCountRuntime.mockResolvedValue(10);
|
||||
runtimeMocks.forkSessionFromParentRuntime.mockImplementation(
|
||||
async ({ sessionsDir }: { sessionsDir: string }) => ({
|
||||
sessionId: "forked-session",
|
||||
sessionFile: path.join(sessionsDir, "forked-session.jsonl"),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await forkSessionEntryFromParent({
|
||||
agentId: "main",
|
||||
config: { session: { store: configStorePath } } as OpenClawConfig,
|
||||
fallbackEntry: { sessionId: "", updatedAt: 2 },
|
||||
parentSessionKey,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("forked");
|
||||
expect(runtimeMocks.forkSessionFromParentRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionsDir: activeStoreDir,
|
||||
}),
|
||||
);
|
||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
|
||||
string,
|
||||
{ sessionFile?: string }
|
||||
>;
|
||||
expect(stored[sessionKey]?.sessionFile).toBe(path.join(activeStoreDir, "forked-session.jsonl"));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
/** Public session-fork facade with parent-size admission checks. */
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import path from "node:path";
|
||||
import { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
import { updateSessionStore } from "../../config/sessions/store.js";
|
||||
import { mergeSessionEntry, type SessionEntry } from "../../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
|
||||
/**
|
||||
@@ -10,7 +13,6 @@ import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
const DEFAULT_PARENT_FORK_MAX_TOKENS = 100_000;
|
||||
const sessionForkRuntimeLoader = createLazyImportLoader(() => import("./session-fork.runtime.js"));
|
||||
|
||||
/** Decision for whether a child session should fork parent context or start isolated. */
|
||||
export type ParentForkDecision =
|
||||
| {
|
||||
status: "fork";
|
||||
@@ -25,6 +27,66 @@ export type ParentForkDecision =
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ParentForkDecisionParams = {
|
||||
parentEntry: SessionEntry;
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
storePath?: string;
|
||||
};
|
||||
|
||||
type ForkSessionFromParentParams = {
|
||||
parentEntry: SessionEntry;
|
||||
agentId: string;
|
||||
config?: OpenClawConfig;
|
||||
sessionsDir?: string;
|
||||
};
|
||||
|
||||
export type ForkedParentSessionEntry = {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
};
|
||||
|
||||
export type ForkSessionEntryFromParentResult =
|
||||
| {
|
||||
status: "forked";
|
||||
fork: ForkedParentSessionEntry;
|
||||
parentEntry: SessionEntry;
|
||||
sessionEntry: SessionEntry;
|
||||
decision: Extract<ParentForkDecision, { status: "fork" }>;
|
||||
}
|
||||
| {
|
||||
status: "skipped";
|
||||
reason: "existing-entry" | "decision-skip";
|
||||
parentEntry?: SessionEntry;
|
||||
sessionEntry: SessionEntry;
|
||||
decision?: ParentForkDecision;
|
||||
}
|
||||
| { status: "missing-entry" }
|
||||
| { status: "missing-parent" }
|
||||
| { status: "failed" };
|
||||
|
||||
export type ForkSessionEntryFromParentParams = Omit<ForkSessionFromParentParams, "parentEntry"> & {
|
||||
parentSessionKey: string;
|
||||
parentStoreKeys?: readonly string[];
|
||||
sessionKey: string;
|
||||
sessionStoreKeys?: readonly string[];
|
||||
storePath?: string;
|
||||
fallbackEntry?: SessionEntry;
|
||||
patch?: (params: {
|
||||
entry: SessionEntry;
|
||||
parentEntry: SessionEntry;
|
||||
fork: ForkedParentSessionEntry;
|
||||
decision: Extract<ParentForkDecision, { status: "fork" }>;
|
||||
}) => Partial<SessionEntry>;
|
||||
skipForkWhen?: (entry: SessionEntry) => boolean;
|
||||
skipPatch?: (entry: SessionEntry) => Partial<SessionEntry> | null;
|
||||
decisionSkipPatch?: (params: {
|
||||
decision: Extract<ParentForkDecision, { status: "skip" }>;
|
||||
entry: SessionEntry;
|
||||
parentEntry: SessionEntry;
|
||||
}) => Partial<SessionEntry> | null;
|
||||
};
|
||||
|
||||
function loadSessionForkRuntime(): Promise<typeof import("./session-fork.runtime.js")> {
|
||||
return sessionForkRuntimeLoader.load();
|
||||
}
|
||||
@@ -39,15 +101,31 @@ function formatParentForkTooLargeMessage(params: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Decides whether parent context is small enough to fork into a child session. */
|
||||
export async function resolveParentForkDecision(params: {
|
||||
parentEntry: SessionEntry;
|
||||
storePath: string;
|
||||
}): Promise<ParentForkDecision> {
|
||||
function resolveParentForkStorePath(params: {
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
storePath?: string;
|
||||
}): string {
|
||||
return (
|
||||
params.storePath ?? resolveStorePath(params.config?.session?.store, { agentId: params.agentId })
|
||||
);
|
||||
}
|
||||
|
||||
function resolveParentForkSessionsDir(params: {
|
||||
agentId: string;
|
||||
config?: OpenClawConfig;
|
||||
sessionsDir?: string;
|
||||
}): string {
|
||||
return params.sessionsDir ?? path.dirname(resolveParentForkStorePath(params));
|
||||
}
|
||||
|
||||
export async function resolveParentForkDecision(
|
||||
params: ParentForkDecisionParams,
|
||||
): Promise<ParentForkDecision> {
|
||||
const maxTokens = DEFAULT_PARENT_FORK_MAX_TOKENS;
|
||||
const parentTokens = await resolveParentForkTokenCount({
|
||||
parentEntry: params.parentEntry,
|
||||
storePath: params.storePath,
|
||||
storePath: resolveParentForkStorePath(params),
|
||||
});
|
||||
if (typeof parentTokens === "number" && parentTokens > maxTokens) {
|
||||
return {
|
||||
@@ -65,14 +143,151 @@ export async function resolveParentForkDecision(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Forks a new session transcript from a parent session. */
|
||||
export async function forkSessionFromParent(params: {
|
||||
parentEntry: SessionEntry;
|
||||
agentId: string;
|
||||
sessionsDir: string;
|
||||
}): Promise<{ sessionId: string; sessionFile: string } | null> {
|
||||
export async function forkSessionFromParent(
|
||||
params: ForkSessionFromParentParams,
|
||||
): Promise<{ sessionId: string; sessionFile: string } | null> {
|
||||
const runtime = await loadSessionForkRuntime();
|
||||
return runtime.forkSessionFromParentRuntime(params);
|
||||
return runtime.forkSessionFromParentRuntime({
|
||||
...params,
|
||||
sessionsDir: resolveParentForkSessionsDir(params),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEntryFromStoreKeys(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
keys: readonly string[];
|
||||
}): SessionEntry | undefined {
|
||||
for (const key of params.keys) {
|
||||
const entry = params.store[key];
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function persistForkedSessionEntry(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
sessionStoreKeys?: readonly string[];
|
||||
existing: SessionEntry;
|
||||
patch: Partial<SessionEntry>;
|
||||
}): SessionEntry {
|
||||
const next = mergeSessionEntry(params.existing, params.patch);
|
||||
params.store[params.sessionKey] = next;
|
||||
for (const key of params.sessionStoreKeys ?? []) {
|
||||
if (key !== params.sessionKey) {
|
||||
delete params.store[key];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forks the parent transcript and persists the child session entry through one
|
||||
* storage boundary operation.
|
||||
*/
|
||||
export async function forkSessionEntryFromParent(
|
||||
params: ForkSessionEntryFromParentParams,
|
||||
): Promise<ForkSessionEntryFromParentResult> {
|
||||
const storePath = resolveParentForkStorePath(params);
|
||||
return await updateSessionStore(
|
||||
storePath,
|
||||
async (store) => {
|
||||
const parentEntry = resolveEntryFromStoreKeys({
|
||||
store,
|
||||
keys: params.parentStoreKeys ?? [params.parentSessionKey],
|
||||
});
|
||||
if (!parentEntry?.sessionId) {
|
||||
return { status: "missing-parent" };
|
||||
}
|
||||
|
||||
const entry =
|
||||
resolveEntryFromStoreKeys({
|
||||
store,
|
||||
keys: params.sessionStoreKeys ?? [params.sessionKey],
|
||||
}) ?? params.fallbackEntry;
|
||||
if (!entry) {
|
||||
return { status: "missing-entry" };
|
||||
}
|
||||
|
||||
if (params.skipForkWhen?.(entry)) {
|
||||
const patch = params.skipPatch?.(entry);
|
||||
const sessionEntry = patch
|
||||
? persistForkedSessionEntry({
|
||||
store,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionStoreKeys: params.sessionStoreKeys,
|
||||
existing: entry,
|
||||
patch,
|
||||
})
|
||||
: entry;
|
||||
return { status: "skipped", reason: "existing-entry", parentEntry, sessionEntry };
|
||||
}
|
||||
|
||||
const decision = await resolveParentForkDecision({
|
||||
parentEntry,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
storePath,
|
||||
});
|
||||
if (decision.status === "skip") {
|
||||
const patch = params.decisionSkipPatch?.({ decision, entry, parentEntry });
|
||||
const sessionEntry = patch
|
||||
? persistForkedSessionEntry({
|
||||
store,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionStoreKeys: params.sessionStoreKeys,
|
||||
existing: entry,
|
||||
patch,
|
||||
})
|
||||
: entry;
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "decision-skip",
|
||||
parentEntry,
|
||||
sessionEntry,
|
||||
decision,
|
||||
};
|
||||
}
|
||||
|
||||
const fork = await forkSessionFromParent({
|
||||
parentEntry,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
sessionsDir: params.sessionsDir ?? path.dirname(storePath),
|
||||
});
|
||||
if (!fork) {
|
||||
return { status: "failed" };
|
||||
}
|
||||
const sessionEntry = persistForkedSessionEntry({
|
||||
store,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionStoreKeys: params.sessionStoreKeys,
|
||||
existing: entry,
|
||||
patch: {
|
||||
...params.patch?.({ entry, parentEntry, fork, decision }),
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
forkedFromParent: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
status: "forked",
|
||||
fork,
|
||||
parentEntry,
|
||||
sessionEntry,
|
||||
decision,
|
||||
};
|
||||
},
|
||||
{
|
||||
skipSaveWhenResult: (result) =>
|
||||
result.status === "missing-entry" ||
|
||||
result.status === "missing-parent" ||
|
||||
result.status === "failed" ||
|
||||
(result.status === "skipped" && result.sessionEntry === params.fallbackEntry),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveParentForkTokenCount(params: {
|
||||
|
||||
@@ -50,6 +50,97 @@ type ForkSessionParamsForTest = {
|
||||
};
|
||||
|
||||
vi.mock("./session-fork.js", () => ({
|
||||
forkSessionEntryFromParent: async (params: {
|
||||
fallbackEntry?: SessionEntry;
|
||||
parentSessionKey: string;
|
||||
storePath: string;
|
||||
patch?: (patchParams: {
|
||||
entry: SessionEntry;
|
||||
parentEntry: SessionEntry;
|
||||
fork: { sessionId: string; sessionFile: string };
|
||||
decision: { status: "fork"; maxTokens: number; parentTokens?: number };
|
||||
}) => Partial<SessionEntry>;
|
||||
decisionSkipPatch?: (patchParams: {
|
||||
decision: {
|
||||
status: "skip";
|
||||
reason: "parent-too-large";
|
||||
maxTokens: number;
|
||||
parentTokens: number;
|
||||
message: string;
|
||||
};
|
||||
entry: SessionEntry;
|
||||
parentEntry: SessionEntry;
|
||||
}) => Partial<SessionEntry>;
|
||||
sessionsDir: string;
|
||||
}) => {
|
||||
const store = JSON.parse(await fs.readFile(params.storePath, "utf-8")) as Record<
|
||||
string,
|
||||
SessionEntry
|
||||
>;
|
||||
const parentEntry = store[params.parentSessionKey];
|
||||
if (!parentEntry?.sessionId) {
|
||||
return { status: "missing-parent" };
|
||||
}
|
||||
const maxTokens = 100_000;
|
||||
const parentTokens = await sessionForkMocks.resolveParentForkTokenCount({
|
||||
parentEntry,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
if (typeof parentTokens === "number" && parentTokens > maxTokens) {
|
||||
const entry = params.fallbackEntry ?? { sessionId: "", updatedAt: Date.now() };
|
||||
const decision = {
|
||||
status: "skip" as const,
|
||||
reason: "parent-too-large" as const,
|
||||
maxTokens,
|
||||
parentTokens,
|
||||
message: `Parent context is too large to fork (${parentTokens}/${maxTokens} tokens); starting with isolated context instead.`,
|
||||
};
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "decision-skip",
|
||||
parentEntry,
|
||||
sessionEntry: {
|
||||
...entry,
|
||||
...params.decisionSkipPatch?.({ decision, entry, parentEntry }),
|
||||
},
|
||||
decision,
|
||||
};
|
||||
}
|
||||
const fork = await sessionForkMocks.forkSessionFromParent({
|
||||
parentEntry,
|
||||
sessionsDir: params.sessionsDir,
|
||||
});
|
||||
if (!fork) {
|
||||
return { status: "failed" };
|
||||
}
|
||||
const entry = params.fallbackEntry ?? { sessionId: "", updatedAt: Date.now() };
|
||||
return {
|
||||
status: "forked",
|
||||
fork,
|
||||
parentEntry,
|
||||
sessionEntry: {
|
||||
...entry,
|
||||
...params.patch?.({
|
||||
entry,
|
||||
parentEntry,
|
||||
fork,
|
||||
decision: {
|
||||
status: "fork",
|
||||
maxTokens,
|
||||
...(typeof parentTokens === "number" ? { parentTokens } : {}),
|
||||
},
|
||||
}),
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
forkedFromParent: true,
|
||||
},
|
||||
decision: {
|
||||
status: "fork",
|
||||
maxTokens,
|
||||
...(typeof parentTokens === "number" ? { parentTokens } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
forkSessionFromParent: (...args: [ForkSessionParamsForTest]) =>
|
||||
sessionForkMocks.forkSessionFromParent(...args),
|
||||
resolveParentForkTokenCount: (...args: [{ parentEntry: SessionEntry; storePath: string }]) =>
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
resolveLastChannelRaw,
|
||||
resolveLastToRaw,
|
||||
} from "./session-delivery.js";
|
||||
import { forkSessionFromParent, resolveParentForkDecision } from "./session-fork.js";
|
||||
import { forkSessionEntryFromParent } from "./session-fork.js";
|
||||
import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./session-hooks.js";
|
||||
import { clearSessionResetRuntimeState } from "./session-reset-cleanup.js";
|
||||
|
||||
@@ -753,47 +753,39 @@ export async function initSessionState(params: {
|
||||
const parentSessionKey = normalizeOptionalString(ctx.ParentSessionKey);
|
||||
const alreadyForked = sessionEntry.forkedFromParent === true;
|
||||
let inheritedParentContext = false;
|
||||
if (
|
||||
parentSessionKey &&
|
||||
parentSessionKey !== sessionKey &&
|
||||
sessionStore[parentSessionKey] &&
|
||||
!alreadyForked
|
||||
) {
|
||||
const parentEntry = sessionStore[parentSessionKey];
|
||||
const forkDecision = await resolveParentForkDecision({
|
||||
parentEntry,
|
||||
if (parentSessionKey && parentSessionKey !== sessionKey && !alreadyForked) {
|
||||
const forked = await forkSessionEntryFromParent({
|
||||
parentSessionKey,
|
||||
sessionKey,
|
||||
storePath,
|
||||
fallbackEntry: sessionEntry,
|
||||
agentId,
|
||||
sessionsDir: path.dirname(storePath),
|
||||
decisionSkipPatch: () => ({ ...sessionEntry, forkedFromParent: true }),
|
||||
patch: () => ({
|
||||
...sessionEntry,
|
||||
totalTokens: undefined,
|
||||
totalTokensFresh: false,
|
||||
}),
|
||||
});
|
||||
if (forkDecision.status === "skip") {
|
||||
if (forked.status === "skipped" && forked.decision?.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=${forkDecision.parentTokens} maxTokens=${forkDecision.maxTokens}`,
|
||||
`parentTokens=${forked.decision.parentTokens} maxTokens=${forked.decision.maxTokens}`,
|
||||
);
|
||||
sessionEntry.forkedFromParent = true;
|
||||
} else {
|
||||
sessionEntry = forked.sessionEntry;
|
||||
} else if (forked.status === "forked") {
|
||||
log.warn(
|
||||
`forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
|
||||
`parentTokens=${forkDecision.parentTokens ?? "unknown"}`,
|
||||
`parentTokens=${forked.decision.parentTokens ?? "unknown"}`,
|
||||
);
|
||||
const forked = await forkSessionFromParent({
|
||||
parentEntry,
|
||||
agentId,
|
||||
sessionsDir: path.dirname(storePath),
|
||||
});
|
||||
if (forked) {
|
||||
sessionId = forked.sessionId;
|
||||
sessionEntry.sessionId = forked.sessionId;
|
||||
sessionEntry.sessionFile = forked.sessionFile;
|
||||
sessionEntry.forkedFromParent = true;
|
||||
// The fork replaces the target transcript with inherited parent
|
||||
// history, so any prior target-session token snapshot is stale.
|
||||
sessionEntry.totalTokens = undefined;
|
||||
sessionEntry.totalTokensFresh = false;
|
||||
inheritedParentContext = true;
|
||||
log.warn(`forked session created: file=${forked.sessionFile}`);
|
||||
}
|
||||
sessionId = forked.fork.sessionId;
|
||||
sessionEntry = forked.sessionEntry;
|
||||
sessionEntry.forkedFromParent = true;
|
||||
inheritedParentContext = true;
|
||||
log.warn(`forked session created: file=${forked.fork.sessionFile}`);
|
||||
}
|
||||
}
|
||||
const threadIdFromSessionKey = parseSessionThreadInfoFast(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// restore/preview/send flows over session stores, transcripts, and active runs.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
@@ -86,7 +85,7 @@ import {
|
||||
import { ADMIN_SCOPE } from "../operator-scopes.js";
|
||||
import { resolveSessionKeyForRun } from "../server-session-key.js";
|
||||
import {
|
||||
forkCompactionCheckpointTranscriptAsync,
|
||||
createFileBackedCompactionCheckpointStore,
|
||||
getSessionCompactionCheckpoint,
|
||||
listSessionCompactionCheckpoints,
|
||||
} from "../session-compaction-checkpoints.js";
|
||||
@@ -136,6 +135,8 @@ import type {
|
||||
} from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
const compactionCheckpointStore = createFileBackedCompactionCheckpointStore();
|
||||
|
||||
function filterSessionStoreToConfiguredAgents(
|
||||
cfg: OpenClawConfig,
|
||||
store: Record<string, SessionEntry>,
|
||||
@@ -350,74 +351,6 @@ function buildDashboardSessionKey(agentId: string): string {
|
||||
return `agent:${agentId}:dashboard:${randomUUID()}`;
|
||||
}
|
||||
|
||||
function cloneCheckpointSessionEntry(params: {
|
||||
currentEntry: SessionEntry;
|
||||
nextSessionId: string;
|
||||
nextSessionFile: string;
|
||||
label?: string;
|
||||
parentSessionKey?: string;
|
||||
totalTokens?: number;
|
||||
preserveCompactionCheckpoints?: boolean;
|
||||
}): SessionEntry {
|
||||
return {
|
||||
...params.currentEntry,
|
||||
sessionId: params.nextSessionId,
|
||||
sessionFile: params.nextSessionFile,
|
||||
updatedAt: Date.now(),
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
startedAt: undefined,
|
||||
endedAt: undefined,
|
||||
runtimeMs: undefined,
|
||||
status: undefined,
|
||||
inputTokens: undefined,
|
||||
outputTokens: undefined,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
estimatedCostUsd: undefined,
|
||||
totalTokens:
|
||||
typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens)
|
||||
? params.totalTokens
|
||||
: undefined,
|
||||
totalTokensFresh:
|
||||
typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens)
|
||||
? true
|
||||
: undefined,
|
||||
label: params.label ?? params.currentEntry.label,
|
||||
parentSessionKey: params.parentSessionKey ?? params.currentEntry.parentSessionKey,
|
||||
compactionCheckpoints: params.preserveCompactionCheckpoints
|
||||
? params.currentEntry.compactionCheckpoints
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCheckpointForkSource(
|
||||
checkpoint: NonNullable<ReturnType<typeof getSessionCompactionCheckpoint>>,
|
||||
): { sourceFile: string; sourceLeafId?: string; totalTokens?: number } | null {
|
||||
const preCompactionFile = checkpoint.preCompaction.sessionFile?.trim();
|
||||
if (preCompactionFile) {
|
||||
return {
|
||||
sourceFile: preCompactionFile,
|
||||
sourceLeafId: checkpoint.preCompaction.entryId ?? checkpoint.preCompaction.leafId,
|
||||
totalTokens: checkpoint.tokensBefore,
|
||||
};
|
||||
}
|
||||
const postCompactionFile = checkpoint.postCompaction.sessionFile?.trim();
|
||||
if (!postCompactionFile) {
|
||||
return null;
|
||||
}
|
||||
const postCompactionLeafId =
|
||||
checkpoint.postCompaction.entryId ?? checkpoint.postCompaction.leafId;
|
||||
if (!postCompactionLeafId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceFile: postCompactionFile,
|
||||
sourceLeafId: postCompactionLeafId,
|
||||
totalTokens: checkpoint.tokensAfter,
|
||||
};
|
||||
}
|
||||
|
||||
function isAgentMainSessionKey(cfg: OpenClawConfig, sessionKey: string): boolean {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
if (!parsed) {
|
||||
@@ -1663,7 +1596,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const loaded = loadSessionEntry(key, { agentId: requestedAgent.agentId });
|
||||
const { cfg: loadedCfg, entry, canonicalKey } = loaded;
|
||||
const { cfg: loadedCfg, entry, canonicalKey, legacyKey } = loaded;
|
||||
const target = resolveGatewaySessionStoreTarget({
|
||||
cfg: loadedCfg,
|
||||
key: canonicalKey,
|
||||
@@ -1678,8 +1611,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId });
|
||||
const forkSource = checkpoint ? resolveCheckpointForkSource(checkpoint) : null;
|
||||
if (!checkpoint || !forkSource) {
|
||||
if (!checkpoint) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -1687,12 +1619,34 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const branchedSession = await forkCompactionCheckpointTranscriptAsync({
|
||||
sourceFile: forkSource.sourceFile,
|
||||
sourceLeafId: forkSource.sourceLeafId,
|
||||
sessionDir: path.dirname(forkSource.sourceFile),
|
||||
const nextKey = buildDashboardSessionKey(target.agentId);
|
||||
const branchedSession = await compactionCheckpointStore.branchCheckpointSession({
|
||||
storePath: target.storePath,
|
||||
sourceKey: canonicalKey,
|
||||
sourceStoreKey: legacyKey,
|
||||
nextKey,
|
||||
checkpointId,
|
||||
});
|
||||
if (!branchedSession?.sessionFile) {
|
||||
if (
|
||||
branchedSession.status === "missing-checkpoint" ||
|
||||
branchedSession.status === "missing-boundary"
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (branchedSession.status === "missing-session") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (branchedSession.status === "failed") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -1700,30 +1654,16 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const nextKey = buildDashboardSessionKey(target.agentId);
|
||||
const label = entry.label?.trim() ? `${entry.label.trim()} (checkpoint)` : "Checkpoint branch";
|
||||
const nextEntry = cloneCheckpointSessionEntry({
|
||||
currentEntry: entry,
|
||||
nextSessionId: branchedSession.sessionId,
|
||||
nextSessionFile: branchedSession.sessionFile,
|
||||
label,
|
||||
parentSessionKey: canonicalKey,
|
||||
totalTokens: forkSource.totalTokens,
|
||||
});
|
||||
|
||||
await updateSessionStore(target.storePath, (store) => {
|
||||
store[nextKey] = nextEntry;
|
||||
});
|
||||
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
sourceKey: canonicalKey,
|
||||
key: nextKey,
|
||||
sessionId: nextEntry.sessionId,
|
||||
checkpoint,
|
||||
entry: nextEntry,
|
||||
key: branchedSession.key,
|
||||
sessionId: branchedSession.entry.sessionId,
|
||||
checkpoint: branchedSession.checkpoint,
|
||||
entry: branchedSession.entry,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -1735,7 +1675,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
reason: "checkpoint-branch",
|
||||
});
|
||||
emitSessionsChanged(context, {
|
||||
sessionKey: nextKey,
|
||||
sessionKey: branchedSession.key,
|
||||
reason: "checkpoint-branch",
|
||||
});
|
||||
},
|
||||
@@ -1778,7 +1718,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const loaded = loadSessionEntry(key, { agentId: requestedAgent.agentId });
|
||||
const { entry, canonicalKey, storePath } = loaded;
|
||||
const { entry, canonicalKey, legacyKey, storePath } = loaded;
|
||||
if (!entry?.sessionId) {
|
||||
respond(
|
||||
false,
|
||||
@@ -1788,8 +1728,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId });
|
||||
const forkSource = checkpoint ? resolveCheckpointForkSource(checkpoint) : null;
|
||||
if (!checkpoint || !forkSource) {
|
||||
if (!checkpoint) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -1812,12 +1751,32 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
|
||||
const restoredSession = await forkCompactionCheckpointTranscriptAsync({
|
||||
sourceFile: forkSource.sourceFile,
|
||||
sourceLeafId: forkSource.sourceLeafId,
|
||||
sessionDir: path.dirname(forkSource.sourceFile),
|
||||
const restoredSession = await compactionCheckpointStore.restoreCheckpointSession({
|
||||
storePath,
|
||||
sessionKey: canonicalKey,
|
||||
sessionStoreKey: legacyKey,
|
||||
checkpointId,
|
||||
});
|
||||
if (!restoredSession?.sessionFile) {
|
||||
if (
|
||||
restoredSession.status === "missing-checkpoint" ||
|
||||
restoredSession.status === "missing-boundary"
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (restoredSession.status === "missing-session") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (restoredSession.status === "failed") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
@@ -1825,26 +1784,15 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const nextEntry = cloneCheckpointSessionEntry({
|
||||
currentEntry: entry,
|
||||
nextSessionId: restoredSession.sessionId,
|
||||
nextSessionFile: restoredSession.sessionFile,
|
||||
totalTokens: forkSource.totalTokens,
|
||||
preserveCompactionCheckpoints: true,
|
||||
});
|
||||
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[canonicalKey] = nextEntry;
|
||||
});
|
||||
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
key: canonicalKey,
|
||||
sessionId: nextEntry.sessionId,
|
||||
checkpoint,
|
||||
entry: nextEntry,
|
||||
key: restoredSession.key,
|
||||
sessionId: restoredSession.entry.sessionId,
|
||||
checkpoint: restoredSession.checkpoint,
|
||||
entry: restoredSession.entry,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
captureCompactionCheckpointSnapshotAsync,
|
||||
cleanupCompactionCheckpointSnapshot,
|
||||
createFileBackedCompactionCheckpointStore,
|
||||
forkCompactionCheckpointTranscriptAsync,
|
||||
MAX_COMPACTION_CHECKPOINT_LEAF_SCAN_BYTES,
|
||||
MAX_COMPACTION_CHECKPOINT_RETAINED_BYTES_PER_SESSION,
|
||||
@@ -91,7 +92,10 @@ function checkpointConfig(storePath: string): OpenClawConfig {
|
||||
async function writeSessionStore(
|
||||
storePath: string,
|
||||
sessionKey: string,
|
||||
entry: { sessionId: string; updatedAt: number; compactionCheckpoints?: unknown[] },
|
||||
entry: { sessionId: string; updatedAt: number; compactionCheckpoints?: unknown[] } & Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
): Promise<void> {
|
||||
await fs.writeFile(storePath, JSON.stringify({ [sessionKey]: entry }, null, 2), "utf-8");
|
||||
}
|
||||
@@ -629,6 +633,79 @@ describe("session-compaction-checkpoints", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("file-backed checkpoint store restores from the stored transcript boundary", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-store-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const session = SessionManager.create(dir, dir);
|
||||
session.appendMessage({
|
||||
role: "user",
|
||||
content: "checkpoint source",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const checkpointLeafId = requireNonEmptyString(
|
||||
session.getLeafId(),
|
||||
"checkpoint leaf id missing",
|
||||
);
|
||||
session.appendMessage({
|
||||
role: "assistant",
|
||||
content: "future turn",
|
||||
api: "responses",
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
timestamp: Date.now(),
|
||||
} as unknown as AssistantMessage);
|
||||
|
||||
const sessionFile = requireNonEmptyString(session.getSessionFile(), "session file missing");
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore(storePath, MAIN_SESSION_KEY, {
|
||||
sessionId: "current-session",
|
||||
sessionFile,
|
||||
updatedAt: Date.now() - 1,
|
||||
totalTokens: 200,
|
||||
compactionCheckpoints: [
|
||||
{
|
||||
checkpointId: "checkpoint-1",
|
||||
sessionKey: MAIN_SESSION_KEY,
|
||||
sessionId: "stored-session",
|
||||
createdAt: Date.now(),
|
||||
reason: "manual",
|
||||
tokensAfter: 45,
|
||||
preCompaction: { sessionId: "pre-session", leafId: "pre-leaf" },
|
||||
postCompaction: {
|
||||
sessionId: "post-session",
|
||||
sessionFile,
|
||||
leafId: checkpointLeafId,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const store = createFileBackedCompactionCheckpointStore();
|
||||
const restored = await store.restoreCheckpointSession({
|
||||
storePath,
|
||||
sessionKey: MAIN_SESSION_KEY,
|
||||
checkpointId: "checkpoint-1",
|
||||
});
|
||||
|
||||
if (restored.status !== "created") {
|
||||
throw new Error("expected restored checkpoint transcript");
|
||||
}
|
||||
expect(restored.entry.totalTokens).toBe(45);
|
||||
const restoredSessionFile = requireNonEmptyString(
|
||||
restored.entry.sessionFile,
|
||||
"restored session file missing",
|
||||
);
|
||||
const messages = SessionManager.open(restoredSessionFile, dir).buildSessionContext().messages;
|
||||
expect(messages.map((message) => (message as { content?: unknown }).content)).toEqual([
|
||||
"checkpoint source",
|
||||
]);
|
||||
const nextStore = await readSessionStore<{ sessionFile?: string; totalTokens?: number }>(
|
||||
storePath,
|
||||
);
|
||||
expect(nextStore[MAIN_SESSION_KEY]?.sessionFile).toBe(restored.entry.sessionFile);
|
||||
expect(nextStore[MAIN_SESSION_KEY]?.totalTokens).toBe(45);
|
||||
});
|
||||
|
||||
test("async fork migrates legacy checkpoint snapshots before writing a current header", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-legacy-fork-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -57,6 +57,87 @@ type ForkedCompactionCheckpointTranscript = {
|
||||
sessionFile: string;
|
||||
};
|
||||
|
||||
export type CompactionCheckpointForkedTranscript = ForkedCompactionCheckpointTranscript & {
|
||||
totalTokens?: number;
|
||||
};
|
||||
|
||||
export type CompactionCheckpointTranscriptForkResult =
|
||||
| { status: "created"; transcript: CompactionCheckpointForkedTranscript }
|
||||
| { status: "missing-boundary" }
|
||||
| { status: "failed" };
|
||||
|
||||
export type CompactionCheckpointSessionMutationResult =
|
||||
| {
|
||||
status: "created";
|
||||
key: string;
|
||||
checkpoint: SessionCompactionCheckpoint;
|
||||
entry: SessionEntry;
|
||||
}
|
||||
| { status: "missing-session" }
|
||||
| { status: "missing-checkpoint" }
|
||||
| { status: "missing-boundary" }
|
||||
| { status: "failed" };
|
||||
|
||||
export type BranchCheckpointSessionParams = {
|
||||
storePath: string;
|
||||
sourceKey: string;
|
||||
sourceStoreKey?: string;
|
||||
nextKey: string;
|
||||
checkpointId: string;
|
||||
};
|
||||
|
||||
export type RestoreCheckpointSessionParams = {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
sessionStoreKey?: string;
|
||||
checkpointId: string;
|
||||
};
|
||||
|
||||
export type PersistSessionCompactionCheckpointParams = {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
reason: SessionCompactionCheckpointReason;
|
||||
snapshot: CapturedCompactionCheckpointSnapshot;
|
||||
summary?: string;
|
||||
firstKeptEntryId?: string;
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
postSessionFile?: string;
|
||||
postLeafId?: string;
|
||||
postEntryId?: string;
|
||||
createdAt?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Storage boundary for compaction checkpoint capture, persistence, branch,
|
||||
* restore, and cleanup operations.
|
||||
*/
|
||||
export type CompactionCheckpointStore = {
|
||||
/** Captures the pre-compaction transcript identity without copying rows/files. */
|
||||
captureSnapshot: typeof captureCompactionCheckpointSnapshotAsync;
|
||||
/** Persists checkpoint metadata and prunes checkpoint artifacts owned by this store. */
|
||||
persistCheckpoint: (
|
||||
params: PersistSessionCompactionCheckpointParams,
|
||||
) => Promise<SessionCompactionCheckpoint | null>;
|
||||
/** Cleans unpersisted legacy snapshot artifacts after failed persistence. */
|
||||
cleanupSnapshot: typeof cleanupCompactionCheckpointSnapshot;
|
||||
/**
|
||||
* Creates a checkpoint branch and records its session entry in one logical
|
||||
* store mutation.
|
||||
*/
|
||||
branchCheckpointSession: (
|
||||
params: BranchCheckpointSessionParams,
|
||||
) => Promise<CompactionCheckpointSessionMutationResult>;
|
||||
/**
|
||||
* Restores a checkpoint and replaces the current session entry in one logical
|
||||
* store mutation.
|
||||
*/
|
||||
restoreCheckpointSession: (
|
||||
params: RestoreCheckpointSessionParams,
|
||||
) => Promise<CompactionCheckpointSessionMutationResult>;
|
||||
};
|
||||
|
||||
function checkpointSnapshotPath(checkpoint: SessionCompactionCheckpoint): string | undefined {
|
||||
return checkpoint.preCompaction.sessionFile?.trim() || undefined;
|
||||
}
|
||||
@@ -401,6 +482,209 @@ export async function forkCompactionCheckpointTranscriptAsync(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCheckpointTranscriptForkSource(
|
||||
checkpoint: SessionCompactionCheckpoint,
|
||||
): { sourceFile: string; sourceLeafId?: string; totalTokens?: number } | null {
|
||||
const preCompactionFile = checkpoint.preCompaction.sessionFile?.trim();
|
||||
if (preCompactionFile) {
|
||||
return {
|
||||
sourceFile: preCompactionFile,
|
||||
sourceLeafId: checkpoint.preCompaction.entryId ?? checkpoint.preCompaction.leafId,
|
||||
totalTokens: checkpoint.tokensBefore,
|
||||
};
|
||||
}
|
||||
|
||||
const postCompactionFile = checkpoint.postCompaction.sessionFile?.trim();
|
||||
if (!postCompactionFile) {
|
||||
return null;
|
||||
}
|
||||
const postCompactionLeafId =
|
||||
checkpoint.postCompaction.entryId ?? checkpoint.postCompaction.leafId;
|
||||
if (!postCompactionLeafId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceFile: postCompactionFile,
|
||||
sourceLeafId: postCompactionLeafId,
|
||||
totalTokens: checkpoint.tokensAfter,
|
||||
};
|
||||
}
|
||||
|
||||
async function forkCheckpointTranscriptFromStoredBoundary(params: {
|
||||
checkpoint: SessionCompactionCheckpoint;
|
||||
sessionDir?: string;
|
||||
targetCwd?: string;
|
||||
}): Promise<CompactionCheckpointTranscriptForkResult> {
|
||||
const forkSource = resolveCheckpointTranscriptForkSource(params.checkpoint);
|
||||
if (!forkSource) {
|
||||
return { status: "missing-boundary" };
|
||||
}
|
||||
const forked = await forkCompactionCheckpointTranscriptAsync({
|
||||
sourceFile: forkSource.sourceFile,
|
||||
sourceLeafId: forkSource.sourceLeafId,
|
||||
sessionDir: params.sessionDir ?? path.dirname(forkSource.sourceFile),
|
||||
...(params.targetCwd ? { targetCwd: params.targetCwd } : {}),
|
||||
});
|
||||
if (!forked) {
|
||||
return { status: "failed" };
|
||||
}
|
||||
return {
|
||||
status: "created",
|
||||
transcript: {
|
||||
...forked,
|
||||
...(typeof forkSource.totalTokens === "number"
|
||||
? { totalTokens: forkSource.totalTokens }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cloneCheckpointSessionEntry(params: {
|
||||
currentEntry: SessionEntry;
|
||||
nextSessionId: string;
|
||||
nextSessionFile: string;
|
||||
label?: string;
|
||||
parentSessionKey?: string;
|
||||
totalTokens?: number;
|
||||
preserveCompactionCheckpoints?: boolean;
|
||||
}): SessionEntry {
|
||||
return {
|
||||
...params.currentEntry,
|
||||
sessionId: params.nextSessionId,
|
||||
sessionFile: params.nextSessionFile,
|
||||
updatedAt: Date.now(),
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
startedAt: undefined,
|
||||
endedAt: undefined,
|
||||
runtimeMs: undefined,
|
||||
status: undefined,
|
||||
inputTokens: undefined,
|
||||
outputTokens: undefined,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
estimatedCostUsd: undefined,
|
||||
totalTokens:
|
||||
typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens)
|
||||
? params.totalTokens
|
||||
: undefined,
|
||||
totalTokensFresh:
|
||||
typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens)
|
||||
? true
|
||||
: undefined,
|
||||
label: params.label ?? params.currentEntry.label,
|
||||
parentSessionKey: params.parentSessionKey ?? params.currentEntry.parentSessionKey,
|
||||
compactionCheckpoints: params.preserveCompactionCheckpoints
|
||||
? params.currentEntry.compactionCheckpoints
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function branchCheckpointSessionFromStoredBoundary(
|
||||
params: BranchCheckpointSessionParams,
|
||||
): Promise<CompactionCheckpointSessionMutationResult> {
|
||||
return await updateSessionStore(
|
||||
params.storePath,
|
||||
async (store) => {
|
||||
const currentEntry = store[params.sourceStoreKey ?? params.sourceKey];
|
||||
if (!currentEntry?.sessionId) {
|
||||
return { status: "missing-session" };
|
||||
}
|
||||
const checkpoint = getSessionCompactionCheckpoint({
|
||||
entry: currentEntry,
|
||||
checkpointId: params.checkpointId,
|
||||
});
|
||||
if (!checkpoint) {
|
||||
return { status: "missing-checkpoint" };
|
||||
}
|
||||
const forkedSession = await forkCheckpointTranscriptFromStoredBoundary({ checkpoint });
|
||||
if (forkedSession.status !== "created") {
|
||||
return forkedSession;
|
||||
}
|
||||
|
||||
const forkedTranscript = forkedSession.transcript;
|
||||
const label = currentEntry.label?.trim()
|
||||
? `${currentEntry.label.trim()} (checkpoint)`
|
||||
: "Checkpoint branch";
|
||||
const nextEntry = cloneCheckpointSessionEntry({
|
||||
currentEntry,
|
||||
nextSessionId: forkedTranscript.sessionId,
|
||||
nextSessionFile: forkedTranscript.sessionFile,
|
||||
label,
|
||||
parentSessionKey: params.sourceKey,
|
||||
totalTokens: forkedTranscript.totalTokens,
|
||||
});
|
||||
store[params.nextKey] = nextEntry;
|
||||
return {
|
||||
status: "created",
|
||||
key: params.nextKey,
|
||||
checkpoint,
|
||||
entry: nextEntry,
|
||||
};
|
||||
},
|
||||
{ skipSaveWhenResult: (result) => result.status !== "created" },
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreCheckpointSessionFromStoredBoundary(
|
||||
params: RestoreCheckpointSessionParams,
|
||||
): Promise<CompactionCheckpointSessionMutationResult> {
|
||||
return await updateSessionStore(
|
||||
params.storePath,
|
||||
async (store) => {
|
||||
const currentEntry = store[params.sessionStoreKey ?? params.sessionKey];
|
||||
if (!currentEntry?.sessionId) {
|
||||
return { status: "missing-session" };
|
||||
}
|
||||
const checkpoint = getSessionCompactionCheckpoint({
|
||||
entry: currentEntry,
|
||||
checkpointId: params.checkpointId,
|
||||
});
|
||||
if (!checkpoint) {
|
||||
return { status: "missing-checkpoint" };
|
||||
}
|
||||
const restoredSession = await forkCheckpointTranscriptFromStoredBoundary({ checkpoint });
|
||||
if (restoredSession.status !== "created") {
|
||||
return restoredSession;
|
||||
}
|
||||
|
||||
const restoredTranscript = restoredSession.transcript;
|
||||
const nextEntry = cloneCheckpointSessionEntry({
|
||||
currentEntry,
|
||||
nextSessionId: restoredTranscript.sessionId,
|
||||
nextSessionFile: restoredTranscript.sessionFile,
|
||||
totalTokens: restoredTranscript.totalTokens,
|
||||
preserveCompactionCheckpoints: true,
|
||||
});
|
||||
store[params.sessionKey] = nextEntry;
|
||||
return {
|
||||
status: "created",
|
||||
key: params.sessionKey,
|
||||
checkpoint,
|
||||
entry: nextEntry,
|
||||
};
|
||||
},
|
||||
{ skipSaveWhenResult: (result) => result.status !== "created" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the current file-backed compaction checkpoint domain store.
|
||||
*
|
||||
* The branch/restore operations own the transcript fork plus session entry
|
||||
* update so a SQLite implementation can copy transcript rows and update
|
||||
* `session_entries.entry_json` inside one write transaction.
|
||||
*/
|
||||
export function createFileBackedCompactionCheckpointStore(): CompactionCheckpointStore {
|
||||
return {
|
||||
captureSnapshot: captureCompactionCheckpointSnapshotAsync,
|
||||
persistCheckpoint: persistSessionCompactionCheckpoint,
|
||||
cleanupSnapshot: cleanupCompactionCheckpointSnapshot,
|
||||
branchCheckpointSession: branchCheckpointSessionFromStoredBoundary,
|
||||
restoreCheckpointSession: restoreCheckpointSessionFromStoredBoundary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the stable pre-compaction identity without duplicating the transcript.
|
||||
* Branch/restore uses the compacted successor transcript, while legacy
|
||||
@@ -488,21 +772,9 @@ async function cleanupTrimmedCompactionCheckpointFiles(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistSessionCompactionCheckpoint(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
reason: SessionCompactionCheckpointReason;
|
||||
snapshot: CapturedCompactionCheckpointSnapshot;
|
||||
summary?: string;
|
||||
firstKeptEntryId?: string;
|
||||
tokensBefore?: number;
|
||||
tokensAfter?: number;
|
||||
postSessionFile?: string;
|
||||
postLeafId?: string;
|
||||
postEntryId?: string;
|
||||
createdAt?: number;
|
||||
}): Promise<SessionCompactionCheckpoint | null> {
|
||||
export async function persistSessionCompactionCheckpoint(
|
||||
params: PersistSessionCompactionCheckpointParams,
|
||||
): Promise<SessionCompactionCheckpoint | null> {
|
||||
const snapshotSessionFile = params.snapshot.sessionFile?.trim();
|
||||
const postSessionFile = params.postSessionFile?.trim();
|
||||
const postSourceLeafId = params.postEntryId?.trim() || params.postLeafId?.trim();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Agent consult runtime tests cover consult session creation and runtime handoff.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RunEmbeddedAgentParams } from "../agents/embedded-agent-runner/run/params.js";
|
||||
import type {
|
||||
ForkSessionEntryFromParentParams,
|
||||
ForkSessionEntryFromParentResult,
|
||||
} from "../auto-reply/reply/session-fork.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import {
|
||||
setRealtimeVoiceAgentConsultDepsForTest,
|
||||
consultRealtimeVoiceAgent,
|
||||
@@ -267,13 +271,47 @@ describe("realtime voice agent consult runtime", () => {
|
||||
maxTokens: 100_000,
|
||||
parentTokens: 100,
|
||||
}));
|
||||
const forkSessionFromParent = vi.fn(async () => ({
|
||||
sessionId: "forked-session",
|
||||
sessionFile: "/tmp/forked.jsonl",
|
||||
}));
|
||||
const forkSessionEntryFromParent = vi.fn(
|
||||
async (
|
||||
params: ForkSessionEntryFromParentParams,
|
||||
): Promise<ForkSessionEntryFromParentResult> => {
|
||||
const fork = {
|
||||
sessionId: "forked-session",
|
||||
sessionFile: "/tmp/forked.jsonl",
|
||||
};
|
||||
const parentEntry = sessionStore["agent:main:main"];
|
||||
if (!parentEntry?.sessionId) {
|
||||
return { status: "missing-parent" };
|
||||
}
|
||||
const typedParentEntry: SessionEntry = {
|
||||
...parentEntry,
|
||||
sessionId: parentEntry.sessionId,
|
||||
updatedAt: parentEntry.updatedAt ?? Date.now(),
|
||||
};
|
||||
const decision = {
|
||||
status: "fork" as const,
|
||||
maxTokens: 100_000,
|
||||
};
|
||||
const entry = params.fallbackEntry ?? { sessionId: "", updatedAt: Date.now() };
|
||||
const sessionEntry: SessionEntry = {
|
||||
...entry,
|
||||
...params.patch?.({ entry, parentEntry: typedParentEntry, fork, decision }),
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
forkedFromParent: true,
|
||||
};
|
||||
sessionStore[params.sessionKey] = sessionEntry;
|
||||
return {
|
||||
status: "forked" as const,
|
||||
fork,
|
||||
parentEntry: typedParentEntry,
|
||||
sessionEntry,
|
||||
decision,
|
||||
};
|
||||
},
|
||||
);
|
||||
setRealtimeVoiceAgentConsultDepsForTest({
|
||||
resolveParentForkDecision,
|
||||
forkSessionFromParent,
|
||||
forkSessionEntryFromParent,
|
||||
});
|
||||
|
||||
await consultRealtimeVoiceAgent({
|
||||
@@ -293,15 +331,16 @@ describe("realtime voice agent consult runtime", () => {
|
||||
userLabel: "Participant",
|
||||
});
|
||||
|
||||
expect(resolveParentForkDecision).toHaveBeenCalledWith({
|
||||
parentEntry: sessionStore["agent:main:main"],
|
||||
storePath: "/tmp/sessions.json",
|
||||
});
|
||||
expect(forkSessionFromParent).toHaveBeenCalledWith({
|
||||
parentEntry: sessionStore["agent:main:main"],
|
||||
agentId: "main",
|
||||
sessionsDir: "/tmp",
|
||||
});
|
||||
expect(resolveParentForkDecision).not.toHaveBeenCalled();
|
||||
expect(forkSessionEntryFromParent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parentSessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
config: {},
|
||||
sessionKey: "agent:main:subagent:google-meet:meet-1",
|
||||
}),
|
||||
);
|
||||
expect(runtime.session.patchSessionEntry).not.toHaveBeenCalled();
|
||||
const forkedEntry = sessionStore["agent:main:subagent:google-meet:meet-1"];
|
||||
if (!forkedEntry) {
|
||||
throw new Error("Expected forked consult session entry");
|
||||
@@ -316,7 +355,75 @@ describe("realtime voice agent consult runtime", () => {
|
||||
expectPositiveTimestamp(forkedEntry.updatedAt);
|
||||
const call = requireEmbeddedAgentCall(runEmbeddedAgent);
|
||||
expect(call.sessionId).toBe("forked-session");
|
||||
expect(call.sessionFile).toBe("/tmp/forked.jsonl");
|
||||
expect(call.sessionFile).toBeUndefined();
|
||||
expect(call.sessionTarget).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionId: "forked-session",
|
||||
sessionKey: "agent:main:subagent:google-meet:meet-1",
|
||||
storePath: "/tmp/sessions.json",
|
||||
});
|
||||
expect(call.spawnedBy).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
it("falls back to a fresh isolated consult session when requester context is too large", async () => {
|
||||
const { runtime, runEmbeddedAgent } = createAgentRuntime();
|
||||
const warn = vi.fn();
|
||||
const forkSessionEntryFromParent = vi.fn(
|
||||
async (
|
||||
params: ForkSessionEntryFromParentParams,
|
||||
): Promise<ForkSessionEntryFromParentResult> => ({
|
||||
status: "skipped",
|
||||
reason: "decision-skip",
|
||||
sessionEntry: {
|
||||
...(params.fallbackEntry ?? { sessionId: "", updatedAt: Date.now() }),
|
||||
sessionId: "",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
decision: {
|
||||
status: "skip",
|
||||
reason: "parent-too-large",
|
||||
maxTokens: 100_000,
|
||||
parentTokens: 150_000,
|
||||
message:
|
||||
"Parent context is too large to fork (150000/100000 tokens); starting with isolated context instead.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
setRealtimeVoiceAgentConsultDepsForTest({
|
||||
forkSessionEntryFromParent,
|
||||
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
||||
});
|
||||
|
||||
await consultRealtimeVoiceAgent({
|
||||
cfg: {} as never,
|
||||
agentRuntime: runtime as never,
|
||||
logger: { warn },
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:subagent:google-meet:meet-1",
|
||||
spawnedBy: "agent:main:main",
|
||||
contextMode: "fork",
|
||||
messageProvider: "google-meet",
|
||||
lane: "google-meet",
|
||||
runIdPrefix: "google-meet:meet-1",
|
||||
args: { question: "What should I say?" },
|
||||
transcript: [],
|
||||
surface: "a private Google Meet",
|
||||
userLabel: "Participant",
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[talk] Parent context is too large to fork (150000/100000 tokens); starting with isolated context instead.",
|
||||
);
|
||||
expect(runtime.session.patchSessionEntry).toHaveBeenCalled();
|
||||
const call = requireEmbeddedAgentCall(runEmbeddedAgent);
|
||||
expect(call.sessionId).toBe("00000000-0000-4000-8000-000000000000");
|
||||
expect(call.sessionFile).toBeUndefined();
|
||||
expect(call.sessionTarget).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionId: "00000000-0000-4000-8000-000000000000",
|
||||
sessionKey: "agent:main:subagent:google-meet:meet-1",
|
||||
storePath: "/tmp/sessions.json",
|
||||
});
|
||||
expect(call.spawnedBy).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// Agent consult runtime starts agent consultation flows from talk sessions.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { RunEmbeddedAgentParams } from "../agents/embedded-agent-runner/run/params.js";
|
||||
import {
|
||||
forkSessionFromParent,
|
||||
resolveParentForkDecision,
|
||||
} from "../auto-reply/reply/session-fork.js";
|
||||
import { forkSessionEntryFromParent } from "../auto-reply/reply/session-fork.js";
|
||||
import { parseSessionThreadInfoFast } from "../config/sessions/thread-info.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -44,14 +40,12 @@ export {
|
||||
|
||||
type RealtimeVoiceAgentConsultDeps = {
|
||||
randomUUID: typeof randomUUID;
|
||||
resolveParentForkDecision: typeof resolveParentForkDecision;
|
||||
forkSessionFromParent: typeof forkSessionFromParent;
|
||||
forkSessionEntryFromParent: typeof forkSessionEntryFromParent;
|
||||
};
|
||||
|
||||
const defaultRealtimeVoiceAgentConsultDeps: RealtimeVoiceAgentConsultDeps = {
|
||||
randomUUID,
|
||||
resolveParentForkDecision,
|
||||
forkSessionFromParent,
|
||||
forkSessionEntryFromParent,
|
||||
};
|
||||
|
||||
let realtimeVoiceAgentConsultDeps = defaultRealtimeVoiceAgentConsultDeps;
|
||||
@@ -133,6 +127,7 @@ function resolveRealtimeVoiceAgentDeliveryContext(params: {
|
||||
|
||||
async function resolveRealtimeVoiceAgentConsultSessionEntry(params: {
|
||||
agentId: string;
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
spawnedBy?: string | null;
|
||||
contextMode?: RealtimeVoiceAgentConsultContextMode;
|
||||
@@ -151,7 +146,37 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: {
|
||||
(!requesterAgentId || requesterAgentId === params.agentId);
|
||||
let forkDecisionWarning: string | undefined;
|
||||
|
||||
const patched = await params.agentRuntime.session.patchSessionEntry({
|
||||
let patched: SessionEntry | null = null;
|
||||
if (shouldFork) {
|
||||
const forked = await realtimeVoiceAgentConsultDeps.forkSessionEntryFromParent({
|
||||
storePath: params.storePath,
|
||||
parentSessionKey: requesterSessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
fallbackEntry: {
|
||||
sessionId: "",
|
||||
updatedAt: now,
|
||||
},
|
||||
skipForkWhen: (entry) => Boolean(entry.sessionId?.trim()),
|
||||
skipPatch: () => ({ ...deliveryFields, updatedAt: now }),
|
||||
patch: () => ({
|
||||
...deliveryFields,
|
||||
spawnedBy: requesterSessionKey,
|
||||
updatedAt: now,
|
||||
}),
|
||||
});
|
||||
if (forked.status === "forked" || forked.status === "skipped") {
|
||||
if (forked.status === "skipped" && forked.decision?.status === "skip") {
|
||||
forkDecisionWarning = forked.decision.message;
|
||||
}
|
||||
if (forked.sessionEntry.sessionId?.trim()) {
|
||||
patched = forked.sessionEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
patched ??= await params.agentRuntime.session.patchSessionEntry({
|
||||
storePath: params.storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
fallbackEntry: {
|
||||
@@ -162,39 +187,6 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: {
|
||||
if (entry.sessionId?.trim()) {
|
||||
return { ...deliveryFields, updatedAt: now };
|
||||
}
|
||||
// Fork only from same-agent requester sessions. Cross-agent parent sessions may carry
|
||||
// incompatible provider state, so they get a fresh consult session with spawnedBy linkage.
|
||||
if (shouldFork) {
|
||||
const parentEntry = params.agentRuntime.session.getSessionEntry({
|
||||
storePath: params.storePath,
|
||||
sessionKey: requesterSessionKey,
|
||||
});
|
||||
if (parentEntry?.sessionId?.trim()) {
|
||||
const decision = await realtimeVoiceAgentConsultDeps.resolveParentForkDecision({
|
||||
parentEntry,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
if (decision.status === "fork") {
|
||||
const fork = await realtimeVoiceAgentConsultDeps.forkSessionFromParent({
|
||||
parentEntry,
|
||||
agentId: params.agentId,
|
||||
sessionsDir: path.dirname(params.storePath),
|
||||
});
|
||||
if (fork) {
|
||||
return {
|
||||
...deliveryFields,
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
spawnedBy: requesterSessionKey,
|
||||
forkedFromParent: true,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
forkDecisionWarning = decision.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...deliveryFields,
|
||||
sessionId: realtimeVoiceAgentConsultDeps.randomUUID(),
|
||||
@@ -259,6 +251,7 @@ export async function consultRealtimeVoiceAgent(params: {
|
||||
});
|
||||
const sessionEntry = await resolveRealtimeVoiceAgentConsultSessionEntry({
|
||||
agentId,
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
spawnedBy: params.spawnedBy,
|
||||
contextMode: params.contextMode,
|
||||
@@ -271,14 +264,17 @@ export async function consultRealtimeVoiceAgent(params: {
|
||||
resolvedDeliveryContext ?? deliveryContextFromSession(sessionEntry);
|
||||
const sessionId = sessionEntry.sessionId;
|
||||
|
||||
const sessionFile = params.agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, {
|
||||
agentId,
|
||||
});
|
||||
// Voice consults suppress verbose/reasoning output because the bridge needs a short,
|
||||
// speakable answer, not agent-run diagnostics or hidden reasoning artifacts.
|
||||
const result = await params.agentRuntime.runEmbeddedAgent({
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionTarget: {
|
||||
agentId,
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath,
|
||||
},
|
||||
sandboxSessionKey: resolveRealtimeVoiceAgentSandboxSessionKey(agentId, params.sessionKey),
|
||||
agentId,
|
||||
spawnedBy: params.spawnedBy,
|
||||
@@ -291,7 +287,6 @@ export async function consultRealtimeVoiceAgent(params: {
|
||||
consultDeliveryContext?.threadId != null
|
||||
? String(consultDeliveryContext.threadId)
|
||||
: undefined,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: params.cfg,
|
||||
prompt: buildRealtimeVoiceAgentConsultPrompt({
|
||||
|
||||
Reference in New Issue
Block a user