mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 11:21:10 +00:00
* fix: resume replay-safe work after gateway restart * chore: leave restart note to release process * fix: sync restart recovery protocol artifacts
1418 lines
51 KiB
TypeScript
1418 lines
51 KiB
TypeScript
/**
|
|
* Orchestrates one agent attempt across embedded, CLI, and ACP runtimes.
|
|
*/
|
|
import type { AcpRuntimeEvent } from "@openclaw/acp-core/runtime/types";
|
|
import {
|
|
normalizeOptionalLowercaseString,
|
|
type FastMode,
|
|
} from "@openclaw/normalization-core/string-coerce";
|
|
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
|
import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
|
|
import { ACP_TURN_TIMEOUT_DETAIL_CODE } from "../../acp/control-plane/manager.turn-timeout.js";
|
|
import { formatAcpErrorChain } from "../../acp/runtime/errors.js";
|
|
import { resolveAcpToolTerminalOutcome } from "../../acp/tool-status.js";
|
|
import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js";
|
|
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
|
|
import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js";
|
|
import { readTailAssistantTextFromSessionTranscript } from "../../config/sessions/transcript.js";
|
|
import type { SessionEntry } from "../../config/sessions/types.js";
|
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
|
import {
|
|
injectTimestamp,
|
|
timestampOptsFromConfig,
|
|
} from "../../gateway/server-methods/agent-timestamp.js";
|
|
import { emitAgentAuditEvent, emitAgentEvent } from "../../infra/agent-events.js";
|
|
import { emitTrustedDiagnosticEvent } from "../../infra/diagnostic-events.js";
|
|
import { readErrorName } from "../../infra/errors.js";
|
|
import { redactSensitiveText } from "../../logging/redact.js";
|
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
|
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
|
|
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
|
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
|
|
import {
|
|
buildPersistedUserTurnMessage,
|
|
preparePersistedUserTurnMessageForTranscriptWrite,
|
|
type PersistedUserTurnMessage,
|
|
type UserTurnInput,
|
|
type UserTurnTranscriptRecorder,
|
|
} from "../../sessions/user-turn-transcript.js";
|
|
import { buildWorkspaceSkillSnapshot } from "../../skills/loading/workspace.js";
|
|
import {
|
|
getGeneratedMediaTaskIdsForSessionKey,
|
|
hasNewGeneratedMediaTaskForSessionKey,
|
|
} from "../../tasks/task-status-access.js";
|
|
import { resolveUserPath } from "../../utils.js";
|
|
import { resolveMessageChannel } from "../../utils/message-channel.js";
|
|
import { resolveAuthProfileOrder } from "../auth-profiles/order.js";
|
|
import { ensureAuthProfileStore } from "../auth-profiles/store.js";
|
|
import { resolveBootstrapWarningSignaturesSeen } from "../bootstrap-budget.js";
|
|
import { resolveCliBackendConfig } from "../cli-backends.js";
|
|
import {
|
|
cliBackendAcceptsAuthProfileForwarding,
|
|
resolveCliExecutionAuthProfileId,
|
|
} from "../cli-execution-auth.js";
|
|
import { runCliAgent } from "../cli-runner.js";
|
|
import { hasClaudeLiveSessionForOwner } from "../cli-runner/claude-live-session.js";
|
|
import { resolveCliRuntimeToolsAllow } from "../cli-runner/tool-policy.js";
|
|
import { getCliSessionBinding } from "../cli-session.js";
|
|
import { runEmbeddedAgent, type EmbeddedAgentRunResult } from "../embedded-agent.js";
|
|
import { FailoverError } from "../failover-error.js";
|
|
import { runAgentHarnessBeforeMessageWriteHook } from "../harness/hook-helpers.js";
|
|
import { resolveAvailableAgentHarnessPolicy } from "../harness/selection.js";
|
|
import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js";
|
|
import { isCliProvider } from "../model-selection.js";
|
|
import { resolveOpenAIRuntimeProvider } from "../openai-routing.js";
|
|
import type { AgentRunSessionTarget } from "../run-session-target.js";
|
|
import { resolveAgentRunAbortLifecycleFields } from "../run-termination.js";
|
|
import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
|
|
import type { AgentMessage } from "../runtime/index.js";
|
|
import { buildUsageWithNoCost } from "../stream-message-shared.js";
|
|
import {
|
|
buildClaudeCliFallbackContextPrelude,
|
|
claudeCliSessionTranscriptHasContent,
|
|
resolveFallbackRetryPrompt,
|
|
} from "./attempt-execution.helpers.js";
|
|
import { resolveAgentRunContext } from "./run-context.js";
|
|
import {
|
|
clearCliSessionInStore,
|
|
consumeCliSessionForkInStore,
|
|
persistCliSessionForkSuccessorInStore,
|
|
restoreCliSessionForkInStore,
|
|
} from "./session-store.js";
|
|
import type { AgentCommandOpts } from "./types.js";
|
|
|
|
export {
|
|
createAcpVisibleTextAccumulator,
|
|
sessionFileHasContent,
|
|
} from "./attempt-execution.helpers.js";
|
|
|
|
const log = createSubsystemLogger("agents/agent-command");
|
|
|
|
function shouldClearReusedCliSessionAfterError(err: unknown): boolean {
|
|
if (readErrorName(err) === "AbortError") {
|
|
return true;
|
|
}
|
|
return err instanceof FailoverError;
|
|
}
|
|
|
|
function resolveClearedCliSessionReason(err: unknown): string {
|
|
if (err instanceof FailoverError) {
|
|
return err.reason;
|
|
}
|
|
return readErrorName(err) || "error";
|
|
}
|
|
|
|
function normalizeTranscriptMirrorText(value: string): string {
|
|
return value.trim().replace(/\s+/gu, " ");
|
|
}
|
|
|
|
const ACP_TRANSCRIPT_USAGE = {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
total: 0,
|
|
},
|
|
} as const;
|
|
function shouldSuppressEmbeddedLiveStreamOutput(params: { opts: AgentCommandOpts }): boolean {
|
|
return params.opts.sessionEffects === "internal" && params.opts.deliver !== true;
|
|
}
|
|
|
|
type TranscriptUsage = {
|
|
input?: number;
|
|
output?: number;
|
|
cacheRead?: number;
|
|
cacheWrite?: number;
|
|
total?: number;
|
|
};
|
|
|
|
type PersistTextTurnTranscriptParams = {
|
|
body: string;
|
|
transcriptBody?: string;
|
|
userMessage?: PersistedUserTurnMessage;
|
|
finalText: string;
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
sessionFile?: string;
|
|
sessionEntry: SessionEntry | undefined;
|
|
sessionStore?: Record<string, SessionEntry>;
|
|
storePath?: string;
|
|
sessionAgentId: string;
|
|
threadId?: string | number;
|
|
sessionCwd: string;
|
|
config: OpenClawConfig;
|
|
embeddedAssistantGapFill?: boolean;
|
|
assistant: {
|
|
api: string;
|
|
provider: string;
|
|
model: string;
|
|
usage?: TranscriptUsage;
|
|
};
|
|
};
|
|
|
|
type PersistTextTurnTranscriptResult =
|
|
| { kind: "persisted"; sessionEntry: SessionEntry | undefined }
|
|
| { kind: "session-rebound"; sessionEntry: undefined };
|
|
|
|
type HarnessAuthProfileSelection = {
|
|
authProfileId?: string;
|
|
authProfileIdSource?: "auto" | "user";
|
|
authProfileProvider: string;
|
|
authProfileMode?: string;
|
|
};
|
|
|
|
function resolveProfileAuthFromStore(params: { agentDir: string; profileId: string | undefined }): {
|
|
provider?: string;
|
|
mode?: string;
|
|
} {
|
|
const profileId = params.profileId?.trim();
|
|
if (!profileId) {
|
|
return {};
|
|
}
|
|
const credential = ensureAuthProfileStore(params.agentDir, {
|
|
allowKeychainPrompt: false,
|
|
externalCliProfileIds: [profileId],
|
|
}).profiles[profileId];
|
|
return { provider: credential?.provider, mode: credential?.type };
|
|
}
|
|
|
|
function resolveHarnessAuthProfileSelection(params: {
|
|
config: OpenClawConfig;
|
|
agentDir: string;
|
|
workspaceDir: string;
|
|
provider: string;
|
|
authProfileProvider: string;
|
|
sessionAuthProfileId?: string;
|
|
sessionAuthProfileSource?: "auto" | "user";
|
|
harnessId?: string;
|
|
harnessRuntime?: string;
|
|
metadataSnapshot?: PluginMetadataSnapshot;
|
|
providerAuthAliasesEnabled?: boolean;
|
|
allowHarnessAuthProfileForwarding: boolean;
|
|
}): HarnessAuthProfileSelection {
|
|
const sessionAuthProfileId = params.sessionAuthProfileId?.trim();
|
|
if (sessionAuthProfileId) {
|
|
const profileAuth = resolveProfileAuthFromStore({
|
|
agentDir: params.agentDir,
|
|
profileId: sessionAuthProfileId,
|
|
});
|
|
return {
|
|
authProfileId: sessionAuthProfileId,
|
|
authProfileIdSource: params.sessionAuthProfileSource,
|
|
authProfileProvider: profileAuth.provider ?? params.authProfileProvider,
|
|
authProfileMode: profileAuth.mode,
|
|
};
|
|
}
|
|
|
|
if (!params.allowHarnessAuthProfileForwarding) {
|
|
return { authProfileProvider: params.authProfileProvider };
|
|
}
|
|
|
|
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
|
|
provider: params.provider,
|
|
authProfileProvider: params.authProfileProvider,
|
|
config: params.config,
|
|
workspaceDir: params.workspaceDir,
|
|
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
|
|
providerAuthAliasesEnabled: params.providerAuthAliasesEnabled,
|
|
harnessId: params.harnessId,
|
|
harnessRuntime: params.harnessRuntime,
|
|
allowHarnessAuthProfileForwarding: params.allowHarnessAuthProfileForwarding,
|
|
});
|
|
const harnessAuthProvider = runtimeAuthPlan.harnessAuthProvider;
|
|
if (!harnessAuthProvider) {
|
|
return { authProfileProvider: params.authProfileProvider };
|
|
}
|
|
|
|
const store = ensureAuthProfileStore(params.agentDir, {
|
|
allowKeychainPrompt: false,
|
|
externalCliProviderIds: [harnessAuthProvider],
|
|
});
|
|
const authProfileId = resolveAuthProfileOrder({
|
|
cfg: params.config,
|
|
store,
|
|
provider: harnessAuthProvider,
|
|
})[0];
|
|
|
|
return authProfileId
|
|
? {
|
|
authProfileId,
|
|
authProfileIdSource: "auto",
|
|
authProfileProvider: harnessAuthProvider,
|
|
}
|
|
: { authProfileProvider: params.authProfileProvider };
|
|
}
|
|
|
|
function resolveTranscriptUsage(usage: PersistTextTurnTranscriptParams["assistant"]["usage"]) {
|
|
if (!usage) {
|
|
return ACP_TRANSCRIPT_USAGE;
|
|
}
|
|
return buildUsageWithNoCost({
|
|
input: usage.input,
|
|
output: usage.output,
|
|
cacheRead: usage.cacheRead,
|
|
cacheWrite: usage.cacheWrite,
|
|
totalTokens: usage.total,
|
|
});
|
|
}
|
|
|
|
async function persistTextTurnTranscript(
|
|
params: PersistTextTurnTranscriptParams,
|
|
): Promise<PersistTextTurnTranscriptResult> {
|
|
const promptText = params.transcriptBody ?? params.body;
|
|
const replyText = params.finalText;
|
|
const userMessage =
|
|
params.userMessage ??
|
|
(promptText
|
|
? ({
|
|
role: "user",
|
|
content: promptText,
|
|
timestamp: Date.now(),
|
|
} as PersistedUserTurnMessage)
|
|
: undefined);
|
|
if (!userMessage && !replyText) {
|
|
return { kind: "persisted", sessionEntry: params.sessionEntry };
|
|
}
|
|
|
|
const messages = [];
|
|
if (userMessage) {
|
|
messages.push({
|
|
message: userMessage,
|
|
idempotencyLookup: "scan" as const,
|
|
prepareMessageAfterIdempotencyCheck: (message: unknown) =>
|
|
preparePersistedUserTurnMessageForTranscriptWrite(message as PersistedUserTurnMessage, {
|
|
agentId: params.sessionAgentId,
|
|
sessionKey: params.sessionKey,
|
|
beforeMessageWrite: runAgentHarnessBeforeMessageWriteHook,
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (replyText) {
|
|
messages.push({
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: replyText }],
|
|
api: params.assistant.api,
|
|
provider: params.assistant.provider,
|
|
model: params.assistant.model,
|
|
usage: resolveTranscriptUsage(params.assistant.usage),
|
|
stopReason: "stop",
|
|
timestamp: Date.now(),
|
|
},
|
|
shouldAppend: async ({ sessionFile }: { sessionFile: string }) => {
|
|
if (!params.embeddedAssistantGapFill) {
|
|
return true;
|
|
}
|
|
const latest = await readTailAssistantTextFromSessionTranscript(sessionFile, {
|
|
excludeTranscriptOnlyOpenClawAssistant: true,
|
|
});
|
|
const normalizedReply = normalizeTranscriptMirrorText(replyText);
|
|
const normalizedLatest = latest?.text ? normalizeTranscriptMirrorText(latest.text) : "";
|
|
return !normalizedLatest || normalizedLatest !== normalizedReply;
|
|
},
|
|
});
|
|
}
|
|
|
|
const turn = await persistSessionTranscriptTurn(
|
|
{
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
sessionFile: params.sessionFile,
|
|
sessionEntry: params.sessionEntry,
|
|
sessionStore: params.sessionStore,
|
|
storePath: params.storePath,
|
|
agentId: params.sessionAgentId,
|
|
threadId: params.threadId,
|
|
},
|
|
{
|
|
config: params.config,
|
|
cwd: params.sessionCwd,
|
|
messages,
|
|
publishWhen: "always",
|
|
touchSessionEntry: true,
|
|
updateMode: "file-only",
|
|
...(params.sessionStore && params.storePath ? { expectedSessionId: params.sessionId } : {}),
|
|
},
|
|
);
|
|
if (turn.rejectedReason === "session-rebound") {
|
|
return { kind: "session-rebound", sessionEntry: undefined };
|
|
}
|
|
return { kind: "persisted", sessionEntry: turn.sessionEntry };
|
|
}
|
|
|
|
function resolveCliTranscriptReplyText(result: EmbeddedAgentRunResult): string {
|
|
const visibleText = result.meta.finalAssistantVisibleText?.trim();
|
|
if (visibleText) {
|
|
return visibleText;
|
|
}
|
|
|
|
return (result.payloads ?? [])
|
|
.filter((payload) => !payload.isError && !payload.isReasoning)
|
|
.map((payload) => payload.text?.trim() ?? "")
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
}
|
|
|
|
function isClaudeCliProvider(provider: string): boolean {
|
|
return provider.trim().toLowerCase() === "claude-cli";
|
|
}
|
|
|
|
export async function persistAcpTurnTranscript(params: {
|
|
body: string;
|
|
transcriptBody?: string;
|
|
userInput?: UserTurnInput;
|
|
finalText: string;
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
sessionFile?: string;
|
|
sessionEntry: SessionEntry | undefined;
|
|
sessionStore?: Record<string, SessionEntry>;
|
|
storePath?: string;
|
|
sessionAgentId: string;
|
|
threadId?: string | number;
|
|
sessionCwd: string;
|
|
config: OpenClawConfig;
|
|
}): Promise<PersistTextTurnTranscriptResult> {
|
|
return await persistTextTurnTranscript({
|
|
...params,
|
|
...(params.userInput ? { userMessage: buildPersistedUserTurnMessage(params.userInput) } : {}),
|
|
assistant: {
|
|
api: "openai-responses",
|
|
provider: "openclaw",
|
|
model: "acp-runtime",
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function persistCliTurnTranscript(params: {
|
|
body: string;
|
|
transcriptBody?: string;
|
|
userMessage?: PersistedUserTurnMessage;
|
|
result: EmbeddedAgentRunResult;
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
sessionFile?: string;
|
|
sessionEntry: SessionEntry | undefined;
|
|
sessionStore?: Record<string, SessionEntry>;
|
|
storePath?: string;
|
|
sessionAgentId: string;
|
|
threadId?: string | number;
|
|
sessionCwd: string;
|
|
config: OpenClawConfig;
|
|
embeddedAssistantGapFill?: boolean;
|
|
skipUserTurn?: boolean;
|
|
}): Promise<PersistTextTurnTranscriptResult> {
|
|
const replyText = resolveCliTranscriptReplyText(params.result);
|
|
const provider = params.result.meta.agentMeta?.provider?.trim() ?? "cli";
|
|
const model = params.result.meta.agentMeta?.model?.trim() ?? "default";
|
|
const gapFill = params.embeddedAssistantGapFill ?? false;
|
|
const skipUserTurn = gapFill || params.skipUserTurn === true;
|
|
|
|
return await persistTextTurnTranscript({
|
|
body: skipUserTurn ? "" : params.body,
|
|
transcriptBody: skipUserTurn ? undefined : params.transcriptBody,
|
|
...(!skipUserTurn && params.userMessage ? { userMessage: params.userMessage } : {}),
|
|
finalText: replyText,
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
sessionFile: params.sessionFile,
|
|
sessionEntry: params.sessionEntry,
|
|
sessionStore: params.sessionStore,
|
|
storePath: params.storePath,
|
|
sessionAgentId: params.sessionAgentId,
|
|
threadId: params.threadId,
|
|
sessionCwd: params.sessionCwd,
|
|
config: params.config,
|
|
embeddedAssistantGapFill: gapFill,
|
|
assistant: {
|
|
api: "cli",
|
|
provider,
|
|
model,
|
|
usage: params.result.meta.agentMeta?.usage,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function runAgentAttempt(params: {
|
|
providerOverride: string;
|
|
modelOverride: string;
|
|
configuredAuthProfileId?: string;
|
|
originalProvider: string;
|
|
cfg: OpenClawConfig;
|
|
sessionEntry: SessionEntry | undefined;
|
|
agentHarnessRuntimeOverride?: string;
|
|
sessionId: string;
|
|
sessionKey: string | undefined;
|
|
sessionTarget?: AgentRunSessionTarget;
|
|
sessionAgentId: string;
|
|
sessionFile: string;
|
|
workspaceDir: string;
|
|
cwd?: string;
|
|
body: string;
|
|
transcriptBody?: string;
|
|
isFallbackRetry: boolean;
|
|
resolvedThinkLevel: ThinkLevel;
|
|
fastMode?: FastMode;
|
|
fastModeStartedAtMs?: number;
|
|
fastModeAutoOnSeconds?: number;
|
|
isFinalFallbackAttempt?: boolean;
|
|
timeoutMs: number;
|
|
runTimeoutOverrideMs?: number;
|
|
runId: string;
|
|
lifecycleGeneration: string;
|
|
opts: AgentCommandOpts;
|
|
runContext: ReturnType<typeof resolveAgentRunContext>;
|
|
spawnedBy: string | undefined;
|
|
messageChannel: ReturnType<typeof resolveMessageChannel>;
|
|
skillsSnapshot: ReturnType<typeof buildWorkspaceSkillSnapshot> | undefined;
|
|
resolvedVerboseLevel: VerboseLevel | undefined;
|
|
agentDir: string;
|
|
onAgentEvent: (evt: {
|
|
stream: string;
|
|
data?: Record<string, unknown>;
|
|
sessionKey?: string;
|
|
}) => void;
|
|
deferTerminalLifecycle?: boolean;
|
|
/** @deprecated Use deferTerminalLifecycle. */
|
|
deferTerminalLifecycleEnd?: boolean;
|
|
authProfileProvider: string;
|
|
sessionStore?: Record<string, SessionEntry>;
|
|
storePath?: string;
|
|
pluginsEnabled?: boolean;
|
|
metadataSnapshot?: PluginMetadataSnapshot;
|
|
allowTransientCooldownProbe?: boolean;
|
|
modelFallbacksOverride?: string[];
|
|
sessionHasHistory?: boolean;
|
|
fallbackRuntimeState?: { originRuntime?: "cli" | "embedded" };
|
|
suppressPromptPersistenceOnRetry?: boolean;
|
|
userTurnTranscriptRecorder?: UserTurnTranscriptRecorder;
|
|
onUserMessagePersisted?: (message: Extract<AgentMessage, { role: "user" }>) => void;
|
|
onLifecycleGenerationChanged?: (lifecycleGeneration: string) => void;
|
|
}) {
|
|
const sessionAuthProfileId = params.sessionEntry?.authProfileOverride?.trim();
|
|
const sessionAuthProfileSource = params.sessionEntry?.authProfileOverrideSource;
|
|
// An explicit session choice owns the conversation. Otherwise the profile
|
|
// bound to the configured model replaces a stale automatic session choice.
|
|
const selectedAuthProfile =
|
|
sessionAuthProfileId && sessionAuthProfileSource !== "auto"
|
|
? { id: sessionAuthProfileId, source: sessionAuthProfileSource }
|
|
: params.configuredAuthProfileId?.trim()
|
|
? { id: params.configuredAuthProfileId.trim(), source: "user" as const }
|
|
: sessionAuthProfileId
|
|
? { id: sessionAuthProfileId, source: sessionAuthProfileSource }
|
|
: undefined;
|
|
const isRawModelRun = params.opts.modelRun === true || params.opts.promptMode === "none";
|
|
const claudeCliFallbackPrelude =
|
|
!isRawModelRun &&
|
|
params.isFallbackRetry &&
|
|
isClaudeCliProvider(params.originalProvider) &&
|
|
!isClaudeCliProvider(params.providerOverride)
|
|
? buildClaudeCliFallbackContextPrelude({
|
|
cliSessionId: getCliSessionBinding(params.sessionEntry, "claude-cli")?.sessionId,
|
|
})
|
|
: "";
|
|
const resolvedPrompt = resolveFallbackRetryPrompt({
|
|
body: params.body,
|
|
isFallbackRetry: params.isFallbackRetry,
|
|
sessionHasHistory: params.sessionHasHistory,
|
|
priorContextPrelude: claudeCliFallbackPrelude,
|
|
});
|
|
const effectivePrompt = isRawModelRun
|
|
? resolvedPrompt
|
|
: annotateInterSessionPromptText(resolvedPrompt, params.opts.inputProvenance);
|
|
const bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
|
|
params.sessionEntry?.systemPromptReport,
|
|
);
|
|
const bootstrapPromptWarningSignature =
|
|
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1];
|
|
const requestedAgentHarnessId = isRawModelRun ? "openclaw" : undefined;
|
|
const sessionRuntimeOverride = isRawModelRun ? undefined : params.agentHarnessRuntimeOverride;
|
|
const locksSessionRuntimeOverride =
|
|
sessionRuntimeOverride !== undefined && params.sessionEntry?.modelSelectionLocked === true;
|
|
const sessionCliRuntime =
|
|
sessionRuntimeOverride &&
|
|
!locksSessionRuntimeOverride &&
|
|
isCliProvider(sessionRuntimeOverride, params.cfg)
|
|
? sessionRuntimeOverride
|
|
: undefined;
|
|
const configuredCliRuntime =
|
|
!isRawModelRun && !sessionRuntimeOverride
|
|
? resolveCliRuntimeExecutionProvider({
|
|
provider: params.providerOverride,
|
|
cfg: params.cfg,
|
|
agentId: params.sessionAgentId,
|
|
modelId: params.modelOverride,
|
|
authProfileId: selectedAuthProfile?.id,
|
|
})
|
|
: undefined;
|
|
const cliExecutionProvider = isRawModelRun
|
|
? params.providerOverride
|
|
: (sessionCliRuntime ?? configuredCliRuntime ?? params.providerOverride);
|
|
const isCliExecutionProvider = sessionRuntimeOverride
|
|
? sessionCliRuntime !== undefined
|
|
: isCliProvider(cliExecutionProvider, params.cfg);
|
|
if (params.fallbackRuntimeState && params.fallbackRuntimeState.originRuntime === undefined) {
|
|
params.fallbackRuntimeState.originRuntime =
|
|
!isRawModelRun && isCliExecutionProvider ? "cli" : "embedded";
|
|
}
|
|
const shouldForwardImagesToEmbedded =
|
|
!params.isFallbackRetry || params.fallbackRuntimeState?.originRuntime === "cli";
|
|
const allowCliAuthProfileForwarding =
|
|
isCliExecutionProvider &&
|
|
cliBackendAcceptsAuthProfileForwarding({
|
|
provider: cliExecutionProvider,
|
|
config: params.cfg,
|
|
agentId: params.sessionAgentId,
|
|
});
|
|
const agentHarnessPolicy = isRawModelRun
|
|
? ({ runtime: "openclaw", runtimeSource: "model" } as const)
|
|
: sessionRuntimeOverride
|
|
? ({ runtime: sessionRuntimeOverride, runtimeSource: "model" } as const)
|
|
: resolveAvailableAgentHarnessPolicy({
|
|
provider: params.providerOverride,
|
|
modelId: params.modelOverride,
|
|
config: params.cfg,
|
|
agentId: params.sessionAgentId,
|
|
sessionKey: params.sessionKey ?? params.sessionId,
|
|
});
|
|
const harnessAuthSelection = resolveHarnessAuthProfileSelection({
|
|
config: params.cfg,
|
|
agentDir: params.agentDir,
|
|
workspaceDir: params.workspaceDir,
|
|
provider: params.providerOverride,
|
|
authProfileProvider: params.authProfileProvider,
|
|
sessionAuthProfileId: selectedAuthProfile?.id,
|
|
sessionAuthProfileSource: selectedAuthProfile?.source,
|
|
harnessId: requestedAgentHarnessId,
|
|
harnessRuntime: agentHarnessPolicy.runtime,
|
|
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
|
|
providerAuthAliasesEnabled: params.pluginsEnabled,
|
|
allowHarnessAuthProfileForwarding: !isCliExecutionProvider,
|
|
});
|
|
const runtimeAuthPlan = buildAgentRuntimeAuthPlan({
|
|
provider: params.providerOverride,
|
|
authProfileProvider: harnessAuthSelection.authProfileProvider,
|
|
authProfileMode: harnessAuthSelection.authProfileMode,
|
|
sessionAuthProfileId: harnessAuthSelection.authProfileId,
|
|
config: params.cfg,
|
|
workspaceDir: params.workspaceDir,
|
|
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
|
|
providerAuthAliasesEnabled: params.pluginsEnabled,
|
|
harnessId: requestedAgentHarnessId,
|
|
harnessRuntime: agentHarnessPolicy.runtime,
|
|
allowHarnessAuthProfileForwarding: !isCliExecutionProvider,
|
|
});
|
|
const cliAuthProfileId = allowCliAuthProfileForwarding
|
|
? resolveCliExecutionAuthProfileId({
|
|
cliExecutionProvider,
|
|
authProfileProvider: params.authProfileProvider,
|
|
config: params.cfg,
|
|
agentDir: params.agentDir,
|
|
selected: harnessAuthSelection,
|
|
})
|
|
: undefined;
|
|
const authProfileId = allowCliAuthProfileForwarding
|
|
? cliAuthProfileId
|
|
: runtimeAuthPlan.forwardedAuthProfileId;
|
|
const embeddedAgentProvider = resolveOpenAIRuntimeProvider({
|
|
provider: params.providerOverride,
|
|
harnessRuntime: agentHarnessPolicy.runtime,
|
|
agentHarnessId: requestedAgentHarnessId,
|
|
authProfileProvider: runtimeAuthPlan.authProfileProviderForAuth,
|
|
authProfileId,
|
|
config: params.cfg,
|
|
workspaceDir: params.workspaceDir,
|
|
});
|
|
const embeddedAgentHarnessOverride =
|
|
requestedAgentHarnessId ??
|
|
sessionRuntimeOverride ??
|
|
(agentHarnessPolicy.runtime === "openclaw" && agentHarnessPolicy.runtimeSource !== "implicit"
|
|
? "openclaw"
|
|
: undefined);
|
|
if (!isRawModelRun && isCliExecutionProvider) {
|
|
const cliSessionBinding = getCliSessionBinding(params.sessionEntry, cliExecutionProvider);
|
|
const cliProcessCwd = params.cwd ? resolveUserPath(params.cwd) : params.workspaceDir;
|
|
const cliPrompt =
|
|
params.opts.inputProvenance?.kind === "inter_session"
|
|
? effectivePrompt
|
|
: injectTimestamp(effectivePrompt, timestampOptsFromConfig(params.cfg));
|
|
const mutableCliSessionStore =
|
|
params.sessionKey && params.sessionStore && params.storePath
|
|
? {
|
|
sessionKey: params.sessionKey,
|
|
sessionStore: params.sessionStore,
|
|
storePath: params.storePath,
|
|
}
|
|
: undefined;
|
|
const resolveReusableCliSessionBinding = async () => {
|
|
const hasManagedClaudeLiveSession = Boolean(
|
|
isClaudeCliProvider(cliExecutionProvider) &&
|
|
cliSessionBinding?.sessionId &&
|
|
hasClaudeLiveSessionForOwner({
|
|
backendId: cliExecutionProvider,
|
|
agentAccountId: params.runContext.accountId,
|
|
agentId: params.sessionAgentId,
|
|
authProfileId: cliSessionBinding.authProfileId,
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
}),
|
|
);
|
|
if (
|
|
!isClaudeCliProvider(cliExecutionProvider) ||
|
|
!cliSessionBinding?.sessionId ||
|
|
hasManagedClaudeLiveSession ||
|
|
(await claudeCliSessionTranscriptHasContent({
|
|
sessionId: cliSessionBinding.sessionId,
|
|
workspaceDir: cliProcessCwd,
|
|
}))
|
|
) {
|
|
return cliSessionBinding;
|
|
}
|
|
|
|
log.warn(
|
|
`cli session reset: provider=${sanitizeForLog(cliExecutionProvider)} reason=transcript-missing sessionKey=${params.sessionKey ?? params.sessionId}`,
|
|
);
|
|
|
|
if (mutableCliSessionStore) {
|
|
params.sessionEntry =
|
|
(await clearCliSessionInStore({
|
|
provider: cliExecutionProvider,
|
|
...mutableCliSessionStore,
|
|
})) ?? params.sessionEntry;
|
|
}
|
|
|
|
// The store is already cleared above, so no stale --resume can leak to a
|
|
// later turn. Still return the bound id as the reuse candidate: prepare
|
|
// re-detects the missing transcript, keeps useResume=false, and arms
|
|
// raw-transcript reseed from prior OpenClaw history. Returning undefined
|
|
// strips the candidate and starves reseed, losing warm-stdin continuity.
|
|
return cliSessionBinding;
|
|
};
|
|
const mediaTaskIdsBefore = getGeneratedMediaTaskIdsForSessionKey(params.sessionKey);
|
|
const runCliWithSession = async (
|
|
nextCliSessionId: string | undefined,
|
|
activeCliSessionBinding = cliSessionBinding,
|
|
) => {
|
|
const forkCliSessionOnResume = activeCliSessionBinding?.forkNextResume === true;
|
|
if (
|
|
forkCliSessionOnResume &&
|
|
!resolveCliBackendConfig(cliExecutionProvider, params.cfg, {
|
|
agentId: params.sessionAgentId,
|
|
})?.config.forkArg
|
|
) {
|
|
throw new Error(`CLI backend "${cliExecutionProvider}" does not support session forks`);
|
|
}
|
|
const forkStoreParams =
|
|
forkCliSessionOnResume && nextCliSessionId && mutableCliSessionStore
|
|
? {
|
|
provider: cliExecutionProvider,
|
|
expectedCliSessionId: nextCliSessionId,
|
|
...mutableCliSessionStore,
|
|
}
|
|
: undefined;
|
|
return runCliAgent({
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
sessionEntry: params.sessionEntry,
|
|
agentId: params.sessionAgentId,
|
|
trigger: "user",
|
|
sessionFile: params.sessionFile,
|
|
storePath: params.storePath,
|
|
workspaceDir: params.workspaceDir,
|
|
cwd: params.cwd,
|
|
config: params.cfg,
|
|
prompt: cliPrompt,
|
|
transcriptPrompt: params.transcriptBody,
|
|
modelProvider: params.providerOverride,
|
|
provider: cliExecutionProvider,
|
|
model: params.modelOverride,
|
|
thinkLevel: params.resolvedThinkLevel,
|
|
timeoutMs: params.timeoutMs,
|
|
runTimeoutOverrideMs: params.runTimeoutOverrideMs,
|
|
runId: params.runId,
|
|
lifecycleGeneration: params.lifecycleGeneration,
|
|
lane: params.opts.lane,
|
|
extraSystemPrompt: params.opts.extraSystemPrompt,
|
|
inputProvenance: params.opts.inputProvenance,
|
|
sourceReplyDeliveryMode: params.opts.sourceReplyDeliveryMode,
|
|
requireExplicitMessageTarget:
|
|
params.opts.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
|
|
cliSessionBindingFacts: params.opts.cliSessionBindingFacts,
|
|
cliSessionId: nextCliSessionId,
|
|
cliSessionBinding:
|
|
nextCliSessionId === activeCliSessionBinding?.sessionId
|
|
? activeCliSessionBinding
|
|
: undefined,
|
|
forkCliSessionOnResume,
|
|
...(forkStoreParams
|
|
? {
|
|
claimCliSessionFork: async () => {
|
|
const claimed = await consumeCliSessionForkInStore(forkStoreParams);
|
|
if (claimed) {
|
|
params.sessionEntry = claimed;
|
|
}
|
|
return Boolean(claimed);
|
|
},
|
|
restoreCliSessionFork: async () => {
|
|
const restored = await restoreCliSessionForkInStore(forkStoreParams);
|
|
if (restored) {
|
|
params.sessionEntry = restored;
|
|
}
|
|
},
|
|
persistCliSessionForkSuccessor: async (successorCliSessionId: string) => {
|
|
const persisted = await persistCliSessionForkSuccessorInStore({
|
|
...forkStoreParams,
|
|
successorCliSessionId,
|
|
});
|
|
if (!persisted) {
|
|
throw new Error("CLI session fork successor could not be persisted");
|
|
}
|
|
params.sessionEntry = persisted;
|
|
},
|
|
}
|
|
: {}),
|
|
authProfileId,
|
|
bootstrapPromptWarningSignaturesSeen,
|
|
bootstrapPromptWarningSignature,
|
|
// Image discovery must use the original turn, before retry/history decoration.
|
|
imagePrompt: params.body,
|
|
// Fallback prompts repeat the current task, so prompt-local images must
|
|
// accompany every CLI process. Native dedupe requires a runtime receipt.
|
|
images: params.opts.images,
|
|
imageOrder: params.opts.imageOrder,
|
|
skillsSnapshot: params.skillsSnapshot,
|
|
messageChannel: params.messageChannel,
|
|
streamParams: params.opts.streamParams,
|
|
messageProvider: params.opts.messageProvider ?? params.messageChannel,
|
|
currentChannelId: params.runContext.currentChannelId,
|
|
chatId: params.runContext.chatId,
|
|
channelContext: params.runContext.channelContext,
|
|
currentThreadTs: params.runContext.currentThreadTs,
|
|
currentInboundAudio: params.runContext.currentInboundAudio,
|
|
approvalReviewerDeviceId: params.opts.approvalReviewerDeviceId,
|
|
agentAccountId: params.runContext.accountId,
|
|
senderId: params.runContext.senderId,
|
|
senderIsOwner: params.opts.senderIsOwner,
|
|
bashElevated: params.opts.bashElevated,
|
|
groupId: params.runContext.groupId,
|
|
groupChannel: params.runContext.groupChannel,
|
|
groupSpace: params.runContext.groupSpace,
|
|
spawnedBy: params.spawnedBy,
|
|
toolsAllow: resolveCliRuntimeToolsAllow(
|
|
params.opts.toolsAllow,
|
|
params.opts.toolsAllowIsDefault,
|
|
),
|
|
cleanupBundleMcpOnRunEnd: params.opts.cleanupBundleMcpOnRunEnd,
|
|
cleanupCliLiveSessionOnRunEnd: params.opts.cleanupCliLiveSessionOnRunEnd,
|
|
oneShotCliRun: params.opts.oneShotCliRun,
|
|
userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
|
|
suppressNextUserMessagePersistence: params.suppressPromptPersistenceOnRetry === true,
|
|
...(mutableCliSessionStore && !forkCliSessionOnResume
|
|
? {
|
|
onBeforeFreshCliSessionRetry: async (retry) => {
|
|
if (
|
|
hasNewGeneratedMediaTaskForSessionKey(params.sessionKey, mediaTaskIdsBefore) ||
|
|
retry.sessionId !== activeCliSessionBinding?.sessionId
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
log.warn(
|
|
`CLI session failed, clearing before fresh retry: provider=${sanitizeForLog(cliExecutionProvider)} sessionKey=${mutableCliSessionStore.sessionKey} reason=${sanitizeForLog(retry.reason)}`,
|
|
);
|
|
|
|
params.sessionEntry =
|
|
(await clearCliSessionInStore({
|
|
provider: cliExecutionProvider,
|
|
...mutableCliSessionStore,
|
|
})) ?? params.sessionEntry;
|
|
return true;
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
};
|
|
return resolveReusableCliSessionBinding().then(async (activeCliSessionBinding) => {
|
|
try {
|
|
return await runCliWithSession(activeCliSessionBinding?.sessionId, activeCliSessionBinding);
|
|
} catch (err) {
|
|
if (
|
|
isClaudeCliProvider(cliExecutionProvider) &&
|
|
!activeCliSessionBinding?.forkNextResume &&
|
|
shouldClearReusedCliSessionAfterError(err) &&
|
|
!hasNewGeneratedMediaTaskForSessionKey(params.sessionKey, mediaTaskIdsBefore) &&
|
|
activeCliSessionBinding?.sessionId &&
|
|
mutableCliSessionStore
|
|
) {
|
|
log.warn(
|
|
`CLI session cleared after failed reused turn: provider=${sanitizeForLog(cliExecutionProvider)} sessionKey=${mutableCliSessionStore.sessionKey} reason=${sanitizeForLog(resolveClearedCliSessionReason(err))}`,
|
|
);
|
|
|
|
params.sessionEntry =
|
|
(await clearCliSessionInStore({
|
|
provider: cliExecutionProvider,
|
|
...mutableCliSessionStore,
|
|
})) ?? params.sessionEntry;
|
|
}
|
|
throw err;
|
|
}
|
|
});
|
|
}
|
|
|
|
return runEmbeddedAgent({
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
sessionTarget: params.sessionTarget,
|
|
sandboxSessionKey: params.sessionKey,
|
|
agentId: params.sessionAgentId,
|
|
trigger: "user",
|
|
messageChannel: params.messageChannel,
|
|
messageProvider: params.opts.messageProvider ?? params.messageChannel,
|
|
agentAccountId: params.runContext.accountId,
|
|
messageTo: params.opts.replyTo ?? params.opts.to,
|
|
messageThreadId: params.opts.threadId,
|
|
groupId: params.runContext.groupId,
|
|
groupChannel: params.runContext.groupChannel,
|
|
groupSpace: params.runContext.groupSpace,
|
|
spawnedBy: params.spawnedBy,
|
|
currentChannelId: params.runContext.currentChannelId,
|
|
chatId: params.runContext.chatId,
|
|
channelContext: params.runContext.channelContext,
|
|
currentThreadTs: params.runContext.currentThreadTs,
|
|
currentInboundAudio: params.runContext.currentInboundAudio,
|
|
replyToMode: params.runContext.replyToMode,
|
|
hasRepliedRef: params.runContext.hasRepliedRef,
|
|
senderId: params.runContext.senderId,
|
|
senderIsOwner: params.opts.senderIsOwner,
|
|
sessionFile: params.sessionFile,
|
|
workspaceDir: params.workspaceDir,
|
|
cwd: params.cwd,
|
|
config: params.cfg,
|
|
agentHarnessId: embeddedAgentHarnessOverride,
|
|
modelSelectionLocked: !isRawModelRun && params.sessionEntry?.modelSelectionLocked === true,
|
|
agentHarnessRuntimeOverride: embeddedAgentHarnessOverride,
|
|
skillsSnapshot: params.skillsSnapshot,
|
|
prompt: effectivePrompt,
|
|
transcriptPrompt: params.transcriptBody,
|
|
// CLI-origin retries cannot rely on transcript replay: orphan-user repair
|
|
// removes the persisted CLI turn before the embedded prompt is submitted.
|
|
images: shouldForwardImagesToEmbedded ? params.opts.images : undefined,
|
|
imageOrder: shouldForwardImagesToEmbedded ? params.opts.imageOrder : undefined,
|
|
clientTools: params.opts.clientTools,
|
|
provider: embeddedAgentProvider,
|
|
model: params.modelOverride,
|
|
modelFallbacksOverride: params.modelFallbacksOverride,
|
|
authProfileId,
|
|
authProfileIdSource: authProfileId ? harnessAuthSelection.authProfileIdSource : undefined,
|
|
thinkLevel: params.resolvedThinkLevel,
|
|
fastMode: params.fastMode,
|
|
fastModeStartedAtMs: params.fastModeStartedAtMs,
|
|
fastModeAutoOnSeconds: params.fastModeAutoOnSeconds,
|
|
isFinalFallbackAttempt: params.isFinalFallbackAttempt,
|
|
verboseLevel: params.resolvedVerboseLevel,
|
|
bashElevated: params.opts.bashElevated,
|
|
approvalReviewerDeviceId: params.opts.approvalReviewerDeviceId,
|
|
timeoutMs: params.timeoutMs,
|
|
runId: params.runId,
|
|
lifecycleGeneration: params.lifecycleGeneration,
|
|
lane: params.opts.lane,
|
|
// Hidden internal runs have no assistant-event consumer. Visible subagent
|
|
// lanes can still feed Control UI, session subscribers, and ACP parent relays.
|
|
suppressLiveStreamOutput: shouldSuppressEmbeddedLiveStreamOutput(params),
|
|
abortSignal: params.opts.abortSignal,
|
|
extraSystemPrompt: params.opts.extraSystemPrompt,
|
|
bootstrapContextMode: params.opts.bootstrapContextMode,
|
|
bootstrapContextRunKind: params.opts.bootstrapContextRunKind,
|
|
toolsAllow: params.opts.toolsAllow,
|
|
internalEvents: params.opts.internalEvents,
|
|
inputProvenance: params.opts.inputProvenance,
|
|
sourceReplyDeliveryMode: params.opts.sourceReplyDeliveryMode,
|
|
disableMessageTool: params.opts.disableMessageTool,
|
|
forceRestartSafeTools: params.opts.forceRestartSafeTools,
|
|
streamParams: params.opts.streamParams,
|
|
agentDir: params.agentDir,
|
|
allowGatewaySubagentBinding: params.opts.allowGatewaySubagentBinding,
|
|
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
|
|
cleanupBundleMcpOnRunEnd: params.opts.cleanupBundleMcpOnRunEnd,
|
|
oneShotCliRun: params.opts.oneShotCliRun,
|
|
modelRun: params.opts.modelRun,
|
|
promptMode: params.opts.promptMode,
|
|
disableTools: params.opts.modelRun === true,
|
|
onAgentEvent: params.onAgentEvent,
|
|
deferTerminalLifecycle: params.deferTerminalLifecycle,
|
|
deferTerminalLifecycleEnd: params.deferTerminalLifecycleEnd,
|
|
suppressNextUserMessagePersistence: params.suppressPromptPersistenceOnRetry === true,
|
|
userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
|
|
onUserMessagePersisted: params.onUserMessagePersisted,
|
|
onExecutionStarted: (info) => {
|
|
if (info?.lifecycleGeneration) {
|
|
params.onLifecycleGenerationChanged?.(info.lifecycleGeneration);
|
|
}
|
|
},
|
|
onSessionIdChanged: params.opts.onSessionIdChanged,
|
|
bootstrapPromptWarningSignaturesSeen,
|
|
bootstrapPromptWarningSignature,
|
|
});
|
|
}
|
|
|
|
export function buildAcpResult(params: {
|
|
payloadText: string;
|
|
startedAt: number;
|
|
stopReason?: string;
|
|
resultStatus?: Extract<AcpRuntimeEvent, { type: "done" }>["status"];
|
|
abortSignal?: AbortSignal;
|
|
}) {
|
|
const normalizedFinalPayload = normalizeReplyPayload({
|
|
text: params.payloadText,
|
|
});
|
|
const payloads = normalizedFinalPayload ? [normalizedFinalPayload] : [];
|
|
const abortFields = resolveAgentRunAbortLifecycleFields(params.abortSignal);
|
|
const resultCancelled = params.resultStatus === "cancelled";
|
|
return {
|
|
payloads,
|
|
meta: {
|
|
durationMs: Date.now() - params.startedAt,
|
|
aborted: abortFields.aborted ?? resultCancelled,
|
|
stopReason: abortFields.stopReason ?? (resultCancelled ? "stop" : params.stopReason),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function emitAcpLifecycleStart(params: {
|
|
runId: string;
|
|
startedAt: number;
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
lifecycleGeneration?: string;
|
|
auditOnly?: boolean;
|
|
}) {
|
|
const emit = params.auditOnly ? emitAgentAuditEvent : emitAgentEvent;
|
|
emit({
|
|
runId: params.runId,
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
|
|
stream: "lifecycle",
|
|
data: {
|
|
phase: "start",
|
|
startedAt: params.startedAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
const ACP_PROXY_ENV_KEYS = [
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"ALL_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"all_proxy",
|
|
] as const;
|
|
type ActiveAcpTool = {
|
|
runId: string;
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
toolCallId: string;
|
|
toolName: string;
|
|
startedAt: number;
|
|
};
|
|
|
|
export type AcpToolLifecycleTracker = {
|
|
active: Map<string, ActiveAcpTool>;
|
|
terminalToolCallIds: Set<string>;
|
|
saturated: boolean;
|
|
};
|
|
|
|
const MAX_TRACKED_ACP_TOOLS = 4_096;
|
|
|
|
export function createAcpToolLifecycleTracker(): AcpToolLifecycleTracker {
|
|
return {
|
|
active: new Map(),
|
|
terminalToolCallIds: new Set(),
|
|
saturated: false,
|
|
};
|
|
}
|
|
|
|
function acpAuditToolName(kind: unknown): string {
|
|
switch (kind) {
|
|
case "read":
|
|
case "edit":
|
|
case "delete":
|
|
case "move":
|
|
case "search":
|
|
case "execute":
|
|
case "fetch":
|
|
case "switch_mode":
|
|
case "think":
|
|
case "other":
|
|
return `acp_${kind}`;
|
|
default:
|
|
return "acp_tool";
|
|
}
|
|
}
|
|
|
|
function resolveAcpToolTerminalReason(
|
|
signal: AbortSignal | undefined,
|
|
stopReason?: string,
|
|
error?: unknown,
|
|
resultStatus?: Extract<AcpRuntimeEvent, { type: "done" }>["status"],
|
|
): "failed" | "cancelled" | "timed_out" {
|
|
const abortFields = resolveAgentRunAbortLifecycleFields(signal);
|
|
if (abortFields.aborted) {
|
|
return abortFields.stopReason === "timeout" ? "timed_out" : "cancelled";
|
|
}
|
|
const normalizedStopReason = normalizeOptionalLowercaseString(stopReason);
|
|
if (normalizedStopReason === "timeout") {
|
|
return "timed_out";
|
|
}
|
|
if (resultStatus === "cancelled") {
|
|
return "cancelled";
|
|
}
|
|
if (
|
|
error instanceof Error &&
|
|
(error as Error & { detailCode?: unknown }).detailCode === ACP_TURN_TIMEOUT_DETAIL_CODE
|
|
) {
|
|
return "timed_out";
|
|
}
|
|
if (
|
|
normalizedStopReason === "cancel" ||
|
|
normalizedStopReason === "cancelled" ||
|
|
normalizedStopReason === "manual-cancel"
|
|
) {
|
|
return "cancelled";
|
|
}
|
|
return "failed";
|
|
}
|
|
|
|
function resolveAcpLifecycleEndFields(
|
|
signal: AbortSignal | undefined,
|
|
stopReason?: string,
|
|
resultStatus?: Extract<AcpRuntimeEvent, { type: "done" }>["status"],
|
|
) {
|
|
const abortFields = resolveAgentRunAbortLifecycleFields(signal);
|
|
if (abortFields.aborted) {
|
|
return abortFields;
|
|
}
|
|
const terminalReason = resolveAcpToolTerminalReason(
|
|
undefined,
|
|
stopReason,
|
|
undefined,
|
|
resultStatus,
|
|
);
|
|
if (terminalReason === "timed_out") {
|
|
return { aborted: true, stopReason: "timeout", status: "timed_out" } as const;
|
|
}
|
|
if (terminalReason === "cancelled") {
|
|
return { aborted: true, stopReason: "stop", status: "cancelled" } as const;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function emitAcpToolExecutionEvent(params: {
|
|
runId: string;
|
|
toolTracker: AcpToolLifecycleTracker;
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
abortSignal?: AbortSignal;
|
|
event: Extract<AcpRuntimeEvent, { type: "tool_call" }>;
|
|
}): void {
|
|
const { event } = params;
|
|
const now = Date.now();
|
|
const toolCallId = event.toolCallId?.trim() ? event.toolCallId : undefined;
|
|
const activeTool = toolCallId ? params.toolTracker.active.get(toolCallId) : undefined;
|
|
const terminalOutcome = resolveAcpToolTerminalOutcome(event.status);
|
|
const toolName = acpAuditToolName(event.kind);
|
|
// ACP runtimes may replay terminal updates. Keep the closed identity until the run ends so a
|
|
// late progress/terminal pair cannot reopen one invocation as a second durable audit action.
|
|
if (toolCallId && !activeTool) {
|
|
if (params.toolTracker.terminalToolCallIds.has(toolCallId)) {
|
|
return;
|
|
}
|
|
// Never evict an open identity: once this run reaches its bound, ignore new identities until
|
|
// lifecycle cleanup releases the complete set. Other runs own independent trackers.
|
|
const trackedIdentities =
|
|
params.toolTracker.active.size + params.toolTracker.terminalToolCallIds.size;
|
|
if (params.toolTracker.saturated || trackedIdentities >= MAX_TRACKED_ACP_TOOLS) {
|
|
params.toolTracker.saturated = true;
|
|
return;
|
|
}
|
|
}
|
|
// Without an identity, wait for a terminal event so every observed action closes immediately.
|
|
// Opening on progress would leave an unmatched audit action if the runtime omits its result.
|
|
const startsUnidentifiedTool = toolCallId === undefined && terminalOutcome !== undefined;
|
|
if (!activeTool && (toolCallId !== undefined || startsUnidentifiedTool)) {
|
|
emitTrustedDiagnosticEvent({
|
|
type: "tool.execution.started",
|
|
runId: params.runId,
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
...(toolCallId ? { toolCallId } : {}),
|
|
toolName,
|
|
toolSource: "core",
|
|
toolOwner: "acp",
|
|
});
|
|
if (toolCallId) {
|
|
params.toolTracker.active.set(toolCallId, {
|
|
runId: params.runId,
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
toolCallId,
|
|
toolName,
|
|
startedAt: now,
|
|
});
|
|
}
|
|
}
|
|
if (!terminalOutcome) {
|
|
return;
|
|
}
|
|
const terminalReason = resolveAcpToolTerminalReason(
|
|
params.abortSignal,
|
|
undefined,
|
|
undefined,
|
|
terminalOutcome === "cancelled" ? "cancelled" : undefined,
|
|
);
|
|
const durationMs = Math.max(0, now - (activeTool?.startedAt ?? now));
|
|
emitTrustedDiagnosticEvent(
|
|
terminalOutcome === "completed"
|
|
? {
|
|
type: "tool.execution.completed",
|
|
runId: params.runId,
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
...(toolCallId ? { toolCallId } : {}),
|
|
toolName: activeTool?.toolName ?? toolName,
|
|
toolSource: "core",
|
|
toolOwner: "acp",
|
|
durationMs,
|
|
}
|
|
: {
|
|
type: "tool.execution.error",
|
|
runId: params.runId,
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
...(toolCallId ? { toolCallId } : {}),
|
|
toolName: activeTool?.toolName ?? toolName,
|
|
toolSource: "core",
|
|
toolOwner: "acp",
|
|
durationMs,
|
|
errorCategory: terminalReason === "cancelled" ? "aborted" : "acp_tool",
|
|
terminalReason,
|
|
},
|
|
);
|
|
if (toolCallId) {
|
|
params.toolTracker.active.delete(toolCallId);
|
|
params.toolTracker.terminalToolCallIds.add(toolCallId);
|
|
}
|
|
}
|
|
|
|
function finalizeAcpToolsForRun(
|
|
toolTracker: AcpToolLifecycleTracker,
|
|
runId: string,
|
|
terminalReason: "failed" | "cancelled" | "timed_out",
|
|
): void {
|
|
const now = Date.now();
|
|
for (const activeTool of toolTracker.active.values()) {
|
|
emitTrustedDiagnosticEvent({
|
|
type: "tool.execution.error",
|
|
runId,
|
|
...(activeTool.sessionKey ? { sessionKey: activeTool.sessionKey } : {}),
|
|
...(activeTool.agentId ? { agentId: activeTool.agentId } : {}),
|
|
toolName: activeTool.toolName,
|
|
toolSource: "core",
|
|
toolOwner: "acp",
|
|
toolCallId: activeTool.toolCallId,
|
|
durationMs: Math.max(0, now - activeTool.startedAt),
|
|
errorCategory: terminalReason === "cancelled" ? "aborted" : "acp_tool_incomplete",
|
|
terminalReason,
|
|
});
|
|
}
|
|
toolTracker.active.clear();
|
|
toolTracker.terminalToolCallIds.clear();
|
|
toolTracker.saturated = false;
|
|
}
|
|
|
|
function resolvePresentProxyEnvKeys(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
return ACP_PROXY_ENV_KEYS.filter((key) => {
|
|
const value = env[key];
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
});
|
|
}
|
|
|
|
function sanitizeAcpDiagnosticText(value: string): string {
|
|
return truncateUtf16Safe(redactSensitiveText(value).replace(/\s+/g, " ").trim(), 240);
|
|
}
|
|
|
|
function acpRuntimeEventDiagnostics(event: AcpRuntimeEvent): Record<string, unknown> {
|
|
if (event.type === "status") {
|
|
return {
|
|
eventType: event.type,
|
|
text: sanitizeAcpDiagnosticText(event.text),
|
|
...(event.tag ? { tag: event.tag } : {}),
|
|
};
|
|
}
|
|
if (event.type === "tool_call") {
|
|
return {
|
|
eventType: event.type,
|
|
text: sanitizeAcpDiagnosticText(event.text),
|
|
...(event.tag ? { tag: event.tag } : {}),
|
|
...(event.status ? { status: sanitizeAcpDiagnosticText(event.status) } : {}),
|
|
...(event.title ? { title: sanitizeAcpDiagnosticText(event.title) } : {}),
|
|
...(event.toolCallId ? { toolCallId: sanitizeAcpDiagnosticText(event.toolCallId) } : {}),
|
|
};
|
|
}
|
|
if (event.type === "error") {
|
|
return {
|
|
eventType: event.type,
|
|
message: sanitizeAcpDiagnosticText(event.message),
|
|
...(event.code ? { code: sanitizeAcpDiagnosticText(event.code) } : {}),
|
|
...(typeof event.retryable === "boolean" ? { retryable: event.retryable } : {}),
|
|
};
|
|
}
|
|
if (event.type === "done") {
|
|
return {
|
|
eventType: event.type,
|
|
...(event.status ? { status: event.status } : {}),
|
|
...(event.stopReason ? { stopReason: sanitizeAcpDiagnosticText(event.stopReason) } : {}),
|
|
};
|
|
}
|
|
return {
|
|
eventType: event.type,
|
|
stream: event.stream ?? "output",
|
|
};
|
|
}
|
|
|
|
export function emitAcpPromptSubmitted(params: { runId: string; sessionKey?: string; at: number }) {
|
|
emitAgentEvent({
|
|
runId: params.runId,
|
|
stream: "acp",
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
data: {
|
|
phase: "prompt_submitted",
|
|
at: params.at,
|
|
proxyEnvKeys: resolvePresentProxyEnvKeys(),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function emitAcpRuntimeEvent(params: {
|
|
runId: string;
|
|
toolTracker: AcpToolLifecycleTracker;
|
|
event: AcpRuntimeEvent;
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
abortSignal?: AbortSignal;
|
|
auditOnly?: boolean;
|
|
}) {
|
|
if (params.event.type === "tool_call") {
|
|
emitAcpToolExecutionEvent({
|
|
runId: params.runId,
|
|
toolTracker: params.toolTracker,
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
...(params.abortSignal ? { abortSignal: params.abortSignal } : {}),
|
|
event: params.event,
|
|
});
|
|
}
|
|
if (!params.auditOnly) {
|
|
emitAgentEvent({
|
|
runId: params.runId,
|
|
stream: "acp",
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
data: {
|
|
phase: "runtime_event",
|
|
...acpRuntimeEventDiagnostics(params.event),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export function emitAcpLifecycleEnd(params: {
|
|
runId: string;
|
|
toolTracker: AcpToolLifecycleTracker;
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
lifecycleGeneration?: string;
|
|
abortSignal?: AbortSignal;
|
|
stopReason?: string;
|
|
resultStatus?: Extract<AcpRuntimeEvent, { type: "done" }>["status"];
|
|
auditOnly?: boolean;
|
|
}) {
|
|
finalizeAcpToolsForRun(
|
|
params.toolTracker,
|
|
params.runId,
|
|
resolveAcpToolTerminalReason(
|
|
params.abortSignal,
|
|
params.stopReason,
|
|
undefined,
|
|
params.resultStatus,
|
|
),
|
|
);
|
|
const emit = params.auditOnly ? emitAgentAuditEvent : emitAgentEvent;
|
|
emit({
|
|
runId: params.runId,
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
|
|
stream: "lifecycle",
|
|
data: {
|
|
phase: "end",
|
|
endedAt: Date.now(),
|
|
...resolveAcpLifecycleEndFields(params.abortSignal, params.stopReason, params.resultStatus),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function emitAcpLifecycleError(params: {
|
|
runId: string;
|
|
toolTracker: AcpToolLifecycleTracker;
|
|
error: unknown;
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
lifecycleGeneration?: string;
|
|
abortSignal?: AbortSignal;
|
|
terminalOutcome?: "blocked";
|
|
auditOnly?: boolean;
|
|
}) {
|
|
const terminalReason = resolveAcpToolTerminalReason(params.abortSignal, undefined, params.error);
|
|
finalizeAcpToolsForRun(params.toolTracker, params.runId, terminalReason);
|
|
const lifecycleFields =
|
|
params.terminalOutcome === "blocked"
|
|
? ({ livenessState: "blocked" } as const)
|
|
: terminalReason === "timed_out"
|
|
? ({ aborted: true, stopReason: "timeout", status: "timed_out" } as const)
|
|
: resolveAgentRunAbortLifecycleFields(params.abortSignal);
|
|
const emit = params.auditOnly ? emitAgentAuditEvent : emitAgentEvent;
|
|
emit({
|
|
runId: params.runId,
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
|
|
stream: "lifecycle",
|
|
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
|
data: {
|
|
phase: "error",
|
|
...(!params.auditOnly ? { error: formatAcpErrorChain(params.error) } : {}),
|
|
endedAt: Date.now(),
|
|
...lifecycleFields,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function emitAcpAssistantDelta(params: { runId: string; text: string; delta: string }) {
|
|
emitAgentEvent({
|
|
runId: params.runId,
|
|
stream: "assistant",
|
|
data: {
|
|
text: params.text,
|
|
delta: params.delta,
|
|
},
|
|
});
|
|
}
|