+ {failure.output}
+
+ ]`.
+ * Only this shape is read, so numbers and words in a command's output can't
+ * pass for a provider's status or name.
+ */
+function parseModelRequest(message: string): ModelRequest | null {
+ const lead = /^model request failed \(([a-z_]+)\): provider (\S+)/i.exec(message);
+ const tail = /\[provider ([^,\]]+), status (\d{3})(?:, code ([^\]]+))?\]\s*$/i.exec(message);
+ const provider = tail?.[1] ?? lead?.[2];
+ if (!provider) return null;
+ return {
+ provider: provider.toLowerCase(),
+ kind: lead?.[1]?.toLowerCase() ?? null,
+ status: tail ? Number(tail[2]) : null,
+ code: tail?.[3] ?? null,
+ };
+}
+
+function requestAdvice({ kind, status, code }: ModelRequest, message: string, who: string): Advice {
+ if (/credit balance|insufficient[_ ]quota|payment required|billing|spend limit/i.test(message) || status === 402) {
+ return {
+ headline: `${who} declined the request for billing reasons`,
+ nextStep: `Add credit or raise the spend limit with ${who}, then ${RETRY}.`,
+ };
+ }
+ if (kind === "authentication" || status === 401 || status === 403) {
+ return { headline: `${who} rejected the API key`, nextStep: `Check the key under Settings → Secrets, then ${RETRY}.` };
+ }
+ if (kind === "rate_limit" || status === 429) {
+ return { headline: `${who} rate-limited the request`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." };
+ }
+ if (kind === "timeout" || /timed out/i.test(message)) {
+ return { headline: `The request to ${who} timed out`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." };
+ }
+ if ((status !== null && status >= 500) || /overloaded/i.test(message)) {
+ return { headline: `${who} was unavailable`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." };
+ }
+ const what = [status, code?.replace(/_/g, " ")].filter(Boolean).join(" ");
+ return {
+ headline: what ? `${who} returned ${what}` : `${who} rejected the request`,
+ nextStep: `${who}'s message, under Details, says why.`,
+ };
+}
+
+function stepAdvice(message: string, category: FailureCategory, output: string | null): Advice {
+ const exit = /exited with (?:status|code) (\d+)/i.exec(message);
+ if (exit) {
+ return {
+ headline: `The step's command exited with status ${exit[1]}`,
+ nextStep: output ? "Its output, under Details, usually says why." : "Open the step's logs for its output.",
+ };
+ }
+ if (category === FailureCategory.TRANSIENT_INFRA) {
+ return { headline: "The step hit a temporary infrastructure error", nextStep: "Choose Retry from Actions in a few minutes." };
+ }
+ return { headline: firstSentence(message), nextStep: "Open the failed step's logs for details." };
+}
+
+/**
+ * The last step to start, if it failed. An earlier failure that a later step
+ * recovered from isn't where the run stopped, so it isn't named.
+ */
+function stoppedAt(stages: FailureState["stages"]): string | null {
+ let last: [string, number] | null = null;
+ for (const [key, stage] of Object.entries(stages)) {
+ const started = stage.started_at ? Date.parse(stage.started_at) : NaN;
+ if (!Number.isNaN(started) && (!last || started > last[1])) last = [key, started];
+ }
+ if (!last || stages[last[0]]?.state !== "failed") return null;
+ return last[0].replace(/@1$/, "");
+}
+
+function outputTail(tail: ExecOutputTail | null | undefined): string | null {
+ const stream = [tail?.stderr, tail?.stdout].find((text) => text?.trim());
+ if (!stream) return null;
+ // Carriage-return progress lines keep only what was drawn last.
+ const lines = stream.trimEnd().split(/\r?\n/).map((line) => line.slice(line.lastIndexOf("\r") + 1));
+ return lines.slice(-OUTPUT_LINES).join("\n");
+}
+
+function firstSentence(message: string): string {
+ const sentence = message.split(/(?<=[.!?])\s+/)[0]!.trim().replace(/\s+/g, " ");
+ const chars = Array.from(sentence.charAt(0).toUpperCase() + sentence.slice(1));
+ return chars.length > HEADLINE_CHARS ? `${chars.slice(0, HEADLINE_CHARS).join("").trimEnd()}…` : chars.join("");
+}