Files
openclaw/src/agents/cli-runner.ts
Vincent Koc 4d183af0cf fix: code/cli acpx reliability 20260304 (#34020)
* agents: switch claude-cli defaults to bypassPermissions

* agents: add claude-cli default args coverage

* agents: emit watchdog stall system event for cli runs

* agents: test cli watchdog stall system event

* acpx: fallback to sessions new when ensure returns no ids

* acpx tests: mock sessions new fallback path

* acpx tests: cover ensure-empty fallback flow

* skills: clarify claude print mode without pty

* docs: update cli-backends claude default args

* docs: refresh cli live test default args

* gateway tests: align live claude args defaults

* changelog: credit claude/acpx reliability fixes

* Agents: normalize legacy Claude permission flag overrides

* Tests: cover legacy Claude permission override normalization

* Changelog: note legacy Claude permission flag auto-normalization

* ACPX: fail fast when ensure/new return no session IDs

* ACPX tests: support empty sessions new fixture output

* ACPX tests: assert ensureSession failure when IDs missing

* CLI runner: scope watchdog heartbeat wake to session

* CLI runner tests: assert session-scoped watchdog wake

* Update CHANGELOG.md
2026-03-04 01:15:28 -05:00

511 lines
18 KiB
TypeScript

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 { shouldLogVerbose } from "../globals.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.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,
} from "./bootstrap-budget.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import {
appendImagePathsToPrompt,
buildCliSupervisorScopeKey,
buildCliArgs,
buildSystemPrompt,
enqueueCliRun,
normalizeCliModel,
parseCliJson,
parseCliJsonl,
resolveCliNoOutputTimeoutMs,
resolvePromptInput,
resolveSessionIdToSend,
resolveSystemPromptUsage,
writeCliImages,
} from "./cli-runner/helpers.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/claude-cli");
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("../commands/agent/types.js").AgentStreamParams;
ownerNumbers?: string[];
cliSessionId?: string;
bootstrapPromptWarningSignaturesSeen?: string[];
/** Backward-compat fallback when only the previous signature is available. */
bootstrapPromptWarningSignature?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
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 backend = backendResolved.config;
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,
bootstrapTruncationWarningLines: bootstrapPromptWarning.lines,
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<void>) | undefined;
let prompt = params.prompt;
if (params.images && params.images.length > 0) {
const imagePayload = await writeCliImages(params.images);
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.OPENCLAW_CLAUDE_CLI_LOG_OUTPUT);
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, `<systemPrompt:${systemPromptValue.length} chars>`);
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, "<image>");
i += 1;
continue;
}
logArgs.push(arg);
}
if (argsPrompt) {
const promptIndex = logArgs.indexOf(argsPrompt);
if (promptIndex >= 0) {
logArgs[promptIndex] = `<prompt:${argsPrompt.length} chars>`;
}
}
log.info(`cli argv: ${backend.command} ${logArgs.join(" ")}`);
}
const env = (() => {
const next = { ...process.env, ...backend.env };
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();
}
}
};
// Try with the provided CLI session ID first
try {
const output = await executeCliWithSession(params.cliSessionId);
const text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
return {
payloads,
meta: {
durationMs: Date.now() - started,
systemPromptReport,
agentMeta: {
sessionId: output.sessionId ?? params.cliSessionId ?? params.sessionId ?? "",
provider: params.provider,
model: modelId,
usage: output.usage,
},
},
};
} 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" && params.cliSessionId && params.sessionKey) {
log.warn(
`CLI session expired, clearing session ID and retrying: provider=${params.provider} session=${redactRunIdentifier(params.cliSessionId)}`,
);
// 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 text = output.text?.trim();
const payloads = text ? [{ text }] : undefined;
return {
payloads,
meta: {
durationMs: Date.now() - started,
systemPromptReport,
agentMeta: {
sessionId: output.sessionId ?? params.sessionId ?? "",
provider: params.provider,
model: modelId,
usage: output.usage,
},
},
};
}
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;
}
}
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<EmbeddedPiRunResult> {
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,
});
}