@ixo/oracle-runtime-workers 0.26.0 → 0.28.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 (36) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/channels/auth.ts +2 -1
  4. package/src/channels/contract.ts +104 -0
  5. package/src/channels/replies.ts +58 -0
  6. package/src/channels/routing.ts +222 -0
  7. package/src/channels/turns.ts +1252 -16
  8. package/src/core/domain-context/turn-context.ts +1 -1
  9. package/src/core/env.ts +22 -4
  10. package/src/core/main-agent.ts +31 -5
  11. package/src/core/prompt-composer.ts +14 -0
  12. package/src/core/sub-agent-fallback.ts +3 -2
  13. package/src/core/user-identity.ts +67 -0
  14. package/src/do/contracts.ts +19 -0
  15. package/src/do/room-mirror.ts +8 -1
  16. package/src/do/run-coordinator.ts +27 -0
  17. package/src/do/transcript.ts +14 -11
  18. package/src/do/user-oracle-do.ts +269 -63
  19. package/src/memory/session-history-indexer.ts +442 -103
  20. package/src/memory/user-context-fetcher.ts +13 -0
  21. package/src/memory/user-context-invalidation.ts +2 -2
  22. package/src/plugins/action-proposals/evidence.ts +7 -8
  23. package/src/plugins/hub-actions/hub-actions.plugin.ts +6 -2
  24. package/src/plugins/index.ts +6 -2
  25. package/src/plugins/ixo-chain/blocksync.ts +214 -0
  26. package/src/plugins/ixo-chain/denoms.ts +189 -0
  27. package/src/plugins/ixo-chain/http.ts +388 -0
  28. package/src/plugins/ixo-chain/index.ts +1 -0
  29. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +543 -0
  30. package/src/plugins/ixo-chain/queries.ts +401 -0
  31. package/src/plugins/ixo-chain/rest.ts +79 -0
  32. package/src/plugins/ixo-chain/summaries.ts +799 -0
  33. package/src/plugins/tasks/middleware.ts +4 -9
  34. package/src/shell/app.ts +70 -0
  35. package/src/sqlite/serialization.ts +20 -0
  36. package/src/sqlite/sessions-store.ts +43 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * The network edge shared by the chain REST and Blocksync reads: one bounded
3
+ * HTTP exchange per attempt and the retry policy around it. Upstream
4
+ * failures become a {@link Failure} the tools hand to the model; nothing
5
+ * here throws for them. Only the turn's own abort is rethrown.
6
+ */
7
+ import {
8
+ boundedBytes,
9
+ fetchWithoutRedirects,
10
+ } from '../../core/domain-context/transport';
11
+
12
+ export type Source = 'chain' | 'blocksync';
13
+
14
+ export type FailureReason =
15
+ | 'rate_limited'
16
+ | 'timeout'
17
+ | 'index_timeout'
18
+ | 'unavailable'
19
+ | 'not_found'
20
+ | 'too_large'
21
+ | 'invalid_input';
22
+
23
+ /** Why a read failed: a code, the plain sentence the model relays, and whether asking again can help. */
24
+ export interface Failure {
25
+ reason: FailureReason;
26
+ detail: string;
27
+ retryable: boolean;
28
+ }
29
+
30
+ /**
31
+ * The one retry waits a uniform 1–3 s when the upstream names no
32
+ * `Retry-After`, so isolates that failed together do not retry together.
33
+ */
34
+ export const RETRY_DELAY_MIN_MS = 1_000;
35
+ export const RETRY_DELAY_MAX_MS = 3_000;
36
+ /** Longest `Retry-After` honoured. */
37
+ export const MAX_RETRY_DELAY_MS = 5_000;
38
+ /** Whole-tool budget: every attempt and wait of one tool call fits inside it. */
39
+ export const TOOL_BUDGET_MS = 20_000;
40
+ /** A read, or its retry, is not started with less time than this left in the budget. */
41
+ export const MIN_ATTEMPT_MS = 1_000;
42
+ /** How long a 429 or 503 answers the isolate's other reads of that upstream without asking it. */
43
+ export const UNHEALTHY_MS = 10_000;
44
+
45
+ const SOURCE_NAME: Record<Source, string> = {
46
+ chain: 'The chain node',
47
+ blocksync: 'Blocksync',
48
+ };
49
+
50
+ function rateLimited(source: Source): Failure {
51
+ return {
52
+ reason: 'rate_limited',
53
+ detail: `${SOURCE_NAME[source]} is rate-limiting requests; try again in a few seconds.`,
54
+ retryable: true,
55
+ };
56
+ }
57
+
58
+ function timedOut(source: Source): Failure {
59
+ return {
60
+ reason: 'timeout',
61
+ detail:
62
+ source === 'blocksync'
63
+ ? 'Blocksync did not answer in time; narrow the question (fewer rows or requests).'
64
+ : 'The chain node did not answer in time; try again shortly.',
65
+ retryable: true,
66
+ };
67
+ }
68
+
69
+ export const tooLarge = (detail: string): Failure => ({
70
+ reason: 'too_large',
71
+ detail,
72
+ retryable: false,
73
+ });
74
+
75
+ export function unavailable(
76
+ source: Source,
77
+ why: string,
78
+ retryable: boolean,
79
+ ): Failure {
80
+ return {
81
+ reason: 'unavailable',
82
+ detail: `${SOURCE_NAME[source]} is unavailable right now (${why}).`,
83
+ retryable,
84
+ };
85
+ }
86
+
87
+ /** What one tool call's reads share: where they go, the budget and the turn's abort. */
88
+ export interface ChainCall {
89
+ network: 'mainnet' | 'testnet' | 'devnet';
90
+ restUrl: string;
91
+ blocksyncUrl: string;
92
+ /** Epoch ms by which every read of the call has finished. */
93
+ deadline: number;
94
+ signal: AbortSignal;
95
+ fetch: typeof fetch;
96
+ }
97
+
98
+ /**
99
+ * One attempt's outcome. `retry` asks for the policy's one retry: on a
100
+ * failure it is the failure's, on a value it means the answer was
101
+ * transient (a GraphQL "transaction aborted") and is kept if the retry
102
+ * cannot run or fails.
103
+ */
104
+ export type Attempt<T> =
105
+ | { ok: true; value: T; retry?: false }
106
+ | { ok: true; value: T; retry: true; note: string }
107
+ | {
108
+ ok: false;
109
+ failure: Failure;
110
+ retry: boolean;
111
+ retryAfterMs?: number;
112
+ /** A 429 or 503: the upstream is marked unhealthy for {@link UNHEALTHY_MS}. */
113
+ unhealthy?: true;
114
+ /** An expected answer rather than a fault (a 404 the caller asked for): not logged. */
115
+ quiet?: true;
116
+ /** Log text: status or error class, never a request body or an address. */
117
+ note: string;
118
+ };
119
+
120
+ export type Outcome<T> =
121
+ | { ok: true; value: T }
122
+ | { ok: false; failure: Failure };
123
+
124
+ /** The turn's abort reason as an error (a non-error reason becomes an `AbortError`). */
125
+ function abortReason(signal: AbortSignal): Error {
126
+ return signal.reason instanceof Error
127
+ ? signal.reason
128
+ : new DOMException('Aborted', 'AbortError');
129
+ }
130
+
131
+ /** Throws the turn's abort reason once the turn is aborted. */
132
+ export function throwIfAborted(signal: AbortSignal): void {
133
+ if (signal.aborted) throw abortReason(signal);
134
+ }
135
+
136
+ /** Resolves after `ms`; rejects with the turn's abort reason if it ends first. */
137
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
138
+ return new Promise((resolve, reject) => {
139
+ if (signal.aborted) {
140
+ reject(abortReason(signal));
141
+ return;
142
+ }
143
+ const onAbort = () => {
144
+ clearTimeout(timer);
145
+ reject(abortReason(signal));
146
+ };
147
+ const timer = setTimeout(() => {
148
+ signal.removeEventListener('abort', onAbort);
149
+ resolve();
150
+ }, ms);
151
+ signal.addEventListener('abort', onAbort, { once: true });
152
+ });
153
+ }
154
+
155
+ /** The wait before the one retry when the upstream named none: uniform in 1–3 s. */
156
+ export function retryDelayMs(): number {
157
+ return Math.round(
158
+ RETRY_DELAY_MIN_MS +
159
+ Math.random() * (RETRY_DELAY_MAX_MS - RETRY_DELAY_MIN_MS),
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Upstreams that answered 429 or 503 within the last {@link UNHEALTHY_MS},
165
+ * keyed by base URL, with the failure that marked them. Per isolate: every
166
+ * call of the isolate answers with it instead of asking the upstream again.
167
+ */
168
+ const unhealthy = new Map<string, { until: number; failure: Failure }>();
169
+
170
+ /** Tests only: forget every unhealthy mark. */
171
+ export function resetUpstreamHealth(): void {
172
+ unhealthy.clear();
173
+ }
174
+
175
+ function heldBack(upstream: string): Failure | null {
176
+ const mark = unhealthy.get(upstream);
177
+ if (!mark) return null;
178
+ if (mark.until > Date.now()) return mark.failure;
179
+ unhealthy.delete(upstream);
180
+ return null;
181
+ }
182
+
183
+ /** An answer clears the upstream's mark; a 429 or 503 sets it. */
184
+ function recordHealth(upstream: string, attempt: Attempt<unknown>): void {
185
+ if (attempt.ok) unhealthy.delete(upstream);
186
+ else if (attempt.unhealthy)
187
+ unhealthy.set(upstream, {
188
+ until: Date.now() + UNHEALTHY_MS,
189
+ failure: attempt.failure,
190
+ });
191
+ }
192
+
193
+ /** One logical read: its log label and the base URL its health is kept under. */
194
+ export interface Read {
195
+ label: string;
196
+ upstream: string;
197
+ }
198
+
199
+ /**
200
+ * Runs `attempt`, and once more when it asks for a retry and the budget
201
+ * leaves room: after `Retry-After` (capped at 5 s) when the upstream sent
202
+ * one, else after a uniform 1–3 s. An upstream marked unhealthy is not
203
+ * asked at all; the call's own retry is. A transient answer is kept when
204
+ * its retry fails. Each failed attempt logs one line, except an expected
205
+ * (`quiet`) answer.
206
+ */
207
+ export async function withRetry<T>(
208
+ call: ChainCall,
209
+ read: Read,
210
+ attempt: () => Promise<Attempt<T>>,
211
+ ): Promise<Outcome<T>> {
212
+ const held = heldBack(read.upstream);
213
+ if (held) {
214
+ console.warn(
215
+ `[ixo-chain] ${read.label} not attempted: the upstream answered ${held.reason === 'rate_limited' ? '429' : '503'} moments ago`,
216
+ );
217
+ return { ok: false, failure: held };
218
+ }
219
+ const first = await attempt();
220
+ recordHealth(read.upstream, first);
221
+ if (first.ok && !first.retry) return { ok: true, value: first.value };
222
+ if (first.ok || !first.quiet)
223
+ console.warn(`[ixo-chain] ${read.label} attempt 1 failed: ${first.note}`);
224
+ const wait =
225
+ !first.ok && first.retryAfterMs !== undefined
226
+ ? Math.min(first.retryAfterMs, MAX_RETRY_DELAY_MS)
227
+ : retryDelayMs();
228
+ const giveUp = (): Outcome<T> =>
229
+ first.ok
230
+ ? { ok: true, value: first.value }
231
+ : { ok: false, failure: first.failure };
232
+ if (!first.retry || Date.now() + wait + MIN_ATTEMPT_MS > call.deadline)
233
+ return giveUp();
234
+ await sleep(wait, call.signal);
235
+ const second = await attempt();
236
+ recordHealth(read.upstream, second);
237
+ if (second.ok) {
238
+ if (second.retry)
239
+ console.warn(
240
+ `[ixo-chain] ${read.label} attempt 2 failed: ${second.note}`,
241
+ );
242
+ return { ok: true, value: second.value };
243
+ }
244
+ console.warn(`[ixo-chain] ${read.label} attempt 2 failed: ${second.note}`);
245
+ return first.ok
246
+ ? { ok: true, value: first.value }
247
+ : { ok: false, failure: second.failure };
248
+ }
249
+
250
+ /**
251
+ * `Retry-After` in ms: delta seconds or an HTTP date. Undefined when absent,
252
+ * unreadable, zero or in the past, so the default wait applies.
253
+ */
254
+ export function retryAfterMs(header: string | null): number | undefined {
255
+ if (!header) return undefined;
256
+ const value = header.trim();
257
+ const ms = /^\d+$/.test(value)
258
+ ? Number(value) * 1000
259
+ : Date.parse(value) - Date.now();
260
+ return Number.isFinite(ms) && ms > 0 ? ms : undefined;
261
+ }
262
+
263
+ const REDIRECTED = 'ixo-chain-redirect';
264
+
265
+ /**
266
+ * One bounded HTTP exchange: at most `timeoutMs` (and never past the call's
267
+ * deadline) including the body read, no redirects followed, the body cut
268
+ * off at `maxBytes`; the answer is the body text. Only a 2xx body is read.
269
+ * 429/502/503/504 and network errors ask for a retry; anything else is a
270
+ * failure the retry cannot fix. A 404 is `unavailable` (a wrong route or
271
+ * gateway) unless the caller reads a resource that may be absent
272
+ * (`notFoundOn404`): then it is a quiet `not_found`.
273
+ */
274
+ export async function exchange(
275
+ call: ChainCall,
276
+ source: Source,
277
+ url: string,
278
+ init: RequestInit,
279
+ opts: { timeoutMs: number; maxBytes: number; notFoundOn404?: boolean },
280
+ ): Promise<Attempt<string>> {
281
+ throwIfAborted(call.signal);
282
+ const budget = Math.min(opts.timeoutMs, call.deadline - Date.now());
283
+ if (budget <= 0)
284
+ return {
285
+ ok: false,
286
+ failure: timedOut(source),
287
+ retry: false,
288
+ note: 'no budget left',
289
+ };
290
+ const controller = new AbortController();
291
+ const timer = setTimeout(() => controller.abort(), budget);
292
+ const onAbort = () => controller.abort();
293
+ call.signal.addEventListener('abort', onAbort, { once: true });
294
+ try {
295
+ const response = await fetchWithoutRedirects(
296
+ call.fetch,
297
+ url,
298
+ { ...init, signal: controller.signal },
299
+ REDIRECTED,
300
+ );
301
+ const { status } = response;
302
+ if (!response.ok) {
303
+ await response.body?.cancel().catch(() => undefined);
304
+ if (status === 429)
305
+ return {
306
+ ok: false,
307
+ failure: rateLimited(source),
308
+ retry: true,
309
+ unhealthy: true,
310
+ note: 'HTTP 429',
311
+ ...optionalRetryAfter(response.headers.get('retry-after')),
312
+ };
313
+ if (status === 502 || status === 503 || status === 504)
314
+ return {
315
+ ok: false,
316
+ failure: unavailable(source, `HTTP ${status}`, true),
317
+ retry: true,
318
+ ...(status === 503 ? { unhealthy: true } : {}),
319
+ note: `HTTP ${status}`,
320
+ ...optionalRetryAfter(response.headers.get('retry-after')),
321
+ };
322
+ if (status === 404 && opts.notFoundOn404)
323
+ return {
324
+ ok: false,
325
+ failure: {
326
+ reason: 'not_found',
327
+ detail: 'Nothing was found for that request.',
328
+ retryable: false,
329
+ },
330
+ retry: false,
331
+ quiet: true,
332
+ note: 'HTTP 404',
333
+ };
334
+ // 501 says the route is not implemented: asking again cannot help.
335
+ return {
336
+ ok: false,
337
+ failure: unavailable(
338
+ source,
339
+ `HTTP ${status}`,
340
+ status >= 500 && status !== 501,
341
+ ),
342
+ retry: false,
343
+ note: `HTTP ${status}`,
344
+ };
345
+ }
346
+ const bytes = await boundedBytes(response, opts.maxBytes);
347
+ return { ok: true, value: new TextDecoder().decode(bytes) };
348
+ } catch (error) {
349
+ throwIfAborted(call.signal);
350
+ if (controller.signal.aborted)
351
+ return {
352
+ ok: false,
353
+ failure: timedOut(source),
354
+ retry: false,
355
+ note: `timeout after ${budget} ms`,
356
+ };
357
+ if (error instanceof Error && error.message === REDIRECTED)
358
+ return {
359
+ ok: false,
360
+ failure: unavailable(source, 'it redirected the request', false),
361
+ retry: false,
362
+ note: 'redirect refused',
363
+ };
364
+ if (error instanceof Error && error.message === 'document-too-large')
365
+ return {
366
+ ok: false,
367
+ failure: tooLarge(
368
+ 'The answer was too large to read; ask for fewer rows or a narrower filter.',
369
+ ),
370
+ retry: false,
371
+ note: `body over ${opts.maxBytes} bytes`,
372
+ };
373
+ return {
374
+ ok: false,
375
+ failure: unavailable(source, 'network error', true),
376
+ retry: true,
377
+ note: `network error (${error instanceof Error ? error.name : 'unknown'})`,
378
+ };
379
+ } finally {
380
+ clearTimeout(timer);
381
+ call.signal.removeEventListener('abort', onAbort);
382
+ }
383
+ }
384
+
385
+ function optionalRetryAfter(header: string | null): { retryAfterMs?: number } {
386
+ const ms = retryAfterMs(header);
387
+ return ms === undefined ? {} : { retryAfterMs: ms };
388
+ }
@@ -0,0 +1 @@
1
+ export { IxoChainPlugin } from './ixo-chain.plugin';