openclaw/src/agents/claude-agent-sdk/sdk-session-transcript.ts

226 lines
5.9 KiB
TypeScript

/**
* Session transcript persistence for the Claude Agent SDK.
*
* Appends user/assistant turn pairs to the session JSONL file for
* multi-turn continuity when using the SDK main-agent mode.
*/
import fs from "node:fs";
import path from "node:path";
import { createSubsystemLogger } from "../../logging/subsystem.js";
const log = createSubsystemLogger("agents/claude-agent-sdk/sdk-session-transcript");
type TranscriptRole = "user" | "assistant";
function fileEndsWithNewline(filePath: string): boolean {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size === 0) return true;
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(1);
fs.readSync(fd, buffer, 0, 1, stat.size - 1);
return buffer[0] === 0x0a;
} finally {
fs.closeSync(fd);
}
} catch {
return true;
}
}
function appendJsonlLine(params: { filePath: string; value: unknown }) {
fs.mkdirSync(path.dirname(params.filePath), { recursive: true });
const prefix =
fs.existsSync(params.filePath) && !fileEndsWithNewline(params.filePath) ? "\n" : "";
fs.appendFileSync(params.filePath, `${prefix}${JSON.stringify(params.value)}\n`, "utf-8");
}
/**
* Append a single text turn to the session transcript.
*/
export function appendSdkTextTurnToSessionTranscript(params: {
sessionFile: string;
role: TranscriptRole;
text: string;
timestamp?: number;
}): void {
const trimmed = params.text.trim();
if (!trimmed) return;
try {
appendJsonlLine({
filePath: params.sessionFile,
value: {
role: params.role,
content: [{ type: "text", text: trimmed }],
timestamp: params.timestamp ?? Date.now(),
},
});
} catch (err) {
log.debug(`Failed to append transcript: ${String(err)}`);
}
}
/**
* Append a user/assistant turn pair to the session transcript.
*
* This enables multi-turn continuity for the SDK main-agent mode
* by recording both the user prompt and assistant response.
*/
export function appendSdkTurnPairToSessionTranscript(params: {
sessionFile: string;
prompt: string;
assistantText?: string;
timestamp?: number;
}): void {
const ts = params.timestamp ?? Date.now();
appendSdkTextTurnToSessionTranscript({
sessionFile: params.sessionFile,
role: "user",
text: params.prompt,
timestamp: ts,
});
if (params.assistantText) {
appendSdkTextTurnToSessionTranscript({
sessionFile: params.sessionFile,
role: "assistant",
text: params.assistantText,
timestamp: ts,
});
}
}
/**
* A tracked tool call for session transcript recording.
*/
export type SdkToolCallRecord = {
/** Tool call ID from the model. */
toolCallId: string;
/** Normalized tool name. */
toolName: string;
/** Tool input arguments. */
args: Record<string, unknown>;
/** Tool result (if completed). */
result?: unknown;
/** Whether the tool execution resulted in an error. */
isError?: boolean;
};
/**
* Append a tool use block to the session transcript.
*
* Records tool calls in a structured format matching the pi-agent session format.
*/
export function appendSdkToolUseToSessionTranscript(params: {
sessionFile: string;
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
timestamp?: number;
}): void {
try {
appendJsonlLine({
filePath: params.sessionFile,
value: {
message: {
role: "assistant",
content: [
{
type: "tool_use",
id: params.toolCallId,
name: params.toolName,
input: params.args,
},
],
timestamp: params.timestamp ?? Date.now(),
},
},
});
} catch (err) {
log.debug(`Failed to append tool use to transcript: ${String(err)}`);
}
}
/**
* Append a tool result block to the session transcript.
*
* Records tool results in a structured format matching the pi-agent session format.
*/
export function appendSdkToolResultToSessionTranscript(params: {
sessionFile: string;
toolCallId: string;
result: unknown;
isError?: boolean;
timestamp?: number;
}): void {
try {
// Serialize result to text if it's an object.
let resultContent: string;
if (typeof params.result === "string") {
resultContent = params.result;
} else if (params.result !== undefined) {
try {
resultContent = JSON.stringify(params.result);
} catch {
resultContent = String(params.result);
}
} else {
resultContent = "(no output)";
}
appendJsonlLine({
filePath: params.sessionFile,
value: {
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: params.toolCallId,
content: resultContent,
is_error: params.isError,
},
],
timestamp: params.timestamp ?? Date.now(),
},
},
});
} catch (err) {
log.debug(`Failed to append tool result to transcript: ${String(err)}`);
}
}
/**
* Append multiple tool calls from a single run to the session transcript.
*
* This records completed tool calls with both the tool_use and tool_result
* blocks for full transcript parity with pi-agent format.
*/
export function appendSdkToolCallsToSessionTranscript(params: {
sessionFile: string;
toolCalls: SdkToolCallRecord[];
timestamp?: number;
}): void {
const ts = params.timestamp ?? Date.now();
for (const call of params.toolCalls) {
appendSdkToolUseToSessionTranscript({
sessionFile: params.sessionFile,
toolCallId: call.toolCallId,
toolName: call.toolName,
args: call.args,
timestamp: ts,
});
if (call.result !== undefined) {
appendSdkToolResultToSessionTranscript({
sessionFile: params.sessionFile,
toolCallId: call.toolCallId,
result: call.result,
isError: call.isError,
timestamp: ts,
});
}
}
}