@ixo/oracle-runtime-workers 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -1
  2. package/package.json +4 -2
  3. package/src/channels/auth.ts +54 -11
  4. package/src/channels/contract.ts +122 -13
  5. package/src/channels/turns.ts +12 -2
  6. package/src/core/budgeted-llm.ts +115 -6
  7. package/src/core/context-budget.ts +24 -26
  8. package/src/core/context-window.ts +39 -12
  9. package/src/core/domain-context/document.ts +100 -0
  10. package/src/core/domain-context/findings.ts +25 -0
  11. package/src/core/domain-context/index.ts +28 -0
  12. package/src/core/domain-context/integrity.ts +60 -0
  13. package/src/core/domain-context/lru.ts +63 -0
  14. package/src/core/domain-context/resolver.ts +651 -0
  15. package/src/core/domain-context/transport.ts +340 -0
  16. package/src/core/domain-context/turn-context.ts +443 -0
  17. package/src/core/domain-context/types.ts +119 -0
  18. package/src/core/domain-context/validator.ts +41 -0
  19. package/src/core/env.ts +60 -6
  20. package/src/core/index.ts +2 -0
  21. package/src/core/llm.ts +225 -4
  22. package/src/core/main-agent-types.ts +28 -0
  23. package/src/core/main-agent.ts +61 -4
  24. package/src/core/middlewares/tool-execution.ts +19 -2
  25. package/src/core/registries.ts +14 -3
  26. package/src/core/state.ts +2 -1
  27. package/src/core/sub-agent-fallback.ts +14 -2
  28. package/src/core/turn-budget.ts +19 -22
  29. package/src/do/contracts.ts +23 -2
  30. package/src/do/domain-context-store.ts +123 -0
  31. package/src/do/run-request.ts +6 -0
  32. package/src/do/sse-stream.ts +1 -1
  33. package/src/do/transcript.ts +9 -0
  34. package/src/do/turn-body.ts +2 -1
  35. package/src/do/turn-metadata.ts +13 -6
  36. package/src/do/user-oracle-do.ts +237 -60
  37. package/src/index.ts +16 -0
  38. package/src/llm/byo-adapter.ts +6 -0
  39. package/src/llm/byo-catalog.ts +7 -4
  40. package/src/llm/byo-client.ts +42 -7
  41. package/src/llm/provider-error.ts +21 -3
  42. package/src/llm/stream-liveness.ts +498 -0
  43. package/src/plugins/firecrawl/firecrawl-tools.ts +6 -0
  44. package/src/shell/app.ts +41 -8
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Liveness guard for model calls: a `fetch` wrapper that notices a provider
3
+ * which has stopped sending bytes and fails (or retries) the call, instead
4
+ * of letting it hang until the turn deadline.
5
+ *
6
+ * Nothing else bounds a single model call below the turn deadline. The
7
+ * OpenAI SDK's request timeout only covers the wait for response headers
8
+ * (its timer is cleared as soon as `fetch` resolves), so a streamed body
9
+ * that goes quiet is never noticed, and LangChain's retry only reacts to a
10
+ * thrown error. The guard watches the raw body bytes:
11
+ *
12
+ * - headers: no response within `headersTimeoutMs`;
13
+ * - first byte: response headers arrived but no body byte within
14
+ * `idleTimeoutMs` (the returned promise only resolves once the first
15
+ * byte is in, so a stall here is retried transparently);
16
+ * - stream: no byte for `idleTimeoutMs` after bytes were forwarded.
17
+ *
18
+ * Any byte counts — SSE comments (OpenRouter's `: OPENROUTER PROCESSING`
19
+ * keep-alives) and Anthropic `ping` events included — so a provider that
20
+ * signals it is working is never cut off. There is no cap on the total
21
+ * duration: a long reply that keeps streaming is legitimate.
22
+ *
23
+ * Most model calls of a turn are streamed (LangChain streams every call
24
+ * inside a `streamEvents` turn). A few are not — the session title, the
25
+ * group-chat summary, the platform model answering for a refused BYO model —
26
+ * and their single JSON body only starts once the generation is done, so for
27
+ * them `headersTimeoutMs` + `idleTimeoutMs` bound the whole generation and a
28
+ * longer one is re-sent and then fails. Those calls are short; this is
29
+ * accepted rather than special-cased.
30
+ *
31
+ * A stall at the headers or first-byte phase re-issues the identical
32
+ * request up to `retries` times (only when the request body can be sent
33
+ * again). A stall after bytes were forwarded cannot be retried
34
+ * transparently — the caller already consumed part of the reply — so the
35
+ * body errors with `ProviderStallError` and the call fails fast.
36
+ *
37
+ * Every timer is cleared when its phase ends, on completion, on error, on
38
+ * cancel and on abort: on workerd a dangling timer keeps the user's Durable
39
+ * Object resident (see `src/plugins/mcp-call-timeout.ts`).
40
+ */
41
+ import { AsyncCaller } from '@langchain/core/utils/async_caller';
42
+ import type { Logger } from '../plugin-api/types';
43
+ import { NOOP_LOGGER } from '../core/utils';
44
+
45
+ /** Where a stalled call stopped. */
46
+ export type StallPhase = 'headers' | 'first-byte' | 'stream';
47
+
48
+ /** The provider stopped sending bytes (or never started). */
49
+ export class ProviderStallError extends Error {
50
+ override readonly name = 'ProviderStallError';
51
+ readonly phase: StallPhase;
52
+ /** The silence budget that ran out (ms). */
53
+ readonly idleMs: number;
54
+ /** Time since the attempt started (ms). */
55
+ readonly elapsedMs: number;
56
+ /** Body bytes received before the stall. */
57
+ readonly bytesSeen: number;
58
+ readonly label: string;
59
+
60
+ constructor(init: {
61
+ phase: StallPhase;
62
+ idleMs: number;
63
+ elapsedMs: number;
64
+ bytesSeen: number;
65
+ label: string;
66
+ }) {
67
+ const seconds = Math.round(init.idleMs / 1000);
68
+ super(
69
+ init.phase === 'headers'
70
+ ? `${init.label}: the provider sent no response for ${seconds} s`
71
+ : init.phase === 'first-byte'
72
+ ? `${init.label}: the provider answered but sent no data for ${seconds} s`
73
+ : `${init.label}: the provider stopped sending data mid-reply (no bytes for ${seconds} s after ${init.bytesSeen} bytes)`,
74
+ );
75
+ this.phase = init.phase;
76
+ this.idleMs = init.idleMs;
77
+ this.elapsedMs = init.elapsedMs;
78
+ this.bytesSeen = init.bytesSeen;
79
+ this.label = init.label;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The `ProviderStallError` in `error` or its `cause` chain — the OpenAI SDK
85
+ * wraps a failed `fetch` in `APIConnectionError` with the original as
86
+ * `cause`, LangChain's middleware errors wrap again.
87
+ */
88
+ export function findProviderStall(error: unknown): ProviderStallError | null {
89
+ let current: unknown = error;
90
+ for (let depth = 0; depth < 8; depth += 1) {
91
+ if (current instanceof ProviderStallError) return current;
92
+ if (!current || typeof current !== 'object' || !('cause' in current))
93
+ return null;
94
+ current = current.cause;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /** The three budgets of one model lane. */
100
+ export interface StreamLivenessSettings {
101
+ /** Time to response headers (ms). */
102
+ headersTimeoutMs: number;
103
+ /** Longest silence between body bytes, and before the first one (ms). */
104
+ idleTimeoutMs: number;
105
+ /** Whole-request retries while no body byte has been forwarded. */
106
+ retries: number;
107
+ }
108
+
109
+ /** What the guard reports, for tests and diagnostics. */
110
+ export type LivenessEvent =
111
+ | { type: 'first-byte'; afterMs: number; attempt: number }
112
+ | { type: 'retry'; phase: StallPhase; attempt: number }
113
+ | { type: 'stall'; error: ProviderStallError; attempt: number };
114
+
115
+ export interface LivenessFetchOptions {
116
+ headersTimeoutMs: number;
117
+ idleTimeoutMs: number;
118
+ /** Default 1. */
119
+ retries?: number;
120
+ /** Names the call in logs and in the stall error (e.g. `openrouter main openai/gpt-5`). */
121
+ label: string;
122
+ logger?: Logger;
123
+ /** The fetch that does the work; default the global `fetch`, resolved per call. */
124
+ fetch?: typeof fetch;
125
+ onEvent?: (event: LivenessEvent) => void;
126
+ }
127
+
128
+ const DEFAULT_RETRIES = 1;
129
+
130
+ /**
131
+ * The SDK request timeout to pair with a guard: longer than the guard can
132
+ * take before its promise settles (every attempt waiting out both budgets),
133
+ * so the guard — never the SDK — decides a stall, and the SDK timer stays a
134
+ * backstop that replaces its 10-minute default.
135
+ */
136
+ export function livenessRequestTimeoutMs(
137
+ settings: StreamLivenessSettings,
138
+ ): number {
139
+ return (
140
+ (settings.retries + 1) *
141
+ (settings.headersTimeoutMs + settings.idleTimeoutMs) +
142
+ 5_000
143
+ );
144
+ }
145
+
146
+ /** A body `fetch` can send again unchanged. */
147
+ function isReplayableBody(body: unknown): boolean {
148
+ return (
149
+ body === undefined ||
150
+ body === null ||
151
+ typeof body === 'string' ||
152
+ body instanceof ArrayBuffer ||
153
+ ArrayBuffer.isView(body) ||
154
+ body instanceof URLSearchParams ||
155
+ (typeof Blob !== 'undefined' && body instanceof Blob)
156
+ );
157
+ }
158
+
159
+ /** The caller's abort reason, as the error the aborted call fails with. */
160
+ function abortReasonOf(signal: AbortSignal): Error {
161
+ const reason: unknown = signal.reason;
162
+ if (reason instanceof Error) return reason;
163
+ return new DOMException(
164
+ reason === undefined ? 'The operation was aborted.' : String(reason),
165
+ 'AbortError',
166
+ );
167
+ }
168
+
169
+ function toError(error: unknown): Error {
170
+ return error instanceof Error ? error : new Error(String(error));
171
+ }
172
+
173
+ /**
174
+ * A caller-supplied `configuration.fetch` (plugins may pass one through
175
+ * `ctx.llm.get(role, { configuration })`), as a typed fetch the guard can
176
+ * wrap; `undefined` when absent or not a function.
177
+ */
178
+ export function asFetch(value: unknown): typeof fetch | undefined {
179
+ if (typeof value !== 'function') return undefined;
180
+ return async (input, init) => {
181
+ const result: unknown = await Reflect.apply(value, undefined, [
182
+ input,
183
+ init,
184
+ ]);
185
+ if (!(result instanceof Response))
186
+ throw new TypeError('configuration.fetch did not return a Response');
187
+ return result;
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Build a `fetch` that enforces the liveness budgets on every request it
193
+ * makes (see the module docblock).
194
+ */
195
+ export function createLivenessFetch(
196
+ options: LivenessFetchOptions,
197
+ ): typeof fetch {
198
+ const logger = options.logger ?? NOOP_LOGGER;
199
+ const retries = Math.max(0, options.retries ?? DEFAULT_RETRIES);
200
+ const { headersTimeoutMs, idleTimeoutMs, label } = options;
201
+ // Never keep the bare global: workerd rejects `fetch` invoked with a
202
+ // foreign `this`, and a test may stub the global after this is built.
203
+ const baseFetch: typeof fetch =
204
+ options.fetch ?? ((input, init) => globalThis.fetch(input, init));
205
+
206
+ const attemptOnce = (
207
+ input: RequestInfo | URL,
208
+ init: RequestInit | undefined,
209
+ attempt: number,
210
+ ): Promise<Response> => {
211
+ const callerSignal =
212
+ init?.signal ?? (input instanceof Request ? input.signal : undefined);
213
+ if (callerSignal?.aborted)
214
+ return Promise.reject(abortReasonOf(callerSignal));
215
+ const started = Date.now();
216
+ const controller = new AbortController();
217
+ let timer: ReturnType<typeof setTimeout> | undefined;
218
+ /** Rejects the step in flight (headers wait or a body read). */
219
+ let failPending: ((reason: Error) => void) | undefined;
220
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
221
+ let bytesSeen = 0;
222
+ let finished = false;
223
+
224
+ const clearTimer = (): void => {
225
+ if (timer !== undefined) clearTimeout(timer);
226
+ timer = undefined;
227
+ };
228
+ const onCallerAbort = (): void => {
229
+ if (!callerSignal) return;
230
+ clearTimer();
231
+ const reason = abortReasonOf(callerSignal);
232
+ controller.abort(reason);
233
+ if (reader) void reader.cancel(reason).catch(() => undefined);
234
+ failPending?.(reason);
235
+ };
236
+ /** The attempt is over (done, failed, cancelled): drop every hook. */
237
+ const finish = (): void => {
238
+ if (finished) return;
239
+ finished = true;
240
+ clearTimer();
241
+ failPending = undefined;
242
+ callerSignal?.removeEventListener('abort', onCallerAbort);
243
+ };
244
+ callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
245
+
246
+ const stall = (phase: StallPhase, budgetMs: number): ProviderStallError => {
247
+ const error = new ProviderStallError({
248
+ phase,
249
+ idleMs: budgetMs,
250
+ elapsedMs: Date.now() - started,
251
+ bytesSeen,
252
+ label,
253
+ });
254
+ options.onEvent?.({ type: 'stall', error, attempt });
255
+ controller.abort(error);
256
+ if (reader) void reader.cancel(error).catch(() => undefined);
257
+ return error;
258
+ };
259
+
260
+ /** One body read, bounded by the idle budget. */
261
+ const readChunk = (
262
+ phase: 'first-byte' | 'stream',
263
+ ): Promise<ReadableStreamReadResult<Uint8Array>> =>
264
+ new Promise((resolve, reject) => {
265
+ const active = reader;
266
+ if (!active) {
267
+ reject(new Error('liveness guard: no body reader'));
268
+ return;
269
+ }
270
+ if (callerSignal?.aborted) {
271
+ reject(abortReasonOf(callerSignal));
272
+ return;
273
+ }
274
+ failPending = reject;
275
+ timer = setTimeout(() => {
276
+ timer = undefined;
277
+ reject(stall(phase, idleTimeoutMs));
278
+ }, idleTimeoutMs);
279
+ active.read().then(
280
+ (result) => {
281
+ clearTimer();
282
+ if (callerSignal?.aborted) reject(abortReasonOf(callerSignal));
283
+ else resolve(result);
284
+ },
285
+ (error: unknown) => {
286
+ clearTimer();
287
+ reject(toError(error));
288
+ },
289
+ );
290
+ });
291
+
292
+ const run = async (): Promise<Response> => {
293
+ // ── headers ──
294
+ const response = await new Promise<Response>((resolve, reject) => {
295
+ let settled = false;
296
+ failPending = (reason) => {
297
+ settled = true;
298
+ reject(reason);
299
+ };
300
+ timer = setTimeout(() => {
301
+ timer = undefined;
302
+ settled = true;
303
+ reject(stall('headers', headersTimeoutMs));
304
+ }, headersTimeoutMs);
305
+ baseFetch(input, { ...init, signal: controller.signal }).then(
306
+ (res) => {
307
+ clearTimer();
308
+ if (settled) {
309
+ // Answered after the guard gave up: release the body.
310
+ void res.body?.cancel().catch(() => undefined);
311
+ return;
312
+ }
313
+ settled = true;
314
+ resolve(res);
315
+ },
316
+ (error: unknown) => {
317
+ clearTimer();
318
+ if (settled) return;
319
+ settled = true;
320
+ reject(toError(error));
321
+ },
322
+ );
323
+ });
324
+ if (!response.body) {
325
+ finish();
326
+ return response;
327
+ }
328
+
329
+ // ── first byte ──
330
+ reader = response.body.getReader();
331
+ const firstChunks: Uint8Array[] = [];
332
+ let ended = false;
333
+ while (bytesSeen === 0) {
334
+ const result = await readChunk('first-byte');
335
+ if (result.done) {
336
+ ended = true;
337
+ break;
338
+ }
339
+ if (result.value.byteLength === 0) continue;
340
+ bytesSeen += result.value.byteLength;
341
+ firstChunks.push(result.value);
342
+ }
343
+ const firstByteMs = Date.now() - started;
344
+ if (bytesSeen > 0) {
345
+ options.onEvent?.({
346
+ type: 'first-byte',
347
+ afterMs: firstByteMs,
348
+ attempt,
349
+ });
350
+ logger.debug?.(`[llm] ${label}: first byte after ${firstByteMs} ms`);
351
+ }
352
+ if (ended) finish();
353
+
354
+ // ── stream ──
355
+ const upstream = reader;
356
+ const body = new ReadableStream<Uint8Array>({
357
+ start(streamController) {
358
+ for (const chunk of firstChunks) streamController.enqueue(chunk);
359
+ if (ended) streamController.close();
360
+ },
361
+ async pull(streamController) {
362
+ try {
363
+ const result = await readChunk('stream');
364
+ // The consumer cancelled while this read was pending: the
365
+ // controller is gone, nothing more to do.
366
+ if (finished) return;
367
+ if (result.done) {
368
+ finish();
369
+ streamController.close();
370
+ return;
371
+ }
372
+ bytesSeen += result.value.byteLength;
373
+ streamController.enqueue(result.value);
374
+ } catch (error) {
375
+ finish();
376
+ if (error instanceof ProviderStallError)
377
+ logger.warn(
378
+ `[llm] ${label}: no bytes for ${Math.round(idleTimeoutMs / 1000)} s after ${bytesSeen} bytes (${Math.round(error.elapsedMs / 1000)} s into the call); failing it — a stall mid-reply is not retried`,
379
+ );
380
+ throw error;
381
+ }
382
+ },
383
+ cancel(reason) {
384
+ finish();
385
+ return upstream.cancel(reason);
386
+ },
387
+ });
388
+ const wrapped = new Response(body, {
389
+ status: response.status,
390
+ statusText: response.statusText,
391
+ headers: response.headers,
392
+ });
393
+ if (response.url)
394
+ Object.defineProperty(wrapped, 'url', { value: response.url });
395
+ return wrapped;
396
+ };
397
+
398
+ return run().catch((error: unknown) => {
399
+ finish();
400
+ throw error;
401
+ });
402
+ };
403
+
404
+ return async (input, init) => {
405
+ const replayable =
406
+ isReplayableBody(init?.body) &&
407
+ !(input instanceof Request && input.body !== null);
408
+ for (let attempt = 1; ; attempt += 1) {
409
+ try {
410
+ return await attemptOnce(input, init, attempt);
411
+ } catch (error) {
412
+ const retry =
413
+ error instanceof ProviderStallError &&
414
+ error.phase !== 'stream' &&
415
+ attempt <= retries &&
416
+ replayable &&
417
+ !init?.signal?.aborted &&
418
+ !(input instanceof Request && input.signal.aborted);
419
+ if (!retry) {
420
+ if (error instanceof ProviderStallError)
421
+ logger.warn(
422
+ `[llm] ${label}: ${error.phase === 'headers' ? 'no response' : 'no bytes'} for ${Math.round(error.idleMs / 1000)} s; giving up after ${attempt} attempt${attempt === 1 ? '' : 's'}`,
423
+ );
424
+ throw error;
425
+ }
426
+ logger.warn(
427
+ `[llm] ${label}: ${error.phase === 'headers' ? 'no response' : 'no bytes'} for ${Math.round(error.idleMs / 1000)} s, retrying (attempt ${attempt + 1})`,
428
+ );
429
+ options.onEvent?.({
430
+ type: 'retry',
431
+ phase: error.phase,
432
+ attempt: attempt + 1,
433
+ });
434
+ }
435
+ }
436
+ };
437
+ }
438
+
439
+ /**
440
+ * LangChain's default retry rule (the one `AsyncCaller` applies when no
441
+ * handler is given), reachable only from a subclass.
442
+ */
443
+ class DefaultRetryRule extends AsyncCaller {
444
+ static handler(): ((error: unknown) => unknown) | undefined {
445
+ return new DefaultRetryRule({}).onFailedAttempt;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * `onFailedAttempt` for a guarded model: a stall the guard already gave up
451
+ * on is not retried again by LangChain (the guard owns stall retries, so
452
+ * `LLM_STREAM_RETRIES` is the whole budget); everything else follows
453
+ * LangChain's default rule.
454
+ */
455
+ export function stallAwareFailedAttemptHandler(): (error: unknown) => unknown {
456
+ const fallback = DefaultRetryRule.handler();
457
+ return (error) => {
458
+ if (findProviderStall(error)) throw error;
459
+ return fallback?.(error);
460
+ };
461
+ }
462
+
463
+ /**
464
+ * The `ChatOpenAI` fields every guarded model gets: the SDK timeout as a
465
+ * backstop behind the guard, and the stall-aware retry rule. Spread them
466
+ * before the caller's params so an explicit caller value still wins.
467
+ */
468
+ export function livenessModelFields(settings: StreamLivenessSettings): {
469
+ timeout: number;
470
+ onFailedAttempt: (error: unknown) => unknown;
471
+ } {
472
+ return {
473
+ timeout: livenessRequestTimeoutMs(settings),
474
+ onFailedAttempt: stallAwareFailedAttemptHandler(),
475
+ };
476
+ }
477
+
478
+ /**
479
+ * The guarded `configuration.fetch` for one model: wraps the caller's own
480
+ * `configuration.fetch` when one was passed, else `base` (default: the
481
+ * global fetch).
482
+ */
483
+ export function livenessFetchFor(args: {
484
+ settings: StreamLivenessSettings;
485
+ label: string;
486
+ logger?: Logger;
487
+ /** `params.configuration.fetch`, as the caller passed it. */
488
+ callerFetch?: unknown;
489
+ base?: typeof fetch;
490
+ }): typeof fetch {
491
+ const inner = asFetch(args.callerFetch) ?? args.base;
492
+ return createLivenessFetch({
493
+ ...args.settings,
494
+ label: args.label,
495
+ ...(args.logger ? { logger: args.logger } : {}),
496
+ ...(inner ? { fetch: inner } : {}),
497
+ });
498
+ }
@@ -204,6 +204,9 @@ export function createFirecrawlTools(
204
204
  name: FIRECRAWL_SEARCH_TOOL,
205
205
  description: SEARCH_DESCRIPTION,
206
206
  schema: searchSchema,
207
+ // Fetches public pages and changes nothing: safe to run again after a
208
+ // reset, and outside the write lane and its claims.
209
+ effect: 'read',
207
210
  },
208
211
  );
209
212
 
@@ -221,6 +224,9 @@ export function createFirecrawlTools(
221
224
  name: FIRECRAWL_SCRAPE_TOOL,
222
225
  description: SCRAPE_DESCRIPTION,
223
226
  schema: scrapeSchema,
227
+ // Fetches public pages and changes nothing: safe to run again after a
228
+ // reset, and outside the write lane and its claims.
229
+ effect: 'read',
224
230
  },
225
231
  );
226
232
 
package/src/shell/app.ts CHANGED
@@ -139,6 +139,20 @@ function errorText(err: unknown): string {
139
139
  return err instanceof Error ? err.message : String(err);
140
140
  }
141
141
 
142
+ /** Longest error message an unexpected channel failure logs. */
143
+ const CHANNEL_FAILURE_LOG_CHARS = 300;
144
+
145
+ /** `<name>: <message>` of an unexpected channel failure, the message bounded. */
146
+ function describeChannelFailure(err: unknown): string {
147
+ const name = err instanceof Error ? err.name : typeof err;
148
+ const message = errorText(err);
149
+ return `${name}: ${
150
+ message.length > CHANNEL_FAILURE_LOG_CHARS
151
+ ? `${message.slice(0, CHANNEL_FAILURE_LOG_CHARS)}…`
152
+ : message
153
+ }`;
154
+ }
155
+
142
156
  /** The rejection an in-flight RPC gets when `ctx.abort()` resets its object. */
143
157
  function isAbortRejection(err: unknown): boolean {
144
158
  return /debug reset requested|reset requested by the debug route|durable object reset|no longer active|aborted/i.test(
@@ -405,16 +419,21 @@ export function createShell(
405
419
  app.post('/channels/turn', async (c) => {
406
420
  try {
407
421
  if (!c.env.CHANNEL_SERVICE_DID)
408
- return c.json({ message: 'Channels are not configured' }, 503);
422
+ throw new ChannelError(
423
+ 503,
424
+ 'Channels are not configured',
425
+ 'not_configured',
426
+ );
409
427
  const raw = await readChannelBody(c.req.raw);
410
428
  let json: unknown;
411
429
  try {
412
430
  json = JSON.parse(raw);
413
431
  } catch {
414
- throw new ChannelError(400, 'Invalid JSON request');
432
+ throw new ChannelError(400, 'Invalid JSON request', 'invalid_request');
415
433
  }
416
434
  const parsed = ChannelTurnBody.safeParse(json);
417
- if (!parsed.success) throw new ChannelError(400, 'Invalid channel turn');
435
+ if (!parsed.success)
436
+ throw new ChannelError(400, 'Invalid channel turn', 'invalid_request');
418
437
  const identity = await authenticateChannel(
419
438
  c.req.raw.headers,
420
439
  raw,
@@ -431,7 +450,7 @@ export function createShell(
431
450
  })
432
451
  ).success
433
452
  )
434
- throw new ChannelError(429, 'Too many requests');
453
+ throw new ChannelError(429, 'Too many requests', 'rate_limited');
435
454
  await assertActiveChannelBinding(identity, c.env);
436
455
  const outcome = await userStub(c.env, identity.userDid).channelTurn(
437
456
  identity,
@@ -439,7 +458,10 @@ export function createShell(
439
458
  await channelRequestHash(raw),
440
459
  );
441
460
  if (!outcome.ok)
442
- return c.json({ message: outcome.message }, outcome.status);
461
+ return c.json(
462
+ { code: outcome.code, message: outcome.message },
463
+ outcome.status,
464
+ );
443
465
  const result = outcome.result;
444
466
  return c.json(
445
467
  result,
@@ -447,9 +469,20 @@ export function createShell(
447
469
  );
448
470
  } catch (error) {
449
471
  if (error instanceof ChannelError)
450
- return c.json({ message: error.message }, error.status);
472
+ return c.json(
473
+ { code: error.code, message: error.message },
474
+ error.status,
475
+ );
476
+ // The cause only, never the request or its tokens: a 503 the gateway
477
+ // retries must leave a trace of why it happened.
478
+ console.error(
479
+ `[channels] turn failed unexpectedly: ${describeChannelFailure(error)}`,
480
+ );
451
481
  return c.json(
452
- { message: 'Channel turn is temporarily unavailable' },
482
+ {
483
+ code: 'unavailable',
484
+ message: 'Channel turn is temporarily unavailable',
485
+ },
453
486
  503,
454
487
  );
455
488
  }
@@ -1084,7 +1117,7 @@ export function createShell(
1084
1117
  // Thrown on purpose with a status and a message meant for the client.
1085
1118
  if (err instanceof HTTPException) return err.getResponse();
1086
1119
  if (err instanceof ChannelError)
1087
- return c.json({ message: err.message }, err.status);
1120
+ return c.json({ code: err.code, message: err.message }, err.status);
1088
1121
  const requestId = errorRequestId(c.req.header('x-request-id'));
1089
1122
  console.error(
1090
1123
  `[shell] ${c.req.method} ${loggedRoute(c)} failed (request ${requestId}):`,