import type { ImageContent } from "@mariozechner/pi-ai"; import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/config.js"; import type { CliSessionBinding } from "../config/sessions.js"; import { shouldLogVerbose } from "../globals.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { sanitizeHostExecEnv } from "../infra/host-env-security.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getProcessSupervisor } from "../process/supervisor/index.js"; import { scopedHeartbeatWakeOptions } from "../routing/session-key.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; import { analyzeBootstrapBudget, buildBootstrapInjectionStats, buildBootstrapPromptWarning, buildBootstrapTruncationReportMeta, prependBootstrapPromptWarning, } from "./bootstrap-budget.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; import { prepareCliBundleMcpConfig } from "./cli-runner/bundle-mcp.js"; import { appendImagePathsToPrompt, buildCliSupervisorScopeKey, buildCliArgs, buildSystemPrompt, enqueueCliRun, loadPromptRefImages, normalizeCliModel, parseCliJson, parseCliJsonl, resolveCliNoOutputTimeoutMs, resolvePromptInput, resolveSessionIdToSend, resolveSystemPromptUsage, writeCliImages, } from "./cli-runner/helpers.js"; import { hashCliSessionText, resolveCliSessionReuse } from "./cli-session.js"; import { resolveOpenClawDocsPath } from "./docs-path.js"; import { FailoverError, resolveFailoverStatus } from "./failover-error.js"; import { classifyFailoverReason, isFailoverErrorMessage, resolveBootstrapMaxChars, resolveBootstrapPromptTruncationWarningMode, resolveBootstrapTotalMaxChars, } from "./pi-embedded-helpers.js"; import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; import { buildSystemPromptReport } from "./system-prompt-report.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "./workspace-run.js"; const log = createSubsystemLogger("agent/cli-backend"); const CLI_BACKEND_LOG_OUTPUT_ENV = "OPENCLAW_CLI_BACKEND_LOG_OUTPUT"; const LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV = "OPENCLAW_CLAUDE_CLI_LOG_OUTPUT"; export async function runCliAgent(params: { sessionId: string; sessionKey?: string; agentId?: string; sessionFile: string; workspaceDir: string; config?: OpenClawConfig; prompt: string; provider: string; model?: string; thinkLevel?: ThinkLevel; timeoutMs: number; runId: string; extraSystemPrompt?: string; streamParams?: import("./command/types.js").AgentStreamParams; ownerNumbers?: string[]; cliSessionId?: string; cliSessionBinding?: CliSessionBinding; authProfileId?: string; bootstrapPromptWarningSignaturesSeen?: string[]; /** Backward-compat fallback when only the previous signature is available. */ bootstrapPromptWarningSignature?: string; images?: ImageContent[]; }): Promise { const started = Date.now(); const workspaceResolution = resolveRunWorkspaceDir({ workspaceDir: params.workspaceDir, sessionKey: params.sessionKey, agentId: params.agentId, config: params.config, }); const resolvedWorkspace = workspaceResolution.workspaceDir; const redactedSessionId = redactRunIdentifier(params.sessionId); const redactedSessionKey = redactRunIdentifier(params.sessionKey); const redactedWorkspace = redactRunIdentifier(resolvedWorkspace); if (workspaceResolution.usedFallback) { log.warn( `[workspace-fallback] caller=runCliAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`, ); } const workspaceDir = resolvedWorkspace; const backendResolved = resolveCliBackendConfig(params.provider, params.config); if (!backendResolved) { throw new Error(`Unknown CLI backend: ${params.provider}`); } const preparedBackend = await prepareCliBundleMcpConfig({ enabled: backendResolved.bundleMcp, backend: backendResolved.config, workspaceDir, config: params.config, warn: (message) => log.warn(message), }); const backend = preparedBackend.backend; const extraSystemPromptHash = hashCliSessionText(params.extraSystemPrompt); const reusableCliSession = resolveCliSessionReuse({ binding: params.cliSessionBinding ?? (params.cliSessionId ? { sessionId: params.cliSessionId } : undefined), authProfileId: params.authProfileId, extraSystemPromptHash, mcpConfigHash: preparedBackend.mcpConfigHash, }); if (reusableCliSession.invalidatedReason) { log.info( `cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`, ); } const modelId = (params.model ?? "default").trim() || "default"; const normalizedModel = normalizeCliModel(modelId, backend); const modelDisplay = `${params.provider}/${modelId}`; const extraSystemPrompt = [ params.extraSystemPrompt?.trim(), "Tools are disabled in this session. Do not call tools.", ] .filter(Boolean) .join("\n"); const sessionLabel = params.sessionKey ?? params.sessionId; const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({ workspaceDir, config: params.config, sessionKey: params.sessionKey, sessionId: params.sessionId, warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), }); const bootstrapMaxChars = resolveBootstrapMaxChars(params.config); const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.config); const bootstrapAnalysis = analyzeBootstrapBudget({ files: buildBootstrapInjectionStats({ bootstrapFiles, injectedFiles: contextFiles, }), bootstrapMaxChars, bootstrapTotalMaxChars, }); const bootstrapPromptWarningMode = resolveBootstrapPromptTruncationWarningMode(params.config); const bootstrapPromptWarning = buildBootstrapPromptWarning({ analysis: bootstrapAnalysis, mode: bootstrapPromptWarningMode, seenSignatures: params.bootstrapPromptWarningSignaturesSeen, previousSignature: params.bootstrapPromptWarningSignature, }); const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, agentId: params.agentId, }); const heartbeatPrompt = sessionAgentId === defaultAgentId ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) : undefined; const docsPath = await resolveOpenClawDocsPath({ workspaceDir, argv1: process.argv[1], cwd: process.cwd(), moduleUrl: import.meta.url, }); const systemPrompt = buildSystemPrompt({ workspaceDir, config: params.config, defaultThinkLevel: params.thinkLevel, extraSystemPrompt, ownerNumbers: params.ownerNumbers, heartbeatPrompt, docsPath: docsPath ?? undefined, tools: [], contextFiles, modelDisplay, agentId: sessionAgentId, }); const systemPromptReport = buildSystemPromptReport({ source: "run", generatedAt: Date.now(), sessionId: params.sessionId, sessionKey: params.sessionKey, provider: params.provider, model: modelId, workspaceDir, bootstrapMaxChars, bootstrapTotalMaxChars, bootstrapTruncation: buildBootstrapTruncationReportMeta({ analysis: bootstrapAnalysis, warningMode: bootstrapPromptWarningMode, warning: bootstrapPromptWarning, }), sandbox: { mode: "off", sandboxed: false }, systemPrompt, bootstrapFiles, injectedFiles: contextFiles, skillsPrompt: "", tools: [], }); // Helper function to execute CLI with given session ID const executeCliWithSession = async ( cliSessionIdToUse?: string, ): Promise<{ text: string; sessionId?: string; usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number; }; }> => { const { sessionId: resolvedSessionId, isNew } = resolveSessionIdToSend({ backend, cliSessionId: cliSessionIdToUse, }); const useResume = Boolean( cliSessionIdToUse && resolvedSessionId && backend.resumeArgs && backend.resumeArgs.length > 0, ); const systemPromptArg = resolveSystemPromptUsage({ backend, isNewSession: isNew, systemPrompt, }); let imagePaths: string[] | undefined; let cleanupImages: (() => Promise) | undefined; let prompt = prependBootstrapPromptWarning(params.prompt, bootstrapPromptWarning.lines, { preserveExactPrompt: heartbeatPrompt, }); const resolvedImages = params.images && params.images.length > 0 ? params.images : await loadPromptRefImages({ prompt, workspaceDir }); if (resolvedImages.length > 0) { const imagePayload = await writeCliImages(resolvedImages); imagePaths = imagePayload.paths; cleanupImages = imagePayload.cleanup; if (!backend.imageArg) { prompt = appendImagePathsToPrompt(prompt, imagePaths); } } const { argsPrompt, stdin } = resolvePromptInput({ backend, prompt, }); const stdinPayload = stdin ?? ""; const baseArgs = useResume ? (backend.resumeArgs ?? backend.args ?? []) : (backend.args ?? []); const resolvedArgs = useResume ? baseArgs.map((entry) => entry.replaceAll("{sessionId}", resolvedSessionId ?? "")) : baseArgs; const args = buildCliArgs({ backend, baseArgs: resolvedArgs, modelId: normalizedModel, sessionId: resolvedSessionId, systemPrompt: systemPromptArg, imagePaths, promptArg: argsPrompt, useResume, }); const serialize = backend.serialize ?? true; const queueKey = serialize ? backendResolved.id : `${backendResolved.id}:${params.runId}`; try { const output = await enqueueCliRun(queueKey, async () => { log.info( `cli exec: provider=${params.provider} model=${normalizedModel} promptChars=${params.prompt.length}`, ); const logOutputText = isTruthyEnvValue(process.env[CLI_BACKEND_LOG_OUTPUT_ENV]) || isTruthyEnvValue(process.env[LEGACY_CLAUDE_CLI_LOG_OUTPUT_ENV]); if (logOutputText) { const logArgs: string[] = []; for (let i = 0; i < args.length; i += 1) { const arg = args[i] ?? ""; if (arg === backend.systemPromptArg) { const systemPromptValue = args[i + 1] ?? ""; logArgs.push(arg, ``); i += 1; continue; } if (arg === backend.sessionArg) { logArgs.push(arg, args[i + 1] ?? ""); i += 1; continue; } if (arg === backend.modelArg) { logArgs.push(arg, args[i + 1] ?? ""); i += 1; continue; } if (arg === backend.imageArg) { logArgs.push(arg, ""); i += 1; continue; } logArgs.push(arg); } if (argsPrompt) { const promptIndex = logArgs.indexOf(argsPrompt); if (promptIndex >= 0) { logArgs[promptIndex] = ``; } } log.info(`cli argv: ${backend.command} ${logArgs.join(" ")}`); } const env = (() => { const next = sanitizeHostExecEnv({ baseEnv: process.env, overrides: backend.env, blockPathOverrides: true, }); for (const key of backend.clearEnv ?? []) { delete next[key]; } return next; })(); const noOutputTimeoutMs = resolveCliNoOutputTimeoutMs({ backend, timeoutMs: params.timeoutMs, useResume, }); const supervisor = getProcessSupervisor(); const scopeKey = buildCliSupervisorScopeKey({ backend, backendId: backendResolved.id, cliSessionId: useResume ? resolvedSessionId : undefined, }); const managedRun = await supervisor.spawn({ sessionId: params.sessionId, backendId: backendResolved.id, scopeKey, replaceExistingScope: Boolean(useResume && scopeKey), mode: "child", argv: [backend.command, ...args], timeoutMs: params.timeoutMs, noOutputTimeoutMs, cwd: workspaceDir, env, input: stdinPayload, }); const result = await managedRun.wait(); const stdout = result.stdout.trim(); const stderr = result.stderr.trim(); if (logOutputText) { if (stdout) { log.info(`cli stdout:\n${stdout}`); } if (stderr) { log.info(`cli stderr:\n${stderr}`); } } if (shouldLogVerbose()) { if (stdout) { log.debug(`cli stdout:\n${stdout}`); } if (stderr) { log.debug(`cli stderr:\n${stderr}`); } } if (result.exitCode !== 0 || result.reason !== "exit") { if (result.reason === "no-output-timeout" || result.noOutputTimedOut) { const timeoutReason = `CLI produced no output for ${Math.round(noOutputTimeoutMs / 1000)}s and was terminated.`; log.warn( `cli watchdog timeout: provider=${params.provider} model=${modelId} session=${resolvedSessionId ?? params.sessionId} noOutputTimeoutMs=${noOutputTimeoutMs} pid=${managedRun.pid ?? "unknown"}`, ); if (params.sessionKey) { const stallNotice = [ `CLI agent (${params.provider}) produced no output for ${Math.round(noOutputTimeoutMs / 1000)}s and was terminated.`, "It may have been waiting for interactive input or an approval prompt.", "For Claude Code, prefer --permission-mode bypassPermissions --print.", ].join(" "); enqueueSystemEvent(stallNotice, { sessionKey: params.sessionKey }); requestHeartbeatNow( scopedHeartbeatWakeOptions(params.sessionKey, { reason: "cli:watchdog:stall" }), ); } throw new FailoverError(timeoutReason, { reason: "timeout", provider: params.provider, model: modelId, status: resolveFailoverStatus("timeout"), }); } if (result.reason === "overall-timeout") { const timeoutReason = `CLI exceeded timeout (${Math.round(params.timeoutMs / 1000)}s) and was terminated.`; throw new FailoverError(timeoutReason, { reason: "timeout", provider: params.provider, model: modelId, status: resolveFailoverStatus("timeout"), }); } const err = stderr || stdout || "CLI failed."; const reason = classifyFailoverReason(err) ?? "unknown"; const status = resolveFailoverStatus(reason); throw new FailoverError(err, { reason, provider: params.provider, model: modelId, status, }); } const outputMode = useResume ? (backend.resumeOutput ?? backend.output) : backend.output; if (outputMode === "text") { return { text: stdout, sessionId: undefined }; } if (outputMode === "jsonl") { const parsed = parseCliJsonl(stdout, backend); return parsed ?? { text: stdout }; } const parsed = parseCliJson(stdout, backend); return parsed ?? { text: stdout }; }); return output; } finally { if (cleanupImages) { await cleanupImages(); } } }; const buildCliRunResult = (resultParams: { output: Awaited>; effectiveCliSessionId?: string; }): EmbeddedPiRunResult => { const text = resultParams.output.text?.trim(); const payloads = text ? [{ text }] : undefined; return { payloads, meta: { durationMs: Date.now() - started, systemPromptReport, agentMeta: { sessionId: resultParams.effectiveCliSessionId ?? params.sessionId ?? "", provider: params.provider, model: modelId, usage: resultParams.output.usage, ...(resultParams.effectiveCliSessionId ? { cliSessionBinding: { sessionId: resultParams.effectiveCliSessionId, ...(params.authProfileId ? { authProfileId: params.authProfileId } : {}), ...(extraSystemPromptHash ? { extraSystemPromptHash } : {}), ...(preparedBackend.mcpConfigHash ? { mcpConfigHash: preparedBackend.mcpConfigHash } : {}), }, } : {}), }, }, }; }; // Try with the provided CLI session ID first try { try { const output = await executeCliWithSession(reusableCliSession.sessionId); const effectiveCliSessionId = output.sessionId ?? reusableCliSession.sessionId; return buildCliRunResult({ output, effectiveCliSessionId }); } catch (err) { if (err instanceof FailoverError) { // Check if this is a session expired error and we have a session to clear if (err.reason === "session_expired" && reusableCliSession.sessionId && params.sessionKey) { log.warn( `CLI session expired, clearing session ID and retrying: provider=${params.provider} session=${redactRunIdentifier(reusableCliSession.sessionId)}`, ); // Clear the expired session ID from the session entry // This requires access to the session store, which we don't have here // We'll need to modify the caller to handle this case // For now, retry without the session ID to create a new session const output = await executeCliWithSession(undefined); const effectiveCliSessionId = output.sessionId; return buildCliRunResult({ output, effectiveCliSessionId }); } throw err; } const message = err instanceof Error ? err.message : String(err); if (isFailoverErrorMessage(message)) { const reason = classifyFailoverReason(message) ?? "unknown"; const status = resolveFailoverStatus(reason); throw new FailoverError(message, { reason, provider: params.provider, model: modelId, status, }); } throw err; } } finally { await preparedBackend.cleanup?.(); } } export async function runClaudeCliAgent(params: { sessionId: string; sessionKey?: string; agentId?: string; sessionFile: string; workspaceDir: string; config?: OpenClawConfig; prompt: string; provider?: string; model?: string; thinkLevel?: ThinkLevel; timeoutMs: number; runId: string; extraSystemPrompt?: string; ownerNumbers?: string[]; claudeSessionId?: string; images?: ImageContent[]; }): Promise { return runCliAgent({ sessionId: params.sessionId, sessionKey: params.sessionKey, agentId: params.agentId, sessionFile: params.sessionFile, workspaceDir: params.workspaceDir, config: params.config, prompt: params.prompt, provider: params.provider ?? "claude-cli", model: params.model ?? "opus", thinkLevel: params.thinkLevel, timeoutMs: params.timeoutMs, runId: params.runId, extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, cliSessionId: params.claudeSessionId, images: params.images, }); }