/** * Helper functions for agent attempt execution, Claude CLI transcript probing, * fallback prompts, and ACP visible-text accumulation. */ import fs from "node:fs/promises"; import path from "node:path"; import readline from "node:readline"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN, startsWithSilentToken, stripLeadingSilentToken, } from "../../auto-reply/tokens.js"; import { isToolCallBlock, resolveToolUseId, type ToolContentBlock, } from "../../chat/tool-content.js"; import { readSessionTranscriptBoundedMessageTailPage, type SessionTranscriptRuntimeTarget, waitForSessionTranscriptProjection, } from "../../config/sessions/session-accessor.js"; import { type ClaudeCliFallbackSeed, readClaudeCliFallbackSeed, } from "../../gateway/cli-session-history.js"; import { buildAgentRunTerminalReplySnapshot } from "../agent-run-terminal-reply.js"; import type { AgentRunTerminalReplySnapshot } from "../agent-run-terminal-reply.types.js"; import { cliBackendLog } from "../cli-runner/log.js"; import { resolveClaudeCliProjectDirForWorkspace } from "./claude-cli-project-dir.js"; const CLAUDE_CLI_TRANSCRIPT_MAX_RECORDS = 500; function normalizeClaudeCliSessionId(sessionId: string | undefined): string | undefined { const trimmed = sessionId?.trim(); if (!trimmed || trimmed.includes("\0") || trimmed.includes("/") || trimmed.includes("\\")) { return undefined; } return trimmed; } type JsonlFileScan = { fileExists: boolean; hasAssistant: boolean }; async function scanJsonlFile(filePath: string | undefined): Promise { if (!filePath) { return { fileExists: false, hasAssistant: false }; } try { const stat = await fs.lstat(filePath); if (stat.isSymbolicLink() || !stat.isFile()) { return { fileExists: false, hasAssistant: false }; } const fh = await fs.open(filePath, "r"); try { const rl = readline.createInterface({ input: fh.createReadStream({ encoding: "utf-8" }) }); let recordCount = 0; for await (const line of rl) { if (!line.trim()) { continue; } recordCount++; if (recordCount > CLAUDE_CLI_TRANSCRIPT_MAX_RECORDS) { break; } let obj: unknown; try { obj = JSON.parse(line); } catch { continue; } const rec = obj as Record | null; if ((rec?.message as Record | undefined)?.role === "assistant") { return { fileExists: true, hasAssistant: true }; } } return { fileExists: true, hasAssistant: false }; } finally { await fh.close(); } } catch { return { fileExists: false, hasAssistant: false }; } } /** Checks whether the active SQLite history contains a persisted assistant turn. */ export async function sessionTranscriptHasContent( target: SessionTranscriptRuntimeTarget | undefined, abortSignal?: AbortSignal, ): Promise { if (!target) { return false; } await waitForSessionTranscriptProjection(target, abortSignal); const { events } = readSessionTranscriptBoundedMessageTailPage(target, { maxBytes: 5 * 1024 * 1024, maxMessages: 500, offset: 0, }); return events.some( ({ event }) => isRecord(event) && event.type === "message" && isRecord(event.message) && event.message.role === "assistant", ); } /** Resolves the expected Claude CLI transcript JSONL path for a session. */ function claudeCliSessionTranscriptPath(params: { sessionId: string | undefined; workspaceDir: string | undefined; homeDir?: string; }): string | null { const sessionId = normalizeClaudeCliSessionId(params.sessionId); if (!sessionId) { return null; } const workspaceDir = params.workspaceDir?.trim(); if (!workspaceDir) { return null; } return path.join( resolveClaudeCliProjectDirForWorkspace({ workspaceDir, homeDir: params.homeDir, }), `${sessionId}.jsonl`, ); } const CLAUDE_CLI_TRANSCRIPT_FLUSH_GRACE_MS = 250; const CLAUDE_CLI_ORPHAN_PROBE_TAIL_BYTES = 1024 * 1024; /** Checks whether Claude CLI has flushed assistant content for a session. */ export async function claudeCliSessionTranscriptHasContent(params: { sessionId: string | undefined; workspaceDir: string | undefined; homeDir?: string; }): Promise { const expectedPath = claudeCliSessionTranscriptPath({ sessionId: params.sessionId, workspaceDir: params.workspaceDir, homeDir: params.homeDir, }); if (!expectedPath) { return false; } const first = await scanJsonlFile(expectedPath); if (first.hasAssistant) { return true; } await new Promise((resolve) => { setTimeout(resolve, CLAUDE_CLI_TRANSCRIPT_FLUSH_GRACE_MS); }); const second = await scanJsonlFile(expectedPath); if (second.hasAssistant) { return true; } const sessionId = normalizeClaudeCliSessionId(params.sessionId); cliBackendLog.warn( `claude-cli transcript probe v4 miss (sessionId-deterministic path, grace ${CLAUDE_CLI_TRANSCRIPT_FLUSH_GRACE_MS}ms): sessionId=${sessionId ?? ""} expectedPath=${expectedPath} fileExists=${second.fileExists}`, ); return false; } function toToolContentBlocks(content: unknown): ToolContentBlock[] | undefined { if (!Array.isArray(content)) { return undefined; } return content.filter((item): item is ToolContentBlock => Boolean(item && typeof item === "object"), ); } function isClaudeTranscriptToolUseBlock(block: ToolContentBlock): boolean { const type = block.type; return type === "tool_use" || type === "server_tool_use" || type === "mcp_tool_use"; } function isClaudeTranscriptToolResultBlock(block: ToolContentBlock): boolean { const type = block.type; return type === "tool_result" || (typeof type === "string" && type.endsWith("_tool_result")); } async function jsonlFileHasOrphanedTrailingToolUse(filePath: string): Promise { try { const stat = await fs.lstat(filePath); if (stat.isSymbolicLink() || !stat.isFile()) { return false; } const fh = await fs.open(filePath, "r"); try { const tailBytes = Math.min(stat.size, CLAUDE_CLI_ORPHAN_PROBE_TAIL_BYTES); const start = stat.size - tailBytes; const buffer = Buffer.alloc(tailBytes); const { bytesRead } = await fh.read(buffer, 0, tailBytes, start); let tailText = buffer.toString("utf-8", 0, bytesRead); if (start > 0) { const firstNewline = tailText.indexOf("\n"); tailText = firstNewline === -1 ? "" : tailText.slice(firstNewline + 1); } let lastAssistantToolUseIds: Set = new Set(); let answeredToolResultIds: Set = new Set(); for (const line of tailText.split(/\r?\n/)) { if (!line.trim()) { continue; } let obj: unknown; try { obj = JSON.parse(line); } catch { continue; } const rec = obj as Record | null; if (rec?.isSidechain === true) { continue; } const message = rec?.message as Record | undefined; const role = message?.role; if (role === "assistant") { lastAssistantToolUseIds = new Set(); answeredToolResultIds = new Set(); const blocks = toToolContentBlocks(message?.content); if (!blocks) { continue; } for (const block of blocks) { if (isClaudeTranscriptToolUseBlock(block)) { const id = resolveToolUseId(block); if (id) { lastAssistantToolUseIds.add(id); } } else if (isClaudeTranscriptToolResultBlock(block)) { const id = resolveToolUseId(block); if (id) { answeredToolResultIds.add(id); } } } } else if (role === "user") { const blocks = toToolContentBlocks(message?.content); if (!blocks) { continue; } for (const block of blocks) { if (isClaudeTranscriptToolResultBlock(block)) { const id = resolveToolUseId(block); if (id) { answeredToolResultIds.add(id); } } } } } for (const id of lastAssistantToolUseIds) { if (!answeredToolResultIds.has(id)) { return true; } } return false; } finally { await fh.close(); } } catch { return false; } } /** Checks whether the latest Claude CLI transcript tail has unanswered tool use. */ export async function claudeCliSessionTranscriptHasOrphanedToolUse(params: { sessionId: string | undefined; workspaceDir: string | undefined; homeDir?: string; }): Promise { const expectedPath = claudeCliSessionTranscriptPath({ sessionId: params.sessionId, workspaceDir: params.workspaceDir, homeDir: params.homeDir, }); if (!expectedPath) { return false; } return await jsonlFileHasOrphanedTrailingToolUse(expectedPath); } /** Builds the retry prompt sent to fallback models after a failed attempt. */ export function resolveFallbackRetryPrompt(params: { body: string; isFallbackRetry: boolean; sessionHasHistory?: boolean; priorContextPrelude?: string; }): string { if (!params.isFallbackRetry) { return params.body; } const prelude = params.priorContextPrelude?.trim(); if (!params.sessionHasHistory && !prelude) { return params.body; } // Even with persisted session history, fully replacing the body with a // generic "continue where you left off" message strips the original task // from the fallback model's view. Agents then have to reconstruct the // instruction from history alone, which is fragile and sometimes // impossible. Prepend the retry context to the original body instead so // the fallback model has both the recovery signal AND the task. (#65760) const retryMarked = `[Retry after the previous model attempt failed or timed out]\n\n${params.body}`; return prelude ? `${prelude}\n\n${retryMarked}` : retryMarked; } const CLAUDE_CLI_FALLBACK_PRELUDE_DEFAULT_CHAR_BUDGET = 8_000; const CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS = 64; type FallbackTurnLikeMessage = Record; function extractFallbackTurnText(message: FallbackTurnLikeMessage): string { const content = message.content; if (typeof content === "string") { return content; } if (!Array.isArray(content)) { return ""; } const parts: string[] = []; for (const block of content) { if (typeof block === "string") { parts.push(block); continue; } if (!block || typeof block !== "object") { continue; } const rec = block as Record; if (typeof rec.text === "string") { parts.push(rec.text); continue; } // Tool calls: render as a compact "(tool: name)" hint so the fallback // model sees the conversation flow without the full tool argument blob, // which is rarely useful out of context and chews through char budget. if (isToolCallBlock(rec) && typeof rec.name === "string") { parts.push(`(tool call: ${rec.name})`); continue; } if (rec.type === "tool_result") { const inner = typeof rec.content === "string" ? rec.content : undefined; if (inner) { parts.push(`(tool result: ${inner})`); } else { parts.push("(tool result)"); } } } return parts.join("\n").trim(); } function formatFallbackTurns( turns: ReadonlyArray, remainingBudget: number, ): { text: string; consumed: number } { if (turns.length === 0 || remainingBudget <= 0) { return { text: "", consumed: 0 }; } const lines: string[] = []; let consumed = 0; for (let i = turns.length - 1; i >= 0; i -= 1) { const turn = turns[i]; if (!turn || typeof turn !== "object") { continue; } const role = turn.role; if (role !== "user" && role !== "assistant") { continue; } const text = extractFallbackTurnText(turn); if (!text) { continue; } const line = `${role}: ${text}`; if (consumed + line.length + 1 > remainingBudget) { break; } lines.push(line); consumed += line.length + 1; } lines.reverse(); return { text: lines.join("\n"), consumed }; } /** * Format a previously-harvested Claude CLI session into a labeled prelude * suitable for prepending to a fallback candidate's prompt. Behavior matches * Claude Code's own resume strategy after compaction: prefer the explicit * summary, then append the most recent turns up to a char budget. * * Returns an empty string when neither a summary nor any usable turn fits in * the budget; callers can treat that as "no context to seed". */ function formatClaudeCliFallbackPrelude( seed: ClaudeCliFallbackSeed, options?: { charBudget?: number }, ): string { const charBudget = Math.max( CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS, options?.charBudget ?? CLAUDE_CLI_FALLBACK_PRELUDE_DEFAULT_CHAR_BUDGET, ); const heading = "## Prior session context (from claude-cli)"; const sections: string[] = [heading]; let remaining = charBudget - heading.length; if (seed.summaryText) { const summarySection = `\nSummary of earlier conversation:\n${seed.summaryText}`; if (summarySection.length <= remaining) { sections.push(summarySection); remaining -= summarySection.length; } else { // Truncate the summary at a word boundary if it's huge; clearly mark // the truncation so the fallback model treats the prelude as a hint, // not exhaustive state. const slice = truncateUtf16Safe(seed.summaryText, Math.max(0, remaining - 64)); const lastBreak = slice.lastIndexOf(" "); const trimmed = lastBreak > 0 ? slice.slice(0, lastBreak).trimEnd() : slice.trimEnd(); sections.push(`\nSummary of earlier conversation (truncated):\n${trimmed} …`); remaining = 0; } } if (remaining > CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS && seed.recentTurns.length > 0) { const { text } = formatFallbackTurns( seed.recentTurns as ReadonlyArray, remaining - 32, ); if (text) { sections.push(`\nRecent turns:\n${text}`); } } // No summary AND no fittable turns => nothing to seed beyond the heading, // which would just confuse the model. Drop the prelude entirely. if (sections.length === 1) { return ""; } return sections.join("\n"); } /** * Read the Claude CLI session pointed to by `cliSessionId` and format a * fallback prelude. Returns `""` when no session file is found or when the * harvested seed has no usable content. */ export function buildClaudeCliFallbackContextPrelude(params: { cliSessionId: string | undefined; homeDir?: string; charBudget?: number; }): string { const sessionId = params.cliSessionId?.trim(); if (!sessionId) { return ""; } const seed = readClaudeCliFallbackSeed({ cliSessionId: sessionId, homeDir: params.homeDir }); if (!seed) { return ""; } return formatClaudeCliFallbackPrelude(seed, { charBudget: params.charBudget }); } /** Creates an accumulator that strips ACP silent-reply prefixes while streaming. */ export function createAcpVisibleTextAccumulator() { let pendingSilentPrefix = ""; let visibleText = ""; let rawVisibleText = ""; const startsWithWordChar = (chunk: string): boolean => /^[\p{L}\p{N}]/u.test(chunk); const resolveNextCandidate = (base: string, chunk: string): string => { if (!base) { return chunk; } if ( isSilentReplyText(base, SILENT_REPLY_TOKEN) && !chunk.startsWith(base) && startsWithWordChar(chunk) ) { return chunk; } if (chunk.startsWith(base) && chunk.length > base.length) { return chunk; } return `${base}${chunk}`; }; const mergeVisibleChunk = (base: string, chunk: string): { rawText: string; delta: string } => { if (!base) { return { rawText: chunk, delta: chunk }; } if (chunk.startsWith(base) && chunk.length > base.length) { const delta = chunk.slice(base.length); return { rawText: chunk, delta }; } return { rawText: `${base}${chunk}`, delta: chunk, }; }; return { consume(chunk: string): { text: string; delta: string } | null { if (!chunk) { return null; } if (!visibleText) { const leadCandidate = resolveNextCandidate(pendingSilentPrefix, chunk); const trimmedLeadCandidate = leadCandidate.trim(); if ( isSilentReplyText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) || isSilentReplyPrefixText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) ) { pendingSilentPrefix = leadCandidate; return null; } if (startsWithSilentToken(trimmedLeadCandidate, SILENT_REPLY_TOKEN)) { const stripped = stripLeadingSilentToken(leadCandidate, SILENT_REPLY_TOKEN); if (stripped) { pendingSilentPrefix = ""; rawVisibleText = leadCandidate; visibleText = stripped; return { text: stripped, delta: stripped }; } pendingSilentPrefix = leadCandidate; return null; } if (pendingSilentPrefix) { pendingSilentPrefix = ""; rawVisibleText = leadCandidate; visibleText = leadCandidate; return { text: visibleText, delta: leadCandidate, }; } } const nextVisible = mergeVisibleChunk(rawVisibleText, chunk); rawVisibleText = nextVisible.rawText; if (!nextVisible.delta) { return null; } visibleText = `${visibleText}${nextVisible.delta}`; return { text: visibleText, delta: nextVisible.delta }; }, finalize(): string { return visibleText.trim(); }, finalizeRaw(): string { return visibleText; }, finalizeReplySnapshot(): AgentRunTerminalReplySnapshot { return buildAgentRunTerminalReplySnapshot({ visibleText, rawText: pendingSilentPrefix, }); }, }; } if (process.env.VITEST || process.env.NODE_ENV === "test") { (globalThis as Record)[ Symbol.for("openclaw.attemptExecutionHelpersTestApi") ] = { claudeCliSessionTranscriptPath, formatClaudeCliFallbackPrelude }; }