Skip to content

Commit efa9a61

Browse files
authored
refactor: add transcript runtime identity contract (#89201)
1 parent 32af1c0 commit efa9a61

14 files changed

Lines changed: 684 additions & 41 deletions

scripts/check-session-accessor-boundary.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ const legacyWholeStoreAccessNames = new Set([
2525
]);
2626

2727
export const migratedSessionAccessorFiles = new Set([
28+
"src/agents/embedded-agent-runner/compaction-successor-transcript.ts",
29+
"src/agents/embedded-agent-runner/tool-result-truncation.ts",
30+
"src/agents/embedded-agent-runner/transcript-rewrite.ts",
31+
"src/agents/embedded-agent-runner/transcript-runtime-state.ts",
2832
"src/commands/export-trajectory.ts",
2933
"src/commands/health.ts",
3034
"src/commands/sandbox-explain.ts",
@@ -35,6 +39,7 @@ export const migratedSessionAccessorFiles = new Set([
3539
"src/config/sessions/combined-store-gateway.ts",
3640
"src/cron/isolated-agent/delivery-target.ts",
3741
"src/cron/service/timer.ts",
42+
"src/gateway/session-compaction-checkpoints.ts",
3843
"src/gateway/session-utils.ts",
3944
"src/gateway/sessions-resolve.ts",
4045
"src/gateway/server-methods/sessions.ts",
@@ -161,6 +166,7 @@ export async function main() {
161166
const sourceRoots = resolveSourceRoots(repoRoot, [
162167
"extensions/discord/src/monitor",
163168
"extensions/telegram/src",
169+
"src/agents/embedded-agent-runner",
164170
"src/commands",
165171
"src/config/sessions",
166172
"src/cron",

src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
66
import { afterEach, describe, expect, it, vi } from "vitest";
77
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
88
import {
9+
rotateRuntimeTranscriptAfterCompaction,
910
rotateTranscriptAfterCompaction,
1011
rotateTranscriptFileAfterCompaction,
1112
shouldRotateCompactionTranscript,
@@ -117,6 +118,24 @@ function createCompactedSession(sessionDir: string): {
117118
}
118119

119120
describe("rotateTranscriptAfterCompaction", () => {
121+
it("does not create session metadata for missing runtime transcripts", async () => {
122+
const dir = await createTmpDir();
123+
const storePath = path.join(dir, "sessions.json");
124+
await fs.writeFile(storePath, "{}\n", "utf8");
125+
126+
const result = await rotateRuntimeTranscriptAfterCompaction({
127+
scope: {
128+
agentId: "main",
129+
sessionId: "missing-session",
130+
sessionKey: "agent:main:missing",
131+
storePath,
132+
},
133+
});
134+
135+
expect(result.rotated).toBe(false);
136+
expect(await fs.readFile(storePath, "utf8")).toBe("{}\n");
137+
});
138+
120139
it("can rotate a persisted transcript without opening a manager", async () => {
121140
const dir = await createTmpDir();
122141
const { sessionFile } = createCompactedSession(dir);

src/agents/embedded-agent-runner/compaction-successor-transcript.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
TranscriptFileState,
1515
writeTranscriptFileAtomic,
1616
} from "./transcript-file-state.js";
17+
import {
18+
resolveRuntimeTranscriptReadTarget,
19+
type RuntimeTranscriptScope,
20+
} from "./transcript-runtime-state.js";
1721

1822
type ReadonlySessionManagerForRotation = Pick<
1923
TranscriptFileState,
@@ -99,6 +103,33 @@ export async function rotateTranscriptFileAfterCompaction(params: {
99103
});
100104
}
101105

106+
/**
107+
* Rotates a runtime transcript after compaction using agent/session identity.
108+
*/
109+
export async function rotateRuntimeTranscriptAfterCompaction(params: {
110+
sessionManager?: ReadonlySessionManagerForRotation;
111+
scope: RuntimeTranscriptScope;
112+
now?: () => Date;
113+
}): Promise<CompactionTranscriptRotation> {
114+
const target = await resolveRuntimeTranscriptReadTarget(params.scope);
115+
let sessionManager = params.sessionManager;
116+
if (!sessionManager) {
117+
try {
118+
sessionManager = await readTranscriptFileState(target.sessionFile);
119+
} catch (err) {
120+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
121+
return { rotated: false, reason: "missing session file" };
122+
}
123+
throw err;
124+
}
125+
}
126+
return await rotateTranscriptAfterCompaction({
127+
sessionManager,
128+
sessionFile: target.sessionFile,
129+
...(params.now ? { now: params.now } : {}),
130+
});
131+
}
132+
102133
function findLatestCompactionIndex(entries: SessionEntry[]): number {
103134
for (let index = entries.length - 1; index >= 0; index -= 1) {
104135
if (entries[index]?.type === "compaction") {

src/agents/embedded-agent-runner/tool-result-truncation.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ let calculateMaxToolResultCharsWithCap: typeof import("./tool-result-truncation.
1717
let resolveAutoLiveToolResultMaxChars: typeof import("./tool-result-truncation.js").resolveAutoLiveToolResultMaxChars;
1818
let getToolResultTextLength: typeof import("./tool-result-truncation.js").getToolResultTextLength;
1919
let truncateOversizedToolResultsInMessages: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInMessages;
20+
let truncateOversizedToolResultsInRuntimeTranscript: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInRuntimeTranscript;
2021
let truncateOversizedToolResultsInSession: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInSession;
2122
let isOversizedToolResult: typeof import("./tool-result-truncation.js").isOversizedToolResult;
2223
let sessionLikelyHasOversizedToolResults: typeof import("./tool-result-truncation.js").sessionLikelyHasOversizedToolResults;
@@ -36,6 +37,7 @@ async function loadFreshToolResultTruncationModuleForTest() {
3637
resolveAutoLiveToolResultMaxChars,
3738
getToolResultTextLength,
3839
truncateOversizedToolResultsInMessages,
40+
truncateOversizedToolResultsInRuntimeTranscript,
3941
truncateOversizedToolResultsInSession,
4042
isOversizedToolResult,
4143
sessionLikelyHasOversizedToolResults,
@@ -464,6 +466,25 @@ describe("truncateOversizedToolResultsInMessages", () => {
464466
});
465467

466468
describe("truncateOversizedToolResultsInSession", () => {
469+
it("does not create session metadata for missing runtime transcripts", async () => {
470+
const dir = await createTmpDir();
471+
const storePath = path.join(dir, "sessions.json");
472+
await fs.writeFile(storePath, "{}\n", "utf8");
473+
474+
const result = await truncateOversizedToolResultsInRuntimeTranscript({
475+
scope: {
476+
agentId: "main",
477+
sessionId: "missing-session",
478+
sessionKey: "agent:main:missing",
479+
storePath,
480+
},
481+
contextWindowTokens: 100,
482+
});
483+
484+
expect(result.truncated).toBe(false);
485+
expect(await fs.readFile(storePath, "utf8")).toBe("{}\n");
486+
});
487+
467488
it("readably truncates aggregate medium tool results in a session file", async () => {
468489
// Persisted truncation rewrites JSONL directly and emits the transcript
469490
// update event instead of reopening through SessionManager internals.

src/agents/embedded-agent-runner/tool-result-truncation.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import {
2525
rewriteTranscriptEntriesInSessionManager,
2626
rewriteTranscriptEntriesInState,
2727
} from "./transcript-rewrite.js";
28+
import {
29+
resolveRuntimeTranscriptReadTarget,
30+
type RuntimeTranscriptScope,
31+
} from "./transcript-runtime-state.js";
2832

2933
/**
3034
* Maximum share of the context window a single tool result should occupy.
@@ -818,6 +822,49 @@ export function truncateOversizedToolResultsInSessionManager(params: {
818822
}
819823
}
820824

825+
/**
826+
* Truncates oversized tool results for a runtime transcript scope.
827+
*/
828+
export async function truncateOversizedToolResultsInRuntimeTranscript(params: {
829+
scope: RuntimeTranscriptScope;
830+
contextWindowTokens: number;
831+
maxCharsOverride?: number;
832+
aggregateMaxCharsOverride?: number;
833+
config?: SessionWriteLockAcquireTimeoutConfig;
834+
}): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> {
835+
let sessionLock: Awaited<ReturnType<typeof acquireSessionWriteLock>> | undefined;
836+
837+
try {
838+
const target = await resolveRuntimeTranscriptReadTarget(params.scope);
839+
sessionLock = await acquireSessionWriteLock({
840+
sessionFile: target.sessionFile,
841+
...resolveSessionWriteLockOptions(params.config),
842+
});
843+
const state = await readTranscriptFileState(target.sessionFile);
844+
return await truncateOversizedToolResultsInTranscriptState({
845+
state,
846+
contextWindowTokens: params.contextWindowTokens,
847+
maxCharsOverride: params.maxCharsOverride,
848+
aggregateMaxCharsOverride: params.aggregateMaxCharsOverride,
849+
sessionFile: target.sessionFile,
850+
sessionId: target.sessionId,
851+
sessionKey: target.sessionKey,
852+
agentId: target.agentId,
853+
config: params.config,
854+
});
855+
} catch (err) {
856+
const errMsg = formatErrorMessage(err);
857+
log.warn(`[tool-result-truncation] Failed to truncate: ${errMsg}`);
858+
return { truncated: false, truncatedCount: 0, reason: errMsg };
859+
} finally {
860+
await sessionLock?.release();
861+
}
862+
}
863+
864+
/**
865+
* Truncates a named transcript file artifact. Runtime callers should prefer
866+
* truncateOversizedToolResultsInRuntimeTranscript with agent/session scope.
867+
*/
821868
export async function truncateOversizedToolResultsInSession(params: {
822869
sessionFile: string;
823870
contextWindowTokens: number;

src/agents/embedded-agent-runner/transcript-rewrite.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock("../session-write-lock.js", () =>
2222

2323
let rewriteTranscriptEntriesInSessionFile: typeof import("./transcript-rewrite.js").rewriteTranscriptEntriesInSessionFile;
2424
let rewriteTranscriptEntriesInSessionManager: typeof import("./transcript-rewrite.js").rewriteTranscriptEntriesInSessionManager;
25+
let rewriteTranscriptEntriesInRuntimeTranscript: typeof import("./transcript-rewrite.js").rewriteTranscriptEntriesInRuntimeTranscript;
2526
let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate;
2627
let installSessionToolResultGuard: typeof import("../session-tool-result-guard.js").installSessionToolResultGuard;
2728

@@ -159,8 +160,11 @@ function requireString(value: string | undefined, label: string): string {
159160
beforeAll(async () => {
160161
({ onSessionTranscriptUpdate } = await import("../../sessions/transcript-events.js"));
161162
({ installSessionToolResultGuard } = await import("../session-tool-result-guard.js"));
162-
({ rewriteTranscriptEntriesInSessionFile, rewriteTranscriptEntriesInSessionManager } =
163-
await import("./transcript-rewrite.js"));
163+
({
164+
rewriteTranscriptEntriesInRuntimeTranscript,
165+
rewriteTranscriptEntriesInSessionFile,
166+
rewriteTranscriptEntriesInSessionManager,
167+
} = await import("./transcript-rewrite.js"));
164168
});
165169

166170
beforeEach(() => {
@@ -306,6 +310,25 @@ describe("rewriteTranscriptEntriesInSessionManager", () => {
306310
});
307311

308312
describe("rewriteTranscriptEntriesInSessionFile", () => {
313+
it("does not create session metadata for missing runtime transcripts", async () => {
314+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-runtime-"));
315+
const storePath = path.join(dir, "sessions.json");
316+
await fs.writeFile(storePath, "{}\n", "utf8");
317+
318+
const result = await rewriteTranscriptEntriesInRuntimeTranscript({
319+
scope: {
320+
agentId: "main",
321+
sessionId: "missing-session",
322+
sessionKey: "agent:main:missing",
323+
storePath,
324+
},
325+
request: { replacements: [] },
326+
});
327+
328+
expect(result.changed).toBe(false);
329+
expect(await fs.readFile(storePath, "utf8")).toBe("{}\n");
330+
});
331+
309332
it("aborts under the write lock when the active suffix contains an unexpected entry", async () => {
310333
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-guard-"));
311334
const sessionManager = SessionManager.create(dir, dir);

src/agents/embedded-agent-runner/transcript-rewrite.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import {
2222
readTranscriptFileState,
2323
type TranscriptFileState,
2424
} from "./transcript-file-state.js";
25+
import {
26+
persistRuntimeTranscriptStateMutation,
27+
resolveRuntimeTranscriptReadTarget,
28+
type RuntimeTranscriptScope,
29+
} from "./transcript-runtime-state.js";
2530

2631
type SessionManagerLike = ReturnType<typeof SessionManager.open>;
2732
type SessionBranchEntry = ReturnType<SessionManagerLike["getBranch"]>[number];
@@ -372,8 +377,65 @@ export function rewriteTranscriptEntriesInState(params: {
372377
}
373378

374379
/**
375-
* Open a transcript file, rewrite message entries on the active branch, and
376-
* emit a transcript update when the active branch changed.
380+
* Rewrites message entries for a runtime transcript without using the
381+
* file-backed path as caller identity.
382+
*/
383+
export async function rewriteTranscriptEntriesInRuntimeTranscript(params: {
384+
scope: RuntimeTranscriptScope;
385+
request: TranscriptRewriteRequest;
386+
config?: SessionWriteLockAcquireTimeoutConfig;
387+
}): Promise<TranscriptRewriteResult> {
388+
let sessionLock: Awaited<ReturnType<typeof acquireSessionWriteLock>> | undefined;
389+
try {
390+
const target = await resolveRuntimeTranscriptReadTarget(params.scope);
391+
sessionLock = await acquireSessionWriteLock({
392+
sessionFile: target.sessionFile,
393+
...resolveSessionWriteLockOptions(params.config),
394+
});
395+
const state = await readTranscriptFileState(target.sessionFile);
396+
const result = rewriteTranscriptEntriesInState({
397+
state,
398+
replacements: params.request.replacements,
399+
...(params.request.allowedRewriteSuffixEntryIds
400+
? { allowedRewriteSuffixEntryIds: params.request.allowedRewriteSuffixEntryIds }
401+
: {}),
402+
});
403+
if (result.changed) {
404+
await persistRuntimeTranscriptStateMutation({
405+
target,
406+
state,
407+
appendedEntries: result.appendedEntries,
408+
});
409+
emitSessionTranscriptUpdate({
410+
sessionFile: target.sessionFile,
411+
sessionKey: target.sessionKey,
412+
agentId: target.agentId,
413+
});
414+
log.info(
415+
`[transcript-rewrite] rewrote ${result.rewrittenEntries} entr` +
416+
`${result.rewrittenEntries === 1 ? "y" : "ies"} ` +
417+
`bytesFreed=${result.bytesFreed} ` +
418+
`sessionKey=${target.sessionKey}`,
419+
);
420+
}
421+
return result;
422+
} catch (err) {
423+
const reason = formatErrorMessage(err);
424+
log.warn(`[transcript-rewrite] failed: ${reason}`);
425+
return {
426+
changed: false,
427+
bytesFreed: 0,
428+
rewrittenEntries: 0,
429+
reason,
430+
};
431+
} finally {
432+
await sessionLock?.release();
433+
}
434+
}
435+
436+
/**
437+
* Rewrites a named transcript file artifact. Runtime callers should prefer
438+
* rewriteTranscriptEntriesInRuntimeTranscript with agent/session scope.
377439
*/
378440
export async function rewriteTranscriptEntriesInSessionFile(params: {
379441
sessionFile: string;

0 commit comments

Comments
 (0)