Files
openclaw/src/agents/cli-runner.ts

911 lines
30 KiB
TypeScript

import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
import { resolveBlockMessage } from "../plugins/hook-decision-types.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { cliBackendLog, formatCliBackendOutputDigest } from "./cli-runner/log.js";
import {
loadCliSessionContextEngineMessages,
loadCliSessionHistoryMessages,
} from "./cli-runner/session-history.js";
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
import { claudeCliSessionTranscriptHasContent as claudeCliSessionTranscriptHasContentImpl } from "./command/attempt-execution.helpers.js";
import { classifyFailoverReason, isFailoverErrorMessage } from "./embedded-agent-helpers.js";
import type { EmbeddedAgentRunResult } from "./embedded-agent-runner.js";
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
import {
awaitAgentEndSideEffects,
runAgentEndSideEffects,
} from "./harness/agent-end-side-effects.js";
import {
bootstrapHarnessContextEngine,
finalizeHarnessContextEngineTurn,
runHarnessContextEngineMaintenance,
} from "./harness/context-engine-lifecycle.js";
import { buildAgentHookContext } from "./harness/hook-context.js";
import { buildAgentHookConversationMessages } from "./harness/hook-history.js";
import {
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
} from "./harness/lifecycle-hook-helpers.js";
import {
applyAgentRunSessionTargetIdentity,
resolveAgentRunSessionTarget,
} from "./run-session-target.js";
import type { AgentMessage } from "./runtime/index.js";
import { SessionManager } from "./sessions/session-manager.js";
const log = createSubsystemLogger("agents/cli-runner");
type RunCliAgentParamsWithSessionFile = RunCliAgentParams & { sessionFile: string };
const cliRunnerDeps = {
claudeCliSessionTranscriptHasContent: claudeCliSessionTranscriptHasContentImpl,
delay: async (delayMs: number) => {
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
},
};
export function setCliRunnerTestDeps(overrides: Partial<typeof cliRunnerDeps>): void {
Object.assign(cliRunnerDeps, overrides);
}
export function restoreCliRunnerTestDeps(): void {
cliRunnerDeps.claudeCliSessionTranscriptHasContent = claudeCliSessionTranscriptHasContentImpl;
cliRunnerDeps.delay = async (delayMs: number) => {
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
};
}
function isClaudeCliProvider(provider: string): boolean {
return provider.trim().toLowerCase() === "claude-cli";
}
function shouldRetryFreshCliSessionAfterFailover(params: {
error: FailoverError;
hasHistoryPrompt: boolean;
}): boolean {
if (!params.hasHistoryPrompt) {
return false;
}
switch (params.error.reason) {
case "session_expired":
return true;
case "unknown":
return params.error.code === "cli_unknown_empty_failure";
case "timeout":
return params.error.code === "cli_no_output_timeout";
default:
return false;
}
}
export async function isCliBindingFlushed(
sessionId: string | undefined,
provider: string | undefined,
workspaceDir?: string,
): Promise<boolean> {
if (!provider || !isClaudeCliProvider(provider)) {
return true;
}
if (!sessionId) {
return false;
}
for (const delayMs of [0, 50, 150]) {
if (delayMs > 0) {
await cliRunnerDeps.delay(delayMs);
}
if (await cliRunnerDeps.claudeCliSessionTranscriptHasContent({ sessionId, workspaceDir })) {
return true;
}
}
return false;
}
function flushSessionManagerFile(sessionManager: SessionManager): void {
(sessionManager as unknown as { rewriteFile?: () => void }).rewriteFile?.();
}
function buildHandledReplyPayloads(reply?: ReplyPayload) {
const normalized = reply ?? { text: SILENT_REPLY_TOKEN };
return [
{
text: normalized.text,
mediaUrl: normalized.mediaUrl,
mediaUrls: normalized.mediaUrls,
replyToId: normalized.replyToId,
audioAsVoice: normalized.audioAsVoice,
isError: normalized.isError,
isReasoning: normalized.isReasoning,
},
];
}
function buildCliHookUserMessage(prompt: string): unknown {
return {
role: "user",
content: prompt,
timestamp: Date.now(),
};
}
function buildCliHookAssistantMessage(params: {
text: string;
provider: string;
model: string;
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
}): unknown {
return {
role: "assistant",
content: [{ type: "text", text: params.text }],
api: "responses",
provider: params.provider,
model: params.model,
...(params.usage ? { usage: params.usage } : {}),
stopReason: "stop",
timestamp: Date.now(),
};
}
function isAgentMessage(value: unknown): value is AgentMessage {
return Boolean(value && typeof value === "object" && "role" in value);
}
function buildCliContextEngineUserMessage(prompt: string): AgentMessage {
return {
role: "user",
content: prompt,
timestamp: Date.now(),
} as AgentMessage;
}
function buildCliContextEngineAssistantMessage(params: {
text: string;
provider: string;
model: string;
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
}): AgentMessage {
return buildCliHookAssistantMessage(params) as AgentMessage;
}
type CliAgentEndHookParams = Parameters<typeof runAgentEndSideEffects>[0];
function shouldAwaitCliAgentEndHook(params: RunCliAgentParams): boolean {
return !params.messageChannel && !params.messageProvider;
}
async function runCliAgentEndHook(
params: RunCliAgentParams,
hookParams: CliAgentEndHookParams,
): Promise<void> {
if (shouldAwaitCliAgentEndHook(params)) {
await awaitAgentEndSideEffects(hookParams);
return;
}
runAgentEndSideEffects(hookParams);
}
async function persistApprovedCliUserTurnTranscript(
params: RunCliAgentParamsWithSessionFile,
): Promise<void> {
if (params.suppressNextUserMessagePersistence === true || !params.userTurnTranscriptRecorder) {
return;
}
const target = {
transcriptPath: params.sessionFile,
sessionId: params.sessionId,
agentId: params.agentId,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
cwd: params.cwd ?? params.workspaceDir,
...(params.config ? { config: params.config } : {}),
};
const persisted = await params.userTurnTranscriptRecorder.persistApproved({ target });
if (persisted) {
try {
const notification = params.onUserMessagePersisted?.(persisted.message);
if (notification) {
void Promise.resolve(notification).catch((error: unknown) => {
log.warn(`CLI user turn persistence notification failed: ${formatErrorMessage(error)}`);
});
}
} catch (error) {
log.warn(`CLI user turn persistence notification failed: ${formatErrorMessage(error)}`);
}
}
}
async function finalizeCliContextEngineTurn(params: {
context: PreparedCliRunContext;
historyMessages: unknown[];
assistantText: string;
output: Awaited<
ReturnType<typeof import("./cli-runner/execute.runtime.js").executePreparedCliRun>
>;
}): Promise<void> {
const { context } = params;
if (!context.contextEngine) {
return;
}
const { params: runParams } = context;
const prePromptMessages = params.historyMessages.filter(isAgentMessage);
const turnMessages: AgentMessage[] = [];
if (context.contextEngineTurnPrompt) {
turnMessages.push(buildCliContextEngineUserMessage(context.contextEngineTurnPrompt));
}
if (params.assistantText) {
turnMessages.push(
buildCliContextEngineAssistantMessage({
text: params.assistantText,
provider: runParams.provider,
model: context.modelId,
usage: params.output.usage,
}),
);
}
let deferredTurnMaintenance: Promise<void> | undefined;
const result = await finalizeHarnessContextEngineTurn({
contextEngine: context.contextEngine,
promptError: false,
aborted: runParams.abortSignal?.aborted === true,
yieldAborted: false,
sessionIdUsed: runParams.sessionId,
sessionKey: runParams.sessionKey,
sessionFile: runParams.sessionFile,
messagesSnapshot: [...prePromptMessages, ...turnMessages],
prePromptMessageCount: prePromptMessages.length,
config: context.contextEngineConfig,
runMaintenance: async (maintenanceParams) =>
await runHarnessContextEngineMaintenance({
...maintenanceParams,
onDeferredMaintenance: (promise) => {
deferredTurnMaintenance = promise;
},
}),
warn: (message) => log.warn(message),
});
if (result.postTurnFinalizationSucceeded && deferredTurnMaintenance) {
context.contextEngineDeferredTurnMaintenance = deferredTurnMaintenance;
}
}
export async function runCliAgent(paramsInput: RunCliAgentParams): Promise<EmbeddedAgentRunResult> {
const paramsBase = applyAgentRunSessionTargetIdentity(paramsInput);
const runSessionTarget = await resolveAgentRunSessionTarget(paramsBase);
const params: RunCliAgentParamsWithSessionFile = {
...paramsBase,
agentId: paramsBase.agentId ?? runSessionTarget.agentId,
sessionId: runSessionTarget.sessionId,
sessionKey: paramsBase.sessionKey ?? runSessionTarget.sessionKey,
sessionFile: runSessionTarget.sessionFile,
};
// Cron gate must fire before prepareCliRunContext — that call allocates
// backend resources released only by runPreparedCliAgent's try…finally.
params.onExecutionStarted?.();
if (params.trigger === "cron") {
const startedAt = Date.now();
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_agent_reply")) {
const hookContext = {
runId: params.runId,
jobId: params.jobId,
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
trigger: params.trigger,
...buildAgentHookContextChannelFields(params),
} as const;
params.onExecutionPhase?.({
phase: "before_agent_reply",
provider: params.provider,
model: params.model ?? "",
});
const hookResult = await hookRunner.runBeforeAgentReply(
{ cleanedBody: params.prompt },
hookContext,
);
if (hookResult?.handled) {
const finalText = hookResult.reply?.text ?? SILENT_REPLY_TOKEN;
cliBackendLog.info(
`cli synthetic turn: provider=${params.provider} model=<synthetic> requestedModel=${params.model ?? ""} durationMs=${Date.now() - startedAt} ${formatCliBackendOutputDigest(finalText)}`,
);
return {
payloads: buildHandledReplyPayloads(hookResult.reply),
meta: {
durationMs: Date.now() - startedAt,
agentMeta: {
sessionId: params.sessionId,
provider: params.provider,
model: params.model ?? "",
},
finalAssistantVisibleText: finalText,
finalAssistantRawText: finalText,
},
};
}
params.onExecutionPhase?.({
phase: "runtime_plugins",
provider: params.provider,
model: params.model ?? "",
});
}
}
const { prepareCliRunContext } = await import("./cli-runner/prepare.runtime.js");
const context = await prepareCliRunContext(params);
try {
return await runPreparedCliAgent(context);
} finally {
if (params.cleanupCliLiveSessionOnRunEnd === true) {
const { closeClaudeLiveSessionForContext } =
await import("./cli-runner/claude-live-session.js");
await closeClaudeLiveSessionForContext(context);
}
if (params.cleanupBundleMcpOnRunEnd === true) {
const { closeMcpLoopbackServer } = await import("../gateway/mcp-http.js");
await closeMcpLoopbackServer();
}
}
}
export async function runPreparedCliAgent(
context: PreparedCliRunContext,
): Promise<EmbeddedAgentRunResult> {
const { executePreparedCliRun } = await import("./cli-runner/execute.runtime.js");
const { params } = context;
const hookRunner = getGlobalHookRunner();
const hasLlmInputHooks = hookRunner?.hasHooks("llm_input") === true;
const hasLlmOutputHooks = hookRunner?.hasHooks("llm_output") === true;
const hasAgentEndHooks = hookRunner?.hasHooks("agent_end") === true;
const hasBeforeAgentRunHooks = hookRunner?.hasHooks("before_agent_run") === true;
const needsHookHistory = hasLlmInputHooks || hasAgentEndHooks || hasBeforeAgentRunHooks;
const historyMessages = needsHookHistory
? await loadCliSessionHistoryMessages({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
})
: [];
const llmInputEvent = {
runId: params.runId,
sessionId: params.sessionId,
provider: params.provider,
model: context.modelId,
systemPrompt: context.systemPrompt,
prompt: params.prompt,
historyMessages,
imagesCount: params.images?.length ?? 0,
} as const;
const hookContext = {
runId: params.runId,
jobId: params.jobId,
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
trigger: params.trigger,
...(params.config ? { config: params.config } : {}),
...(context.contextWindowInfo?.tokens
? { contextTokenBudget: context.contextWindowInfo.tokens }
: {}),
...(context.contextWindowInfo?.source
? { contextWindowSource: context.contextWindowInfo.source }
: {}),
...(context.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: context.contextWindowInfo.referenceTokens }
: {}),
...buildAgentHookContextChannelFields(params),
} as const;
const buildAgentEndMessages = (lastAssistant?: unknown): unknown[] => [
...buildAgentHookConversationMessages({
historyMessages,
currentTurnMessages: [
buildCliHookUserMessage(params.prompt),
...(lastAssistant ? [lastAssistant] : []),
],
}),
];
const buildFailedAgentEndEvent = (error: string) => ({
messages: buildAgentEndMessages(),
success: false,
error,
durationMs: Date.now() - context.started,
});
const buildBlockedAgentEndEvent = (message: string) => ({
messages: buildAgentHookConversationMessages({
historyMessages,
currentTurnMessages: [buildCliHookUserMessage(message)],
}),
success: false,
error: message,
durationMs: Date.now() - context.started,
});
const buildBlockedBeforeAgentRunResult = (message: string): EmbeddedAgentRunResult => ({
payloads: [{ text: message, isError: true }],
meta: {
durationMs: Date.now() - context.started,
finalAssistantVisibleText: message,
finalAssistantRawText: message,
livenessState: "blocked",
error: {
kind: "hook_block",
message,
},
systemPromptReport: context.systemPromptReport,
executionTrace: {
winnerProvider: params.provider,
winnerModel: context.modelId,
attempts: [
{
provider: params.provider,
model: context.modelId,
result: "error",
reason: "before_agent_run blocked the run",
},
],
fallbackUsed: false,
runner: "cli",
},
requestShaping: {
...(params.thinkLevel ? { thinking: params.thinkLevel } : {}),
...(context.effectiveAuthProfileId ? { authMode: "auth-profile" } : {}),
},
completion: {
finishReason: "blocked",
stopReason: "blocked",
refusal: true,
},
agentMeta: {
sessionId: params.sessionId ?? "",
provider: params.provider,
model: context.modelId,
},
},
});
const persistBlockedBeforeAgentRun = async (block: {
message: string;
pluginId: string;
}): Promise<void> => {
try {
const nowMs = Date.now();
const sessionManager = SessionManager.open(params.sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: block.message }],
timestamp: nowMs,
idempotencyKey: `hook-block:before_agent_run:user:${params.runId}`,
__openclaw: {
beforeAgentRunBlocked: {
blockedBy: block.pluginId,
blockedAt: nowMs,
},
},
} as Parameters<typeof sessionManager.appendMessage>[0]);
flushSessionManagerFile(sessionManager);
} catch (err) {
log.warn(
`before_agent_run block: failed to persist redacted CLI user message: ${formatErrorMessage(
err,
)}`,
);
}
};
const toCliRunFailure = (error: unknown): never => {
if (isFailoverError(error)) {
throw error;
}
const message = formatErrorMessage(error);
if (isFailoverErrorMessage(message, { provider: params.provider })) {
const reason = classifyFailoverReason(message, { provider: params.provider }) ?? "unknown";
const status = resolveFailoverStatus(reason);
throw new FailoverError(message, {
reason,
provider: params.provider,
model: context.modelId,
sessionId: params.sessionId,
lane: params.lane,
status,
});
}
throw error;
};
const executeCliAttempt = async (cliSessionIdToUse?: string, timeoutMs = params.timeoutMs) => {
const attemptContext =
timeoutMs === params.timeoutMs
? context
: {
...context,
params: {
...context.params,
timeoutMs,
},
};
const output = await executePreparedCliRun(attemptContext, cliSessionIdToUse);
const assistantText = output.text.trim();
if (!assistantText) {
throw new FailoverError("CLI backend returned an empty response.", {
reason: "empty_response",
provider: params.provider,
model: context.modelId,
sessionId: params.sessionId,
lane: params.lane,
});
}
const assistantTexts = assistantText ? [assistantText] : [];
const lastAssistant =
assistantText.length > 0
? buildCliHookAssistantMessage({
text: assistantText,
provider: params.provider,
model: context.modelId,
usage: output.usage,
})
: undefined;
if (assistantText.length > 0 && hasLlmOutputHooks) {
runAgentHarnessLlmOutputHook({
event: {
runId: params.runId,
sessionId: params.sessionId,
provider: params.provider,
model: context.modelId,
...(context.contextWindowInfo?.tokens
? { contextTokenBudget: context.contextWindowInfo.tokens }
: {}),
...(context.contextWindowInfo?.source
? { contextWindowSource: context.contextWindowInfo.source }
: {}),
...(context.contextWindowInfo?.referenceTokens
? { contextWindowReferenceTokens: context.contextWindowInfo.referenceTokens }
: {}),
resolvedRef: `${params.provider}/${context.modelId}`,
assistantTexts,
...(lastAssistant ? { lastAssistant } : {}),
...(output.usage ? { usage: output.usage } : {}),
},
ctx: hookContext,
hookRunner,
});
}
return { output, assistantText, lastAssistant };
};
const buildCliRunResult = (resultParams: {
output: Awaited<ReturnType<typeof executePreparedCliRun>>;
effectiveCliSessionId?: string;
bindingFlushOk?: boolean;
}): EmbeddedAgentRunResult => {
const text = resultParams.output.text?.trim();
const rawText = resultParams.output.rawText?.trim();
const payloads = text ? [{ text }] : undefined;
const unflushedCliSessionId =
resultParams.effectiveCliSessionId && resultParams.bindingFlushOk === false
? resultParams.effectiveCliSessionId
: undefined;
const persistedCliSessionId = unflushedCliSessionId
? undefined
: resultParams.effectiveCliSessionId;
const agentSessionId = unflushedCliSessionId
? ""
: (resultParams.effectiveCliSessionId ?? params.sessionId ?? "");
return {
payloads,
meta: {
durationMs: Date.now() - context.started,
...(resultParams.output.finalPromptText
? { finalPromptText: resultParams.output.finalPromptText }
: {}),
...(text || rawText
? {
...(text ? { finalAssistantVisibleText: text } : {}),
...(rawText ? { finalAssistantRawText: rawText } : {}),
}
: {}),
systemPromptReport: context.systemPromptReport,
executionTrace: {
winnerProvider: params.provider,
winnerModel: context.modelId,
attempts: [
{
provider: params.provider,
model: context.modelId,
result: "success",
},
],
fallbackUsed: false,
runner: "cli",
},
requestShaping: {
...(params.thinkLevel ? { thinking: params.thinkLevel } : {}),
...(context.effectiveAuthProfileId ? { authMode: "auth-profile" } : {}),
},
completion: {
finishReason: "stop",
stopReason: "completed",
refusal: false,
},
agentMeta: {
sessionId: agentSessionId,
provider: params.provider,
model: context.modelId,
usage: resultParams.output.usage,
...(resultParams.output.usage ? { lastCallUsage: resultParams.output.usage } : {}),
...(persistedCliSessionId
? {
cliSessionBinding: {
sessionId: persistedCliSessionId,
...(context.effectiveAuthProfileId
? { authProfileId: context.effectiveAuthProfileId }
: {}),
...(context.authEpoch ? { authEpoch: context.authEpoch } : {}),
authEpochVersion: context.authEpochVersion,
...(context.extraSystemPromptHash
? { extraSystemPromptHash: context.extraSystemPromptHash }
: {}),
...(context.promptToolNamesHash
? { promptToolNamesHash: context.promptToolNamesHash }
: {}),
...(context.cwdHash ? { cwdHash: context.cwdHash } : {}),
...(context.preparedBackend.mcpConfigHash
? { mcpConfigHash: context.preparedBackend.mcpConfigHash }
: {}),
...(context.preparedBackend.mcpResumeHash
? { mcpResumeHash: context.preparedBackend.mcpResumeHash }
: {}),
},
}
: {}),
...(unflushedCliSessionId ? { clearCliSessionBinding: true } : {}),
},
},
};
};
try {
await bootstrapHarnessContextEngine({
hadSessionFile: context.hadSessionFile,
contextEngine: context.contextEngine,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
config: context.contextEngineConfig,
warn: (message) => log.warn(message),
});
const contextEngineHistoryMessages = context.contextEngine
? await loadCliSessionContextEngineMessages({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
})
: [];
const finishCliAttempt = async (
result: Awaited<ReturnType<typeof executeCliAttempt>>,
fallbackCliSessionId?: string,
) => {
const { output, lastAssistant } = result;
const assistantText = output.text.trim();
const effectiveCliSessionId = output.sessionId ?? fallbackCliSessionId;
await finalizeCliContextEngineTurn({
context,
historyMessages: context.contextEngine ? contextEngineHistoryMessages : historyMessages,
assistantText,
output,
});
const bindingFlushOk = await isCliBindingFlushed(
effectiveCliSessionId,
params.provider,
context.cwd ?? context.workspaceDir,
);
await runCliAgentEndHook(params, {
event: {
messages: buildAgentEndMessages(lastAssistant),
success: true,
durationMs: Date.now() - context.started,
},
ctx: hookContext,
hookRunner,
});
return buildCliRunResult({ output, effectiveCliSessionId, bindingFlushOk });
};
if (hasBeforeAgentRunHooks && hookRunner) {
let beforeRunResult:
| Awaited<ReturnType<NonNullable<typeof hookRunner>["runBeforeAgentRun"]>>
| undefined;
try {
beforeRunResult = await hookRunner.runBeforeAgentRun(
{
prompt: params.prompt,
systemPrompt: context.systemPrompt,
messages: buildAgentHookConversationMessages({
historyMessages,
currentTurnMessages: [],
}),
channelId: hookContext.channelId,
accountId: params.agentAccountId,
},
buildAgentHookContext(hookContext),
);
} catch {
const blockMessage = resolveBlockMessage(
{ outcome: "block", reason: "before_agent_run hook failed" },
{ blockedBy: "before_agent_run" },
);
await persistBlockedBeforeAgentRun({
message: blockMessage,
pluginId: "before_agent_run",
});
await runCliAgentEndHook(params, {
event: buildBlockedAgentEndEvent(blockMessage),
ctx: hookContext,
hookRunner,
});
return buildBlockedBeforeAgentRunResult(blockMessage);
}
const beforeRunDecision = beforeRunResult?.decision;
if (beforeRunDecision?.outcome === "block") {
const blockMessage = resolveBlockMessage(beforeRunDecision, {
blockedBy: beforeRunResult?.pluginId ?? "unknown",
});
await persistBlockedBeforeAgentRun({
message: blockMessage,
pluginId: beforeRunResult?.pluginId ?? "unknown",
});
await runCliAgentEndHook(params, {
event: buildBlockedAgentEndEvent(blockMessage),
ctx: hookContext,
hookRunner,
});
return buildBlockedBeforeAgentRunResult(blockMessage);
}
}
await persistApprovedCliUserTurnTranscript(params);
runAgentHarnessLlmInputHook({
event: llmInputEvent,
ctx: hookContext,
hookRunner,
});
try {
return await finishCliAttempt(
await executeCliAttempt(context.reusableCliSession.sessionId),
context.reusableCliSession.sessionId,
);
} catch (err) {
if (isFailoverError(err)) {
const retryableSessionId = context.reusableCliSession.sessionId;
if (
shouldRetryFreshCliSessionAfterFailover({
error: err,
hasHistoryPrompt: Boolean(context.openClawHistoryPrompt),
}) &&
retryableSessionId &&
params.sessionKey
) {
try {
const retryTimeoutMs = params.timeoutMs - (Date.now() - context.started);
if (retryTimeoutMs <= 0) {
throw err;
}
if (params.onBeforeFreshCliSessionRetry) {
const clearedStaleBinding = await params.onBeforeFreshCliSessionRetry({
provider: params.provider,
reason: err.reason,
sessionId: retryableSessionId,
});
if (!clearedStaleBinding) {
throw err;
}
}
cliBackendLog.warn(
`cli session recovery retry: provider=${params.provider} reason=${err.reason} sessionKey=${params.sessionKey}`,
);
return await finishCliAttempt(await executeCliAttempt(undefined, retryTimeoutMs));
} catch (retryErr) {
const retryMessage = formatErrorMessage(retryErr);
await runCliAgentEndHook(params, {
event: buildFailedAgentEndEvent(retryMessage),
ctx: hookContext,
hookRunner,
});
return toCliRunFailure(retryErr);
}
}
await runCliAgentEndHook(params, {
event: buildFailedAgentEndEvent(formatErrorMessage(err)),
ctx: hookContext,
hookRunner,
});
throw err;
}
const message = formatErrorMessage(err);
await runCliAgentEndHook(params, {
event: buildFailedAgentEndEvent(message),
ctx: hookContext,
hookRunner,
});
return toCliRunFailure(err);
}
} finally {
await context.preparedBackend.cleanup?.();
}
}
export type RunClaudeCliAgentParams = Omit<RunCliAgentParams, "provider" | "cliSessionId"> & {
provider?: string;
claudeSessionId?: string;
};
export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): RunCliAgentParams {
return {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
agentId: params.agentId,
trigger: params.trigger,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
config: params.config,
prompt: params.prompt,
provider: params.provider ?? "claude-cli",
model: params.model ?? "opus",
thinkLevel: params.thinkLevel,
timeoutMs: params.timeoutMs,
runId: params.runId,
jobId: params.jobId,
extraSystemPrompt: params.extraSystemPrompt,
inputProvenance: params.inputProvenance,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
silentReplyPromptMode: params.silentReplyPromptMode,
extraSystemPromptStatic: params.extraSystemPromptStatic,
ownerNumbers: params.ownerNumbers,
// Legacy `claudeSessionId` callers predate the shared CLI session contract.
// Ignore it here so the compatibility wrapper does not accidentally resume
// an incompatible Claude session on the generic runner path.
images: params.images,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
};
}
export async function runClaudeCliAgent(
params: RunClaudeCliAgentParams,
): Promise<EmbeddedAgentRunResult> {
return runCliAgent(buildRunClaudeCliAgentParams(params));
}