/** Agent-runner execution loop, fallback handling, and user-facing failure mapping. */
import crypto from "node:crypto";
import {
  hasNonEmptyString,
  normalizeLowercaseStringOrEmpty,
  normalizeOptionalString,
  readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import {
  hasOutboundReplyContent,
  resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
import {
  clearAutoFallbackPrimaryProbeSelection,
  entryMatchesAutoFallbackPrimaryProbe,
  hasSessionAutoModelFallbackProvenance,
  markAutoFallbackPrimaryProbe,
  resolveAutoFallbackPrimaryProbe,
} from "../../agents/agent-scope.js";
import { formatAuthProfileFailureMessage } from "../../agents/auth-profiles/failure-copy.js";
import {
  buildOAuthRefreshFailureLoginCommand,
  classifyOAuthRefreshFailure,
  classifyOAuthRefreshFailureError,
} from "../../agents/auth-profiles/oauth-refresh-failure.js";
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
import { getCliSessionBinding } from "../../agents/cli-session.js";
import { resolveContextTokensForModel } from "../../agents/context.js";
import {
  BILLING_ERROR_USER_MESSAGE,
  formatBillingErrorMessage,
  formatRateLimitOrOverloadedErrorCopy,
  isCompactionFailureError,
  isContextOverflowError,
  isBillingErrorMessage,
  isLikelyContextOverflowError,
  isOverloadedErrorMessage,
  isRateLimitErrorMessage,
  isTransientHttpError,
} from "../../agents/embedded-agent-helpers.js";
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js";
import { mergeEmbeddedAgentRunResultForModelFallbackExhaustion } from "../../agents/embedded-agent-runner/result-fallback-classifier.js";
import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js";
import { runEmbeddedAgent } from "../../agents/embedded-agent.js";
import { isFailoverError } from "../../agents/failover-error.js";
import type { FastModeAutoProgressState } from "../../agents/fast-mode.js";
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
import { isMissingProviderAuthError } from "../../agents/model-auth.js";
import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js";
import {
  isCliRuntimeAliasForProvider,
  resolveCliRuntimeExecutionProvider,
} from "../../agents/model-runtime-aliases.js";
import {
  isCliProvider,
  resolveModelRefFromString,
  resolvePersistedOverrideModelRef,
} from "../../agents/model-selection.js";
import { resolveOpenAIRuntimeProvider } from "../../agents/openai-routing.js";
import {
  AGENT_RUN_RESTART_ABORT_STOP_REASON,
  createAgentRunRestartAbortError,
  isAgentRunRestartAbortReason,
  resolveAgentRunAbortLifecycleFields,
} from "../../agents/run-termination.js";
import { buildAgentRuntimeOutcomePlan } from "../../agents/runtime-plan/build.js";
import { resolveGroupSessionKey, type SessionEntry } from "../../config/sessions.js";
import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
import { resolveSilentReplyPolicy } from "../../config/silent-reply.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import {
  captureAgentRunLifecycleGeneration,
  clearAgentRunContext,
  emitAgentEvent,
  registerAgentRunContext,
} from "../../infra/agent-events.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { logSessionTurnCreated } from "../../logging/diagnostic.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
import { CommandLane } from "../../process/lanes.js";
import { defaultRuntime } from "../../runtime.js";
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
import {
  isMarkdownCapableMessageChannel,
  resolveMessageChannel,
} from "../../utils/message-channel.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
import type { TemplateContext } from "../templating.js";
import type { VerboseLevel } from "../thinking.js";
import {
  HEARTBEAT_TOKEN,
  isSilentReplyPrefixText,
  isSilentReplyText,
  SILENT_REPLY_TOKEN,
  startsWithSilentToken,
  stripLeadingSilentToken,
} from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import {
  createAgentLifecycleTerminalBackstop,
  resolveAgentLifecycleTerminalMetadata,
  type AgentLifecycleTerminalBackstop,
} from "./agent-lifecycle-terminal.js";
import { resolveRunAuthProfile } from "./agent-runner-auth-profile.js";
import {
  clearDroppedCliSessionBinding,
  createCliToolSummaryTracker,
  keepCliSessionBindingOnlyWhenReused,
  runCliAgentWithLifecycle,
} from "./agent-runner-cli-dispatch.js";
import {
  GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
  HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
} from "./agent-runner-failure-copy.js";
import {
  buildEmbeddedRunExecutionParams,
  resolveQueuedReplyRuntimeConfig,
  resolveModelFallbackOptions,
  resolveRunFastModeForFallbackCandidate,
} from "./agent-runner-utils.js";
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
import {
  createCompactionHookNoticePayload,
  createCompactionNoticePayload,
  readCompactionHookMessages,
  shouldNotifyUserAboutCompaction,
} from "./compaction-notice.js";
import { resolveCurrentTurnImages } from "./current-turn-images.js";
import { hasInboundAudio } from "./inbound-media.js";
import { resolveOriginMessageProvider } from "./origin-routing.js";
import {
  classifyProviderRequestError,
  PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
} from "./provider-request-error-classifier.js";
import type { FollowupRun } from "./queue.js";
import { createBlockReplyDeliveryHandler } from "./reply-delivery.js";
import type { ReplyMediaContext } from "./reply-media-paths.js";
import { createReplyMediaContext } from "./reply-media-paths.runtime.js";
import type { ReplyOperation } from "./reply-run-registry.js";
import { isReplyProfilerEnabled } from "./reply-timing-tracker.js";
import type { TypingSignaler } from "./typing-mode.js";

// Maximum number of LiveSessionModelSwitchError retries before surfacing a
// user-visible error. Prevents infinite ping-pong when the persisted session
// selection keeps conflicting with fallback model choices.
// See: https://github.com/openclaw/openclaw/issues/58348
export const MAX_LIVE_SWITCH_RETRIES = 2;

type AgentTurnTimingSpan = {
  name: string;
  durationMs: number;
  elapsedMs: number;
};

type AgentTurnTimingSummary = {
  totalMs: number;
  spans: AgentTurnTimingSpan[];
};

const agentTurnTimingLog = createSubsystemLogger("auto-reply/agent-turn-timing");
const agentCompactionLog = createSubsystemLogger("auto-reply/compaction");
const CODEX_APP_SERVER_COMPACTION_BACKEND = "codex-app-server";
const AGENT_TURN_TIMING_WARN_TOTAL_MS = 1_000;
const AGENT_TURN_TIMING_WARN_STAGE_MS = 500;

function formatCompactionModelRef(provider?: string, model?: string): string {
  const normalizedProvider = normalizeOptionalString(provider);
  const normalizedModel = normalizeOptionalString(model);
  if (normalizedProvider && normalizedModel) {
    return `${sanitizeForLog(normalizedProvider)}/${sanitizeForLog(normalizedModel)}`;
  }
  if (normalizedProvider) {
    return sanitizeForLog(normalizedProvider);
  }
  if (normalizedModel) {
    return sanitizeForLog(normalizedModel);
  }
  return "unknown model";
}

function createAgentTurnTimingTracker(options: { profilerEnabled?: boolean } = {}): {
  measure: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
  measureSync: <T>(name: string, run: () => T) => T;
  logIfSlow: (params: {
    runId: string;
    sessionId?: string;
    sessionKey?: string;
    outcome: "completed" | "error";
    error?: string;
  }) => void;
  logMilestoneIfSlow: (params: {
    runId: string;
    sessionId?: string;
    sessionKey?: string;
    milestone: string;
  }) => void;
} {
  if (!options.profilerEnabled) {
    // This tracker wraps the agent-turn hot path. Without an explicit profiler
    // flag, keep every wrapper pass-through so normal turns avoid Date.now and
    // span-array work entirely.
    return {
      async measure(_name, run) {
        return await run();
      },
      measureSync(_name, run) {
        return run();
      },
      logIfSlow() {},
      logMilestoneIfSlow() {},
    };
  }

  const startedAt = Date.now();
  let didLog = false;
  const spans: AgentTurnTimingSpan[] = [];
  const toMs = (value: number) => Math.max(0, Math.round(value));
  const record = (name: string, spanStartedAt: number) => {
    spans.push({
      name,
      durationMs: toMs(Date.now() - spanStartedAt),
      elapsedMs: toMs(Date.now() - startedAt),
    });
  };
  const snapshot = (): AgentTurnTimingSummary => ({
    totalMs: toMs(Date.now() - startedAt),
    spans: spans.slice(),
  });
  const shouldLog = (summary: AgentTurnTimingSummary) =>
    summary.totalMs >= AGENT_TURN_TIMING_WARN_TOTAL_MS ||
    summary.spans.some((span) => span.durationMs >= AGENT_TURN_TIMING_WARN_STAGE_MS);
  const formatSpans = (summary: AgentTurnTimingSummary) =>
    summary.spans.length > 0
      ? summary.spans
          .map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
          .join(",")
      : "none";
  return {
    async measure(name, run) {
      const spanStartedAt = Date.now();
      try {
        return await run();
      } finally {
        record(name, spanStartedAt);
      }
    },
    measureSync(name, run) {
      const spanStartedAt = Date.now();
      try {
        return run();
      } finally {
        record(name, spanStartedAt);
      }
    },
    logIfSlow(params) {
      if (didLog) {
        return;
      }
      const summary = snapshot();
      if (!shouldLog(summary)) {
        return;
      }
      didLog = true;
      agentTurnTimingLog.warn(
        `agent turn timings runId=${params.runId} sessionId=${
          params.sessionId ?? "unknown"
        } sessionKey=${params.sessionKey ?? "unknown"} outcome=${params.outcome} totalMs=${
          summary.totalMs
        } stages=${formatSpans(summary)}${params.error ? ` error="${params.error}"` : ""}`,
        {
          runId: params.runId,
          sessionId: params.sessionId,
          sessionKey: params.sessionKey,
          outcome: params.outcome,
          error: params.error,
          totalMs: summary.totalMs,
          spans: summary.spans,
        },
      );
    },
    logMilestoneIfSlow(params) {
      if (!options.profilerEnabled) {
        return;
      }
      const summary = snapshot();
      if (!shouldLog(summary)) {
        return;
      }
      agentTurnTimingLog.warn(
        `agent turn milestone runId=${params.runId} sessionId=${
          params.sessionId ?? "unknown"
        } sessionKey=${params.sessionKey ?? "unknown"} milestone=${params.milestone} totalMs=${
          summary.totalMs
        } stages=${formatSpans(summary)}`,
        {
          runId: params.runId,
          sessionId: params.sessionId,
          sessionKey: params.sessionKey,
          milestone: params.milestone,
          totalMs: summary.totalMs,
          spans: summary.spans,
        },
      );
    },
  };
}

function readApprovalScopeValue(value: unknown): "turn" | "session" | undefined {
  return value === "turn" || value === "session" ? value : undefined;
}

function readRecordValue(value: unknown): Record<string, unknown> | undefined {
  return value && typeof value === "object" && !Array.isArray(value)
    ? (value as Record<string, unknown>)
    : undefined;
}

function readFiniteNumberValue(value: unknown): number | undefined {
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

function readNullableNumberValue(value: unknown): number | null | undefined {
  if (value === null) {
    return null;
  }
  return readFiniteNumberValue(value);
}

function isCommandToolName(name: string | undefined): boolean {
  const normalized = normalizeLowercaseStringOrEmpty(name);
  return normalized === "exec" || normalized === "bash" || normalized === "shell";
}

export function buildCommandOutputFromToolResultEvent(evt: {
  stream: string;
  data: Record<string, unknown>;
}): Parameters<NonNullable<GetReplyOptions["onCommandOutput"]>>[0] | undefined {
  if (evt.stream !== "tool" || readStringValue(evt.data.phase) !== "result") {
    return undefined;
  }
  const name = readStringValue(evt.data.name);
  if (!isCommandToolName(name)) {
    return undefined;
  }
  const result = readRecordValue(evt.data.result);
  const details = readRecordValue(result?.details);
  const output =
    readStringValue(evt.data.output) ??
    readStringValue(result?.output) ??
    readStringValue(details?.output);
  const explicitStatus =
    readStringValue(evt.data.status) ??
    readStringValue(result?.status) ??
    readStringValue(details?.status);
  const exitCode = readNullableNumberValue(
    result?.exitCode ?? details?.exitCode ?? evt.data.exitCode,
  );
  const durationMs = readFiniteNumberValue(
    result?.durationMs ?? details?.durationMs ?? evt.data.durationMs,
  );
  const cwd = readStringValue(evt.data.cwd);
  const hasConcreteCommandResult =
    output !== undefined ||
    explicitStatus !== undefined ||
    exitCode !== undefined ||
    durationMs !== undefined ||
    cwd !== undefined ||
    (result !== undefined && Object.keys(result).length > 0);
  if (!hasConcreteCommandResult) {
    return undefined;
  }
  const errorStatus =
    evt.data.isError === true ? "failed" : evt.data.isError === false ? "completed" : undefined;
  const status = explicitStatus ?? errorStatus;
  return {
    itemId: readStringValue(evt.data.itemId),
    phase: "end",
    title: readStringValue(evt.data.title),
    toolCallId: readStringValue(evt.data.toolCallId),
    name,
    output,
    status,
    exitCode,
    durationMs,
    cwd,
  };
}

/** One attempted runtime fallback candidate and its failure reason. */
export type RuntimeFallbackAttempt = {
  provider: string;
  model: string;
  error: string;
  reason?: string;
  status?: number;
  code?: string;
};

/** Result of running an agent turn through fallback/retry handling. */
export type AgentRunLoopResult =
  | {
      kind: "success";
      runId: string;
      runResult: Awaited<ReturnType<typeof runEmbeddedAgent>>;
      fallbackProvider?: string;
      fallbackModel?: string;
      fallbackExhausted?: true;
      fallbackAttempts: RuntimeFallbackAttempt[];
      didLogHeartbeatStrip: boolean;
      autoCompactionCount: number;
      /** Payload keys sent directly (not via pipeline) during tool flush. */
      directlySentBlockKeys?: Set<string>;
      /** Payloads successfully sent directly during tool flush. */
      directlySentBlockPayloads?: ReplyPayload[];
    }
  | { kind: "final"; payload: ReplyPayload };

type EmbeddedAgentRunResult = Awaited<ReturnType<typeof runEmbeddedAgent>>;

type FallbackSelectionState = Pick<
  SessionEntry,
  | "providerOverride"
  | "modelOverride"
  | "modelOverrideSource"
  | "modelOverrideFallbackOriginProvider"
  | "modelOverrideFallbackOriginModel"
  | "authProfileOverride"
  | "authProfileOverrideSource"
  | "authProfileOverrideCompactionCount"
>;

const FALLBACK_SELECTION_STATE_KEYS = [
  "providerOverride",
  "modelOverride",
  "modelOverrideSource",
  "modelOverrideFallbackOriginProvider",
  "modelOverrideFallbackOriginModel",
  "authProfileOverride",
  "authProfileOverrideSource",
  "authProfileOverrideCompactionCount",
] as const satisfies ReadonlyArray<keyof FallbackSelectionState>;

function setFallbackSelectionStateField(
  entry: SessionEntry,
  key: keyof FallbackSelectionState,
  value: FallbackSelectionState[keyof FallbackSelectionState],
): boolean {
  switch (key) {
    case "providerOverride":
      if (entry.providerOverride !== value) {
        entry.providerOverride = value as SessionEntry["providerOverride"];
        return true;
      }
      return false;
    case "modelOverride":
      if (entry.modelOverride !== value) {
        entry.modelOverride = value as SessionEntry["modelOverride"];
        return true;
      }
      return false;
    case "modelOverrideSource":
      if (entry.modelOverrideSource !== value) {
        entry.modelOverrideSource = value as SessionEntry["modelOverrideSource"];
        return true;
      }
      return false;
    case "modelOverrideFallbackOriginProvider":
      if (entry.modelOverrideFallbackOriginProvider !== value) {
        entry.modelOverrideFallbackOriginProvider =
          value as SessionEntry["modelOverrideFallbackOriginProvider"];
        return true;
      }
      return false;
    case "modelOverrideFallbackOriginModel":
      if (entry.modelOverrideFallbackOriginModel !== value) {
        entry.modelOverrideFallbackOriginModel =
          value as SessionEntry["modelOverrideFallbackOriginModel"];
        return true;
      }
      return false;
    case "authProfileOverride":
      if (entry.authProfileOverride !== value) {
        entry.authProfileOverride = value as SessionEntry["authProfileOverride"];
        return true;
      }
      return false;
    case "authProfileOverrideSource":
      if (entry.authProfileOverrideSource !== value) {
        entry.authProfileOverrideSource = value as SessionEntry["authProfileOverrideSource"];
        return true;
      }
      return false;
    case "authProfileOverrideCompactionCount":
      if (entry.authProfileOverrideCompactionCount !== value) {
        entry.authProfileOverrideCompactionCount =
          value as SessionEntry["authProfileOverrideCompactionCount"];
        return true;
      }
      return false;
  }
  throw new Error("Unsupported fallback selection state key");
}

function snapshotFallbackSelectionState(entry: SessionEntry): FallbackSelectionState {
  return {
    providerOverride: entry.providerOverride,
    modelOverride: entry.modelOverride,
    modelOverrideSource: entry.modelOverrideSource,
    modelOverrideFallbackOriginProvider: entry.modelOverrideFallbackOriginProvider,
    modelOverrideFallbackOriginModel: entry.modelOverrideFallbackOriginModel,
    authProfileOverride: entry.authProfileOverride,
    authProfileOverrideSource: entry.authProfileOverrideSource,
    authProfileOverrideCompactionCount: entry.authProfileOverrideCompactionCount,
  };
}

function buildFallbackSelectionStatePatch(entry: SessionEntry): Partial<SessionEntry> {
  return {
    ...snapshotFallbackSelectionState(entry),
    updatedAt: entry.updatedAt,
  };
}

function buildFallbackSelectionState(params: {
  provider: string;
  model: string;
  originProvider: string;
  originModel: string;
  authProfileId?: string;
  authProfileIdSource?: "auto" | "user";
}): FallbackSelectionState {
  return {
    providerOverride: params.provider,
    modelOverride: params.model,
    modelOverrideSource: "auto",
    modelOverrideFallbackOriginProvider: params.originProvider,
    modelOverrideFallbackOriginModel: params.originModel,
    authProfileOverride: params.authProfileId,
    authProfileOverrideSource: params.authProfileId ? params.authProfileIdSource : undefined,
    authProfileOverrideCompactionCount: undefined,
  };
}

function resolveFallbackSelectionOrigin(params: { entry: SessionEntry; run: FollowupRun["run"] }): {
  provider: string;
  model: string;
} {
  if (
    params.entry.modelOverrideSource === "auto" ||
    (params.entry.modelOverrideSource === undefined &&
      hasSessionAutoModelFallbackProvenance(params.entry))
  ) {
    const persistedOriginProvider = normalizeOptionalString(
      params.entry.modelOverrideFallbackOriginProvider,
    );
    const persistedOriginModel = normalizeOptionalString(
      params.entry.modelOverrideFallbackOriginModel,
    );
    if (persistedOriginProvider && persistedOriginModel) {
      return { provider: persistedOriginProvider, model: persistedOriginModel };
    }
  }
  return { provider: params.run.provider, model: params.run.model };
}

/** Persists the fallback candidate selection onto a session entry. */
export function applyFallbackCandidateSelectionToEntry(params: {
  entry: SessionEntry;
  run: FollowupRun["run"];
  provider: string;
  model: string;
  origin?: { provider: string; model: string };
  force?: boolean;
  now?: number;
}): { updated: boolean; nextState?: FallbackSelectionState } {
  if (
    !params.force &&
    params.provider === params.run.provider &&
    params.model === params.run.model
  ) {
    return { updated: false };
  }
  const scopedAuthProfile = resolveRunAuthProfile(params.run, params.provider);
  const origin =
    params.origin ?? resolveFallbackSelectionOrigin({ entry: params.entry, run: params.run });
  const nextState = buildFallbackSelectionState({
    provider: params.provider,
    model: params.model,
    originProvider: origin.provider,
    originModel: origin.model,
    authProfileId: scopedAuthProfile.authProfileId,
    authProfileIdSource: scopedAuthProfile.authProfileIdSource,
  });
  return {
    updated: applyFallbackSelectionState(params.entry, nextState, params.now),
    nextState,
  };
}

function applyFallbackSelectionState(
  entry: SessionEntry,
  nextState: FallbackSelectionState,
  now = Date.now(),
): boolean {
  let updated = false;
  for (const key of FALLBACK_SELECTION_STATE_KEYS) {
    const nextValue = nextState[key];
    if (nextValue === undefined) {
      if (Object.hasOwn(entry, key)) {
        delete entry[key];
        updated = true;
      }
      continue;
    }
    if (entry[key] !== nextValue) {
      updated = setFallbackSelectionStateField(entry, key, nextValue) || updated;
    }
  }
  if (updated) {
    entry.updatedAt = now;
  }
  return updated;
}

function rollbackFallbackSelectionStateIfUnchanged(
  entry: SessionEntry,
  expectedState: FallbackSelectionState,
  previousState: FallbackSelectionState,
  now = Date.now(),
): boolean {
  let updated = false;
  for (const key of FALLBACK_SELECTION_STATE_KEYS) {
    if (entry[key] !== expectedState[key]) {
      continue;
    }
    const previousValue = previousState[key];
    if (previousValue === undefined) {
      if (Object.hasOwn(entry, key)) {
        delete entry[key];
        updated = true;
      }
      continue;
    }
    if (entry[key] !== previousValue) {
      updated = setFallbackSelectionStateField(entry, key, previousValue) || updated;
    }
  }
  if (updated) {
    entry.updatedAt = now;
  }
  return updated;
}

/**
 * Build a human-friendly rate-limit message from a FallbackSummaryError.
 * Includes a countdown when the soonest cooldown expiry is known.
 */
function buildRateLimitCooldownMessage(err: unknown): string {
  const codexUsageLimitMessage = extractCodexUsageLimitErrorMessage(err);
  if (codexUsageLimitMessage) {
    return codexUsageLimitMessage;
  }
  if (isFallbackSummaryError(err) && hasBillingAttemptSummary(err)) {
    return BILLING_ERROR_USER_MESSAGE;
  }
  const message = formatErrorMessage(err);
  if (isBillingErrorMessage(message)) {
    return BILLING_ERROR_USER_MESSAGE;
  }
  if (!isFallbackSummaryError(err)) {
    return "⚠️ All models are temporarily rate-limited. Please try again in a few minutes.";
  }
  const expiry = err.soonestCooldownExpiry;
  const now = Date.now();
  if (typeof expiry === "number" && expiry > now) {
    const secsLeft = Math.max(1, Math.ceil((expiry - now) / 1000));
    if (secsLeft <= 60) {
      return `⚠️ Rate-limited — ready in ~${secsLeft}s. Please wait a moment.`;
    }
    const minsLeft = Math.ceil(secsLeft / 60);
    return `⚠️ Rate-limited — ready in ~${minsLeft} min. Please try again shortly.`;
  }
  return "⚠️ All models are temporarily rate-limited. Please try again in a few minutes.";
}

function resolveBillingFailureReplyText(err: unknown): string {
  const billingFailure = isFallbackSummaryError(err)
    ? err.attempts.find(
        (attempt) =>
          attempt.reason === "billing" &&
          (attempt.authMode === "oauth" || attempt.authMode === "token"),
      )
    : isFailoverError(err) && err.reason === "billing"
      ? err
      : undefined;
  if (
    !billingFailure ||
    (billingFailure.authMode !== "oauth" && billingFailure.authMode !== "token")
  ) {
    return BILLING_ERROR_USER_MESSAGE;
  }
  return formatBillingErrorMessage(
    billingFailure.provider,
    billingFailure.model,
    billingFailure.authMode,
  );
}

function extractCodexUsageLimitErrorMessage(err: unknown): string | undefined {
  if (isFallbackSummaryError(err)) {
    for (const attempt of err.attempts) {
      const message = extractCodexUsageLimitMessage(attempt.error);
      if (message) {
        return `⚠️ ${message}`;
      }
    }
    return undefined;
  }
  const message = extractCodexUsageLimitMessage(formatErrorMessage(err));
  return message ? `⚠️ ${message}` : undefined;
}

function extractCodexUsageLimitMessage(text: string): string | undefined {
  const markers = [
    "You've reached your Codex subscription usage limit.",
    "Codex usage limit reached.",
  ];
  let markerIndex: number | undefined;
  for (const marker of markers) {
    const index = text.indexOf(marker);
    if (index >= 0 && (markerIndex === undefined || index < markerIndex)) {
      markerIndex = index;
    }
  }
  if (markerIndex === undefined) {
    return undefined;
  }
  const message = sanitizeUserFacingText(text.slice(markerIndex), { errorContext: true })
    .split(/\r?\n/u)
    .map((line) => line.trim())
    .filter(Boolean)
    .join(" ")
    .trim();
  if (!message) {
    return undefined;
  }
  return message.length > 500 ? `${message.slice(0, 497)}...` : message;
}

function isPureTransientRateLimitSummary(err: unknown): boolean {
  return (
    isFallbackSummaryError(err) &&
    err.attempts.length > 0 &&
    err.attempts.every((attempt) => {
      const reason = attempt.reason;
      return reason === "rate_limit" || reason === "overloaded";
    })
  );
}

function hasBillingAttemptSummary(err: unknown): boolean {
  return (
    isFallbackSummaryError(err) &&
    err.attempts.length > 0 &&
    err.attempts.some((attempt) => attempt.reason === "billing")
  );
}

function collapseRepeatedFailureDetail(message: string): string {
  const parts = message
    .split(/\s+\|\s+/u)
    .map((part) => part.trim())
    .filter(Boolean);
  if (parts.length >= 2 && parts.every((part) => part === parts[0])) {
    return parts[0];
  }
  return message.trim();
}

const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]);
const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900;
const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:";
const PREFLIGHT_COMPACTION_FAILURE_PREFIX = "Preflight compaction required but failed:";

type ExternalRunFailureReply = {
  text: string;
  isGenericRunnerFailure: boolean;
};

type ExternalRunFailureInput = string | { message: string; error?: unknown };

function isNonDirectConversationContext(ctx: TemplateContext): boolean {
  const chatType = normalizeLowercaseStringOrEmpty(ctx.ChatType);
  return chatType === "group" || chatType === "channel";
}

function isVerboseFailureDetailEnabled(level: VerboseLevel | undefined): boolean {
  return level === "on" || level === "full";
}

function resolveExternalRunFailureTextForConversation(params: {
  text: string;
  sessionCtx: TemplateContext;
  isGenericRunnerFailure: boolean;
  cfg?: OpenClawConfig;
}): string {
  if (!isNonDirectConversationContext(params.sessionCtx)) {
    return params.text;
  }
  if (!params.isGenericRunnerFailure && !params.text.includes(AGENT_FAILED_BEFORE_REPLY_TEXT)) {
    return params.text;
  }
  // Match normal reply routing: default group/channel failures stay silent,
  // while explicit default or per-surface policy can surface the failure copy.
  const silentPolicy = resolveSilentReplyPolicy({
    cfg: params.cfg,
    sessionKey: params.sessionCtx.SessionKey,
    surface: params.sessionCtx.Surface ?? params.sessionCtx.Provider,
    conversationType: "group",
  });
  if (silentPolicy === "disallow") {
    return params.text;
  }
  return SILENT_REPLY_TOKEN;
}

const CLI_BACKEND_NO_OUTPUT_STALL_RE =
  /\bCLI produced no output for\s+(\d+)\s*s\s+and was terminated\b/iu;
const CLI_BACKEND_OVERALL_TIMEOUT_RE =
  /\bCLI exceeded timeout\s*\(\s*(\d+)\s*s\s*\)\s+and was terminated\b/iu;
const CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE = /\b([\w.-]+\/[A-Za-z][\w.-]*)\s*:\s*CLI\b/iu;
const CODEX_APP_SERVER_CLIENT_CLOSED_BEFORE_REPLY_RE =
  /\bcodex app-server client closed before turn completed\b/iu;
const CODEX_APP_SERVER_TURN_COMPLETION_IDLE_TIMEOUT_RE =
  /\bcodex app-server turn idle timed out waiting for turn\/completed\b/iu;

function buildCodexAppServerFailureText(message: string): string | null {
  const normalizedMessage = collapseRepeatedFailureDetail(message);
  if (CODEX_APP_SERVER_CLIENT_CLOSED_BEFORE_REPLY_RE.test(normalizedMessage)) {
    return "⚠️ Codex app-server connection closed before this turn finished. OpenClaw retried once when the stdio turn was still replay-safe; please try again if this keeps happening.";
  }
  if (CODEX_APP_SERVER_TURN_COMPLETION_IDLE_TIMEOUT_RE.test(normalizedMessage)) {
    return "⚠️ Codex app-server stopped before confirming turn completion. OpenClaw did not replay the turn automatically because it may still be active; try again, or use /new if the session stays stuck.";
  }
  return null;
}

/** Formats the reply shown when preflight compaction fails before a run. */
export function buildPreflightCompactionFailureText(
  message: string,
  options?: { includeDetails?: boolean },
): string | null {
  const normalizedMessage = collapseRepeatedFailureDetail(message);
  if (!normalizedMessage.startsWith(PREFLIGHT_COMPACTION_FAILURE_PREFIX)) {
    return null;
  }
  const reason = sanitizeUserFacingText(
    normalizedMessage.slice(PREFLIGHT_COMPACTION_FAILURE_PREFIX.length),
    { errorContext: true },
  )
    .trim()
    .replace(/\s+/gu, " ");
  const reasonSuffix = options?.includeDetails && reason ? ` Reason: ${reason}.` : "";
  return (
    "⚠️ Context is too large and auto-compaction could not recover this turn." +
    `${reasonSuffix} Try again, use /compact, or use /new to start a fresh session.`
  );
}

function buildCliBackendTimeoutFailureText(message: string): string | null {
  const normalizedMessage = collapseRepeatedFailureDetail(message);
  const stall = normalizedMessage.match(CLI_BACKEND_NO_OUTPUT_STALL_RE);
  const overall = normalizedMessage.match(CLI_BACKEND_OVERALL_TIMEOUT_RE);
  const timeout = stall ?? overall;
  const seconds = timeout?.[1];
  if (!seconds) {
    return null;
  }
  const routedModelRef = normalizedMessage.match(CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE)?.[1];
  const routingSuffix = routedModelRef ? ` (routing ${routedModelRef})` : "";
  const modeLabel = stall ? "no-output stall" : "overall CLI turn budget";
  return (
    `⚠️ CLI subprocess${routingSuffix}: timed out after ${seconds}s (${modeLabel}). The gateway may still be healthy. Try \`/new\`, a lighter model, or raise ` +
    "`agents.defaults.timeoutSeconds` and the watchdog `noOutputTimeoutMs` entries under `cliBackends.<your-runtime>`."
  );
}

function buildMissingApiKeyFailureText(input: { message: string; error?: unknown }): string | null {
  const normalizedMessage = collapseRepeatedFailureDetail(input.message);
  const provider = isMissingProviderAuthError(input.error)
    ? input.error.provider.trim().toLowerCase()
    : normalizedMessage
        .match(/No API key found for provider "([^"]+)"/u)?.[1]
        ?.trim()
        .toLowerCase();
  if (!provider) {
    return null;
  }
  if (provider === "openai" && normalizedMessage.includes("OpenAI Codex OAuth")) {
    return "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.5` with the OpenAI OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs.";
  }
  if (provider === "openai") {
    return '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.';
  }
  if (SAFE_MISSING_API_KEY_PROVIDERS.has(provider)) {
    return `⚠️ Missing API key for provider "${provider}". Configure the gateway auth for that provider, then try again.`;
  }
  return "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again.";
}

function buildAuthProfileFailoverFailureText(error: unknown): string | null {
  if (!isFailoverError(error) || !error.provider || !error.authProfileFailure) {
    return null;
  }
  return formatAuthProfileFailureMessage({
    reason: error.reason,
    provider: error.provider,
    allInCooldown: error.authProfileFailure.allInCooldown,
    cause: error.cause,
  });
}

function formatForwardedExternalRunFailureText(message: string): string {
  const sanitized = sanitizeUserFacingText(message, { errorContext: true })
    .trim()
    .replace(/^⚠️\s*/u, "")
    .replace(/\s+/gu, " ");
  if (!sanitized) {
    return GENERIC_EXTERNAL_RUN_FAILURE_TEXT;
  }
  const detail =
    sanitized.length > EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS
      ? `${sanitized.slice(0, EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS - 1).trimEnd()}…`
      : sanitized;
  const suffix = /[.!?]$/u.test(detail) ? "" : ".";
  return `⚠️ Agent failed before reply: ${detail}${suffix} Please try again, or use /new to start a fresh session.`;
}

function buildExternalRunFailureReply(
  input: ExternalRunFailureInput,
  options?: { includeDetails?: boolean; isHeartbeat?: boolean },
): ExternalRunFailureReply {
  const message = typeof input === "string" ? input : input.message;
  const error = typeof input === "string" ? undefined : input.error;
  const normalizedMessage = collapseRepeatedFailureDetail(message);
  const authProfileFailoverFailure = buildAuthProfileFailoverFailureText(error);
  if (authProfileFailoverFailure) {
    return { text: authProfileFailoverFailure, isGenericRunnerFailure: false };
  }
  const providerRequestError = classifyProviderRequestError(error ?? normalizedMessage);
  if (providerRequestError) {
    return {
      text: providerRequestError.userMessage,
      isGenericRunnerFailure: false,
    };
  }
  const missingApiKeyFailure = buildMissingApiKeyFailureText({
    message: normalizedMessage,
    error,
  });
  if (missingApiKeyFailure) {
    return { text: missingApiKeyFailure, isGenericRunnerFailure: false };
  }
  const oauthRefreshFailure =
    classifyOAuthRefreshFailureError(error) ?? classifyOAuthRefreshFailure(normalizedMessage);
  if (oauthRefreshFailure) {
    const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider);
    if (oauthRefreshFailure.reason) {
      return {
        text: `⚠️ Model login expired on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Re-auth with \`${loginCommand}\`, then try again.`,
        isGenericRunnerFailure: false,
      };
    }
    return {
      text: `⚠️ Model login failed on the gateway${oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""}. Please try again. If this keeps happening, re-auth with \`${loginCommand}\`.`,
      isGenericRunnerFailure: false,
    };
  }
  if (options?.isHeartbeat) {
    return { text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, isGenericRunnerFailure: false };
  }
  const cliBackendTimeoutFailure = buildCliBackendTimeoutFailureText(normalizedMessage);
  if (cliBackendTimeoutFailure) {
    return { text: cliBackendTimeoutFailure, isGenericRunnerFailure: false };
  }
  const codexAppServerFailure = buildCodexAppServerFailureText(normalizedMessage);
  if (codexAppServerFailure) {
    return { text: codexAppServerFailure, isGenericRunnerFailure: false };
  }
  return {
    text: options?.includeDetails
      ? formatForwardedExternalRunFailureText(normalizedMessage)
      : GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
    isGenericRunnerFailure: true,
  };
}

function markAgentRunFailureReplyPayload<T extends ReplyPayload>(payload: T): T {
  const marked = markReplyPayloadForSourceSuppressionDelivery(payload);
  if (!isSilentReplyText(marked.text, SILENT_REPLY_TOKEN)) {
    marked.isError = true;
  }
  return marked;
}

/** Converts known agent-run failures into user-facing reply payloads. */
export function buildKnownAgentRunFailureReplyPayload(params: {
  err: unknown;
  sessionCtx: TemplateContext;
  resolvedVerboseLevel: VerboseLevel | undefined;
  cfg?: OpenClawConfig;
}): ReplyPayload | undefined {
  const message = formatErrorMessage(params.err);
  const isFallbackSummary = isFallbackSummaryError(params.err);
  const isBilling = isFallbackSummary
    ? hasBillingAttemptSummary(params.err)
    : isFailoverError(params.err)
      ? params.err.reason === "billing"
      : isBillingErrorMessage(message);
  if (isBilling) {
    return markAgentRunFailureReplyPayload({
      text: resolveExternalRunFailureTextForConversation({
        text: resolveBillingFailureReplyText(params.err),
        sessionCtx: params.sessionCtx,
        isGenericRunnerFailure: false,
        cfg: params.cfg,
      }),
    });
  }

  const preflightCompactionFailureText = buildPreflightCompactionFailureText(message, {
    includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
  });
  if (preflightCompactionFailureText) {
    return markAgentRunFailureReplyPayload({
      text: resolveExternalRunFailureTextForConversation({
        text: preflightCompactionFailureText,
        sessionCtx: params.sessionCtx,
        isGenericRunnerFailure: false,
        cfg: params.cfg,
      }),
    });
  }

  const isPureTransientSummary = isFallbackSummary
    ? isPureTransientRateLimitSummary(params.err)
    : false;
  const isRateLimit = isFallbackSummary ? isPureTransientSummary : isRateLimitErrorMessage(message);
  const rateLimitOrOverloadedCopy =
    !isFallbackSummary || isPureTransientSummary
      ? formatRateLimitOrOverloadedErrorCopy(message)
      : undefined;

  if (isRateLimit && !isOverloadedErrorMessage(message)) {
    return markAgentRunFailureReplyPayload({
      text: resolveExternalRunFailureTextForConversation({
        text: buildRateLimitCooldownMessage(params.err),
        sessionCtx: params.sessionCtx,
        isGenericRunnerFailure: false,
        cfg: params.cfg,
      }),
    });
  }

  if (rateLimitOrOverloadedCopy) {
    return markAgentRunFailureReplyPayload({
      text: resolveExternalRunFailureTextForConversation({
        text: rateLimitOrOverloadedCopy,
        sessionCtx: params.sessionCtx,
        isGenericRunnerFailure: false,
        cfg: params.cfg,
      }),
    });
  }

  const externalRunFailureReply = buildExternalRunFailureReply(
    { message, error: params.err },
    {
      includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
    },
  );
  if (externalRunFailureReply.isGenericRunnerFailure) {
    return undefined;
  }
  return markAgentRunFailureReplyPayload({
    text: resolveExternalRunFailureTextForConversation({
      text: externalRunFailureReply.text,
      sessionCtx: params.sessionCtx,
      isGenericRunnerFailure: false,
      cfg: params.cfg,
    }),
  });
}

const DEFAULT_RESERVE_TOKENS_FLOOR = 20_000;

/** Computes a reserve-token floor scaled to the selected context window. */
export function computeContextAwareReserveTokensFloor(contextWindow: number | undefined): number {
  if (typeof contextWindow !== "number" || contextWindow <= 0) {
    return DEFAULT_RESERVE_TOKENS_FLOOR;
  }
  if (contextWindow >= 1_000_000) {
    return 100_000;
  }
  if (contextWindow >= 200_000) {
    return 50_000;
  }
  if (contextWindow >= 100_000) {
    return 35_000;
  }
  return DEFAULT_RESERVE_TOKENS_FLOOR;
}

function resolveContextWindowForCompactionHint(params: {
  cfg: FollowupRun["run"]["config"];
  primaryProvider?: string;
  primaryModel?: string;
  runtimeProvider?: string;
  runtimeModel?: string;
  agentId?: string;
  activeSessionEntry?: SessionEntry;
}): number | undefined {
  let modelWindow: number | undefined;
  const entryProvider = params.activeSessionEntry?.modelProvider;
  const entryModel = params.activeSessionEntry?.model;
  const runtimeProvider = params.runtimeProvider ?? entryProvider;
  const runtimeModel = params.runtimeModel ?? entryModel;
  const hasExplicitRuntimeRef = Boolean(params.runtimeProvider && params.runtimeModel);
  if (runtimeProvider && runtimeModel) {
    const resolved = resolveContextTokensForModel({
      cfg: params.cfg,
      provider: runtimeProvider,
      model: runtimeModel,
      allowAsyncLoad: false,
    });
    if (typeof resolved === "number" && resolved > 0) {
      modelWindow = resolved;
    }
  }
  const sessionWindow = normalizePositiveContextTokens(params.activeSessionEntry?.contextTokens);
  const sessionMatchesRuntimeRef = runtimeProvider === entryProvider && runtimeModel === entryModel;
  const trustedSessionWindow =
    !hasExplicitRuntimeRef || sessionMatchesRuntimeRef ? sessionWindow : undefined;
  if (modelWindow === undefined && sessionMatchesRuntimeRef && sessionWindow !== undefined) {
    modelWindow = sessionWindow;
  }
  if (
    modelWindow === undefined &&
    !hasExplicitRuntimeRef &&
    params.primaryProvider &&
    params.primaryModel
  ) {
    const resolved = resolveContextTokensForModel({
      cfg: params.cfg,
      provider: params.primaryProvider,
      model: params.primaryModel,
      allowAsyncLoad: false,
    });
    if (typeof resolved === "number" && resolved > 0) {
      modelWindow = resolved;
    }
  }
  const contextWindow = modelWindow ?? trustedSessionWindow;
  const agentCap = resolveAgentContextTokensForHint({
    cfg: params.cfg,
    agentId: params.agentId,
  });
  if (agentCap !== undefined && contextWindow !== undefined) {
    return Math.min(agentCap, contextWindow);
  }
  return agentCap ?? contextWindow;
}

function buildContextOverflowResetHint(contextWindowTokens: number | undefined): string {
  const reserveFloor = computeContextAwareReserveTokensFloor(contextWindowTokens);
  return (
    "\n\nTo prevent this, increase your compaction buffer by setting " +
    `\`agents.defaults.compaction.reserveTokensFloor\` to ${reserveFloor} or higher in your config.`
  );
}

type ModelRefLike = {
  provider: string;
  model: string;
};

function resolveAgentHeartbeatModelRaw(params: {
  cfg: FollowupRun["run"]["config"];
  agentId?: string;
}): string | undefined {
  const defaultModel = normalizeOptionalString(params.cfg.agents?.defaults?.heartbeat?.model);
  const agentId = normalizeLowercaseStringOrEmpty(params.agentId);
  const agentModel = agentId
    ? normalizeOptionalString(
        params.cfg.agents?.list?.find(
          (entry) => normalizeLowercaseStringOrEmpty(entry?.id) === agentId,
        )?.heartbeat?.model,
      )
    : undefined;
  return agentModel ?? defaultModel;
}

function normalizeModelRefForCompare(ref: ModelRefLike | undefined) {
  if (!ref) {
    return undefined;
  }
  const provider = normalizeLowercaseStringOrEmpty(ref.provider);
  const model = normalizeLowercaseStringOrEmpty(ref.model);
  return provider && model ? { provider, model } : undefined;
}

function modelRefsEqual(left: ModelRefLike | undefined, right: ModelRefLike | undefined) {
  const normalizedLeft = normalizeModelRefForCompare(left);
  const normalizedRight = normalizeModelRefForCompare(right);
  if (!normalizedLeft || !normalizedRight) {
    return false;
  }
  return (
    normalizedLeft.provider === normalizedRight.provider &&
    normalizedLeft.model === normalizedRight.model
  );
}

function formatContextWindowLabel(tokens: number): string {
  if (tokens >= 1_000_000) {
    return `${Math.round((tokens / 1_000_000) * 10) / 10}M`;
  }
  return `${Math.round(tokens / 1024)}k`;
}

function normalizePositiveContextTokens(value: unknown): number | undefined {
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
    return undefined;
  }
  return Math.floor(value);
}

function resolveAgentContextTokensForHint(params: {
  cfg: FollowupRun["run"]["config"];
  agentId?: string;
}): number | undefined {
  const defaultContextTokens = normalizePositiveContextTokens(
    params.cfg.agents?.defaults?.contextTokens,
  );
  const agentId = normalizeLowercaseStringOrEmpty(params.agentId);
  const agentContextTokens = agentId
    ? normalizePositiveContextTokens(
        params.cfg.agents?.list?.find(
          (entry) => normalizeLowercaseStringOrEmpty(entry?.id) === agentId,
        )?.contextTokens,
      )
    : undefined;
  return agentContextTokens ?? defaultContextTokens;
}

function resolveContextWindowForHint(params: {
  cfg: FollowupRun["run"]["config"];
  agentId?: string;
  ref: ModelRefLike;
  activeSessionEntry?: SessionEntry;
}) {
  const sessionContextTokens = normalizePositiveContextTokens(
    params.activeSessionEntry?.contextTokens,
  );
  const modelContextTokens = resolveContextTokensForModel({
    cfg: params.cfg,
    provider: params.ref.provider,
    model: params.ref.model,
    allowAsyncLoad: false,
  });
  const contextTokens = modelContextTokens ?? sessionContextTokens;
  if (contextTokens === undefined) {
    return undefined;
  }

  const agentContextTokens = resolveAgentContextTokensForHint({
    cfg: params.cfg,
    agentId: params.agentId,
  });
  return agentContextTokens !== undefined
    ? Math.min(agentContextTokens, contextTokens)
    : contextTokens;
}

function resolveHeartbeatBleedHint(params: {
  cfg: FollowupRun["run"]["config"];
  agentId?: string;
  primaryProvider?: string;
  primaryModel?: string;
  activeSessionEntry?: SessionEntry;
}): string | undefined {
  const primaryProvider = normalizeOptionalString(params.primaryProvider);
  const primaryModel = normalizeOptionalString(params.primaryModel);
  if (!primaryProvider || !primaryModel) {
    return undefined;
  }

  const runtimeProvider = normalizeOptionalString(params.activeSessionEntry?.modelProvider);
  const runtimeModel = normalizeOptionalString(params.activeSessionEntry?.model);
  if (!runtimeProvider || !runtimeModel) {
    return undefined;
  }

  const primaryRef = { provider: primaryProvider, model: primaryModel };
  const runtimeRef = { provider: runtimeProvider, model: runtimeModel };
  if (modelRefsEqual(primaryRef, runtimeRef)) {
    return undefined;
  }

  const heartbeatModelRaw = resolveAgentHeartbeatModelRaw({
    cfg: params.cfg,
    agentId: params.agentId,
  });
  const heartbeatRef = heartbeatModelRaw
    ? resolveModelRefFromString({
        cfg: params.cfg,
        raw: heartbeatModelRaw,
        defaultProvider: primaryProvider,
      })?.ref
    : undefined;
  if (!modelRefsEqual(runtimeRef, heartbeatRef)) {
    return undefined;
  }

  const runtimeWindow = resolveContextWindowForHint({
    cfg: params.cfg,
    agentId: params.agentId,
    ref: runtimeRef,
    activeSessionEntry: params.activeSessionEntry,
  });
  const primaryWindow = resolveContextWindowForHint({
    cfg: params.cfg,
    agentId: params.agentId,
    ref: primaryRef,
  });
  if (
    typeof runtimeWindow === "number" &&
    typeof primaryWindow === "number" &&
    runtimeWindow >= primaryWindow
  ) {
    return undefined;
  }

  const runtimeLabel =
    typeof runtimeWindow === "number" && runtimeWindow > 0
      ? ` (${formatContextWindowLabel(runtimeWindow)} context)`
      : "";
  return (
    `\n\nThe previous heartbeat turn left this session on ${runtimeProvider}/${runtimeModel}` +
    `${runtimeLabel} instead of ${primaryProvider}/${primaryModel}. This matches the configured ` +
    "`heartbeat.model`, so the overflow is likely heartbeat model bleed rather than a " +
    "compaction-buffer problem. Set `heartbeat.isolatedSession: true`, enable " +
    "`heartbeat.lightContext: true`, or use a heartbeat model with a larger context window."
  );
}

/** Builds recovery instructions for context-overflow failures. */
export function buildContextOverflowRecoveryText(params: {
  duringCompaction?: boolean;
  preserveSessionMapping?: boolean;
  cfg: FollowupRun["run"]["config"];
  agentId?: string;
  primaryProvider?: string;
  primaryModel?: string;
  runtimeProvider?: string;
  runtimeModel?: string;
  activeSessionEntry?: SessionEntry;
}): string {
  const prefix = params.preserveSessionMapping
    ? "⚠️ Auto-compaction could not recover this turn. I kept this conversation mapped to the current session. Please try again, use /compact, or use /new to start a fresh session."
    : params.duringCompaction
      ? "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again."
      : "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.";
  const primaryContextWindow = resolveContextWindowForCompactionHint({
    cfg: params.cfg,
    primaryProvider: params.primaryProvider,
    primaryModel: params.primaryModel,
    runtimeProvider: params.runtimeProvider,
    runtimeModel: params.runtimeModel,
    agentId: params.agentId,
    activeSessionEntry: params.activeSessionEntry,
  });
  const explicitRuntimeMatchesSession =
    !params.runtimeProvider ||
    !params.runtimeModel ||
    (params.runtimeProvider === params.activeSessionEntry?.modelProvider &&
      params.runtimeModel === params.activeSessionEntry?.model);
  const heartbeatBleedHint = explicitRuntimeMatchesSession
    ? resolveHeartbeatBleedHint({
        cfg: params.cfg,
        agentId: params.agentId,
        primaryProvider: params.primaryProvider,
        primaryModel: params.primaryModel,
        activeSessionEntry: params.activeSessionEntry,
      })
    : undefined;
  return prefix + (heartbeatBleedHint ?? buildContextOverflowResetHint(primaryContextWindow));
}

function buildRestartLifecycleReplyText(): string {
  return "⚠️ Gateway is restarting. Please wait a few seconds and try again.";
}

function resolveRestartLifecycleError(
  err: unknown,
): GatewayDrainingError | CommandLaneClearedError | undefined {
  const pending = [err];
  const seen = new Set<unknown>();

  let pendingIndex = 0;
  while (pendingIndex < pending.length) {
    const candidate = pending[pendingIndex++];
    if (!candidate || seen.has(candidate)) {
      continue;
    }
    seen.add(candidate);

    if (candidate instanceof GatewayDrainingError || candidate instanceof CommandLaneClearedError) {
      return candidate;
    }

    if (isFallbackSummaryError(candidate)) {
      for (const attempt of candidate.attempts) {
        pending.push(attempt.error);
      }
    }

    if (candidate instanceof Error && "cause" in candidate) {
      pending.push(candidate.cause);
    }
  }

  return undefined;
}

function isReplyOperationUserAbort(replyOperation?: ReplyOperation): boolean {
  return (
    replyOperation?.result?.kind === "aborted" && replyOperation.result.code === "aborted_by_user"
  );
}

function isReplyOperationRestartAbort(replyOperation?: ReplyOperation): boolean {
  return (
    replyOperation?.result?.kind === "aborted" &&
    replyOperation.result.code === "aborted_for_restart"
  );
}

function emitModelFallbackStepLifecycle(params: {
  runId: string;
  sessionKey?: string;
  step: Record<string, unknown>;
}) {
  emitAgentEvent({
    runId: params.runId,
    ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
    stream: "lifecycle",
    data: {
      phase: "fallback_step",
      ...params.step,
    },
  });
}

/** Resolves runtime provider override stored on the session entry. */
export function resolveSessionRuntimeOverrideForProvider(params: {
  provider: string;
  entry?: Pick<SessionEntry, "agentRuntimeOverride">;
  cfg?: OpenClawConfig;
}): string | undefined {
  const provider = normalizeLowercaseStringOrEmpty(params.provider);
  const runtime = normalizeLowercaseStringOrEmpty(params.entry?.agentRuntimeOverride);
  if (!runtime || runtime === "auto" || runtime === "default") {
    return undefined;
  }
  if (provider === "openai" && runtime === "codex") {
    return "codex";
  }
  if (isCliRuntimeAliasForProvider({ provider, runtime, cfg: params.cfg })) {
    return runtime;
  }
  return undefined;
}

/** Decides whether to retry after rechecking auto-fallback primary probe state. */
export function resolveRunAfterAutoFallbackPrimaryProbeRecheck(params: {
  run: FollowupRun["run"];
  entry?: SessionEntry;
  sessionKey?: string;
}): FollowupRun["run"] {
  const probe = params.run.autoFallbackPrimaryProbe;
  if (!probe || !params.sessionKey) {
    return params.run;
  }
  if (!params.entry) {
    return params.run;
  }
  const resolveEntrySelectionRun = (): FollowupRun["run"] => {
    const entryRef = resolvePersistedOverrideModelRef({
      defaultProvider: params.run.provider,
      overrideProvider: params.entry?.providerOverride,
      overrideModel: params.entry?.modelOverride,
    });
    const hasEntryModelOverride = Boolean(entryRef);
    const authProfileId = normalizeOptionalString(params.entry?.authProfileOverride);
    const fallbackRun: FollowupRun["run"] = {
      ...params.run,
      provider: entryRef?.provider ?? params.run.provider,
      model: entryRef?.model ?? params.run.model,
      autoFallbackPrimaryProbe: undefined,
    };
    if (hasEntryModelOverride) {
      fallbackRun.hasSessionModelOverride = true;
      fallbackRun.hasAutoFallbackProvenance =
        hasSessionAutoModelFallbackProvenance(params.entry) || undefined;
    } else {
      delete fallbackRun.hasSessionModelOverride;
      delete fallbackRun.hasAutoFallbackProvenance;
    }
    if (hasEntryModelOverride && params.entry?.modelOverrideSource) {
      fallbackRun.modelOverrideSource = params.entry.modelOverrideSource;
    } else {
      delete fallbackRun.modelOverrideSource;
    }
    if (hasEntryModelOverride && authProfileId) {
      fallbackRun.authProfileId = authProfileId;
      if (params.entry?.authProfileOverrideSource) {
        fallbackRun.authProfileIdSource = params.entry.authProfileOverrideSource;
      } else {
        delete fallbackRun.authProfileIdSource;
      }
    } else if (hasEntryModelOverride) {
      delete fallbackRun.authProfileId;
      delete fallbackRun.authProfileIdSource;
    }
    return fallbackRun;
  };
  const refreshedProbe = resolveAutoFallbackPrimaryProbe({
    entry: params.entry,
    sessionKey: params.sessionKey,
    primaryProvider: probe.provider,
    primaryModel: probe.model,
  });
  if (!refreshedProbe) {
    return resolveEntrySelectionRun();
  }
  return {
    ...params.run,
    provider: refreshedProbe.provider,
    model: refreshedProbe.model,
    autoFallbackPrimaryProbe: refreshedProbe,
  };
}

/** Runs the agent turn with provider/model fallback, retry, and failure mapping. */
export async function runAgentTurnWithFallback(params: {
  commandBody: string;
  transcriptCommandBody?: string;
  followupRun: FollowupRun;
  sessionCtx: TemplateContext;
  replyThreading?: TemplateContext["ReplyThreading"];
  replyOperation?: ReplyOperation;
  opts?: GetReplyOptions;
  typingSignals: TypingSignaler;
  blockReplyPipeline: BlockReplyPipeline | null;
  blockStreamingEnabled: boolean;
  blockReplyChunking?: {
    minChars: number;
    maxChars: number;
    breakPreference: "paragraph" | "newline" | "sentence";
    flushOnParagraph?: boolean;
  };
  resolvedBlockStreamingBreak: "text_end" | "message_end";
  applyReplyToMode: (payload: ReplyPayload) => ReplyPayload;
  shouldEmitToolResult: () => boolean;
  shouldEmitToolOutput: () => boolean;
  pendingToolTasks: Set<Promise<void>>;
  resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
  isHeartbeat: boolean;
  sessionKey?: string;
  runtimePolicySessionKey?: string;
  getActiveSessionEntry: () => SessionEntry | undefined;
  activeSessionStore?: Record<string, SessionEntry>;
  storePath?: string;
  resolvedVerboseLevel: VerboseLevel;
  toolProgressDetail?: "explain" | "raw";
  replyMediaContext?: ReplyMediaContext;
  onCompactionNoticePayload?: (payload: ReplyPayload) => Promise<void> | void;
  isRestartRecoveryArmed?: () => boolean;
}): Promise<AgentRunLoopResult> {
  const TRANSIENT_HTTP_RETRY_DELAY_MS = 2_500;
  let didLogHeartbeatStrip = false;
  let autoCompactionCount = 0;
  // Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
  const directlySentBlockKeys = new Set<string>();
  const directlySentBlockPayloads: Array<ReplyPayload | undefined> = [];
  const runnableRun = resolveRunAfterAutoFallbackPrimaryProbeRecheck({
    run: params.followupRun.run,
    entry: params.activeSessionStore?.[params.sessionKey ?? ""] ?? params.getActiveSessionEntry(),
    sessionKey: params.sessionKey,
  });
  if (runnableRun !== params.followupRun.run) {
    params.followupRun.run = runnableRun;
  }
  const runtimeConfig = resolveQueuedReplyRuntimeConfig(runnableRun.config);
  const effectiveRun =
    runtimeConfig === runnableRun.config
      ? runnableRun
      : {
          ...runnableRun,
          config: runtimeConfig,
        };
  const preserveUserFacingSessionState = shouldPreserveUserFacingSessionStateForInputProvenance(
    effectiveRun.inputProvenance,
  );
  const resolveRunForFallbackCandidate = (provider: string, model: string): FollowupRun["run"] => {
    const probe = effectiveRun.autoFallbackPrimaryProbe;
    const isPrimaryProbeCandidate = probe && provider === probe.provider && model === probe.model;
    if (
      probe &&
      provider === probe.fallbackProvider &&
      !isPrimaryProbeCandidate &&
      probe.fallbackAuthProfileId
    ) {
      const candidateRun: FollowupRun["run"] = {
        ...effectiveRun,
        provider,
        model,
        authProfileId: probe.fallbackAuthProfileId,
      };
      if (probe.fallbackAuthProfileIdSource) {
        candidateRun.authProfileIdSource = probe.fallbackAuthProfileIdSource;
      } else {
        delete candidateRun.authProfileIdSource;
      }
      return candidateRun;
    }
    return effectiveRun;
  };
  const applyLiveModelSwitchToRun = (
    run: FollowupRun["run"],
    err: LiveSessionModelSwitchError,
  ): void => {
    run.provider = err.provider;
    run.model = err.model;
    run.authProfileId = err.authProfileId;
    run.authProfileIdSource = err.authProfileId ? err.authProfileIdSource : undefined;
    run.autoFallbackPrimaryProbe = undefined;
  };

  const runId = params.opts?.runId ?? crypto.randomUUID();
  const agentTurnTiming = createAgentTurnTimingTracker({
    profilerEnabled: isReplyProfilerEnabled({ config: runtimeConfig }),
  });
  const shouldSurfaceToControlUi = isInternalMessageChannel(
    params.followupRun.run.messageProvider ??
      params.sessionCtx.Surface ??
      params.sessionCtx.Provider,
  );
  let lifecycleGeneration = captureAgentRunLifecycleGeneration(runId);
  if (params.sessionKey) {
    registerAgentRunContext(runId, {
      sessionKey: params.sessionKey,
      ...(params.followupRun.run.sessionId ? { sessionId: params.followupRun.run.sessionId } : {}),
      lifecycleGeneration,
      verboseLevel: params.resolvedVerboseLevel,
      isHeartbeat: params.isHeartbeat,
      isControlUiVisible: shouldSurfaceToControlUi,
    });
  }
  if (isDiagnosticsEnabled(runtimeConfig)) {
    logSessionTurnCreated({
      runId,
      sessionKey: params.sessionKey,
      sessionId: params.followupRun.run.sessionId,
      agentId: params.followupRun.run.agentId,
      channel:
        params.followupRun.run.messageProvider ??
        params.sessionCtx.Surface ??
        params.sessionCtx.Provider,
      trigger: params.isHeartbeat ? "heartbeat" : "user",
    });
  }
  let replyMediaContext: ReplyMediaContext;
  let currentTurnImages: Awaited<ReturnType<typeof resolveCurrentTurnImages>>;
  try {
    replyMediaContext =
      params.replyMediaContext ??
      agentTurnTiming.measureSync("reply_media_context", () =>
        createReplyMediaContext({
          cfg: runtimeConfig,
          sessionKey: params.sessionKey,
          workspaceDir: params.followupRun.run.workspaceDir,
          messageProvider: params.followupRun.run.messageProvider,
          accountId:
            params.followupRun.originatingAccountId ?? params.followupRun.run.agentAccountId,
          groupId: params.followupRun.run.groupId,
          groupChannel: params.followupRun.run.groupChannel,
          groupSpace: params.followupRun.run.groupSpace,
          requesterSenderId: params.followupRun.run.senderId,
          requesterSenderName: params.followupRun.run.senderName,
          requesterSenderUsername: params.followupRun.run.senderUsername,
          requesterSenderE164: params.followupRun.run.senderE164,
        }),
      );
    currentTurnImages = await agentTurnTiming.measure("current_turn_images", () =>
      resolveCurrentTurnImages({
        ctx: params.sessionCtx,
        cfg: runtimeConfig,
        images: params.followupRun.images ?? params.opts?.images,
        imageOrder: params.followupRun.imageOrder ?? params.opts?.imageOrder,
      }),
    );
  } catch (error) {
    clearAgentRunContext(runId, lifecycleGeneration);
    throw error;
  }
  let didNotifyAgentRunStart = false;
  const notifyAgentRunStart = () => {
    if (didNotifyAgentRunStart) {
      return;
    }
    didNotifyAgentRunStart = true;
    params.opts?.onAgentRunStart?.(runId);
  };
  const signalExecutionPhaseForTyping = (
    info: Parameters<NonNullable<RunEmbeddedAgentParams["onExecutionPhase"]>>[0],
  ) => {
    const isUserVisibleExecutionActivity =
      info.phase === "turn_accepted" ||
      info.phase === "process_spawned" ||
      info.phase === "model_call_started" ||
      info.phase === "tool_execution_started" ||
      info.phase === "assistant_output_started";
    if (!isUserVisibleExecutionActivity) {
      return;
    }
    notifyAgentRunStart();
    void (
      params.typingSignals.signalExecutionActivity?.() ?? params.typingSignals.signalRunStart()
    ).catch((err: unknown) => {
      logVerbose(`execution phase typing signal failed: ${String(err)}`);
    });
  };
  const currentMessageId = params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
  const notifyUserAboutCompaction = shouldNotifyUserAboutCompaction(runtimeConfig);
  const deliverCompactionNoticePayload = async (noticePayload: ReplyPayload, label: string) => {
    try {
      if (params.opts?.onBlockReply) {
        await params.opts.onBlockReply(noticePayload);
        return;
      }
      await params.onCompactionNoticePayload?.(noticePayload);
    } catch (err) {
      // Non-critical notice delivery failure should not bubble out of the
      // fire-and-forget event handler.
      logVerbose(`compaction ${label} notice delivery failed (non-fatal): ${String(err)}`);
    }
  };
  const sendCompactionNotice = async (phase: "start" | "end" | "incomplete") => {
    await deliverCompactionNoticePayload(
      createCompactionNoticePayload({
        phase,
        currentMessageId,
        applyReplyToMode: params.applyReplyToMode,
      }),
      phase,
    );
  };
  const sendCompactionHookMessages = async (messages: string[]) => {
    const noticePayload = createCompactionHookNoticePayload({
      messages,
      currentMessageId,
      applyReplyToMode: params.applyReplyToMode,
    });
    if (!noticePayload) {
      return;
    }
    await deliverCompactionNoticePayload(noticePayload, "hook");
  };
  let runResult: Awaited<ReturnType<typeof runEmbeddedAgent>>;
  let fallbackProvider = params.followupRun.run.provider;
  let fallbackModel = params.followupRun.run.model;
  let attemptedRuntimeProvider = fallbackProvider;
  let attemptedRuntimeModel = fallbackModel;
  let fallbackAttempts: RuntimeFallbackAttempt[] = [];
  let fallbackExhausted = false;
  let pendingLifecycleTerminal:
    | {
        provider: string;
        model: string;
        backstop: AgentLifecycleTerminalBackstop;
      }
    | undefined;
  const takePendingLifecycleTerminal = () => {
    const terminal = pendingLifecycleTerminal?.backstop;
    pendingLifecycleTerminal = undefined;
    return terminal;
  };
  let transientHttpRetriesRemaining = 1;
  const consumeTransientHttpRetry = () => transientHttpRetriesRemaining-- > 0;
  let liveModelSwitchRetries = 0;
  let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
    params.getActiveSessionEntry()?.systemPromptReport,
  );
  let pendingFallbackCandidateRollback:
    | {
        provider: string;
        model: string;
        rollback: () => Promise<void>;
      }
    | undefined;
  const clearPendingFallbackRollback = (rollback?: () => Promise<void>) => {
    if (!rollback || pendingFallbackCandidateRollback?.rollback === rollback) {
      pendingFallbackCandidateRollback = undefined;
    }
  };
  const rollbackClassifiedFallbackCandidateSelection = async (provider: string, model: string) => {
    const pending = pendingFallbackCandidateRollback;
    if (!pending || pending.provider !== provider || pending.model !== model) {
      return;
    }
    pendingFallbackCandidateRollback = undefined;
    try {
      await pending.rollback();
    } catch (rollbackError) {
      logVerbose(
        `failed to roll back classified fallback candidate selection (non-fatal): ${String(rollbackError)}`,
      );
    }
  };
  const persistFallbackCandidateSelection = async (
    provider: string,
    model: string,
    candidateRun: FollowupRun["run"],
  ): Promise<(() => Promise<void>) | undefined> => {
    if (
      !params.sessionKey ||
      !params.activeSessionStore ||
      preserveUserFacingSessionState ||
      (provider === effectiveRun.provider && model === effectiveRun.model)
    ) {
      return undefined;
    }

    const activeSessionEntry =
      params.activeSessionStore[params.sessionKey] ?? params.getActiveSessionEntry();
    if (!activeSessionEntry) {
      return undefined;
    }

    // Don't overwrite a user-initiated model override (e.g. from /models or
    // /model) with the fallback model.  The user's explicit selection should
    // survive transient primary-model failures so subsequent messages still
    // target the model the user chose.  Fallback persistence is only
    // appropriate when the override was itself set by a previous fallback
    // ("auto") or when there is no override yet.
    //
    // `modelOverrideSource` was added later, so older persisted sessions can
    // carry a user-selected override without the source field.  Treat any
    // entry with a `modelOverride` but missing `modelOverrideSource` as legacy
    // user state, matching the backward-compat treatment in
    // session-reset-service.
    const isUserModelOverride =
      activeSessionEntry.modelOverrideSource === "user" ||
      (activeSessionEntry.modelOverrideSource === undefined &&
        Boolean(normalizeOptionalString(activeSessionEntry.modelOverride)) &&
        !hasSessionAutoModelFallbackProvenance(activeSessionEntry));
    if (isUserModelOverride) {
      return undefined;
    }

    const previousState = snapshotFallbackSelectionState(activeSessionEntry);
    const selectionRun =
      candidateRun !== effectiveRun && effectiveRun.autoFallbackPrimaryProbe
        ? {
            ...candidateRun,
            provider: candidateRun.provider,
            model: effectiveRun.model,
          }
        : candidateRun;
    const persistedProvider = resolveOpenAIRuntimeProvider({
      provider,
      config: runtimeConfig,
      workspaceDir: params.followupRun.run.workspaceDir,
    });
    const applied = applyFallbackCandidateSelectionToEntry({
      entry: activeSessionEntry,
      run: selectionRun,
      provider: persistedProvider,
      model,
      force: candidateRun !== effectiveRun && Boolean(effectiveRun.autoFallbackPrimaryProbe),
      ...(effectiveRun.autoFallbackPrimaryProbe
        ? {
            origin: {
              provider: effectiveRun.autoFallbackPrimaryProbe.provider,
              model: effectiveRun.autoFallbackPrimaryProbe.model,
            },
          }
        : {}),
    });
    const nextState = applied.nextState;
    if (!applied.updated || !nextState) {
      return undefined;
    }
    params.activeSessionStore[params.sessionKey] = activeSessionEntry;

    try {
      if (params.storePath) {
        await updateSessionEntry(
          { storePath: params.storePath, sessionKey: params.sessionKey },
          (persistedEntry) => {
            applyFallbackSelectionState(persistedEntry, nextState);
            return buildFallbackSelectionStatePatch(persistedEntry);
          },
        );
      }
    } catch (error) {
      rollbackFallbackSelectionStateIfUnchanged(activeSessionEntry, nextState, previousState);
      params.activeSessionStore[params.sessionKey] = activeSessionEntry;
      throw error;
    }

    return async () => {
      const rolledBackInMemory = rollbackFallbackSelectionStateIfUnchanged(
        activeSessionEntry,
        nextState,
        previousState,
      );
      if (rolledBackInMemory) {
        params.activeSessionStore![params.sessionKey!] = activeSessionEntry;
      }
      if (!params.storePath || !params.sessionKey) {
        return;
      }
      await updateSessionEntry(
        { storePath: params.storePath, sessionKey: params.sessionKey },
        (persistedEntry) => {
          if (rollbackFallbackSelectionStateIfUnchanged(persistedEntry, nextState, previousState)) {
            return buildFallbackSelectionStatePatch(persistedEntry);
          }
          return null;
        },
      );
    };
  };
  const clearRecoveredAutoFallbackPrimaryProbe = async (paramsForClear: {
    provider: string;
    model: string;
  }): Promise<void> => {
    if (preserveUserFacingSessionState) {
      return;
    }
    const probe = effectiveRun.autoFallbackPrimaryProbe;
    if (!probe) {
      return;
    }
    if (paramsForClear.provider !== probe.provider || paramsForClear.model !== probe.model) {
      return;
    }
    if (!params.sessionKey || !params.activeSessionStore) {
      return;
    }
    const activeSessionEntry =
      params.activeSessionStore[params.sessionKey] ?? params.getActiveSessionEntry();
    if (!activeSessionEntry) {
      return;
    }
    if (!entryMatchesAutoFallbackPrimaryProbe(activeSessionEntry, probe)) {
      return;
    }
    clearAutoFallbackPrimaryProbeSelection(activeSessionEntry);
    params.activeSessionStore[params.sessionKey] = activeSessionEntry;
    if (!params.storePath) {
      return;
    }
    await updateSessionEntry(
      { storePath: params.storePath, sessionKey: params.sessionKey },
      (persistedEntry) => {
        if (!entryMatchesAutoFallbackPrimaryProbe(persistedEntry, probe)) {
          return null;
        }
        const shouldClearAuthProfile =
          persistedEntry.authProfileOverrideSource === "auto" ||
          (persistedEntry.authProfileOverrideSource === undefined &&
            persistedEntry.authProfileOverrideCompactionCount !== undefined);
        clearAutoFallbackPrimaryProbeSelection(persistedEntry);
        return {
          providerOverride: undefined,
          modelOverride: undefined,
          modelOverrideSource: undefined,
          modelOverrideFallbackOriginProvider: undefined,
          modelOverrideFallbackOriginModel: undefined,
          ...(shouldClearAuthProfile
            ? {
                authProfileOverride: undefined,
                authProfileOverrideSource: undefined,
                authProfileOverrideCompactionCount: undefined,
              }
            : {}),
          fallbackNoticeSelectedModel: undefined,
          fallbackNoticeActiveModel: undefined,
          fallbackNoticeReason: undefined,
          updatedAt: persistedEntry.updatedAt,
        };
      },
    );
  };

  while (true) {
    try {
      const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
        let text = payload.text;
        const reply = resolveSendableOutboundReplyParts(payload);
        if (params.followupRun.run.silentExpected) {
          return { skip: true };
        }
        if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
          const stripped = stripHeartbeatToken(text, {
            mode: "message",
          });
          if (stripped.didStrip && !didLogHeartbeatStrip) {
            didLogHeartbeatStrip = true;
            logVerbose("Stripped stray HEARTBEAT_OK token from reply");
          }
          if (stripped.shouldSkip && !reply.hasMedia) {
            return { skip: true };
          }
          text = stripped.text;
        }
        if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
          return { skip: true };
        }
        if (
          isSilentReplyPrefixText(text, SILENT_REPLY_TOKEN) ||
          isSilentReplyPrefixText(text, HEARTBEAT_TOKEN)
        ) {
          return { skip: true };
        }
        if (text && startsWithSilentToken(text, SILENT_REPLY_TOKEN)) {
          text = stripLeadingSilentToken(text, SILENT_REPLY_TOKEN);
        }
        if (!text) {
          // Allow media-only payloads (e.g. tool result screenshots) through.
          if (reply.hasMedia) {
            return { text: undefined, skip: false };
          }
          return { skip: true };
        }
        const sanitized = sanitizeUserFacingText(text, {
          errorContext: Boolean(payload.isError),
        });
        if (!sanitized.trim()) {
          return { skip: true };
        }
        return { text: sanitized, skip: false };
      };
      const handlePartialForTyping = async (payload: ReplyPayload): Promise<string | undefined> => {
        if (isSilentReplyPrefixText(payload.text, SILENT_REPLY_TOKEN)) {
          return undefined;
        }
        const { text, skip } = normalizeStreamingText(payload);
        if (skip || !text) {
          return undefined;
        }
        await params.typingSignals.signalTextDelta(text);
        return text;
      };
      const blockReplyPipeline = params.blockReplyPipeline;
      // Build the delivery handler once so both onAgentEvent (compaction start
      // notice) and the onBlockReply field share the same instance.  This
      // ensures replyToId threading (replyToMode=all|first) is applied to
      // compaction notices just like every other block reply.
      const blockReplyHandler = params.opts?.onBlockReply
        ? createBlockReplyDeliveryHandler({
            onBlockReply: params.opts.onBlockReply,
            currentMessageId: params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid,
            replyThreading: params.replyThreading,
            normalizeStreamingText,
            applyReplyToMode: params.applyReplyToMode,
            normalizeMediaPaths: replyMediaContext.normalizePayload,
            typingSignals: params.typingSignals,
            blockStreamingEnabled: params.blockStreamingEnabled,
            blockReplyPipeline,
            directlySentBlockKeys,
            directlySentBlockPayloads,
          })
        : undefined;
      let messageToolOnlyDeliveryCompleted = false;
      const messageToolOnlyDeliveryToolCallIds = new Set<string>();
      const sourceRepliesAreToolOnly =
        params.followupRun.run.sourceReplyDeliveryMode === "message_tool_only";
      const shouldSuppressProgressAfterMessageToolDelivery = () =>
        sourceRepliesAreToolOnly &&
        messageToolOnlyDeliveryCompleted &&
        params.opts?.allowProgressCallbacksWhenSourceDeliverySuppressed !== true;
      const onToolResult = params.opts?.onToolResult;
      const outcomePlan = buildAgentRuntimeOutcomePlan();
      const runLane = CommandLane.Main;
      const runAbortSignal = params.replyOperation?.abortSignal ?? params.opts?.abortSignal;
      let queuedUserMessagePersistedAcrossFallback = false;
      let assistantErrorPersistedAcrossFallback = false;
      const userTurnTranscriptRecorder =
        params.followupRun.userTurnTranscriptRecorder ?? params.opts?.userTurnTranscriptRecorder;
      const notifyUserMessagePersisted = () => {
        queuedUserMessagePersistedAcrossFallback = true;
      };
      const fastModeStartedAtMs = Date.now();
      const fastModeAutoProgressState: FastModeAutoProgressState = {
        offAnnounced: false,
        resetAnnounced: false,
      };
      // Profiler-only milestone: it separates fallback setup from the actual
      // model run without adding extra live logs/snapshots to normal turns.
      agentTurnTiming.logMilestoneIfSlow({
        runId,
        sessionId: params.followupRun.run.sessionId,
        sessionKey: params.sessionKey,
        milestone: "before_model_fallback",
      });
      const fallbackResult = await agentTurnTiming.measure("model_fallback", () =>
        runWithModelFallback<EmbeddedAgentRunResult>({
          ...resolveModelFallbackOptions(effectiveRun, runtimeConfig),
          runId,
          sessionId: params.followupRun.run.sessionId,
          lane: runLane,
          abortSignal: runAbortSignal,
          resolveAgentHarnessRuntimeOverride: (provider) =>
            resolveSessionRuntimeOverrideForProvider({
              provider,
              entry: params.getActiveSessionEntry(),
              cfg: runtimeConfig,
            }),
          prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
            await agentTurnTiming.measure("fallback_prepare_harness", () =>
              ensureSelectedAgentHarnessPlugin({
                config: runtimeConfig,
                provider,
                modelId: model,
                agentId: params.followupRun.run.agentId,
                sessionKey: params.followupRun.run.runtimePolicySessionKey ?? params.sessionKey,
                agentHarnessRuntimeOverride,
                workspaceDir: params.followupRun.run.workspaceDir,
              }),
            );
          },
          onFallbackStep: (step) => {
            emitModelFallbackStepLifecycle({
              runId,
              sessionKey: params.sessionKey,
              step,
            });
          },
          classifyResult: async ({ result, provider, model }) => {
            const classification = outcomePlan.classifyRunResult({
              result,
              provider,
              model,
              hasDirectlySentBlockReply: directlySentBlockKeys.size > 0,
              hasBlockReplyPipelineOutput: Boolean(
                blockReplyPipeline?.hasBuffered() || blockReplyPipeline?.didStream(),
              ),
            });
            if (classification) {
              await rollbackClassifiedFallbackCandidateSelection(provider, model);
            }
            return classification;
          },
          mergeExhaustedResult: mergeEmbeddedAgentRunResultForModelFallbackExhaustion,
          run: async (provider, model, runOptions) => {
            attemptedRuntimeProvider = provider;
            attemptedRuntimeModel = model;
            const suppressQueuedUserPersistenceForCandidate =
              (params.followupRun.run.suppressNextUserMessagePersistence ?? false) ||
              queuedUserMessagePersistedAcrossFallback;
            const suppressAssistantErrorPersistenceForCandidate =
              assistantErrorPersistedAcrossFallback;
            const candidateRun = resolveRunForFallbackCandidate(provider, model);
            const candidateFastMode = resolveRunFastModeForFallbackCandidate({
              run: candidateRun,
              config: runtimeConfig,
              provider,
              model,
              sessionEntry: params.getActiveSessionEntry(),
            });
            const activeProbe = effectiveRun.autoFallbackPrimaryProbe;
            if (activeProbe && provider === activeProbe.provider && model === activeProbe.model) {
              markAutoFallbackPrimaryProbe({
                probe: activeProbe,
                sessionKey: params.sessionKey,
              });
            }
            // Notify that model selection is complete (including after fallback).
            // This allows responsePrefix template interpolation with the actual model.
            params.opts?.onModelSelected?.({
              provider,
              model,
              thinkLevel: params.followupRun.run.thinkLevel,
            });
            let rollbackFallbackCandidateSelection: (() => Promise<void>) | undefined;
            try {
              rollbackFallbackCandidateSelection = await agentTurnTiming.measure(
                "fallback_persist_selection",
                () => persistFallbackCandidateSelection(provider, model, candidateRun),
              );
              if (rollbackFallbackCandidateSelection) {
                pendingFallbackCandidateRollback = {
                  provider,
                  model,
                  rollback: rollbackFallbackCandidateSelection,
                };
              }
            } catch (error) {
              logVerbose(
                `failed to persist fallback candidate selection (non-fatal): ${String(error)}`,
              );
            }

            const { sessionRuntimeOverride, cliExecutionProvider } = agentTurnTiming.measureSync(
              "fallback_resolve_runtime",
              () => {
                const resolvedSessionRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
                  provider,
                  entry: params.getActiveSessionEntry(),
                  cfg: runtimeConfig,
                });
                const resolvedSelectedAuthProfile = resolveRunAuthProfile(candidateRun, provider, {
                  config: runtimeConfig,
                });
                const resolvedCliExecutionProvider =
                  (resolvedSessionRuntimeOverride &&
                  isCliProvider(resolvedSessionRuntimeOverride, runtimeConfig)
                    ? resolvedSessionRuntimeOverride
                    : undefined) ??
                  resolveCliRuntimeExecutionProvider({
                    provider,
                    cfg: runtimeConfig,
                    agentId: params.followupRun.run.agentId,
                    modelId: model,
                    authProfileId: resolvedSelectedAuthProfile.authProfileId,
                  }) ??
                  provider;
                return {
                  sessionRuntimeOverride: resolvedSessionRuntimeOverride,
                  cliExecutionProvider: resolvedCliExecutionProvider,
                };
              },
            );

            if (isCliProvider(cliExecutionProvider, runtimeConfig)) {
              const cliSessionBinding = getCliSessionBinding(
                params.getActiveSessionEntry(),
                cliExecutionProvider,
              );
              const cliLifecycleStartedAt = Date.now();
              const lifecycleBackstop = createAgentLifecycleTerminalBackstop({
                runId,
                sessionKey: params.sessionKey,
                startedAt: cliLifecycleStartedAt,
                getLifecycleGeneration: () => lifecycleGeneration,
                resolveAbortLifecycleFields: () => ({
                  ...resolveAgentRunAbortLifecycleFields(runAbortSignal),
                  ...(isReplyOperationRestartAbort(params.replyOperation)
                    ? {
                        aborted: true as const,
                        stopReason: AGENT_RUN_RESTART_ABORT_STOP_REASON,
                      }
                    : {}),
                }),
              });
              pendingLifecycleTerminal = { provider, model, backstop: lifecycleBackstop };
              const authProfile = resolveRunAuthProfile(candidateRun, cliExecutionProvider, {
                config: runtimeConfig,
              });
              let droppedCliSessionReplacement = false;
              const hookMessageProvider = resolveOriginMessageProvider({
                originatingChannel: params.followupRun.originatingChannel,
                provider: params.sessionCtx.Provider,
              });
              const cliCurrentThreadId =
                params.followupRun.originatingThreadId ?? params.sessionCtx.MessageThreadId;
              const isRestartSentinelContinuation =
                params.sessionCtx.InputProvenance?.kind === "internal_system" &&
                params.sessionCtx.InputProvenance.sourceTool === "restart-sentinel";
              const cliCurrentMessageId = isRestartSentinelContinuation
                ? params.sessionCtx.ReplyToId
                : (params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid);
              const cliToolSummaryTracker = createCliToolSummaryTracker({
                detailMode: params.toolProgressDetail,
                shouldEmitToolResult: params.shouldEmitToolResult,
                shouldEmitToolOutput: params.shouldEmitToolOutput,
                deliver: async (payload) => {
                  await params.opts?.onToolResult?.(payload);
                },
              });
              const result = await agentTurnTiming.measure("cli_run", () =>
                runCliAgentWithLifecycle({
                  runId,
                  lifecycleGeneration,
                  provider: cliExecutionProvider,
                  startedAt: cliLifecycleStartedAt,
                  emitLifecycleTerminal: false,
                  onAgentRunStart: notifyAgentRunStart,
                  suppressAssistantBridge: params.followupRun.run.silentExpected,
                  onAssistantText: async (text) => {
                    const textForTyping = await handlePartialForTyping({ text } as ReplyPayload);
                    if (textForTyping === undefined || !params.opts?.onPartialReply) {
                      return;
                    }
                    await params.opts.onPartialReply({ text: textForTyping });
                  },
                  onReasoningText: async (text) => {
                    await params.opts?.onReasoningStream?.({ text });
                  },
                  onToolEvent: async (payload) => {
                    await cliToolSummaryTracker.noteToolEvent(payload);
                    if (payload.phase === "result") {
                      return;
                    }
                    const { name, phase, args } = payload;
                    await Promise.all([
                      params.typingSignals.signalToolStart(),
                      params.opts?.onToolStart?.({
                        name,
                        phase,
                        args,
                        detailMode: params.toolProgressDetail,
                      }),
                    ]);
                  },
                  onCommentaryText:
                    params.opts?.commentaryProgressEnabled === true && params.opts.onItemEvent
                      ? async (payload) => {
                          await params.opts?.onItemEvent?.({
                            itemId: payload.itemId,
                            kind: "preamble",
                            progressText: payload.text,
                          });
                        }
                      : undefined,
                  onFastModeAutoProgress: async (payload) => {
                    await params.opts?.onToolResult?.(payload);
                  },
                  onErrorBeforeLifecycle: async () => {
                    if (!rollbackFallbackCandidateSelection) {
                      return;
                    }
                    try {
                      await rollbackFallbackCandidateSelection();
                      clearPendingFallbackRollback(rollbackFallbackCandidateSelection);
                    } catch (rollbackError) {
                      logVerbose(
                        `failed to roll back fallback candidate selection (non-fatal): ${String(rollbackError)}`,
                      );
                    }
                  },
                  transformResult:
                    params.followupRun.currentInboundEventKind === "room_event"
                      ? (resultLocal) =>
                          keepCliSessionBindingOnlyWhenReused({
                            result: resultLocal,
                            existingSessionId: cliSessionBinding?.sessionId,
                            onDroppedReplacement: () => {
                              droppedCliSessionReplacement = true;
                            },
                          })
                      : undefined,
                  runParams: {
                    sessionId: params.followupRun.run.sessionId,
                    sessionKey: params.sessionKey,
                    agentId: params.followupRun.run.agentId,
                    trigger: params.isHeartbeat ? "heartbeat" : "user",
                    sessionFile: params.followupRun.run.sessionFile,
                    workspaceDir: params.followupRun.run.workspaceDir,
                    cwd: params.followupRun.run.cwd,
                    config: runtimeConfig,
                    prompt: params.commandBody,
                    transcriptPrompt: params.transcriptCommandBody,
                    suppressNextUserMessagePersistence: suppressQueuedUserPersistenceForCandidate,
                    userTurnTranscriptRecorder,
                    onUserMessagePersisted: notifyUserMessagePersisted,
                    persistAssistantTranscript:
                      params.followupRun.currentInboundEventKind !== "room_event" &&
                      params.followupRun.run.suppressTranscriptOnlyAssistantPersistence !== true,
                    storePath: params.storePath,
                    currentInboundEventKind: params.followupRun.currentInboundEventKind,
                    currentInboundContext: params.followupRun.currentInboundContext,
                    inputProvenance: params.followupRun.run.inputProvenance,
                    provider: cliExecutionProvider,
                    model,
                    thinkLevel: params.followupRun.run.thinkLevel,
                    fastMode: candidateFastMode.fastMode,
                    fastModeStartedAtMs,
                    fastModeAutoOnSeconds: candidateFastMode.fastModeAutoOnSeconds,
                    fastModeAutoProgressState,
                    isFinalFallbackAttempt: runOptions?.isFinalFallbackAttempt,
                    timeoutMs: params.followupRun.run.timeoutMs,
                    runTimeoutOverrideMs: params.followupRun.run.runTimeoutOverrideMs,
                    runId,
                    lane: runLane,
                    extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
                    sourceReplyDeliveryMode: params.followupRun.run.sourceReplyDeliveryMode,
                    silentReplyPromptMode: params.followupRun.run.silentReplyPromptMode,
                    allowEmptyAssistantReplyAsSilent:
                      params.followupRun.run.allowEmptyAssistantReplyAsSilent,
                    extraSystemPromptStatic: params.followupRun.run.extraSystemPromptStatic,
                    ownerNumbers: params.followupRun.run.ownerNumbers,
                    cliSessionId: cliSessionBinding?.sessionId,
                    cliSessionBinding,
                    authProfileId: authProfile.authProfileId,
                    bootstrapPromptWarningSignaturesSeen,
                    bootstrapPromptWarningSignature:
                      bootstrapPromptWarningSignaturesSeen[
                        bootstrapPromptWarningSignaturesSeen.length - 1
                      ],
                    images: currentTurnImages.images,
                    imageOrder: currentTurnImages.imageOrder,
                    skillsSnapshot: params.followupRun.run.skillsSnapshot,
                    messageChannel: params.followupRun.originatingChannel ?? undefined,
                    messageProvider: hookMessageProvider,
                    currentChannelId:
                      params.followupRun.originatingTo ??
                      params.sessionCtx.OriginatingTo ??
                      params.sessionCtx.To,
                    senderId: params.followupRun.run.senderId,
                    chatId: params.followupRun.originatingChatId,
                    channelContext: params.followupRun.run.channelContext,
                    currentThreadTs:
                      cliCurrentThreadId != null ? String(cliCurrentThreadId) : undefined,
                    currentMessageId: cliCurrentMessageId,
                    currentInboundAudio: hasInboundAudio(params.sessionCtx),
                    agentAccountId: params.followupRun.run.agentAccountId,
                    senderIsOwner: params.followupRun.run.senderIsOwner,
                    approvalReviewerDeviceId: params.followupRun.run.approvalReviewerDeviceId,
                    toolsAllow: params.opts?.toolsAllow,
                    disableTools: params.opts?.disableTools,
                    abortSignal: runAbortSignal,
                    onExecutionPhase: signalExecutionPhaseForTyping,
                    replyOperation: params.replyOperation,
                  },
                }),
              );
              if (droppedCliSessionReplacement) {
                await clearDroppedCliSessionBinding({
                  provider: cliExecutionProvider,
                  sessionKey: params.sessionKey,
                  sessionStore: params.activeSessionStore,
                  storePath: params.storePath,
                  activeSessionEntry: params.getActiveSessionEntry(),
                });
              }
              bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
                result.meta?.systemPromptReport,
              );
              return result;
            }
            const { embeddedContext, senderContext, runBaseParams } =
              buildEmbeddedRunExecutionParams({
                run: { ...candidateRun, ...candidateFastMode },
                replyRoute: params.followupRun,
                sessionCtx: params.sessionCtx,
                hasRepliedRef: params.opts?.hasRepliedRef,
                provider,
                runId,
                promptCacheKey: params.opts?.promptCacheKey,
                allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
                model,
              });
            const agentHarnessPolicy = sessionRuntimeOverride
              ? ({ runtime: sessionRuntimeOverride, runtimeSource: "model" } as const)
              : resolveAgentHarnessPolicy({
                  provider,
                  modelId: model,
                  config: runtimeConfig,
                  agentId: params.followupRun.run.agentId,
                  sessionKey: params.followupRun.run.runtimePolicySessionKey ?? params.sessionKey,
                });
            const embeddedRunProvider = resolveOpenAIRuntimeProvider({
              provider,
              harnessRuntime: agentHarnessPolicy.runtime,
              authProfileProvider: runBaseParams.authProfileId?.split(":", 1)[0],
              authProfileId: runBaseParams.authProfileId,
              config: runtimeConfig,
              workspaceDir: params.followupRun.run.workspaceDir,
            });
            const embeddedRunHarnessOverride =
              sessionRuntimeOverride ??
              (agentHarnessPolicy.runtime === "openclaw" && embeddedRunProvider !== provider
                ? "openclaw"
                : undefined);
            return (async () => {
              let attemptCompactionCount = 0;
              const lifecycleBackstop = createAgentLifecycleTerminalBackstop({
                runId,
                sessionKey: params.sessionKey,
                getLifecycleGeneration: () => lifecycleGeneration,
                resolveAbortLifecycleFields: () => ({
                  ...resolveAgentRunAbortLifecycleFields(runAbortSignal),
                  ...(isReplyOperationRestartAbort(params.replyOperation)
                    ? {
                        aborted: true as const,
                        stopReason: AGENT_RUN_RESTART_ABORT_STOP_REASON,
                      }
                    : {}),
                }),
              });
              pendingLifecycleTerminal = { provider, model, backstop: lifecycleBackstop };
              try {
                // Profiler-only milestone: it exposes time spent before Codex
                // dispatch while leaving the regular embedded run path inert.
                agentTurnTiming.logMilestoneIfSlow({
                  runId,
                  sessionId: params.followupRun.run.sessionId,
                  sessionKey: params.sessionKey,
                  milestone: "before_embedded_run",
                });
                const result = await agentTurnTiming.measure("embedded_run", () =>
                  runEmbeddedAgent({
                    ...embeddedContext,
                    lifecycleGeneration,
                    allowGatewaySubagentBinding: true,
                    trigger: params.isHeartbeat ? "heartbeat" : "user",
                    groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
                    groupChannel:
                      normalizeOptionalString(params.sessionCtx.GroupChannel) ??
                      normalizeOptionalString(params.sessionCtx.GroupSubject),
                    groupSpace: normalizeOptionalString(params.sessionCtx.GroupSpace),
                    ...senderContext,
                    ...runBaseParams,
                    provider: embeddedRunProvider,
                    agentHarnessId: embeddedRunHarnessOverride,
                    agentHarnessRuntimeOverride: embeddedRunHarnessOverride,
                    fastModeStartedAtMs,
                    fastModeAutoProgressState,
                    isFinalFallbackAttempt: runOptions?.isFinalFallbackAttempt,
                    sandboxSessionKey: params.runtimePolicySessionKey,
                    prompt: params.commandBody,
                    transcriptPrompt: params.transcriptCommandBody,
                    userTurnTranscriptRecorder,
                    currentInboundEventKind: params.followupRun.currentInboundEventKind,
                    currentInboundContext: params.followupRun.currentInboundContext,
                    extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
                    sourceReplyDeliveryMode: params.followupRun.run.sourceReplyDeliveryMode,
                    forceMessageTool:
                      params.followupRun.run.sourceReplyDeliveryMode === "message_tool_only",
                    silentReplyPromptMode: params.followupRun.run.silentReplyPromptMode,
                    suppressNextUserMessagePersistence: suppressQueuedUserPersistenceForCandidate,
                    onUserMessagePersisted: notifyUserMessagePersisted,
                    suppressTranscriptOnlyAssistantPersistence:
                      params.followupRun.run.suppressTranscriptOnlyAssistantPersistence,
                    suppressAssistantErrorPersistence:
                      suppressAssistantErrorPersistenceForCandidate,
                    onAssistantErrorMessagePersisted: () => {
                      assistantErrorPersistedAcrossFallback = true;
                    },
                    toolResultFormat: (() => {
                      const channel = resolveMessageChannel(
                        params.sessionCtx.Surface,
                        params.sessionCtx.Provider,
                      );
                      if (!channel) {
                        return "markdown";
                      }
                      return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
                    })(),
                    toolProgressDetail: params.toolProgressDetail,
                    suppressToolErrorWarnings:
                      params.opts?.shouldSuppressToolErrorWarnings ??
                      params.opts?.suppressToolErrorWarnings,
                    toolsAllow: params.opts?.toolsAllow,
                    disableTools: params.opts?.disableTools,
                    enableHeartbeatTool: params.opts?.enableHeartbeatTool,
                    forceHeartbeatTool: params.opts?.forceHeartbeatTool,
                    bootstrapContextMode: params.opts?.bootstrapContextMode,
                    bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default",
                    images: currentTurnImages.images,
                    imageOrder: currentTurnImages.imageOrder,
                    abortSignal: runAbortSignal,
                    replyOperation: params.replyOperation,
                    deferTerminalLifecycle: true,
                    onExecutionStarted: (info) => {
                      if (info?.lifecycleGeneration) {
                        lifecycleGeneration = info.lifecycleGeneration;
                      }
                    },
                    onExecutionPhase: signalExecutionPhaseForTyping,
                    blockReplyBreak: params.resolvedBlockStreamingBreak,
                    blockReplyChunking: params.blockReplyChunking,
                    onPartialReply: async (payload) => {
                      const textForTyping = await handlePartialForTyping(payload);
                      if (!params.opts?.onPartialReply || textForTyping === undefined) {
                        return;
                      }
                      await params.opts.onPartialReply({
                        text: textForTyping,
                        mediaUrls: payload.mediaUrls,
                      });
                    },
                    onAssistantMessageStart: async () => {
                      await params.typingSignals.signalMessageStart();
                      await params.opts?.onAssistantMessageStart?.();
                    },
                    onReasoningStream:
                      params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
                        ? async (payload) => {
                            if (params.followupRun.run.silentExpected) {
                              return;
                            }
                            await params.typingSignals.signalReasoningDelta();
                            await params.opts?.onReasoningStream?.({
                              text: payload.text,
                              mediaUrls: payload.mediaUrls,
                              isReasoningSnapshot: payload.isReasoningSnapshot,
                            });
                          }
                        : undefined,
                    onReasoningEnd: params.opts?.onReasoningEnd,
                    onAgentEvent: async (evt) => {
                      lifecycleBackstop.note(evt);
                      // Signal run start only after the embedded agent emits real activity.
                      const hasLifecyclePhase =
                        evt.stream === "lifecycle" && typeof evt.data.phase === "string";
                      if (evt.stream !== "lifecycle" || hasLifecyclePhase) {
                        notifyAgentRunStart();
                      }
                      // Trigger typing when tools start executing.
                      // Must await to ensure typing indicator starts before tool summaries are emitted.
                      if (evt.stream === "tool") {
                        const phase = readStringValue(evt.data.phase) ?? "";
                        const name = readStringValue(evt.data.name);
                        const toolCallId = readStringValue(evt.data.toolCallId) ?? "";
                        const args =
                          evt.data.args && typeof evt.data.args === "object"
                            ? (evt.data.args as Record<string, unknown>)
                            : undefined;
                        if (
                          sourceRepliesAreToolOnly &&
                          toolCallId &&
                          name &&
                          (phase === "start" || phase === "update") &&
                          args &&
                          isMessagingToolSendAction(name, args)
                        ) {
                          messageToolOnlyDeliveryToolCallIds.add(toolCallId);
                        }
                        if (shouldSuppressProgressAfterMessageToolDelivery()) {
                          return;
                        }
                        if (phase === "start" || phase === "update") {
                          const toolStartProgressPromise = params.opts?.onToolStart?.({
                            itemId: readStringValue(evt.data.itemId),
                            toolCallId: readStringValue(evt.data.toolCallId),
                            name,
                            phase,
                            args,
                            detailMode: params.toolProgressDetail,
                          });
                          await Promise.all([
                            params.typingSignals.signalToolStart(),
                            toolStartProgressPromise,
                          ]);
                        }
                        const commandOutput = buildCommandOutputFromToolResultEvent(evt);
                        if (commandOutput) {
                          await params.opts?.onCommandOutput?.(commandOutput);
                        }
                      }
                      const suppressItemChannelProgress =
                        evt.stream === "item" &&
                        evt.data.suppressChannelProgress === true &&
                        Boolean(params.opts?.onToolStart);
                      const itemPhase =
                        evt.stream === "item" ? readStringValue(evt.data.phase) : "";
                      const itemName = evt.stream === "item" ? readStringValue(evt.data.name) : "";
                      const itemStatus =
                        evt.stream === "item" ? readStringValue(evt.data.status) : "";
                      const itemToolCallId =
                        evt.stream === "item" ? (readStringValue(evt.data.toolCallId) ?? "") : "";
                      const completedMessageToolDelivery =
                        sourceRepliesAreToolOnly &&
                        itemPhase === "end" &&
                        itemStatus === "completed" &&
                        itemToolCallId.length > 0 &&
                        messageToolOnlyDeliveryToolCallIds.has(itemToolCallId);
                      const suppressProgressAfterMessageToolDelivery =
                        shouldSuppressProgressAfterMessageToolDelivery();
                      if (completedMessageToolDelivery) {
                        messageToolOnlyDeliveryToolCallIds.delete(itemToolCallId);
                        messageToolOnlyDeliveryCompleted = true;
                      }
                      if (
                        evt.stream === "item" &&
                        !suppressItemChannelProgress &&
                        (!suppressProgressAfterMessageToolDelivery || completedMessageToolDelivery)
                      ) {
                        await params.opts?.onItemEvent?.({
                          itemId: readStringValue(evt.data.itemId),
                          toolCallId: readStringValue(evt.data.toolCallId),
                          kind: readStringValue(evt.data.kind),
                          title: readStringValue(evt.data.title),
                          name: itemName,
                          phase: itemPhase,
                          status: itemStatus,
                          summary: readStringValue(evt.data.summary),
                          progressText: readStringValue(evt.data.progressText),
                          meta: readStringValue(evt.data.meta),
                          approvalId: readStringValue(evt.data.approvalId),
                          approvalSlug: readStringValue(evt.data.approvalSlug),
                        });
                      }
                      if (
                        evt.stream === "plan" &&
                        !shouldSuppressProgressAfterMessageToolDelivery()
                      ) {
                        await params.opts?.onPlanUpdate?.({
                          phase: readStringValue(evt.data.phase),
                          title: readStringValue(evt.data.title),
                          explanation: readStringValue(evt.data.explanation),
                          steps: Array.isArray(evt.data.steps)
                            ? evt.data.steps.filter(
                                (step): step is string => typeof step === "string",
                              )
                            : undefined,
                          source: readStringValue(evt.data.source),
                        });
                      }
                      if (
                        evt.stream === "approval" &&
                        !shouldSuppressProgressAfterMessageToolDelivery()
                      ) {
                        await params.opts?.onApprovalEvent?.({
                          phase: readStringValue(evt.data.phase),
                          kind: readStringValue(evt.data.kind),
                          status: readStringValue(evt.data.status),
                          title: readStringValue(evt.data.title),
                          itemId: readStringValue(evt.data.itemId),
                          toolCallId: readStringValue(evt.data.toolCallId),
                          approvalId: readStringValue(evt.data.approvalId),
                          approvalSlug: readStringValue(evt.data.approvalSlug),
                          command: readStringValue(evt.data.command),
                          host: readStringValue(evt.data.host),
                          reason: readStringValue(evt.data.reason),
                          scope: readApprovalScopeValue(evt.data.scope),
                          message: readStringValue(evt.data.message),
                        });
                      }
                      if (
                        evt.stream === "command_output" &&
                        !shouldSuppressProgressAfterMessageToolDelivery()
                      ) {
                        await params.opts?.onCommandOutput?.({
                          itemId: readStringValue(evt.data.itemId),
                          phase: readStringValue(evt.data.phase),
                          title: readStringValue(evt.data.title),
                          toolCallId: readStringValue(evt.data.toolCallId),
                          name: readStringValue(evt.data.name),
                          output: readStringValue(evt.data.output),
                          status: readStringValue(evt.data.status),
                          exitCode:
                            typeof evt.data.exitCode === "number" || evt.data.exitCode === null
                              ? evt.data.exitCode
                              : undefined,
                          durationMs:
                            typeof evt.data.durationMs === "number"
                              ? evt.data.durationMs
                              : undefined,
                          cwd: readStringValue(evt.data.cwd),
                        });
                      }
                      if (
                        evt.stream === "patch" &&
                        !shouldSuppressProgressAfterMessageToolDelivery()
                      ) {
                        await params.opts?.onPatchSummary?.({
                          itemId: readStringValue(evt.data.itemId),
                          phase: readStringValue(evt.data.phase),
                          title: readStringValue(evt.data.title),
                          toolCallId: readStringValue(evt.data.toolCallId),
                          name: readStringValue(evt.data.name),
                          added: Array.isArray(evt.data.added)
                            ? evt.data.added.filter(
                                (entry): entry is string => typeof entry === "string",
                              )
                            : undefined,
                          modified: Array.isArray(evt.data.modified)
                            ? evt.data.modified.filter(
                                (entry): entry is string => typeof entry === "string",
                              )
                            : undefined,
                          deleted: Array.isArray(evt.data.deleted)
                            ? evt.data.deleted.filter(
                                (entry): entry is string => typeof entry === "string",
                              )
                            : undefined,
                          summary: readStringValue(evt.data.summary),
                        });
                      }
                      if (evt.stream === "compaction") {
                        const phase = readStringValue(evt.data.phase) ?? "";
                        const backend = readStringValue(evt.data.backend);
                        const hookMessages = readCompactionHookMessages(evt.data.messages);
                        const sendCompactionUserNotices = async (
                          noticePhase: "start" | "end" | "incomplete",
                        ) => {
                          if (hookMessages.length > 0) {
                            await sendCompactionHookMessages(hookMessages);
                          }
                          if (notifyUserAboutCompaction) {
                            await sendCompactionNotice(noticePhase);
                          }
                        };
                        if (phase === "start") {
                          if (params.opts?.onCompactionStart) {
                            await params.opts.onCompactionStart();
                          }
                          await sendCompactionUserNotices("start");
                        }
                        if (phase === "end") {
                          const completed = evt.data?.completed === true;
                          if (completed) {
                            attemptCompactionCount += 1;
                            if (backend === CODEX_APP_SERVER_COMPACTION_BACKEND) {
                              const modelRef = formatCompactionModelRef(provider, model);
                              const consoleMessage =
                                `codex app-server auto-compaction succeeded for ${modelRef}; ` +
                                "refreshed session context";
                              agentCompactionLog.info(
                                "codex app-server auto-compaction succeeded",
                                {
                                  event: "codex_app_server_compaction_succeeded",
                                  backend,
                                  provider,
                                  model,
                                  sessionKey: params.sessionKey,
                                  sessionId: effectiveRun.sessionId,
                                  threadId: readStringValue(evt.data.threadId),
                                  turnId: readStringValue(evt.data.turnId),
                                  itemId: readStringValue(evt.data.itemId),
                                  compactionCount: attemptCompactionCount,
                                  consoleMessage,
                                },
                              );
                            }
                            if (params.opts?.onCompactionEnd) {
                              await params.opts.onCompactionEnd();
                            }
                            await sendCompactionUserNotices("end");
                          } else {
                            await sendCompactionUserNotices("incomplete");
                          }
                        }
                      }
                    },
                    // Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
                    // even when regular block streaming is disabled. The handler sends directly
                    // via opts.onBlockReply when the pipeline isn't available.
                    onBlockReply: blockReplyHandler,
                    onBlockReplyFlush:
                      params.blockStreamingEnabled && blockReplyPipeline
                        ? async () => {
                            await blockReplyPipeline.flush({ force: true });
                          }
                        : undefined,
                    shouldEmitToolResult: params.shouldEmitToolResult,
                    shouldEmitToolOutput: params.shouldEmitToolOutput,
                    bootstrapPromptWarningSignaturesSeen,
                    bootstrapPromptWarningSignature:
                      bootstrapPromptWarningSignaturesSeen[
                        bootstrapPromptWarningSignaturesSeen.length - 1
                      ],
                    onToolResult: onToolResult
                      ? (() => {
                          // Serialize tool result delivery to preserve message ordering.
                          // Without this, concurrent tool callbacks race through typing signals
                          // and message sends, causing out-of-order delivery to the user.
                          // See: https://github.com/openclaw/openclaw/issues/11044
                          let toolResultChain: Promise<void> = Promise.resolve();
                          return (payload: ReplyPayload) => {
                            toolResultChain = toolResultChain
                              .then(async () => {
                                const { text, skip } = normalizeStreamingText(payload);
                                if (skip) {
                                  return;
                                }
                                if (text !== undefined) {
                                  await params.typingSignals.signalTextDelta(text);
                                }
                                await onToolResult({
                                  ...payload,
                                  text,
                                });
                              })
                              .catch((err: unknown) => {
                                // Keep chain healthy after an error so later tool results still deliver.
                                logVerbose(`tool result delivery failed: ${String(err)}`);
                              });
                            const task = toolResultChain.finally(() => {
                              params.pendingToolTasks.delete(task);
                            });
                            params.pendingToolTasks.add(task);
                          };
                        })()
                      : undefined,
                  }),
                );
                bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
                  result.meta?.systemPromptReport,
                );
                const resultCompactionCount = Math.max(
                  0,
                  result.meta?.agentMeta?.compactionCount ?? 0,
                );
                attemptCompactionCount = Math.max(attemptCompactionCount, resultCompactionCount);
                return result;
              } catch (err) {
                if (rollbackFallbackCandidateSelection) {
                  try {
                    await rollbackFallbackCandidateSelection();
                    clearPendingFallbackRollback(rollbackFallbackCandidateSelection);
                  } catch (rollbackError) {
                    logVerbose(
                      `failed to roll back fallback candidate selection (non-fatal): ${String(rollbackError)}`,
                    );
                  }
                }
                throw err;
              } finally {
                autoCompactionCount += attemptCompactionCount;
              }
            })();
          },
        }),
      );
      agentTurnTiming.logIfSlow({
        runId,
        sessionId: params.followupRun.run.sessionId,
        sessionKey: params.sessionKey,
        outcome: "completed",
      });
      runResult = fallbackResult.result;
      fallbackProvider = fallbackResult.provider;
      fallbackModel = fallbackResult.model;
      fallbackExhausted = fallbackResult.outcome === "exhausted";
      const settledLifecycleTerminal =
        pendingLifecycleTerminal?.provider === fallbackProvider &&
        pendingLifecycleTerminal.model === fallbackModel
          ? pendingLifecycleTerminal.backstop
          : undefined;
      pendingLifecycleTerminal = undefined;
      const restartAbortReason = runAbortSignal?.reason;
      if (isReplyOperationRestartAbort(params.replyOperation)) {
        settledLifecycleTerminal?.emit("end", runResult);
        throw isAgentRunRestartAbortReason(restartAbortReason)
          ? restartAbortReason
          : createAgentRunRestartAbortError();
      }
      fallbackAttempts = Array.isArray(fallbackResult.attempts)
        ? fallbackResult.attempts.map((attempt) => ({
            provider: attempt.provider,
            model: attempt.model,
            error: attempt.error,
            reason: attempt.reason || undefined,
            status: typeof attempt.status === "number" ? attempt.status : undefined,
            code: attempt.code || undefined,
          }))
        : [];
      if (!fallbackExhausted) {
        await clearRecoveredAutoFallbackPrimaryProbe({
          provider: fallbackProvider,
          model: fallbackModel,
        });
      }

      // Some embedded runs surface context overflow as an error payload instead of throwing.
      // Preserve the active session mapping and surface explicit guidance instead
      // of silently rotating the session key to a new session id.
      const embeddedError = runResult.meta?.error;
      const deferredLifecycleError = settledLifecycleTerminal?.getDeferredError();
      const userFacingErrorPayload = runResult.payloads?.find(
        (payload) => payload.isError === true && typeof payload.text === "string",
      )?.text;
      const terminalErrorMessage =
        deferredLifecycleError ??
        userFacingErrorPayload ??
        (embeddedError ? "Agent run failed" : undefined);
      const emitSettledLifecycleError = (error: Error, extraData?: Record<string, unknown>) => {
        if (settledLifecycleTerminal) {
          settledLifecycleTerminal.emit("error", error, extraData);
          return;
        }
        emitAgentEvent({
          runId,
          lifecycleGeneration,
          ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
          stream: "lifecycle",
          data: {
            phase: "error",
            error: error.message,
            endedAt: Date.now(),
            ...extraData,
          },
        });
      };
      if (embeddedError && isContextOverflowError(embeddedError.message)) {
        emitSettledLifecycleError(new Error(terminalErrorMessage ?? "Agent run failed"));
        defaultRuntime.error(
          `Auto-compaction failed (${embeddedError.message}). Preserving existing session mapping for ${params.sessionKey ?? params.followupRun.run.sessionId}.`,
        );
        params.replyOperation?.fail("run_failed", embeddedError);
        return {
          kind: "final",
          payload: markAgentRunFailureReplyPayload({
            text: buildContextOverflowRecoveryText({
              preserveSessionMapping: true,
              cfg: runtimeConfig,
              agentId: params.followupRun.run.agentId,
              primaryProvider: params.followupRun.run.provider,
              primaryModel: params.followupRun.run.model,
              runtimeProvider: attemptedRuntimeProvider,
              runtimeModel: attemptedRuntimeModel,
              activeSessionEntry: params.getActiveSessionEntry(),
            }),
          }),
        };
      }
      if (embeddedError?.kind === "role_ordering") {
        emitSettledLifecycleError(new Error(terminalErrorMessage ?? "Agent run failed"));
        const providerRequestError = classifyProviderRequestError(embeddedError);
        params.replyOperation?.fail("run_failed", embeddedError);
        const embeddedErrorText = formatErrorMessage(embeddedError).replace(/\.\s*$/, "");
        return {
          kind: "final",
          payload: markAgentRunFailureReplyPayload({
            text: shouldSurfaceToControlUi
              ? `⚠️ Agent failed before reply: ${embeddedErrorText}.\nLogs: openclaw logs --follow`
              : (providerRequestError?.userMessage ??
                PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE),
          }),
        };
      }

      const terminalMetadata = resolveAgentLifecycleTerminalMetadata(runResult.meta);
      if (fallbackExhausted) {
        const exhaustionError = new Error(
          terminalErrorMessage ?? "All model fallback candidates failed",
        );
        emitSettledLifecycleError(exhaustionError, {
          ...terminalMetadata,
          fallbackExhaustedFailure: true,
        });
        params.replyOperation?.retainFailureUntilComplete();
        params.replyOperation?.fail("run_failed", exhaustionError);
      } else if (deferredLifecycleError || embeddedError) {
        const terminalError = new Error(terminalErrorMessage ?? "Agent run failed");
        emitSettledLifecycleError(terminalError, terminalMetadata);
        params.replyOperation?.retainFailureUntilComplete();
        params.replyOperation?.fail("run_failed", terminalError);
      } else {
        settledLifecycleTerminal?.emit("end", runResult);
      }
      break;
    } catch (err) {
      if (err instanceof LiveSessionModelSwitchError) {
        liveModelSwitchRetries += 1;
        if (liveModelSwitchRetries > MAX_LIVE_SWITCH_RETRIES) {
          // Prevent infinite loop when persisted session selection keeps
          // conflicting with fallback model choices (e.g. overloaded primary
          // triggers fallback, but session store keeps pulling back to the
          // overloaded model). Surface the last error to the user instead.
          // See: https://github.com/openclaw/openclaw/issues/58348
          defaultRuntime.error(
            `Live model switch failed after ${MAX_LIVE_SWITCH_RETRIES} retries ` +
              `(${sanitizeForLog(err.provider)}/${sanitizeForLog(err.model)}). The requested model may be unavailable.`,
          );
          takePendingLifecycleTerminal()?.emit("error", err);
          const switchErrorText = shouldSurfaceToControlUi
            ? "⚠️ Agent failed before reply: model switch could not be completed. " +
              "The requested model may be temporarily unavailable.\n" +
              "Logs: openclaw logs --follow"
            : isVerboseFailureDetailEnabled(params.resolvedVerboseLevel)
              ? "⚠️ Agent failed before reply: model switch could not be completed. " +
                "The requested model may be temporarily unavailable. Please try again shortly."
              : "⚠️ Model switch could not be completed. The requested model may be temporarily unavailable. Please try again shortly.";
          params.replyOperation?.fail("run_failed", err);
          return {
            kind: "final",
            payload: markAgentRunFailureReplyPayload({
              text: resolveExternalRunFailureTextForConversation({
                text: switchErrorText,
                sessionCtx: params.sessionCtx,
                isGenericRunnerFailure: !shouldSurfaceToControlUi,
                cfg: params.followupRun.run.config,
              }),
            }),
          };
        }
        applyLiveModelSwitchToRun(params.followupRun.run, err);
        if (runnableRun !== params.followupRun.run) {
          applyLiveModelSwitchToRun(runnableRun, err);
        }
        if (effectiveRun !== runnableRun && effectiveRun !== params.followupRun.run) {
          applyLiveModelSwitchToRun(effectiveRun, err);
        }
        pendingLifecycleTerminal = undefined;
        fallbackProvider = err.provider;
        fallbackModel = err.model;
        continue;
      }
      const message = formatErrorMessage(err);
      agentTurnTiming.logIfSlow({
        runId,
        sessionId: params.followupRun.run.sessionId,
        sessionKey: params.sessionKey,
        outcome: "error",
        error: message,
      });
      const isBilling = isFallbackSummaryError(err)
        ? hasBillingAttemptSummary(err)
        : isFailoverError(err)
          ? err.reason === "billing"
          : isBillingErrorMessage(message);
      const isContextOverflow = !isBilling && isLikelyContextOverflowError(message);
      const isCompactionFailure = !isBilling && isCompactionFailureError(message);
      const providerRequestError =
        !isBilling && !shouldSurfaceToControlUi ? classifyProviderRequestError(err) : undefined;
      const isTransientHttp = isTransientHttpError(message);

      // Drain/restart aborts stay silent and defer to post-restart
      // main-session recovery, which resumes the interrupted turn (or emits its
      // own genuine non-resumable notice). A generic "try again" here is a
      // false terminal that invites a duplicate manual retry. Restart abort is
      // treated exactly like user abort for visible output; the fail()
      // bookkeeping for drain/lane-cleared is still recorded.
      if (isReplyOperationRestartAbort(params.replyOperation)) {
        takePendingLifecycleTerminal()?.emit("end", err);
        if (params.isRestartRecoveryArmed?.() !== true) {
          return {
            kind: "final",
            payload: markAgentRunFailureReplyPayload({
              text: buildRestartLifecycleReplyText(),
            }),
          };
        }
        return {
          kind: "final",
          payload: {
            text: SILENT_REPLY_TOKEN,
          },
        };
      }

      if (isReplyOperationUserAbort(params.replyOperation)) {
        takePendingLifecycleTerminal()?.emit("error", err);
        return {
          kind: "final",
          payload: {
            text: SILENT_REPLY_TOKEN,
          },
        };
      }

      const restartLifecycleError = resolveRestartLifecycleError(err);
      if (restartLifecycleError instanceof GatewayDrainingError) {
        takePendingLifecycleTerminal()?.emit("error", restartLifecycleError);
        params.replyOperation?.fail("gateway_draining", restartLifecycleError);
        return {
          kind: "final",
          payload: markAgentRunFailureReplyPayload({
            text: buildRestartLifecycleReplyText(),
          }),
        };
      }

      if (restartLifecycleError instanceof CommandLaneClearedError) {
        takePendingLifecycleTerminal()?.emit("error", restartLifecycleError);
        params.replyOperation?.fail("command_lane_cleared", restartLifecycleError);
        return {
          kind: "final",
          payload: markAgentRunFailureReplyPayload({
            text: buildRestartLifecycleReplyText(),
          }),
        };
      }

      if (isCompactionFailure) {
        takePendingLifecycleTerminal()?.emit("error", err);
        defaultRuntime.error(
          `Auto-compaction failed (${message}). Preserving existing session mapping for ${params.sessionKey ?? params.followupRun.run.sessionId}.`,
        );
        params.replyOperation?.fail("run_failed", err);
        return {
          kind: "final",
          payload: markAgentRunFailureReplyPayload({
            text: buildContextOverflowRecoveryText({
              duringCompaction: true,
              preserveSessionMapping: true,
              cfg: runtimeConfig,
              agentId: params.followupRun.run.agentId,
              primaryProvider: params.followupRun.run.provider,
              primaryModel: params.followupRun.run.model,
              runtimeProvider: attemptedRuntimeProvider,
              runtimeModel: attemptedRuntimeModel,
              activeSessionEntry: params.getActiveSessionEntry(),
            }),
          }),
        };
      }
      if (providerRequestError) {
        takePendingLifecycleTerminal()?.emit("error", err);
        params.replyOperation?.fail("run_failed", err);
        return {
          kind: "final",
          payload: markAgentRunFailureReplyPayload({
            text: providerRequestError.userMessage,
          }),
        };
      }

      if (isTransientHttp && consumeTransientHttpRetry()) {
        pendingLifecycleTerminal = undefined;
        // Retry the full runWithModelFallback() cycle — transient errors
        // (502/521/etc.) typically affect the whole provider, so falling
        // back to an alternate model first would not help. Instead we wait
        // and retry the complete primary→fallback chain.
        defaultRuntime.error(
          `Transient HTTP provider error before reply (${message}). Retrying once in ${TRANSIENT_HTTP_RETRY_DELAY_MS}ms.`,
        );
        await new Promise<void>((resolve) => {
          setTimeout(resolve, TRANSIENT_HTTP_RETRY_DELAY_MS);
        });
        continue;
      }

      defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
      // Only classify as rate-limit when we have concrete evidence from the
      // underlying error. FallbackSummaryError messages embed per-attempt
      // reason labels like `(rate_limit)`, so string-matching the summary text
      // would misclassify mixed-cause exhaustion as a pure transient cooldown.
      const isFallbackSummary = isFallbackSummaryError(err);
      const isPureTransientSummary = isFallbackSummary
        ? isPureTransientRateLimitSummary(err)
        : false;
      const isRateLimit = isFallbackSummary
        ? isPureTransientSummary
        : isRateLimitErrorMessage(message);
      const rateLimitOrOverloadedCopy =
        !isFallbackSummary || isPureTransientSummary
          ? formatRateLimitOrOverloadedErrorCopy(message)
          : undefined;
      const safeMessage = isTransientHttp
        ? sanitizeUserFacingText(message, { errorContext: true })
        : message;
      const trimmedMessage = safeMessage.replace(/\.\s*$/, "");
      const externalRunFailureReply =
        !isBilling &&
        !(isRateLimit && !isOverloadedErrorMessage(message)) &&
        !rateLimitOrOverloadedCopy &&
        !isContextOverflow &&
        !shouldSurfaceToControlUi
          ? buildExternalRunFailureReply(
              { message, error: err },
              {
                includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
                isHeartbeat: params.isHeartbeat,
              },
            )
          : undefined;
      const genericFallbackText = params.isHeartbeat
        ? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT
        : GENERIC_EXTERNAL_RUN_FAILURE_TEXT;
      const fallbackText = isBilling
        ? resolveBillingFailureReplyText(err)
        : isRateLimit && !isOverloadedErrorMessage(message)
          ? buildRateLimitCooldownMessage(err)
          : rateLimitOrOverloadedCopy
            ? rateLimitOrOverloadedCopy
            : isContextOverflow
              ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
              : shouldSurfaceToControlUi
                ? `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`
                : (externalRunFailureReply?.text ?? genericFallbackText);
      const userVisibleFallbackText = resolveExternalRunFailureTextForConversation({
        text: fallbackText,
        sessionCtx: params.sessionCtx,
        isGenericRunnerFailure: externalRunFailureReply?.isGenericRunnerFailure ?? false,
        cfg: params.followupRun.run.config,
      });
      const abortedSignal =
        params.replyOperation?.abortSignal.aborted === true
          ? params.replyOperation.abortSignal
          : params.opts?.abortSignal?.aborted === true
            ? params.opts.abortSignal
            : undefined;
      const abortLifecycleFields = {
        ...resolveAgentRunAbortLifecycleFields(abortedSignal),
        ...(isReplyOperationRestartAbort(params.replyOperation)
          ? {
              aborted: true as const,
              stopReason: AGENT_RUN_RESTART_ABORT_STOP_REASON,
            }
          : {}),
      };

      const failedLifecycleTerminal = takePendingLifecycleTerminal();
      if (failedLifecycleTerminal) {
        failedLifecycleTerminal.emit("error", err, {
          fallbackExhaustedFailure: true,
        });
      } else {
        emitAgentEvent({
          runId,
          lifecycleGeneration,
          ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
          stream: "lifecycle",
          data: {
            phase: "error",
            error: message,
            endedAt: Date.now(),
            ...abortLifecycleFields,
            fallbackExhaustedFailure: true,
          },
        });
      }
      params.replyOperation?.fail("run_failed", err);
      return {
        kind: "final",
        payload: markAgentRunFailureReplyPayload({
          text: userVisibleFallbackText,
        }),
      };
    }
  }

  // If the run completed but with an embedded context overflow error that
  // wasn't recovered from (e.g. compaction reset already attempted), surface
  // the error to the user instead of silently returning an empty response.
  // See #26905: Slack DM sessions silently swallowed messages when context
  // overflow errors were returned as embedded error payloads.
  const finalEmbeddedError = runResult?.meta?.error;
  const hasPayloadText = runResult?.payloads?.some((p) => normalizeOptionalString(p.text));
  if (finalEmbeddedError && !hasPayloadText) {
    const errorMsg = finalEmbeddedError.message ?? "";
    if (isContextOverflowError(errorMsg)) {
      params.replyOperation?.fail("run_failed", finalEmbeddedError);
      return {
        kind: "final",
        payload: markAgentRunFailureReplyPayload({
          text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.",
        }),
      };
    }
  }

  // Surface rate limit and overload errors that occur mid-turn (after tool
  // calls) instead of silently returning an empty response. See #36142.
  // Only applies when the assistant produced no valid (non-error) reply text,
  // so tool-level rate-limit messages don't override a successful turn.
  // Prioritize metaErrorMsg (raw upstream error) over errorPayloadText to
  // avoid self-matching on pre-formatted "⚠️" messages from run.ts, and
  // skip already-formatted payloads so tool-specific 429 errors (e.g.
  // browser/search tool failures) are preserved rather than overwritten.
  //
  // Instead of early-returning kind:"final" (which would bypass
  // buildReplyPayloads() filtering and session bookkeeping), inject the
  // error payload into runResult so it flows through the normal
  // kind:"success" path — preserving streaming dedup, message_send
  // suppression, and usage/model metadata updates.
  if (runResult) {
    const hasNonErrorContent = runResult.payloads?.some(
      (p) => !p.isError && !p.isReasoning && hasOutboundReplyContent(p, { trimText: true }),
    );
    if (!hasNonErrorContent) {
      const metaErrorMsg = finalEmbeddedError?.message ?? "";
      const rawErrorPayloadText =
        runResult.payloads?.find(
          (p) => p.isError && hasNonEmptyString(p.text) && !p.text.startsWith("⚠️"),
        )?.text ?? "";
      const errorCandidate = metaErrorMsg || rawErrorPayloadText;
      const formattedErrorCandidate = errorCandidate
        ? formatRateLimitOrOverloadedErrorCopy(errorCandidate)
        : undefined;
      if (formattedErrorCandidate) {
        runResult.payloads = [
          markAgentRunFailureReplyPayload({
            text: resolveExternalRunFailureTextForConversation({
              text: formattedErrorCandidate,
              sessionCtx: params.sessionCtx,
              isGenericRunnerFailure: false,
              cfg: params.followupRun.run.config,
            }),
            isError: true,
          }),
        ];
      }
    }
  }

  return {
    kind: "success",
    runId,
    runResult,
    fallbackProvider,
    fallbackModel,
    ...(fallbackExhausted ? { fallbackExhausted: true as const } : {}),
    fallbackAttempts,
    didLogHeartbeatStrip,
    autoCompactionCount,
    directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
    directlySentBlockPayloads: directlySentBlockPayloads.filter(
      (payload): payload is ReplyPayload => payload !== undefined,
    ),
  };
}
