Skip to content

Commit 3c95327

Browse files
authored
Fix compacted session transcript rotation
1 parent 0a117b5 commit 3c95327

38 files changed

Lines changed: 823 additions & 734 deletions
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
4d1995e41b659e484afb5a48d6fca0558337123200a4a537f556ca38e8e829e7 config-baseline.json
2-
3245c9a013c55ee8a24db52d5e88c42bc86e26f822d4a144fc7f37fc71e05fa8 config-baseline.core.json
1+
79fa6b9b9df5e22ac56a7edb9bfc25550131e285ce9f4868f468d957a8768240 config-baseline.json
2+
2722504ab6bd37eea9e7542689bd6dba5fb4e485c0eab9c1915427c49a5c5b66 config-baseline.core.json
33
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
4-
f9e0174988718959fe1923a54496ec5b9262721fe1e7306f32ccb1316d9d9c3f config-baseline.plugin.json
4+
74b74cb18ac37c0acaa765f398f1f9edbcee4c43567f02d45c89598a1e13afb4 config-baseline.plugin.json
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
21914ef8c5840e0defc36d571834dc28a92d6d5ca2d42a088c33b4de681e836a plugin-sdk-api-baseline.json
2-
3f22e6af0dad3433d25d996802d7436a3cc0e68bc86ecaf813a22e2b4e5333eb plugin-sdk-api-baseline.jsonl
1+
ba5191d586958233c69921928e4d13ae6e8af61e26cf57eec6f50c5d551d8b43 plugin-sdk-api-baseline.json
2+
e6fc8ea33cfc6251a080c3a49d0db2e7d82c117f412902c79da359ebbc9197cc plugin-sdk-api-baseline.jsonl

docs/concepts/compaction.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ honors that Pi cut-point and keeps the recent tail in rebuilt context. Without
118118
an explicit keep budget, manual compaction behaves as a hard checkpoint and
119119
continues from the new summary alone.
120120

121+
When `agents.defaults.compaction.truncateAfterCompaction` is enabled,
122+
OpenClaw does not rewrite the existing transcript in place. It creates a new
123+
active successor transcript from the compaction summary, preserved state, and
124+
unsummarized tail, then keeps the previous JSONL as the archived checkpoint
125+
source.
126+
121127
## Using a different model
122128

123129
By default, compaction uses your agent's primary model. You can use a more

docs/concepts/context-engine.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ Required members:
194194
Prepended to the system prompt.
195195
</ParamField>
196196

197+
`compact` returns a `CompactResult`. When compaction rotates the active
198+
transcript, `result.sessionId` and `result.sessionFile` identify the successor
199+
session that the next retry or turn must use.
200+
197201
Optional members:
198202

199203
| Member | Kind | Purpose |

docs/reference/session-management-compaction.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,10 @@ OpenClaw also enforces a safety floor for embedded runs:
285285
and keeps Pi's recent-tail cut point. Without an explicit keep budget,
286286
manual compaction remains a hard checkpoint and rebuilt context starts from
287287
the new summary.
288+
- When `agents.defaults.compaction.truncateAfterCompaction` is enabled,
289+
OpenClaw rotates the active transcript to a compacted successor JSONL after
290+
compaction. The old full transcript remains archived and linked from the
291+
compaction checkpoint instead of being rewritten in place.
288292

289293
Why: leave enough headroom for multi-turn “housekeeping” (like memory writes) before compaction becomes unavoidable.
290294

src/agents/bash-tools.exec-host-node-phases.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { buildNodeShellCommand } from "../infra/node-shell.js";
1717
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
1818
import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js";
1919
import { normalizeNullableString } from "../shared/string-coerce.js";
20-
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.js";
20+
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
2121
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
2222
import { callGatewayTool } from "./tools/gateway.js";
2323
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";

src/agents/bash-tools.exec-host-node.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
22
import {
3-
type ExecAsk,
4-
type ExecSecurity,
53
requiresExecApproval,
64
resolveExecApprovalAllowedDecisions,
75
} from "../infra/exec-approvals.js";
@@ -19,6 +17,7 @@ import {
1917
resolveNodeExecutionTarget,
2018
shouldSkipNodeApprovalPrepare,
2119
} from "./bash-tools.exec-host-node-phases.js";
20+
import type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
2221
import * as execHostShared from "./bash-tools.exec-host-shared.js";
2322
import {
2423
DEFAULT_NOTIFY_TAIL_CHARS,
@@ -28,31 +27,7 @@ import {
2827
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
2928
import { callGatewayTool } from "./tools/gateway.js";
3029

31-
export type ExecuteNodeHostCommandParams = {
32-
command: string;
33-
workdir: string | undefined;
34-
env: Record<string, string>;
35-
requestedEnv?: Record<string, string>;
36-
requestedNode?: string;
37-
boundNode?: string;
38-
sessionKey?: string;
39-
turnSourceChannel?: string;
40-
turnSourceTo?: string;
41-
turnSourceAccountId?: string;
42-
turnSourceThreadId?: string | number;
43-
trigger?: string;
44-
agentId?: string;
45-
security: ExecSecurity;
46-
ask: ExecAsk;
47-
strictInlineEval?: boolean;
48-
timeoutSec?: number;
49-
defaultTimeoutSec: number;
50-
approvalRunningNoticeMs: number;
51-
warnings: string[];
52-
notifySessionKey?: string;
53-
notifyOnExit?: boolean;
54-
trustedSafeBinDirs?: ReadonlySet<string>;
55-
};
30+
export type { ExecuteNodeHostCommandParams } from "./bash-tools.exec-host-node.types.js";
5631

5732
export async function executeNodeHostCommand(
5833
params: ExecuteNodeHostCommandParams,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { ExecAsk, ExecSecurity } from "../infra/exec-approvals.js";
2+
3+
export type ExecuteNodeHostCommandParams = {
4+
command: string;
5+
workdir: string | undefined;
6+
env: Record<string, string>;
7+
requestedEnv?: Record<string, string>;
8+
requestedNode?: string;
9+
boundNode?: string;
10+
sessionKey?: string;
11+
turnSourceChannel?: string;
12+
turnSourceTo?: string;
13+
turnSourceAccountId?: string;
14+
turnSourceThreadId?: string | number;
15+
trigger?: string;
16+
agentId?: string;
17+
security: ExecSecurity;
18+
ask: ExecAsk;
19+
strictInlineEval?: boolean;
20+
timeoutSec?: number;
21+
defaultTimeoutSec: number;
22+
approvalRunningNoticeMs: number;
23+
warnings: string[];
24+
notifySessionKey?: string;
25+
notifyOnExit?: boolean;
26+
trustedSafeBinDirs?: ReadonlySet<string>;
27+
};

src/agents/pi-embedded-runner/compact.hooks.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,38 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
752752
);
753753
});
754754

755+
it("passes the rotated session id to engine-owned after_compaction hooks", async () => {
756+
hookRunner.hasHooks.mockReturnValue(true);
757+
const rotatedSessionId = "rotated-session";
758+
const rotatedSessionFile = "/tmp/rotated-session.jsonl";
759+
contextEngineCompactMock.mockResolvedValue({
760+
ok: true,
761+
compacted: true,
762+
reason: undefined,
763+
result: {
764+
summary: "engine-summary",
765+
firstKeptEntryId: "entry-1",
766+
tokensBefore: 120,
767+
tokensAfter: 50,
768+
sessionId: rotatedSessionId,
769+
sessionFile: rotatedSessionFile,
770+
},
771+
} as never);
772+
773+
const result = await compactEmbeddedPiSession(wrappedCompactionArgs());
774+
775+
expect(result.ok).toBe(true);
776+
expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith(
777+
expect.objectContaining({
778+
sessionFile: rotatedSessionFile,
779+
}),
780+
expect.objectContaining({
781+
sessionId: rotatedSessionId,
782+
sessionKey: TEST_SESSION_KEY,
783+
}),
784+
);
785+
});
786+
755787
it("emits a transcript update and post-compaction memory sync on the engine-owned path", async () => {
756788
const listener = vi.fn();
757789
const cleanup = onSessionTranscriptUpdate(listener);
@@ -924,6 +956,58 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
924956
}
925957
});
926958

959+
it("reuses a delegated compaction successor transcript", async () => {
960+
const maintain = vi.fn(async (_params?: unknown) => ({
961+
changed: false,
962+
bytesFreed: 0,
963+
rewrittenEntries: 0,
964+
}));
965+
const delegatedSessionId = "delegated-session";
966+
const delegatedSessionFile = "/tmp/delegated-session.jsonl";
967+
resolveContextEngineMock.mockResolvedValue({
968+
info: { ownsCompaction: false },
969+
compact: contextEngineCompactMock,
970+
maintain,
971+
} as never);
972+
contextEngineCompactMock.mockResolvedValue({
973+
ok: true,
974+
compacted: true,
975+
reason: undefined,
976+
result: {
977+
summary: "engine-summary",
978+
firstKeptEntryId: "entry-1",
979+
tokensBefore: 120,
980+
tokensAfter: 50,
981+
sessionId: delegatedSessionId,
982+
sessionFile: delegatedSessionFile,
983+
},
984+
} as never);
985+
986+
const result = await compactEmbeddedPiSession(
987+
wrappedCompactionArgs({
988+
config: {
989+
agents: {
990+
defaults: {
991+
compaction: {
992+
truncateAfterCompaction: true,
993+
},
994+
},
995+
},
996+
},
997+
}),
998+
);
999+
1000+
expect(result.ok).toBe(true);
1001+
expect(result.result?.sessionId).toBe(delegatedSessionId);
1002+
expect(result.result?.sessionFile).toBe(delegatedSessionFile);
1003+
expect(maintain).toHaveBeenCalledWith(
1004+
expect.objectContaining({
1005+
sessionId: delegatedSessionId,
1006+
sessionFile: delegatedSessionFile,
1007+
}),
1008+
);
1009+
});
1010+
9271011
it("catches and logs hook exceptions without aborting compaction", async () => {
9281012
hookRunner.hasHooks.mockReturnValue(true);
9291013
hookRunner.runBeforeCompaction.mockRejectedValue(new Error("hook boom"));

src/agents/pi-embedded-runner/compact.queued.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import {
2626
buildEmbeddedCompactionRuntimeContext,
2727
resolveEmbeddedCompactionTarget,
2828
} from "./compaction-runtime-context.js";
29+
import {
30+
rotateTranscriptAfterCompaction,
31+
shouldRotateCompactionTranscript,
32+
} from "./compaction-successor-transcript.js";
2933
import { runContextEngineMaintenance } from "./context-engine-maintenance.js";
3034
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
3135
import { log } from "./logger.js";
@@ -158,15 +162,44 @@ export async function compactEmbeddedPiSession(
158162
force: params.trigger === "manual",
159163
runtimeContext,
160164
});
165+
const delegatedSessionId = result.result?.sessionId;
166+
const delegatedSessionFile = result.result?.sessionFile;
167+
const delegatedRotatedTranscript = Boolean(delegatedSessionId || delegatedSessionFile);
168+
let postCompactionSessionId = delegatedSessionId ?? params.sessionId;
169+
let postCompactionSessionFile = delegatedSessionFile ?? params.sessionFile;
170+
let postCompactionLeafId: string | undefined;
161171
if (result.ok && result.compacted) {
172+
if (shouldRotateCompactionTranscript(params.config) && !delegatedRotatedTranscript) {
173+
try {
174+
const rotation = await rotateTranscriptAfterCompaction({
175+
sessionManager: SessionManager.open(params.sessionFile),
176+
sessionFile: params.sessionFile,
177+
});
178+
if (rotation.rotated) {
179+
postCompactionSessionId = rotation.sessionId ?? postCompactionSessionId;
180+
postCompactionSessionFile = rotation.sessionFile ?? postCompactionSessionFile;
181+
postCompactionLeafId = rotation.leafId;
182+
log.info(
183+
`[compaction] rotated active transcript after context-engine compaction ` +
184+
`(sessionKey=${params.sessionKey ?? params.sessionId})`,
185+
);
186+
}
187+
} catch (err) {
188+
log.warn("failed to rotate compacted transcript", {
189+
errorMessage: formatErrorMessage(err),
190+
});
191+
}
192+
}
162193
if (params.config && params.sessionKey && checkpointSnapshot) {
163194
try {
164-
const postCompactionSession = SessionManager.open(params.sessionFile);
165-
const postLeafId = postCompactionSession.getLeafId() ?? undefined;
195+
const postLeafId =
196+
postCompactionLeafId ??
197+
SessionManager.open(postCompactionSessionFile).getLeafId() ??
198+
undefined;
166199
const storedCheckpoint = await persistSessionCompactionCheckpoint({
167200
cfg: params.config,
168201
sessionKey: params.sessionKey,
169-
sessionId: params.sessionId,
202+
sessionId: postCompactionSessionId,
170203
reason: resolveSessionCompactionCheckpointReason({
171204
trigger: params.trigger,
172205
}),
@@ -175,7 +208,7 @@ export async function compactEmbeddedPiSession(
175208
firstKeptEntryId: result.result?.firstKeptEntryId,
176209
tokensBefore: result.result?.tokensBefore,
177210
tokensAfter: result.result?.tokensAfter,
178-
postSessionFile: params.sessionFile,
211+
postSessionFile: postCompactionSessionFile,
179212
postLeafId,
180213
postEntryId: postLeafId,
181214
});
@@ -188,9 +221,9 @@ export async function compactEmbeddedPiSession(
188221
}
189222
await runContextEngineMaintenance({
190223
contextEngine,
191-
sessionId: params.sessionId,
224+
sessionId: postCompactionSessionId,
192225
sessionKey: params.sessionKey,
193-
sessionFile: params.sessionFile,
226+
sessionFile: postCompactionSessionFile,
194227
reason: "compaction",
195228
runtimeContext,
196229
});
@@ -199,7 +232,7 @@ export async function compactEmbeddedPiSession(
199232
await runPostCompactionSideEffects({
200233
config: params.config,
201234
sessionKey: params.sessionKey,
202-
sessionFile: params.sessionFile,
235+
sessionFile: postCompactionSessionFile,
203236
});
204237
}
205238
if (
@@ -209,14 +242,18 @@ export async function compactEmbeddedPiSession(
209242
hookRunner.runAfterCompaction
210243
) {
211244
try {
245+
const afterHookCtx = {
246+
...hookCtx,
247+
sessionId: postCompactionSessionId,
248+
};
212249
await hookRunner.runAfterCompaction(
213250
{
214251
messageCount: -1,
215252
compactedCount: -1,
216253
tokenCount: result.result?.tokensAfter,
217-
sessionFile: params.sessionFile,
254+
sessionFile: postCompactionSessionFile,
218255
},
219-
hookCtx,
256+
afterHookCtx,
220257
);
221258
} catch (err) {
222259
log.warn("after_compaction hook failed", {
@@ -235,6 +272,12 @@ export async function compactEmbeddedPiSession(
235272
tokensBefore: result.result.tokensBefore,
236273
tokensAfter: result.result.tokensAfter,
237274
details: result.result.details,
275+
...(postCompactionSessionId !== params.sessionId
276+
? { sessionId: postCompactionSessionId }
277+
: {}),
278+
...(postCompactionSessionFile !== params.sessionFile
279+
? { sessionFile: postCompactionSessionFile }
280+
: {}),
238281
}
239282
: undefined,
240283
};

0 commit comments

Comments
 (0)