import { type AssistantMessage as PiAssistantMessage, type Usage, isContextOverflow, isRetryableAssistantError, } from "@earendil-works/pi-ai"; import { assistantUsageTokens, nonNegativeFiniteNumber } from "./accounting.js"; import { stripTerminalSequences } from "./terminal-compat.js"; export type AgentStopReason = "stop" | "length" | "toolUse" | "error" | "aborted"; export interface AssistantMessageLike { role: "assistant"; stopReason?: AgentStopReason; errorMessage?: string; content?: PiAssistantMessage["content"]; api?: PiAssistantMessage["api"]; provider?: PiAssistantMessage["provider"]; model?: string; usage?: Usage; timestamp?: number; } const USAGE_LIMIT_GOAL_ERROR_PATTERNS = [ /usage[_\s-]*(?:limit|cap)|chatgpt.{0,32}usage/i, /quota.{0,32}(?:reached|exceeded|exhausted|depleted)|(?:reached|exceeded|exhausted|depleted).{0,32}quota/i, /insufficient[_\s-]*(?:quota|credits?)|out of credits|out of budget|available balance|payment required/i, /(?:credit|balance).{0,32}(?:low|exhausted|depleted)|billing/i, ] as const; const NON_RETRYABLE_GOAL_ERROR_RE = /multi-auth rotation failed|credentials tried|unauthori[sz]ed|invalid api key/i; const RETRYABLE_GOAL_ERROR_PATTERNS = [ /overloaded|rate.?limit|too many requests|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error/i, /provider.?returned.?error|you can retry your request|try your request again|please retry your request/i, /network.?error|connection.?(?:error|refused|lost)|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up/i, /timed? out|timeout|terminated|websocket.?(?:closed|error)|ended without|stream ended before message_stop|http2 request did not get a response|retry delay/i, /context[_\s-]*length[_\s-]*exceeded|input exceeds the context window/i, ] as const; export function safeTerminalText(value: string) { return [...stripTerminalSequences(value)] .map((character) => { const codePoint = character.codePointAt(0) ?? 0; if (character === "\n") return character; return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) ? " " : character; }) .join("") .trim(); } export function safeGoalMenuText(value: string, maxCharacters = 120) { const sanitized = safeTerminalText(value).replace(/\s+/gu, " ").trim(); const characters = [...sanitized]; return characters.length <= maxCharacters ? sanitized : `${characters.slice(0, maxCharacters).join("")}…`; } export function notifyTerminal( ui: { notify: (message: string, level?: "info" | "warning" | "error") => void }, message: string, level?: "info" | "warning" | "error", ) { ui.notify(safeTerminalText(message), level); } export function formatError(error: unknown) { return truncateNotification(error instanceof Error ? error.message : String(error)); } export function truncateNotification(value: string) { const safe = [...safeTerminalText(value)]; return safe.length > 160 ? `${safe.slice(0, 157).join("")}...` : safe.join(""); } export function isUsageLimitedGoalInterruption(assistant: AssistantMessageLike) { const errorMessage = assistant.errorMessage; return ( assistant.stopReason === "error" && typeof errorMessage === "string" && USAGE_LIMIT_GOAL_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage)) ); } export function isRetryableGoalInterruption(assistant: AssistantMessageLike) { if (assistant.stopReason !== "error" || !assistant.errorMessage) return false; if (isUsageLimitedGoalInterruption(assistant) || NON_RETRYABLE_GOAL_ERROR_RE.test(assistant.errorMessage)) { return false; } return ( isGoalContextOverflow(assistant) || isRetryableAssistantError(toPiAssistantMessage(assistant)) || RETRYABLE_GOAL_ERROR_PATTERNS.some((pattern) => pattern.test(assistant.errorMessage ?? "")) ); } export function isGoalContextOverflow(assistant: AssistantMessageLike) { return isContextOverflow(toPiAssistantMessage(assistant)); } export function findFinalAssistantMessage(messages: unknown[]): AssistantMessageLike | undefined { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (!message || typeof message !== "object") continue; const candidate = message as Record; if (candidate.role !== "assistant") continue; const assistant: AssistantMessageLike = { role: "assistant", stopReason: isAgentStopReason(candidate.stopReason) ? candidate.stopReason : undefined, errorMessage: typeof candidate.errorMessage === "string" ? candidate.errorMessage : undefined, }; if (Array.isArray(candidate.content)) { assistant.content = candidate.content as PiAssistantMessage["content"]; } if (typeof candidate.api === "string") assistant.api = candidate.api; if (typeof candidate.provider === "string") assistant.provider = candidate.provider; if (typeof candidate.model === "string") assistant.model = candidate.model; if (typeof candidate.timestamp === "number") assistant.timestamp = candidate.timestamp; const usage = normalizeUsage(candidate.usage); if (usage) assistant.usage = usage; return assistant; } return undefined; } function toPiAssistantMessage(assistant: AssistantMessageLike): PiAssistantMessage { return { role: "assistant", content: assistant.content ?? [], api: assistant.api ?? "openai-responses", provider: assistant.provider ?? "unknown", model: assistant.model ?? "unknown", usage: assistant.usage ?? zeroUsage(), stopReason: assistant.stopReason ?? "error", errorMessage: assistant.errorMessage, timestamp: assistant.timestamp ?? Date.now(), }; } function zeroUsage(): Usage { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } function isAgentStopReason(value: unknown): value is AgentStopReason { return ["stop", "length", "toolUse", "error", "aborted"].includes(String(value)); } function normalizeUsage(value: unknown): Usage | undefined { if (!value || typeof value !== "object") return undefined; const usage = value as Partial; if (typeof usage.input !== "number" || typeof usage.output !== "number") return undefined; return { input: nonNegativeFiniteNumber(usage.input), output: nonNegativeFiniteNumber(usage.output), cacheRead: nonNegativeFiniteNumber(usage.cacheRead), cacheWrite: nonNegativeFiniteNumber(usage.cacheWrite), totalTokens: assistantUsageTokens(usage), cost: { input: usage.cost?.input ?? 0, output: usage.cost?.output ?? 0, cacheRead: usage.cost?.cacheRead ?? 0, cacheWrite: usage.cost?.cacheWrite ?? 0, total: usage.cost?.total ?? 0, }, }; }