1import type {
2 NodeId,
3 OrchestrationV2ProviderFailure,
4 OrchestrationV2ProviderFailureClass,
5 OrchestrationV2ProviderRetry,
6 OrchestrationV2TurnItem,
7 ProviderDriverKind,
8 ProviderThreadId,
9 ProviderTurnId,
10 RunId,
11 ThreadId,
12} from "@t3tools/contracts";
13import * as DateTime from "effect/DateTime";
14import * as Cause from "effect/Cause";
15
16import * as IdAllocator from "./IdAllocator.ts";
17import * as Schema from "effect/Schema";
18
19/** Raised when portable history does not fit the target provider's context window. */
20export class ContextHandoffBudgetError extends Schema.TaggedError<ContextHandoffBudgetError>()(
21 "ContextHandoffBudgetError",
22 {},
23) {
24 override get message() {
25 return "Insufficient context allowance for the provider handoff. Compact the target conversation or use a larger-context model; the current request has not been truncated.";
26 }
27}
28
29export const MAX_PROVIDER_FAILURE_MESSAGE_LENGTH = 4_096;
30export const MAX_PROVIDER_FAILURE_CODE_LENGTH = 128;
31
32const DEFAULT_PROVIDER_FAILURE_MESSAGE = "Provider turn failed.";
33
34/** Translate known categories without exposing arbitrary provider defect text. */
35function causeMessage(cause: unknown): string | undefined {
36 const seen = new Set<unknown>();
37 let message: string | undefined;
38 for (let depth = 0; depth < 16 && cause != null && !seen.has(cause); depth++) {
39 seen.add(cause);
40 try {
41 if (Cause.isCause(cause)) {
42 cause = Cause.squash(cause);
43 continue;
44 }
45 if (typeof cause !== "object") break;
46 switch ((cause as Record<string, unknown>)._tag) {
47 case "ContextHandoffBudgetError":
48 return new ContextHandoffBudgetError().message;
49 case "ClaudeBackgroundWorkBlocksQueryReplacementError":
50 return stringField(cause, "message");
51 case "ContextHandoffDeliveryUncertainError":
52 return "T3 could not confirm whether conversation history reached the provider. Retry the turn to recover the session.";
53 case "ProviderAdapterTurnStartError":
54 message =
55 "The provider could not start this turn. Retry the turn; if it keeps failing, check the provider setup and server logs.";
56 break;
57 case "ProviderAdapterEventStreamError":
58 message =
59 "The provider event stream closed unexpectedly. Retry the turn; if it keeps failing, check the provider and server logs.";
60 break;
61 case "ProviderAdapterOpenSessionError":
62 message =
63 "The provider session could not be opened. Check that the provider is installed and signed in, then retry the turn.";
64 break;
65 case "ProviderAdapterResumeThreadError":
66 message =
67 "The provider conversation could not be resumed. Retry the turn; if it keeps failing, check the provider and server logs.";
68 break;
69 }
70 cause = (cause as Record<string, unknown>).cause;
71 } catch {
72 break;
73 }
74 }
75 return message;
76}
77
78function stringField(value: unknown, key: "message" | "code"): string | undefined {
79 if (typeof value !== "object" || value === null) return undefined;
80 try {
81 const candidate = (value as Record<string, unknown>)[key];
82 return typeof candidate === "string" ? candidate : undefined;
83 } catch {
84 return undefined;
85 }
86}
87
88function redactUrl(match: string): string {
89 const trailing = /[),.;!?]+$/u.exec(match)?.[0] ?? "";
90 const candidate = trailing.length === 0 ? match : match.slice(0, -trailing.length);
91 try {
92 const url = new URL(candidate);
93 url.username = "";
94 url.password = "";
95 url.search = "";
96 url.hash = "";
97 return `${url.toString()}${trailing}`;
98 } catch {
99 return "[REDACTED_URL]";
100 }
101}
102
103function replaceUnsafeControlCharacters(value: string): string {
104 const sanitized: Array<string> = [];
105 for (const character of value) {
106 const codePoint = character.codePointAt(0) ?? 0;
107 sanitized.push(
108 codePoint <= 0x08 ||
109 (codePoint >= 0x0b && codePoint <= 0x0c) ||
110 (codePoint >= 0x0e && codePoint <= 0x1f) ||
111 codePoint === 0x7f
112 ? " "
113 : character,
114 );
115 }
116 return sanitized.join("");
117}
118
119/** Removes common credential forms before provider text crosses a transport boundary. */
120function redactProviderFailureText(value: string): string {
121 return replaceUnsafeControlCharacters(value)
122 .replace(/\bhttps?:\/\/[^\s<>"']+/giu, redactUrl)
123 .replace(/\b(Bearer|Basic)\s+[^\s,;]+/giu, "$1 [REDACTED]")
124 .replace(
125 /(["'](?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)["']\s*:\s*["'])[^"']*(["'])/giu,
126 "$1[REDACTED]$2",
127 )
128 .replace(
129 /(\b(?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu,
130 "$1[REDACTED]",
131 )
132 .replace(/\bsk-[A-Za-z0-9_-]{16,}\b/gu, "[REDACTED]")
133 .trim();
134}
135
136function boundedText(value: string, maxLength: number): string {
137 const redacted = redactProviderFailureText(value);
138 if (redacted.length <= maxLength) return redacted;
139 let end = Math.max(0, maxLength - 1);
140 const finalCodeUnit = redacted.charCodeAt(end - 1);
141 if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
142 end -= 1;
143 }
144 return `${redacted.slice(0, end)}…`;
145}
146
147export function makeProviderFailure(input: {
148 readonly cause?: unknown;
149 readonly message?: string | undefined;
150 readonly code?: string | null | undefined;
151 readonly class?: OrchestrationV2ProviderFailureClass;
152 readonly retryable?: boolean | null;
153 readonly resetAt?: string | null;
154}): OrchestrationV2ProviderFailure {
155 const rawMessage = input.message ?? causeMessage(input.cause) ?? DEFAULT_PROVIDER_FAILURE_MESSAGE;
156 const message = boundedText(rawMessage, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH);
157 const rawCode = input.code ?? stringField(input.cause, "code") ?? null;
158 const code =
159 rawCode === null ? null : boundedText(rawCode, MAX_PROVIDER_FAILURE_CODE_LENGTH) || null;
160
161 return {
162 class: input.class ?? "unknown",
163 message: message || DEFAULT_PROVIDER_FAILURE_MESSAGE,
164 code,
165 retryable: input.retryable ?? null,
166 ...(input.class === "usage_limit" &&
167 input.resetAt != null &&
168 Number.isFinite(Date.parse(input.resetAt))
169 ? { resetAt: DateTime.formatIso(DateTime.makeUnsafe(input.resetAt)) }
170 : {}),
171 };
172}
173
174export function makeProviderFailureTurnItem(input: {
175 readonly driver: ProviderDriverKind;
176 readonly threadId: ThreadId;
177 readonly runId: RunId | null;
178 readonly nodeId: NodeId | null;
179 readonly providerThreadId: ProviderThreadId;
180 readonly providerTurnId: ProviderTurnId;
181 readonly itemOrdinal: number;
182 readonly failure: OrchestrationV2ProviderFailure;
183 readonly retry?: OrchestrationV2ProviderRetry;
184 readonly retryStartedAt?: DateTime.Utc;
185 readonly occurredAt: DateTime.Utc;
186}): Extract<OrchestrationV2TurnItem, { readonly type: "error" }> {
187 return {
188 id: IdAllocator.derive.turnItemFromProviderItem({
189 driver: input.driver,
190 nativeItemId: `terminal-failure:${input.providerTurnId}`,
191 }),
192 threadId: input.threadId,
193 runId: input.runId,
194 nodeId: input.nodeId,
195 providerThreadId: input.providerThreadId,
196 providerTurnId: input.providerTurnId,
197 nativeItemRef: null,
198 parentItemId: null,
199 ordinal: input.itemOrdinal,
200 status: "failed",
201 title: input.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error",
202 startedAt: input.retryStartedAt ?? input.occurredAt,
203 completedAt: input.occurredAt,
204 updatedAt: input.occurredAt,
205 type: "error",
206 failure: input.failure,
207 ...(input.retry === undefined ? {} : { retry: input.retry }),
208 };
209}
210
211export function makeProviderRetryTurnItem(input: {
212 readonly driver: ProviderDriverKind;
213 readonly threadId: ThreadId;
214 readonly runId: RunId | null;
215 readonly nodeId: NodeId | null;
216 readonly providerThreadId: ProviderThreadId;
217 readonly providerTurnId: ProviderTurnId;
218 readonly itemOrdinal: number;
219 readonly failure: OrchestrationV2ProviderFailure;
220 readonly retry: OrchestrationV2ProviderRetry;
221 readonly status: Extract<
222 OrchestrationV2TurnItem["status"],
223 "running" | "completed" | "failed" | "interrupted" | "cancelled"
224 >;
225 readonly startedAt: DateTime.Utc;
226 readonly updatedAt: DateTime.Utc;
227}): Extract<OrchestrationV2TurnItem, { readonly type: "error" }> {
228 const completed = input.status !== "running";
229 let title = "Provider retry";
230 if (input.status === "completed") {
231 title = "Provider recovered";
232 } else if (input.status === "failed") {
233 title = input.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error";
234 } else if (input.status === "interrupted" || input.status === "cancelled") {
235 title = "Provider retry stopped";
236 }
237 return {
238 id: IdAllocator.derive.turnItemFromProviderItem({
239 driver: input.driver,
240 nativeItemId: `terminal-failure:${input.providerTurnId}`,
241 }),
242 threadId: input.threadId,
243 runId: input.runId,
244 nodeId: input.nodeId,
245 providerThreadId: input.providerThreadId,
246 providerTurnId: input.providerTurnId,
247 nativeItemRef: null,
248 parentItemId: null,
249 ordinal: input.itemOrdinal,
250 status: input.status,
251 title,
252 startedAt: input.startedAt,
253 completedAt: completed ? input.updatedAt : null,
254 updatedAt: input.updatedAt,
255 type: "error",
256 failure: input.failure,
257 retry: input.retry,
258 };
259}