334 lines
10 KiB
TypeScript
334 lines
10 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 { ClawdbotConfig } from "../config/config.js";
|
|
import { isTruthyEnvValue } from "../infra/env.js";
|
|
import { shouldLogVerbose } from "../globals.js";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { runCommandWithTimeout } from "../process/exec.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { resolveClawdbotDocsPath } from "./docs-path.js";
|
|
import { resolveSessionAgentIds } from "./agent-scope.js";
|
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
|
|
import { resolveCliBackendConfig } from "./cli-backends.js";
|
|
import {
|
|
appendImagePathsToPrompt,
|
|
buildCliArgs,
|
|
buildSystemPrompt,
|
|
cleanupResumeProcesses,
|
|
cleanupSuspendedCliProcesses,
|
|
enqueueCliRun,
|
|
normalizeCliModel,
|
|
parseCliJson,
|
|
parseCliJsonl,
|
|
resolvePromptInput,
|
|
resolveSessionIdToSend,
|
|
resolveSystemPromptUsage,
|
|
writeCliImages,
|
|
} from "./cli-runner/helpers.js";
|
|
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
|
|
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
|
|
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
|
|
|
|
const log = createSubsystemLogger("agent/claude-cli");
|
|
|
|
export async function runCliAgent(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
sessionFile: string;
|
|
workspaceDir: string;
|
|
config?: ClawdbotConfig;
|
|
prompt: string;
|
|
provider: string;
|
|
model?: string;
|
|
thinkLevel?: ThinkLevel;
|
|
timeoutMs: number;
|
|
runId: string;
|
|
extraSystemPrompt?: string;
|
|
ownerNumbers?: string[];
|
|
cliSessionId?: string;
|
|
images?: ImageContent[];
|
|
}): Promise<EmbeddedPiRunResult> {
|
|
const started = Date.now();
|
|
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
|
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 { contextFiles } = await resolveBootstrapContextForRun({
|
|
workspaceDir,
|
|
config: params.config,
|
|
sessionKey: params.sessionKey,
|
|
sessionId: params.sessionId,
|
|
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
|
});
|
|
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
|
sessionKey: params.sessionKey,
|
|
config: params.config,
|
|
});
|
|
const heartbeatPrompt =
|
|
sessionAgentId === defaultAgentId
|
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
|
: undefined;
|
|
const docsPath = await resolveClawdbotDocsPath({
|
|
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,
|
|
});
|
|
|
|
const { sessionId: cliSessionIdToSend, isNew } = resolveSessionIdToSend({
|
|
backend,
|
|
cliSessionId: params.cliSessionId,
|
|
});
|
|
const useResume = Boolean(
|
|
params.cliSessionId &&
|
|
cliSessionIdToSend &&
|
|
backend.resumeArgs &&
|
|
backend.resumeArgs.length > 0,
|
|
);
|
|
const sessionIdSent = cliSessionIdToSend
|
|
? useResume || Boolean(backend.sessionArg) || Boolean(backend.sessionArgs?.length)
|
|
? cliSessionIdToSend
|
|
: undefined
|
|
: undefined;
|
|
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}", cliSessionIdToSend ?? ""))
|
|
: baseArgs;
|
|
const args = buildCliArgs({
|
|
backend,
|
|
baseArgs: resolvedArgs,
|
|
modelId: normalizedModel,
|
|
sessionId: cliSessionIdToSend,
|
|
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.CLAWDBOT_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;
|
|
})();
|
|
|
|
// Cleanup suspended processes that have accumulated (regardless of sessionId)
|
|
await cleanupSuspendedCliProcesses(backend);
|
|
if (useResume && cliSessionIdToSend) {
|
|
await cleanupResumeProcesses(backend, cliSessionIdToSend);
|
|
}
|
|
|
|
const result = await runCommandWithTimeout([backend.command, ...args], {
|
|
timeoutMs: params.timeoutMs,
|
|
cwd: workspaceDir,
|
|
env,
|
|
input: stdinPayload,
|
|
});
|
|
|
|
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.code !== 0) {
|
|
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 };
|
|
});
|
|
|
|
const text = output.text?.trim();
|
|
const payloads = text ? [{ text }] : undefined;
|
|
|
|
return {
|
|
payloads,
|
|
meta: {
|
|
durationMs: Date.now() - started,
|
|
agentMeta: {
|
|
sessionId: output.sessionId ?? sessionIdSent ?? params.sessionId ?? "",
|
|
provider: params.provider,
|
|
model: modelId,
|
|
usage: output.usage,
|
|
},
|
|
},
|
|
};
|
|
} catch (err) {
|
|
if (err instanceof FailoverError) 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 {
|
|
if (cleanupImages) {
|
|
await cleanupImages();
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function runClaudeCliAgent(params: {
|
|
sessionId: string;
|
|
sessionKey?: string;
|
|
sessionFile: string;
|
|
workspaceDir: string;
|
|
config?: ClawdbotConfig;
|
|
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,
|
|
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,
|
|
});
|
|
}
|