@ixo/oracle-runtime-workers 0.41.2 → 0.42.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.41.2",
3
+ "version": "0.42.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -67,8 +67,8 @@
67
67
  "protobufjs": "^7.6.5",
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
- "@ixo/ixo-transaction": "0.2.0",
71
70
  "@ixo/common": "1.8.0",
71
+ "@ixo/ixo-transaction": "0.2.0",
72
72
  "@ixo/ucan": "2.3.0",
73
73
  "@ixo/action-interactions": "0.1.1"
74
74
  },
@@ -168,7 +168,7 @@ export class HomeserverUnauthorizedError extends Error {
168
168
  }
169
169
 
170
170
  /** `mxc://<server>/<media id>`, the parts checked before they go into a URL. */
171
- const MXC_PATTERN = /^mxc:\/\/([A-Za-z0-9.:[\]-]+)\/([A-Za-z0-9_-]+)$/;
171
+ export const MXC_PATTERN = /^mxc:\/\/([A-Za-z0-9.:[\]-]+)\/([A-Za-z0-9_-]+)$/;
172
172
 
173
173
  /**
174
174
  * Open the media behind `mxc` on the homeserver as the account behind
@@ -1,17 +1,32 @@
1
1
  /**
2
- * A turn that dies between the model's tool call and the tool's result — the
3
- * user aborted it, or the runtime was reset while the tool ran — leaves the
4
- * checkpoint with an assistant message whose `tool_calls` have no
5
- * `ToolMessage`. OpenRouter chat completions tolerate that history; the
6
- * ChatGPT-subscription lane (Responses API) rejects every later turn on the
7
- * session with `400 No tool output found for function call …`. This
8
- * middleware answers each unanswered call, for the model request only, with
9
- * a tool result that says the call was interrupted. The checkpoint is left as
10
- * it is: nothing is rewritten in place, and the model is free to call the
11
- * tool again.
2
+ * Every provider's request converter needs each assistant message's
3
+ * `tool_calls` answered by the tool messages that directly follow it. Two
4
+ * histories break that, and this middleware mends both for the model request
5
+ * only (the checkpoint is left as it is: nothing is rewritten in place):
6
+ *
7
+ * - Messages a sub-agent forwarded into the chat (`forwardTools`, marked with
8
+ * `forwardedFrom`) are left out: they are for the UI, and the parent call's
9
+ * own result is what the model reads.
10
+ * - A result that arrived later. A sub-agent that forwards its own tool calls
11
+ * (`forwardTools`, `subagent-as-tool.ts`) hands back a `Command` whose
12
+ * forwarded assistant and tool messages come before the parent call's own
13
+ * result, so that result lands after them; with parallel calls, after
14
+ * another call's forwarded messages too. The result is moved up to its
15
+ * call. Before this, such a call read as interrupted and the model was told
16
+ * "nothing from it was recorded, call it again" for every sub-agent call
17
+ * that forwarded anything.
18
+ * - A result that never came. A turn that dies between the model's tool call
19
+ * and the tool's result (the user aborted it, or the runtime was reset
20
+ * while the tool ran) leaves the call unanswered. OpenRouter chat
21
+ * completions tolerate that history; the ChatGPT-subscription lane
22
+ * (Responses API) rejects every later turn on the session with
23
+ * `400 No tool output found for function call …`. Such a call is answered
24
+ * with a tool result that says it was interrupted, and the model is free to
25
+ * call the tool again.
12
26
  */
13
27
  import {
14
28
  type BaseMessage,
29
+ HumanMessage,
15
30
  isAIMessage,
16
31
  isToolMessage,
17
32
  ToolMessage,
@@ -30,26 +45,73 @@ export function interruptedToolResult(toolName: string): string {
30
45
  return `The call to ${toolName} was interrupted before it returned a result (the turn was aborted or the runtime restarted while it ran), so nothing from it was recorded. Call it again if its result is still needed.`;
31
46
  }
32
47
 
48
+ /**
49
+ * `additional_kwargs` key a forwarded sub-agent message carries
50
+ * (`FORWARDED_FROM_KEY` in `subagent-as-tool.ts`, repeated here so the
51
+ * middleware does not import the sub-agent wrapper).
52
+ */
53
+ const FORWARDED_FROM = 'forwardedFrom';
54
+
55
+ /** The messages without those a sub-agent forwarded into the chat: they are for the UI, not the model. */
56
+ export function withoutForwarded(messages: BaseMessage[]): BaseMessage[] {
57
+ const kept = messages.filter(
58
+ (message) =>
59
+ typeof message.additional_kwargs?.[FORWARDED_FROM] !== 'string',
60
+ );
61
+ return kept.length === messages.length ? messages : kept;
62
+ }
63
+
33
64
  export interface DanglingToolCallRepair {
34
- /** The original array when nothing was missing, otherwise a repaired copy. */
65
+ /** The original array when nothing was changed, otherwise a repaired copy. */
35
66
  messages: BaseMessage[];
36
- /** How many tool calls were answered. */
67
+ /** How many tool calls were answered as interrupted (no result anywhere). */
37
68
  repaired: number;
69
+ /** How many results were moved up to their call from later in the turn. */
70
+ regrouped: number;
38
71
  }
39
72
 
40
73
  /**
41
- * Insert a synthetic `ToolMessage` for every `tool_calls` entry of an
42
- * assistant message that has no `ToolMessage` among the tool messages that
43
- * directly follow it. The synthetic results go right after the real ones,
44
- * before the next non-tool message, which is the position every provider's
45
- * request converter requires.
74
+ * The index of the tool message answering `callId` after `from`, before the
75
+ * next human message (a result never answers a call of an earlier turn), and
76
+ * not already taken; -1 when there is none.
77
+ */
78
+ function laterResult(
79
+ messages: readonly BaseMessage[],
80
+ from: number,
81
+ callId: string,
82
+ taken: ReadonlySet<number>,
83
+ ): number {
84
+ for (let j = from; j < messages.length; j += 1) {
85
+ const candidate = messages[j]!;
86
+ if (HumanMessage.isInstance(candidate)) return -1;
87
+ if (
88
+ !taken.has(j) &&
89
+ isToolMessage(candidate) &&
90
+ candidate.tool_call_id === callId
91
+ )
92
+ return j;
93
+ }
94
+ return -1;
95
+ }
96
+
97
+ /**
98
+ * Answer every `tool_calls` entry of an assistant message right after it:
99
+ * first with the tool messages that directly follow it, then with its result
100
+ * from later in the turn (moved up, see the file comment), else with a
101
+ * synthetic result that says the call was interrupted. Results go before the
102
+ * next non-tool message, the position every provider's request converter
103
+ * requires.
46
104
  */
47
105
  export function repairDanglingToolCalls(
48
106
  messages: BaseMessage[],
49
107
  ): DanglingToolCallRepair {
50
108
  const out: BaseMessage[] = [];
109
+ /** Indexes of results moved up to their call; skipped where they were. */
110
+ const taken = new Set<number>();
51
111
  let repaired = 0;
112
+ let regrouped = 0;
52
113
  for (let i = 0; i < messages.length; i += 1) {
114
+ if (taken.has(i)) continue;
53
115
  const message = messages[i]!;
54
116
  out.push(message);
55
117
  if (!isAIMessage(message)) continue;
@@ -59,13 +121,23 @@ export function repairDanglingToolCalls(
59
121
  const answered = new Set<string>();
60
122
  let next = i + 1;
61
123
  while (next < messages.length && isToolMessage(messages[next]!)) {
62
- const toolMessage = messages[next] as ToolMessage;
63
- answered.add(toolMessage.tool_call_id);
64
- out.push(toolMessage);
124
+ if (!taken.has(next)) {
125
+ const toolMessage = messages[next] as ToolMessage;
126
+ answered.add(toolMessage.tool_call_id);
127
+ out.push(toolMessage);
128
+ }
65
129
  next += 1;
66
130
  }
67
131
  for (const call of toolCalls) {
68
132
  if (!call.id || answered.has(call.id)) continue;
133
+ const later = laterResult(messages, next, call.id, taken);
134
+ if (later !== -1) {
135
+ taken.add(later);
136
+ answered.add(call.id);
137
+ out.push(messages[later]!);
138
+ regrouped += 1;
139
+ continue;
140
+ }
69
141
  out.push(
70
142
  new ToolMessage({
71
143
  tool_call_id: call.id,
@@ -78,7 +150,11 @@ export function repairDanglingToolCalls(
78
150
  }
79
151
  i = next - 1;
80
152
  }
81
- return { messages: repaired === 0 ? messages : out, repaired };
153
+ return {
154
+ messages: repaired === 0 && regrouped === 0 ? messages : out,
155
+ repaired,
156
+ regrouped,
157
+ };
82
158
  }
83
159
 
84
160
  export const createDanglingToolCallRepairMiddleware = (
@@ -88,11 +164,21 @@ export const createDanglingToolCallRepairMiddleware = (
88
164
  return createMiddleware({
89
165
  name: 'DanglingToolCallRepairMiddleware',
90
166
  wrapModelCall: async (request, handler) => {
91
- const { messages, repaired } = repairDanglingToolCalls(request.messages);
92
- if (repaired === 0) return handler(request);
93
- logger.warn(
94
- `[DanglingToolCallRepair] ${repaired} tool call(s) had no result in the thread history; answered as interrupted for this model call`,
95
- );
167
+ const visible = withoutForwarded(request.messages);
168
+ const { messages, repaired, regrouped } =
169
+ repairDanglingToolCalls(visible);
170
+ if (repaired === 0 && regrouped === 0)
171
+ return visible === request.messages
172
+ ? handler(request)
173
+ : handler({ ...request, messages });
174
+ if (repaired > 0)
175
+ logger.warn(
176
+ `[DanglingToolCallRepair] ${repaired} tool call(s) had no result in the thread history; answered as interrupted for this model call`,
177
+ );
178
+ if (regrouped > 0)
179
+ logger.debug?.(
180
+ `[DanglingToolCallRepair] ${regrouped} tool result(s) moved up to their call (forwarded sub-agent messages came between)`,
181
+ );
96
182
  return handler({ ...request, messages });
97
183
  },
98
184
  });
@@ -102,7 +102,7 @@ export function capFooter(
102
102
  tail: number,
103
103
  ): string {
104
104
  const where = meta.id
105
- ? `The full result is saved as ${meta.id}: call read_result({ id: "${meta.id}", offset: 0 }) and follow \`next\` to read it in chunks.`
105
+ ? `The full result is saved as ${meta.id}; if your task needs a part that is not shown, read_result({ id: "${meta.id}", offset }) reads it. Do not read it all to check that the call worked.`
106
106
  : 'The full result could not be saved.';
107
107
  return `\n\n[Result truncated: showing the first ${head} and last ${tail} of ${meta.size} characters. ${where}]`;
108
108
  }
@@ -7,12 +7,17 @@
7
7
  * ranges, of a trimmed one character ranges; `next` is the offset of the
8
8
  * following chunk or `null` at the end.
9
9
  */
10
- import { ToolMessage, type BaseMessage } from '@langchain/core/messages';
10
+ import {
11
+ AIMessage,
12
+ ToolMessage,
13
+ type BaseMessage,
14
+ } from '@langchain/core/messages';
11
15
  import { z } from 'zod';
12
16
  import { tool } from '../plugin-api/tool-helper';
13
17
  import type { PluginTool } from '../plugin-api/types';
14
18
  import type { ReadResultOutcome } from '../do/result-store';
15
19
  import { contentToText } from './middlewares/result-cap';
20
+ import { turnStart } from './middlewares/turn-boundary';
16
21
 
17
22
  export interface ReadResultToolOptions {
18
23
  read: (
@@ -26,6 +31,58 @@ export interface ReadResultToolOptions {
26
31
 
27
32
  export const READ_RESULT_TOOL_NAME = 'read_result';
28
33
 
34
+ /**
35
+ * Reads one turn may make. Paging is for the part of a result a task needs,
36
+ * not for checking that a call worked: a model that read every chunk of a
37
+ * saved result (hundreds of characters at a time) spent a turn and two
38
+ * million tokens on it. Six of the largest chunks cover a result of about
39
+ * 96,000 characters.
40
+ */
41
+ export const READ_RESULT_CALLS_PER_TURN = 6;
42
+
43
+ /** The smallest chunk read: a request for less reads this much, so a turn's reads are never spent on slivers. */
44
+ export const READ_RESULT_MIN_CHARS = 2_000;
45
+
46
+ /** The result of a read past {@link READ_RESULT_CALLS_PER_TURN}: not run. */
47
+ export function readBudgetSpent(reads: number): string {
48
+ return `Not read: this turn already read ${reads} chunks, the most one turn may read. Continue with what you have (the head and tail of the result, and each call's own reply, say whether it worked), or tell the person what is still unread.`;
49
+ }
50
+
51
+ /**
52
+ * Reads this turn asked for before the call `toolCallId`, counted on the
53
+ * model's own tool calls so a batch of parallel reads counts each one (their
54
+ * results are not in the thread yet when they run). Without the call's id
55
+ * in the thread, the reads that already returned.
56
+ */
57
+ function readsBefore(
58
+ messages: readonly BaseMessage[],
59
+ toolCallId: string | undefined,
60
+ ): number {
61
+ const start = turnStart(messages);
62
+ if (toolCallId) {
63
+ let asked = 0;
64
+ for (let i = start; i < messages.length; i += 1) {
65
+ const message = messages[i];
66
+ if (!AIMessage.isInstance(message)) continue;
67
+ for (const call of message.tool_calls ?? []) {
68
+ if (call.name !== READ_RESULT_TOOL_NAME) continue;
69
+ if (call.id === toolCallId) return asked;
70
+ asked += 1;
71
+ }
72
+ }
73
+ }
74
+ let returned = 0;
75
+ for (let i = start; i < messages.length; i += 1) {
76
+ const message = messages[i];
77
+ if (
78
+ ToolMessage.isInstance(message) &&
79
+ message.name === READ_RESULT_TOOL_NAME
80
+ )
81
+ returned += 1;
82
+ }
83
+ return returned;
84
+ }
85
+
29
86
  /** The longest chunk `read_result` hands back under a result cap: well under the cap, so a page is never capped itself. */
30
87
  export function readResultMaxChars(resultCapChars: number): number {
31
88
  return Math.min(16_000, Math.max(512, Math.floor(resultCapChars / 2)));
@@ -51,7 +108,7 @@ export function buildReadResultTool(
51
108
  options: ReadResultToolOptions,
52
109
  ): PluginTool {
53
110
  const maxChars = Math.max(512, Math.min(options.maxChars, 64 * 1024));
54
- const defaultChars = Math.min(4_000, maxChars);
111
+ const minChars = Math.min(READ_RESULT_MIN_CHARS, maxChars);
55
112
  const schema = z.object({
56
113
  id: z
57
114
  .string()
@@ -64,12 +121,19 @@ export function buildReadResultTool(
64
121
  .int()
65
122
  .min(256)
66
123
  .max(maxChars)
67
- .default(defaultChars)
68
- .describe(`Length to read (max ${maxChars}).`),
124
+ .default(maxChars)
125
+ .describe(
126
+ `Length to read (default and max ${maxChars}; at least ${minChars} is read).`,
127
+ ),
69
128
  });
70
129
  return tool(
71
130
  async (rawArgs, ctx) => {
72
- const { id, offset, length } = schema.parse(rawArgs);
131
+ const parsed = schema.parse(rawArgs);
132
+ const reads = readsBefore(ctx.history.messages, ctx.toolCallId);
133
+ if (reads >= READ_RESULT_CALLS_PER_TURN)
134
+ return { error: readBudgetSpent(reads) };
135
+ const { id, offset } = parsed;
136
+ const length = Math.max(parsed.length, minChars);
73
137
  const text = threadResult(ctx.history.messages, id);
74
138
  if (text !== undefined) {
75
139
  const end = offset + length;
@@ -103,8 +167,7 @@ export function buildReadResultTool(
103
167
  name: READ_RESULT_TOOL_NAME,
104
168
  effect: 'read',
105
169
  visibility: 'always',
106
- description:
107
- 'Read a tool result you no longer have in full, in chunks. Use the id from a "[Result truncated …]" footer or a stub that says the result was removed; start at offset 0 and pass the returned `next` as the offset of the following call until `next` is null.',
170
+ description: `Read part of a tool result you no longer have in full: the id from a "[Result truncated …]" footer or a stub that says the result was removed. Read only the part your task needs, in large chunks (pass a returned \`next\` as the offset to continue). Never read a result back to check that a call worked: its visible head and tail and the call's own reply say that. At most ${READ_RESULT_CALLS_PER_TURN} reads per turn.`,
108
171
  schema,
109
172
  },
110
173
  );
@@ -95,7 +95,7 @@ export interface MatrixAdapter {
95
95
  getRoomState(roomId: string): Promise<RoomStateSnapshot>;
96
96
  getEventById(roomId: string, eventId: string): Promise<MatrixEvent>;
97
97
  /** See `MatrixGatewayObject.botCredentials`. */
98
- botCredentials(): Promise<BotCredentials>;
98
+ botCredentials(opts?: { rejected?: string }): Promise<BotCredentials>;
99
99
  /** Group rooms: room kind and channel memory (see `plugin-api/types.ts`). Hosts without a gateway omit them. */
100
100
  roomInfo?: RuntimeContext['matrix']['roomInfo'];
101
101
  channelMemory?: RuntimeContext['matrix']['channelMemory'];
@@ -497,7 +497,7 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
497
497
  matrix: {
498
498
  postToRoom: (roomId, content, opts) =>
499
499
  ambient.matrix.postToRoom(roomId, content, opts),
500
- botCredentials: () => ambient.matrix.botCredentials(),
500
+ botCredentials: (opts) => ambient.matrix.botCredentials(opts),
501
501
  postEvent: (roomId, eventType, content, opts) =>
502
502
  ambient.matrix.postEvent(roomId, eventType, content, opts),
503
503
  getRoomState: (roomId) => ambient.matrix.getRoomState(roomId),
@@ -97,6 +97,34 @@ const taskSchema = z.object({
97
97
  ),
98
98
  });
99
99
 
100
+ /** Characters of a sub-agent's last tool result handed back when it ended without a reply. */
101
+ export const SUBAGENT_FALLBACK_RESULT_CHARS = 2_000;
102
+
103
+ /**
104
+ * What the parent reads as a sub-agent's result: its final reply, or, when
105
+ * the sub-agent ended on an empty one (a model that stops right after its
106
+ * last tool call, such as a POD specialist after `submit_section`), its last
107
+ * tool result, labelled as such and cut to
108
+ * {@link SUBAGENT_FALLBACK_RESULT_CHARS}. An empty result leaves the parent
109
+ * nothing to tell whether the work was done.
110
+ */
111
+ export function subAgentResultText(
112
+ messages: BaseMessage[],
113
+ name: string,
114
+ ): string {
115
+ const text = lastMessageContent(messages).toString();
116
+ if (text.trim()) return text;
117
+ const lastTool = messages.findLast((message) =>
118
+ ToolMessage.isInstance(message),
119
+ );
120
+ if (!lastTool) return `${name} finished without a reply.`;
121
+ const result =
122
+ typeof lastTool.content === 'string'
123
+ ? lastTool.content
124
+ : JSON.stringify(lastTool.content);
125
+ return `${name} finished without a reply. Its last tool call${lastTool.name ? ` (${lastTool.name})` : ''} returned: ${result.slice(0, SUBAGENT_FALLBACK_RESULT_CHARS)}`;
126
+ }
127
+
100
128
  function lastMessageContent(messages: BaseMessage[]): string {
101
129
  const last = messages.at(-1);
102
130
  if (!last?.content) return '';
@@ -120,6 +148,18 @@ function lastMessageContent(messages: BaseMessage[]): string {
120
148
  * LangChain-generated ids starting at 0, and two invocations in one chat
121
149
  * collide — the frontend uses these ids as React keys.
122
150
  */
151
+ /**
152
+ * `additional_kwargs` key on every forwarded message: the parent call it came
153
+ * from. Forwarded messages are for the chat (the UI renders the sub-agent's
154
+ * calls); the parent model reads the call's own result instead, so the
155
+ * dangling-call middleware leaves them out of its requests
156
+ * (`withoutForwarded`). Without that, the parent model took a forwarded call
157
+ * for one of its own (it called a specialist's `submit_section` itself) and
158
+ * read each forwarded call's arguments, a whole POD section, on every model
159
+ * call of the thread.
160
+ */
161
+ export const FORWARDED_FROM_KEY = 'forwardedFrom';
162
+
123
163
  export function filterForwardedMessages(
124
164
  messages: BaseMessage[],
125
165
  forwardTools: Set<string>,
@@ -140,7 +180,13 @@ export function filterForwardedMessages(
140
180
  oldToNewId.set(tc.id, newId);
141
181
  return { ...tc, id: newId };
142
182
  });
143
- acc.push(new AIMessage({ content: '', tool_calls: rewritten }));
183
+ acc.push(
184
+ new AIMessage({
185
+ content: '',
186
+ tool_calls: rewritten,
187
+ additional_kwargs: { [FORWARDED_FROM_KEY]: idPrefix },
188
+ }),
189
+ );
144
190
  }
145
191
 
146
192
  if (msg.type === 'tool') {
@@ -152,6 +198,7 @@ export function filterForwardedMessages(
152
198
  content: toolMsg.content,
153
199
  tool_call_id: newId,
154
200
  ...(toolMsg.name ? { name: toolMsg.name } : {}),
201
+ additional_kwargs: { [FORWARDED_FROM_KEY]: idPrefix },
155
202
  }),
156
203
  );
157
204
  }
@@ -273,7 +320,7 @@ export function createSubagentAsTool(
273
320
  messages: BaseMessage[],
274
321
  toolCallId: string,
275
322
  ): string | Command => {
276
- const text = lastMessageContent(messages).toString();
323
+ const text = subAgentResultText(messages, spec.name);
277
324
  if (forwardSet.size === 0) return text;
278
325
 
279
326
  const idPrefix = toolCallId || `run_${crypto.randomUUID().slice(0, 8)}`;
package/src/do/ambient.ts CHANGED
@@ -183,7 +183,8 @@ export function createMatrixAdapter(
183
183
  ),
184
184
  postEvent: (roomId, eventType, content, opts) =>
185
185
  gateway.sendEvent(roomId, eventType, JSON.stringify(content), opts ?? {}),
186
- botCredentials: () => gateway.botCredentials(),
186
+ botCredentials: (opts?: { rejected?: string }) =>
187
+ gateway.botCredentials(opts?.rejected ? { rejected: opts.rejected } : {}),
187
188
  roomInfo: (roomId) => gateway.groupChatRoomInfo(roomId),
188
189
  channelMemory: {
189
190
  recall: async (roomId, limit) =>
@@ -818,8 +818,11 @@ export interface RuntimeContext<TConfig = MergedConfig> {
818
818
  /**
819
819
  * Credentials of the oracle's own crypto-less device for plugins that run
820
820
  * their own Matrix client (editor, flows). Minted and kept by the gateway.
821
+ * A caller whose request the homeserver answered 401 passes the token it
822
+ * used as `rejected`: the host then re-validates the device (or logs it
823
+ * in again) and answers with fresh credentials, so one retry can succeed.
821
824
  */
822
- botCredentials: () => Promise<{
825
+ botCredentials: (opts?: { rejected?: string }) => Promise<{
823
826
  baseUrl: string;
824
827
  userId: string;
825
828
  accessToken: string;
@@ -1317,6 +1320,13 @@ export interface PluginSubAgent {
1317
1320
  *
1318
1321
  * Runtime-injected passthrough tools (e.g. memory CRUD) are never
1319
1322
  * forwarded — they're already on the main agent.
1323
+ *
1324
+ * Forwarded messages are for the chat: they stay in the thread (the UI
1325
+ * and the transcript show them) but are left out of the parent's model
1326
+ * requests. The parent model reads the sub-agent's final reply as the
1327
+ * call's result (its last tool result when that reply is empty), so the
1328
+ * sub-agent's reply must say what it did. Forward the calls the user
1329
+ * should see (a write, its receipt), not large reads.
1320
1330
  */
1321
1331
  forwardTools?: boolean | string[];
1322
1332
  /** Called after sub-agent completes; can emit follow-up events. */
@@ -14,7 +14,7 @@ import { createDomainIndexerTools } from './domain-indexer-tools';
14
14
  * the core `NETWORK` env var. Operators can override with the optional
15
15
  * `DOMAIN_INDEXER_URL` env var (useful for local/staging deployments).
16
16
  */
17
- const DOMAIN_INDEXER_URLS = {
17
+ export const DOMAIN_INDEXER_URLS = {
18
18
  mainnet: 'https://domain-indexer.ixo.earth',
19
19
  testnet: 'https://domain-indexer.testnet.ixo.earth',
20
20
  devnet: 'https://domain-indexer.devnet.ixo.earth',
@@ -151,6 +151,8 @@ export interface ChainCall {
151
151
  deadline: number;
152
152
  signal: AbortSignal;
153
153
  fetch: typeof fetch;
154
+ /** How the call waits before a retry; {@link sleep} unless a test injects its own clock. */
155
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
154
156
  }
155
157
 
156
158
  /**
@@ -192,7 +194,7 @@ export type Outcome<T> =
192
194
  | { ok: false; failure: Failure };
193
195
 
194
196
  /** The turn's abort reason as an error (a non-error reason becomes an `AbortError`). */
195
- function abortReason(signal: AbortSignal): Error {
197
+ export function abortReason(signal: AbortSignal): Error {
196
198
  return signal.reason instanceof Error
197
199
  ? signal.reason
198
200
  : new DOMException('Aborted', 'AbortError');
@@ -288,7 +290,7 @@ export class ReadsInFlight<T> {
288
290
  }
289
291
 
290
292
  /** Resolves after `ms`; rejects with the turn's abort reason if it ends first. */
291
- function sleep(ms: number, signal: AbortSignal): Promise<void> {
293
+ export function sleep(ms: number, signal: AbortSignal): Promise<void> {
292
294
  return new Promise((resolve, reject) => {
293
295
  if (signal.aborted) {
294
296
  reject(abortReason(signal));
@@ -394,7 +396,7 @@ export async function withRetry<T>(
394
396
  : { ok: false, failure: first.failure };
395
397
  if (!first.retry || Date.now() + wait + MIN_ATTEMPT_MS > call.deadline)
396
398
  return giveUp();
397
- await sleep(wait, call.signal);
399
+ await (call.sleep ?? sleep)(wait, call.signal);
398
400
  const second = await attempt();
399
401
  recordHealth(read.upstream, second);
400
402
  if (second.ok) {
@@ -518,7 +520,9 @@ const outOfBudget = (note: string): Attempt<never> => ({
518
520
  * The body of an error answer as text, or undefined when it is longer than
519
521
  * {@link ERROR_BODY_MAX_BYTES} (the rest is not read).
520
522
  */
521
- async function errorBodyOf(response: Response): Promise<string | undefined> {
523
+ export async function errorBodyOf(
524
+ response: Response,
525
+ ): Promise<string | undefined> {
522
526
  const reader = response.body?.getReader();
523
527
  if (!reader) return '';
524
528
  const decoder = new TextDecoder();
@@ -79,12 +79,25 @@ function networkOf(config: MergedConfig): Network {
79
79
 
80
80
  /** The REST base URL: `CHAIN_REST_URL` when set, else the network's default. */
81
81
  export function chainRestUrl(config: MergedConfig): string {
82
+ return chainRestUrlFor(networkOf(config), config);
83
+ }
84
+
85
+ /**
86
+ * The REST base URL for `network`: `CHAIN_REST_URL` when set (the
87
+ * operator's node for the oracle's network), else that network's default.
88
+ * For a caller that already knows its network rather than reading
89
+ * `NETWORK` itself.
90
+ */
91
+ export function chainRestUrlFor(
92
+ network: Network,
93
+ config: MergedConfig,
94
+ ): string {
82
95
  const override = configSchema.shape.CHAIN_REST_URL.safeParse(
83
96
  config.CHAIN_REST_URL,
84
97
  );
85
98
  return override.success && override.data
86
99
  ? override.data
87
- : CHAIN_REST_URLS[networkOf(config)];
100
+ : CHAIN_REST_URLS[network];
88
101
  }
89
102
 
90
103
  /** The names worker's origin: `IXO_NAMES_URL` when set, else the network's default. */
@@ -221,6 +221,32 @@ export function isBech32(address: string, prefix: string): boolean {
221
221
  return padding < 5 && (last & ((1 << padding) - 1)) === 0;
222
222
  }
223
223
 
224
+ /**
225
+ * The bytes a valid bech32 `address` with the prefix `prefix` carries (for
226
+ * an account, the 20-byte hash of its public key); null when
227
+ * {@link isBech32} refuses it.
228
+ */
229
+ export function bech32Bytes(
230
+ address: string,
231
+ prefix: string,
232
+ ): Uint8Array | null {
233
+ if (!isBech32(address, prefix)) return null;
234
+ const data = address.slice(prefix.length + 1, -BECH32_CHECKSUM);
235
+ const bytes: number[] = [];
236
+ let buffer = 0;
237
+ let bits = 0;
238
+ for (const char of data) {
239
+ buffer = (buffer << 5) | BECH32_CHARSET.indexOf(char);
240
+ bits += 5;
241
+ if (bits >= 8) {
242
+ bits -= 8;
243
+ bytes.push((buffer >>> bits) & 0xff);
244
+ }
245
+ }
246
+ // The leftover bits are the zero padding isBech32 checked.
247
+ return Uint8Array.from(bytes);
248
+ }
249
+
224
250
  /**
225
251
  * The account an account field names: the user's own address when the
226
252
  * field is omitted, else an `ixo1…` address or the address a