From dfd5940c3491f1fbe1f1495685a8b6fb9d584322 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 26 Apr 2026 08:31:54 +0530 Subject: [PATCH] fix(cli): compact persisted CLI transcripts --- src/agents/agent-command.ts | 38 +++- src/agents/cli-runner/execute.ts | 7 +- src/agents/cli-runner/prepare.ts | 13 +- src/agents/cli-runner/session-history.ts | 63 ++++++ src/agents/cli-runner/types.ts | 1 + src/agents/command/cli-compaction.ts | 267 +++++++++++++++++++++++ src/agents/command/session-store.ts | 26 +++ 7 files changed, 407 insertions(+), 8 deletions(-) create mode 100644 src/agents/command/cli-compaction.ts diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 8f9f5f13749..a88131a8ce8 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -73,6 +73,7 @@ type AcpRuntimeErrorsRuntime = typeof import("../acp/runtime/errors.js"); type AcpSessionIdentifiersRuntime = typeof import("../acp/runtime/session-identifiers.js"); type DeliveryRuntime = typeof import("./command/delivery.runtime.js"); type SessionStoreRuntime = typeof import("./command/session-store.runtime.js"); +type CliCompactionRuntime = typeof import("./command/cli-compaction.js"); type TranscriptResolveRuntime = typeof import("../config/sessions/transcript-resolve.runtime.js"); type CliDepsRuntime = typeof import("../cli/deps.js"); type ExecDefaultsRuntime = typeof import("./exec-defaults.js"); @@ -88,6 +89,7 @@ let acpRuntimeErrorsRuntimePromise: Promise | undefined let acpSessionIdentifiersRuntimePromise: Promise | undefined; let deliveryRuntimePromise: Promise | undefined; let sessionStoreRuntimePromise: Promise | undefined; +let cliCompactionRuntimePromise: Promise | undefined; let transcriptResolveRuntimePromise: Promise | undefined; let cliDepsRuntimePromise: Promise | undefined; let execDefaultsRuntimePromise: Promise | undefined; @@ -131,6 +133,11 @@ function loadSessionStoreRuntime(): Promise { return sessionStoreRuntimePromise; } +function loadCliCompactionRuntime(): Promise { + cliCompactionRuntimePromise ??= import("./command/cli-compaction.js"); + return cliCompactionRuntimePromise; +} + function loadTranscriptResolveRuntime(): Promise { transcriptResolveRuntimePromise ??= import("../config/sessions/transcript-resolve.runtime.js"); return transcriptResolveRuntimePromise; @@ -874,6 +881,11 @@ async function agentCommandInternal( const startedAt = Date.now(); let lifecycleEnded = false; const attemptExecutionRuntime = await loadAttemptExecutionRuntime(); + const runContext = resolveAgentRunContext(opts); + const messageChannel = resolveMessageChannel( + runContext.messageChannel, + opts.replyChannel ?? opts.channel, + ); let result: Awaited>; let fallbackProvider = provider; @@ -882,11 +894,6 @@ async function agentCommandInternal( let liveSwitchRetries = 0; for (;;) { try { - const runContext = resolveAgentRunContext(opts); - const messageChannel = resolveMessageChannel( - runContext.messageChannel, - opts.replyChannel ?? opts.channel, - ); const spawnedBy = normalizedSpawned.spawnedBy ?? sessionEntry?.spawnedBy; const effectiveFallbacksOverride = resolveEffectiveModelFallbacks({ cfg, @@ -1103,6 +1110,27 @@ async function agentCommandInternal( threadId: opts.threadId, sessionCwd: workspaceDir, }); + sessionEntry = await ( + await loadCliCompactionRuntime() + ).runCliTurnCompactionLifecycle({ + cfg, + sessionId, + sessionKey: sessionKey ?? sessionId, + sessionEntry, + sessionStore, + storePath, + sessionAgentId, + workspaceDir, + agentDir, + provider: result.meta.agentMeta?.provider ?? provider, + model: result.meta.agentMeta?.model ?? model, + skillsSnapshot, + messageChannel, + agentAccountId: runContext.accountId, + senderIsOwner: opts.senderIsOwner, + thinkLevel: resolvedThinkLevel, + extraSystemPrompt: opts.extraSystemPrompt, + }); } catch (error) { log.warn( `CLI transcript persistence failed for ${sessionKey ?? sessionId}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 65a4e990a35..31f6687b75a 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -205,8 +205,11 @@ export async function executePreparedCliRun( }) : undefined; + const basePrompt = cliSessionIdToUse + ? params.prompt + : (context.openClawHistoryPrompt ?? params.prompt); let prompt = applyPluginTextReplacements( - prependBootstrapPromptWarning(params.prompt, context.bootstrapPromptWarningLines, { + prependBootstrapPromptWarning(basePrompt, context.bootstrapPromptWarningLines, { preserveExactPrompt: context.heartbeatPrompt, }), context.backendResolved.textTransforms?.input, @@ -270,7 +273,7 @@ export async function executePreparedCliRun( : undefined; try { cliBackendLog.info( - `cli exec: provider=${params.provider} model=${context.normalizedModel} promptChars=${params.prompt.length}`, + `cli exec: provider=${params.provider} model=${context.normalizedModel} promptChars=${basePrompt.length}`, ); const logOutputText = isTruthyEnvValue(process.env[CLI_BACKEND_LOG_OUTPUT_ENV]) || diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 25f20ee6fbf..1c1509c1e46 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -42,7 +42,7 @@ import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; import { buildSystemPrompt, normalizeCliModel } from "./helpers.js"; import { cliBackendLog } from "./log.js"; -import { loadCliSessionHistoryMessages } from "./session-history.js"; +import { buildCliSessionHistoryPrompt, loadCliSessionHistoryMessages } from "./session-history.js"; import type { PreparedCliRunContext, RunCliAgentParams } from "./types.js"; const prepareDeps = { @@ -259,6 +259,16 @@ export async function prepareCliRunContext( `cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`, ); } + const openClawHistoryPrompt = buildCliSessionHistoryPrompt({ + messages: loadCliSessionHistoryMessages({ + sessionId: params.sessionId, + sessionFile: params.sessionFile, + sessionKey: params.sessionKey, + agentId: params.agentId, + config: params.config, + }), + prompt: params.prompt, + }); const heartbeatPrompt = resolveHeartbeatPromptForSystemPrompt({ config: params.config, agentId: sessionAgentId, @@ -392,6 +402,7 @@ export async function prepareCliRunContext( systemPrompt, systemPromptReport, bootstrapPromptWarningLines: bootstrapPromptWarning.lines, + ...(openClawHistoryPrompt ? { openClawHistoryPrompt } : {}), heartbeatPrompt, authEpoch, authEpochVersion: CLI_AUTH_EPOCH_VERSION, diff --git a/src/agents/cli-runner/session-history.ts b/src/agents/cli-runner/session-history.ts index 0879bb222de..4ed266ceb63 100644 --- a/src/agents/cli-runner/session-history.ts +++ b/src/agents/cli-runner/session-history.ts @@ -15,6 +15,69 @@ import { export const MAX_CLI_SESSION_HISTORY_FILE_BYTES = 5 * 1024 * 1024; export const MAX_CLI_SESSION_HISTORY_MESSAGES = MAX_AGENT_HOOK_HISTORY_MESSAGES; +type HistoryMessage = { + role?: unknown; + content?: unknown; +}; + +function coerceHistoryText(content: unknown): string { + if (typeof content === "string") { + return content.trim(); + } + if (!Array.isArray(content)) { + return ""; + } + return content + .flatMap((block) => { + if (!block || typeof block !== "object") { + return []; + } + const text = (block as { text?: unknown }).text; + return typeof text === "string" && text.trim().length > 0 ? [text.trim()] : []; + }) + .join("\n") + .trim(); +} + +export function buildCliSessionHistoryPrompt(params: { + messages: unknown[]; + prompt: string; +}): string | undefined { + const renderedHistory = params.messages + .flatMap((message) => { + if (!message || typeof message !== "object") { + return []; + } + const entry = message as HistoryMessage; + const role = + entry.role === "assistant" ? "Assistant" : entry.role === "user" ? "User" : undefined; + if (!role) { + return []; + } + const text = coerceHistoryText(entry.content); + return text ? [`${role}: ${text}`] : []; + }) + .join("\n\n") + .trim(); + + if (!renderedHistory) { + return undefined; + } + + return [ + "Continue this conversation using the OpenClaw transcript below as prior session history.", + "Treat it as authoritative context for this fresh CLI session.", + "", + "", + renderedHistory, + "", + "", + "", + params.prompt, + "", + ].join("\n"); +} + function safeRealpathSync(filePath: string): string | undefined { try { return fs.realpathSync(filePath); diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 9c59aa7f753..c9c063b6e1e 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -79,6 +79,7 @@ export type PreparedCliRunContext = { systemPrompt: string; systemPromptReport: SessionSystemPromptReport; bootstrapPromptWarningLines: string[]; + openClawHistoryPrompt?: string; heartbeatPrompt?: string; authEpoch?: string; authEpochVersion: number; diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts new file mode 100644 index 00000000000..7167f6852e4 --- /dev/null +++ b/src/agents/command/cli-compaction.ts @@ -0,0 +1,267 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import type { SessionEntry } from "../../config/sessions/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveContextEngine as resolveContextEngineImpl } from "../../context-engine/registry.js"; +import type { ContextEngine } from "../../context-engine/types.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { buildEmbeddedCompactionRuntimeContext } from "../pi-embedded-runner/compaction-runtime-context.js"; +import { runContextEngineMaintenance as runContextEngineMaintenanceImpl } from "../pi-embedded-runner/context-engine-maintenance.js"; +import { shouldPreemptivelyCompactBeforePrompt as shouldPreemptivelyCompactBeforePromptImpl } from "../pi-embedded-runner/run/preemptive-compaction.js"; +import { resolveLiveToolResultMaxChars as resolveLiveToolResultMaxCharsImpl } from "../pi-embedded-runner/tool-result-truncation.js"; +import { createPreparedEmbeddedPiSettingsManager as createPreparedEmbeddedPiSettingsManagerImpl } from "../pi-project-settings.js"; +import { applyPiAutoCompactionGuard as applyPiAutoCompactionGuardImpl } from "../pi-settings.js"; +import type { SkillSnapshot } from "../skills.js"; +import { recordCliCompactionInStore as recordCliCompactionInStoreImpl } from "./session-store.js"; + +type SessionManagerLike = ReturnType; +type SettingsManagerLike = { + getCompactionReserveTokens: () => number; + getCompactionKeepRecentTokens: () => number; + applyOverrides: (overrides: { + compaction: { + reserveTokens?: number; + keepRecentTokens?: number; + }; + }) => void; + setCompactionEnabled?: (enabled: boolean) => void; +}; +type CliCompactionDeps = { + openSessionManager: (sessionFile: string) => SessionManagerLike; + resolveContextEngine: (cfg: OpenClawConfig) => Promise; + createPreparedEmbeddedPiSettingsManager: (params: { + cwd: string; + agentDir: string; + cfg?: OpenClawConfig; + contextTokenBudget?: number; + }) => SettingsManagerLike | Promise; + applyPiAutoCompactionGuard: (params: { + settingsManager: SettingsManagerLike; + contextEngineInfo?: ContextEngine["info"]; + }) => unknown; + shouldPreemptivelyCompactBeforePrompt: typeof shouldPreemptivelyCompactBeforePromptImpl; + resolveLiveToolResultMaxChars: typeof resolveLiveToolResultMaxCharsImpl; + runContextEngineMaintenance: typeof runContextEngineMaintenanceImpl; + recordCliCompactionInStore: typeof recordCliCompactionInStoreImpl; +}; + +const log = createSubsystemLogger("agents/cli-compaction"); + +const cliCompactionDeps: CliCompactionDeps = { + openSessionManager: (sessionFile: string) => SessionManager.open(sessionFile), + resolveContextEngine: resolveContextEngineImpl, + createPreparedEmbeddedPiSettingsManager: createPreparedEmbeddedPiSettingsManagerImpl, + applyPiAutoCompactionGuard: applyPiAutoCompactionGuardImpl, + shouldPreemptivelyCompactBeforePrompt: shouldPreemptivelyCompactBeforePromptImpl, + resolveLiveToolResultMaxChars: resolveLiveToolResultMaxCharsImpl, + runContextEngineMaintenance: runContextEngineMaintenanceImpl, + recordCliCompactionInStore: recordCliCompactionInStoreImpl, +}; + +export function setCliCompactionTestDeps(overrides: Partial): void { + Object.assign(cliCompactionDeps, overrides); +} + +export function resetCliCompactionTestDeps(): void { + Object.assign(cliCompactionDeps, { + openSessionManager: (sessionFile: string) => SessionManager.open(sessionFile), + resolveContextEngine: resolveContextEngineImpl, + createPreparedEmbeddedPiSettingsManager: createPreparedEmbeddedPiSettingsManagerImpl, + applyPiAutoCompactionGuard: applyPiAutoCompactionGuardImpl, + shouldPreemptivelyCompactBeforePrompt: shouldPreemptivelyCompactBeforePromptImpl, + resolveLiveToolResultMaxChars: resolveLiveToolResultMaxCharsImpl, + runContextEngineMaintenance: runContextEngineMaintenanceImpl, + recordCliCompactionInStore: recordCliCompactionInStoreImpl, + }); +} + +function resolvePositiveInteger(value: number | undefined): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return Math.floor(value); +} + +function getSessionBranchMessages(sessionManager: SessionManagerLike): AgentMessage[] { + return sessionManager + .getBranch() + .flatMap((entry) => + entry.type === "message" && typeof entry.message === "object" && entry.message !== null + ? [entry.message] + : [], + ); +} + +function resolveSessionTokenSnapshot(sessionEntry: SessionEntry | undefined): number | undefined { + return resolvePositiveInteger( + sessionEntry?.totalTokensFresh === false ? undefined : sessionEntry?.totalTokens, + ); +} + +async function compactCliTranscript(params: { + contextEngine: ContextEngine; + sessionId: string; + sessionKey: string; + sessionFile: string; + sessionManager: SessionManagerLike; + cfg: OpenClawConfig; + workspaceDir: string; + agentDir: string; + provider: string; + model: string; + contextTokenBudget: number; + currentTokenCount: number; + skillsSnapshot?: SkillSnapshot; + messageChannel?: string; + agentAccountId?: string; + senderIsOwner?: boolean; + thinkLevel?: Parameters[0]["thinkLevel"]; + extraSystemPrompt?: string; +}) { + const runtimeContext = { + ...buildEmbeddedCompactionRuntimeContext({ + sessionKey: params.sessionKey, + messageChannel: params.messageChannel, + messageProvider: params.messageChannel, + agentAccountId: params.agentAccountId, + authProfileId: undefined, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + config: params.cfg, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + provider: params.provider, + modelId: params.model, + thinkLevel: params.thinkLevel, + extraSystemPrompt: params.extraSystemPrompt, + }), + currentTokenCount: params.currentTokenCount, + tokenBudget: params.contextTokenBudget, + trigger: "cli_budget", + }; + + const compactResult = await params.contextEngine.compact({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + tokenBudget: params.contextTokenBudget, + currentTokenCount: params.currentTokenCount, + force: true, + compactionTarget: "budget", + runtimeContext, + }); + + if (!compactResult.compacted) { + log.warn( + `CLI transcript compaction did not reduce context for ${params.provider}/${params.model}: ${compactResult.reason ?? "nothing to compact"}`, + ); + return false; + } + + await cliCompactionDeps.runContextEngineMaintenance({ + contextEngine: params.contextEngine, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + reason: "compaction", + sessionManager: params.sessionManager, + runtimeContext, + }); + return true; +} + +export async function runCliTurnCompactionLifecycle(params: { + cfg: OpenClawConfig; + sessionId: string; + sessionKey: string; + sessionEntry: SessionEntry | undefined; + sessionStore?: Record; + storePath?: string; + sessionAgentId: string; + workspaceDir: string; + agentDir: string; + provider: string; + model: string; + skillsSnapshot?: SkillSnapshot; + messageChannel?: string; + agentAccountId?: string; + senderIsOwner?: boolean; + thinkLevel?: Parameters[0]["thinkLevel"]; + extraSystemPrompt?: string; +}): Promise { + const sessionFile = params.sessionEntry?.sessionFile; + const contextTokenBudget = resolvePositiveInteger(params.sessionEntry?.contextTokens); + if (!sessionFile || !contextTokenBudget) { + return params.sessionEntry; + } + + const contextEngine = await cliCompactionDeps.resolveContextEngine(params.cfg); + const sessionManager = cliCompactionDeps.openSessionManager(sessionFile); + const settingsManager = await cliCompactionDeps.createPreparedEmbeddedPiSettingsManager({ + cwd: params.workspaceDir, + agentDir: params.agentDir, + cfg: params.cfg, + contextTokenBudget, + }); + await cliCompactionDeps.applyPiAutoCompactionGuard({ + settingsManager, + contextEngineInfo: contextEngine.info, + }); + + const preemptiveCompaction = cliCompactionDeps.shouldPreemptivelyCompactBeforePrompt({ + messages: getSessionBranchMessages(sessionManager), + prompt: "", + contextTokenBudget, + reserveTokens: settingsManager.getCompactionReserveTokens(), + toolResultMaxChars: cliCompactionDeps.resolveLiveToolResultMaxChars({ + contextWindowTokens: contextTokenBudget, + cfg: params.cfg, + agentId: params.sessionAgentId, + }), + }); + const tokenSnapshot = resolveSessionTokenSnapshot(params.sessionEntry); + const currentTokenCount = Math.max( + preemptiveCompaction.estimatedPromptTokens, + tokenSnapshot ?? 0, + ); + if ( + !preemptiveCompaction.shouldCompact && + currentTokenCount <= preemptiveCompaction.promptBudgetBeforeReserve + ) { + return params.sessionEntry; + } + + const compacted = await compactCliTranscript({ + contextEngine, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile, + sessionManager, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + provider: params.provider, + model: params.model, + contextTokenBudget, + currentTokenCount, + skillsSnapshot: params.skillsSnapshot, + messageChannel: params.messageChannel, + agentAccountId: params.agentAccountId, + senderIsOwner: params.senderIsOwner, + thinkLevel: params.thinkLevel, + extraSystemPrompt: params.extraSystemPrompt, + }); + + if (!compacted || !params.sessionStore || !params.storePath) { + return params.sessionEntry; + } + + return ( + (await cliCompactionDeps.recordCliCompactionInStore({ + provider: params.provider, + sessionKey: params.sessionKey, + sessionStore: params.sessionStore, + storePath: params.storePath, + })) ?? params.sessionEntry + ); +} diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 8508176069c..de0f2b69bae 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -202,3 +202,29 @@ export async function clearCliSessionInStore(params: { sessionStore[sessionKey] = persisted; return persisted; } + +export async function recordCliCompactionInStore(params: { + provider: string; + sessionKey: string; + sessionStore: Record; + storePath: string; +}): Promise { + const { provider, sessionKey, sessionStore, storePath } = params; + const entry = sessionStore[sessionKey]; + if (!entry) { + return undefined; + } + + const next = { ...entry }; + clearCliSession(next, provider); + next.compactionCount = (entry.compactionCount ?? 0) + 1; + next.updatedAt = Date.now(); + + const persisted = await updateSessionStore(storePath, (store) => { + const merged = mergeSessionEntry(store[sessionKey], next); + store[sessionKey] = merged; + return merged; + }); + sessionStore[sessionKey] = persisted; + return persisted; +}