fix(cli): compact persisted CLI transcripts

This commit is contained in:
Ayaan Zaidi
2026-04-26 08:31:54 +05:30
parent b277eac656
commit dfd5940c34
7 changed files with 407 additions and 8 deletions

View File

@@ -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<AcpRuntimeErrorsRuntime> | undefined
let acpSessionIdentifiersRuntimePromise: Promise<AcpSessionIdentifiersRuntime> | undefined;
let deliveryRuntimePromise: Promise<DeliveryRuntime> | undefined;
let sessionStoreRuntimePromise: Promise<SessionStoreRuntime> | undefined;
let cliCompactionRuntimePromise: Promise<CliCompactionRuntime> | undefined;
let transcriptResolveRuntimePromise: Promise<TranscriptResolveRuntime> | undefined;
let cliDepsRuntimePromise: Promise<CliDepsRuntime> | undefined;
let execDefaultsRuntimePromise: Promise<ExecDefaultsRuntime> | undefined;
@@ -131,6 +133,11 @@ function loadSessionStoreRuntime(): Promise<SessionStoreRuntime> {
return sessionStoreRuntimePromise;
}
function loadCliCompactionRuntime(): Promise<CliCompactionRuntime> {
cliCompactionRuntimePromise ??= import("./command/cli-compaction.js");
return cliCompactionRuntimePromise;
}
function loadTranscriptResolveRuntime(): Promise<TranscriptResolveRuntime> {
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<ReturnType<AttemptExecutionRuntime["runAgentAttempt"]>>;
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)}`,

View File

@@ -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]) ||

View File

@@ -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,

View File

@@ -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.",
"",
"<conversation_history>",
renderedHistory,
"</conversation_history>",
"",
"<next_user_message>",
params.prompt,
"</next_user_message>",
].join("\n");
}
function safeRealpathSync(filePath: string): string | undefined {
try {
return fs.realpathSync(filePath);

View File

@@ -79,6 +79,7 @@ export type PreparedCliRunContext = {
systemPrompt: string;
systemPromptReport: SessionSystemPromptReport;
bootstrapPromptWarningLines: string[];
openClawHistoryPrompt?: string;
heartbeatPrompt?: string;
authEpoch?: string;
authEpochVersion: number;

View File

@@ -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<typeof SessionManager.open>;
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<ContextEngine>;
createPreparedEmbeddedPiSettingsManager: (params: {
cwd: string;
agentDir: string;
cfg?: OpenClawConfig;
contextTokenBudget?: number;
}) => SettingsManagerLike | Promise<SettingsManagerLike>;
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<typeof cliCompactionDeps>): 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<typeof buildEmbeddedCompactionRuntimeContext>[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<string, SessionEntry>;
storePath?: string;
sessionAgentId: string;
workspaceDir: string;
agentDir: string;
provider: string;
model: string;
skillsSnapshot?: SkillSnapshot;
messageChannel?: string;
agentAccountId?: string;
senderIsOwner?: boolean;
thinkLevel?: Parameters<typeof buildEmbeddedCompactionRuntimeContext>[0]["thinkLevel"];
extraSystemPrompt?: string;
}): Promise<SessionEntry | undefined> {
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
);
}

View File

@@ -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<string, SessionEntry>;
storePath: string;
}): Promise<SessionEntry | undefined> {
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;
}