@ixo/oracle-runtime-workers 0.21.2 → 0.22.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.
@@ -98,11 +98,8 @@ export function buildActionTool(action: AgAction): PluginTool | null {
98
98
  `AG-UI action ${action.name} needs the realtime channel, which this host does not provide.`,
99
99
  );
100
100
  }
101
- if (!frontend.hasClient(sessionId)) {
102
- throw new Error(
103
- `No browser is connected to session ${sessionId}, so AG-UI action ${action.name} cannot run — the client must open the realtime (socket.io) channel for this session first.`,
104
- );
105
- }
101
+ // No browser of the session connected yet: the bridge parks the call
102
+ // until one joins (or its park deadline / the turn's abort ends it).
106
103
  // The bridge makes every invocation unique (`ag_<requestId>:<uuid>`).
107
104
  let invocationId: string | undefined;
108
105
  const result = await frontend.callAgAction({
@@ -112,6 +109,7 @@ export function buildActionTool(action: AgAction): PluginTool | null {
112
109
  args: parseArgs(input),
113
110
  timeoutMs: AG_ACTION_TIMEOUT_MS,
114
111
  signal: ctx.abortSignal,
112
+ ...(ctx.markDispatched ? { onDispatched: ctx.markDispatched } : {}),
115
113
  onInvocation: (id) => {
116
114
  invocationId = id;
117
115
  },
@@ -125,6 +123,10 @@ export function buildActionTool(action: AgAction): PluginTool | null {
125
123
  schema,
126
124
  // A UI action on the user's screen: running it again is a new action.
127
125
  repeatable: true,
126
+ // It runs on this session's AG-UI surface in the browser: scheduled in
127
+ // the session's frontend lane, so a parked call holds no slot another
128
+ // chat needs.
129
+ surface: 'frontend',
128
130
  },
129
131
  );
130
132
  }
@@ -65,7 +65,10 @@ type WalletChannel =
65
65
 
66
66
  /**
67
67
  * The turn's way to the wallet: a session with a realtime channel and a
68
- * Portal browser connected to it.
68
+ * Portal tab that can run the action right now (`canDispatch`: a foreground
69
+ * socket; a background executor never runs an AG-UI action). A signing
70
+ * request is never parked: without such a tab it is refused at once, so it
71
+ * never waits minutes in the user-wide write lane for a tab to open.
69
72
  */
70
73
  function walletChannel(ctx: RuntimeContext): WalletChannel {
71
74
  const sessionId = ctx.session.id;
@@ -77,7 +80,7 @@ function walletChannel(ctx: RuntimeContext): WalletChannel {
77
80
  'Wallet signing needs a Portal session with a realtime connection, which this conversation does not have.',
78
81
  };
79
82
  }
80
- if (!frontend.hasClient(sessionId)) {
83
+ if (!frontend.canDispatch(sessionId, 'agui', SIGN_TRANSACTION_ACTION_NAME)) {
81
84
  return {
82
85
  ok: false,
83
86
  reason:
@@ -25,6 +25,18 @@ export interface DeclaredTool {
25
25
  schema: Record<string, unknown>;
26
26
  }
27
27
 
28
+ /**
29
+ * The effect a client declares for one of its tools (`effect` on a browser
30
+ * tool declaration): exactly `'read'` or `'write'`. Any other value is
31
+ * treated as no declaration — the entry itself is kept, and the runtime's
32
+ * own classification applies.
33
+ */
34
+ export function declaredToolEffect(
35
+ value: unknown,
36
+ ): 'read' | 'write' | undefined {
37
+ return value === 'read' || value === 'write' ? value : undefined;
38
+ }
39
+
28
40
  export interface DroppedDeclaration {
29
41
  name: string;
30
42
  reason: string;
@@ -24,7 +24,11 @@ import type {
24
24
  RuntimeContext,
25
25
  } from '../../plugin-api/types';
26
26
  import { logFrontendAction } from './action-log';
27
- import { describeDropped, sanitizeDeclaredTools } from './declared-tools';
27
+ import {
28
+ declaredToolEffect,
29
+ describeDropped,
30
+ sanitizeDeclaredTools,
31
+ } from './declared-tools';
28
32
 
29
33
  const manifest: PluginManifest = {
30
34
  title: 'Portal',
@@ -79,6 +83,7 @@ const BROWSER_TOOL_SHAPE = z.object({
79
83
  name: z.string(),
80
84
  description: z.string(),
81
85
  schema: z.record(z.string(), z.unknown()),
86
+ effect: z.unknown().optional(),
82
87
  });
83
88
 
84
89
  const ARGS_RECORD_SHAPE = z.record(z.string(), z.unknown());
@@ -92,7 +97,9 @@ export function parseBrowserTools(value: unknown): BrowserToolCall[] {
92
97
  const parsed = BROWSER_TOOL_SHAPE.safeParse(entry);
93
98
  if (!parsed.success || names.has(parsed.data.name)) continue;
94
99
  names.add(parsed.data.name);
95
- out.push(parsed.data);
100
+ const { effect: rawEffect, ...declaration } = parsed.data;
101
+ const effect = declaredToolEffect(rawEffect);
102
+ out.push(effect ? { ...declaration, effect } : declaration);
96
103
  }
97
104
  return out;
98
105
  }
@@ -170,11 +177,8 @@ export function buildBrowserTool(
170
177
  `Browser tool ${descriptor.name} needs the realtime channel, which this host does not provide.`,
171
178
  );
172
179
  }
173
- if (!frontend.hasClient(sessionId)) {
174
- throw new Error(
175
- `No browser is connected to session ${sessionId}, so browser tool ${descriptor.name} cannot run — the client must open the realtime (socket.io) channel for this session first.`,
176
- );
177
- }
180
+ // No browser of the session connected yet: the bridge parks the call
181
+ // until one joins (or its park deadline / the turn's abort ends it).
178
182
  const args = withRuntimeSuppliedArgs(
179
183
  parseArgs(input),
180
184
  descriptor,
@@ -189,6 +193,7 @@ export function buildBrowserTool(
189
193
  args,
190
194
  timeoutMs: browserToolTimeoutMs(descriptor.name),
191
195
  signal: ctx.abortSignal,
196
+ ...(ctx.markDispatched ? { onDispatched: ctx.markDispatched } : {}),
192
197
  onInvocation: (id) => {
193
198
  invocationId = id;
194
199
  },
@@ -202,9 +207,18 @@ export function buildBrowserTool(
202
207
  schema,
203
208
  // A browser step (scroll, page, click) repeated is a new step.
204
209
  repeatable: !HUMAN_DECISION_TOOLS.has(descriptor.name),
210
+ // It runs in this session's browser tab: scheduled in the session's
211
+ // frontend lane, so a parked call (or a question to the user) holds
212
+ // no slot another chat needs.
213
+ surface: 'frontend',
214
+ // A question to the user is a write whatever the client declares:
215
+ // it is never asked twice after a restart once it was sent. Any
216
+ // other tool keeps the effect its declaration names, if any.
205
217
  ...(HUMAN_DECISION_TOOLS.has(descriptor.name)
206
218
  ? { effect: 'write' as const }
207
- : {}),
219
+ : descriptor.effect
220
+ ? { effect: descriptor.effect }
221
+ : {}),
208
222
  },
209
223
  );
210
224
  }
@@ -10,11 +10,16 @@
10
10
  * arriving on that socket — same engine.io sid, same session, same
11
11
  * authenticated DID — settles it. Another tab of the same session, a
12
12
  * reconnected tab (a new sid) or another session cannot;
13
+ * - until it is sent an invocation is parked: its clock is the park
14
+ * deadline, and a park deadline that passes — or the turn's abort —
15
+ * rejects it as never sent (`FrontendCallNotSentError`, a definite
16
+ * failure: no browser saw it). `dispatched` restarts the clock with the
17
+ * call's own deadline;
13
18
  * - a settled invocation is remembered (bounded by count and age), so a
14
19
  * replayed or late result is recognised and rejected as such;
15
- * - pending invocations are never evicted to make room; a full table
16
- * refuses new calls instead;
17
- * - an invocation whose answer cannot arrive any more — its deadline
20
+ * - pending invocations — parked ones included — are never evicted to
21
+ * make room; a full table refuses new calls instead;
22
+ * - a sent invocation whose answer cannot arrive any more — its deadline
18
23
  * passed, or its socket went — resolves with `FRONTEND_OUTCOME_UNKNOWN`:
19
24
  * the browser may have performed the write, so it is neither a success
20
25
  * nor a failure, and it is never sent again.
@@ -27,10 +32,12 @@
27
32
  *
28
33
  * Purely in-memory, which is exact on Workers: a pending call holds a timer,
29
34
  * so its object cannot hibernate before the call ends, and an object restart
30
- * ends the turn that made it (a resumed durable run does not re-run a write
31
- * that had started — see tool-marks). Finished-call records are lost when
32
- * the object hibernates; a result for a forgotten id is then rejected as
33
- * never issued, which is the same outcome.
35
+ * ends the turn that made it. What a resumed durable run does with the call
36
+ * is the tool marks' policy: `onDispatched` lets the caller record the
37
+ * moment the call left the object, so a call still parked at the restart
38
+ * (never sent) is issued again and a sent write is not (see tool-marks).
39
+ * Finished-call records are lost when the object hibernates; a result for a
40
+ * forgotten id is then rejected as never issued, which is the same outcome.
34
41
  */
35
42
  import {
36
43
  frontendOutcomeUnknown,
@@ -79,15 +86,40 @@ export interface PendingFrontendCall {
79
86
  toolName: string;
80
87
  sessionId: string;
81
88
  startedAt: number;
82
- /** sid of the socket executing it (unset only before it is sent). */
89
+ /** Not sent yet: no socket that can run it has joined the session. */
90
+ parked: boolean;
91
+ /** sid of the socket executing it (unset while it is parked). */
83
92
  executorSid?: string;
84
93
  }
85
94
 
86
- interface Pending extends Omit<PendingFrontendCall, 'executorSid'> {
95
+ interface Pending extends Omit<PendingFrontendCall, 'executorSid' | 'parked'> {
87
96
  executor?: FrontendExecutor;
97
+ /** The call's own deadline once it is sent. */
98
+ timeoutMs: number;
88
99
  resolve: (value: unknown) => void;
89
100
  reject: (error: Error) => void;
90
101
  timer: ReturnType<typeof setTimeout>;
102
+ /** Removes the listener on the turn's abort signal (set when one was added). */
103
+ detach?: () => void;
104
+ onDispatched?: () => void;
105
+ }
106
+
107
+ export interface FrontendCallOpenOptions {
108
+ /** How long the browser has to answer once the call is sent. */
109
+ timeoutMs: number;
110
+ /**
111
+ * How long the call may wait unsent for a socket that can run it (default
112
+ * `timeoutMs`). Past it the call rejects as never sent.
113
+ */
114
+ parkTimeoutMs?: number;
115
+ /** The turn's abort signal. */
116
+ signal?: AbortSignal;
117
+ /**
118
+ * Called once, when the call is sent to a socket (`dispatched`); never for
119
+ * a call that ends parked (park deadline, abort, full table). A throwing
120
+ * callback does not affect the call.
121
+ */
122
+ onDispatched?: () => void;
91
123
  }
92
124
 
93
125
  /** Finished invocations kept to recognise replays: at most this many… */
@@ -102,6 +134,53 @@ export const FRONTEND_CALL_LABEL: Record<FrontendCallKind, string> = {
102
134
  agui: 'AG-UI action',
103
135
  };
104
136
 
137
+ /** Why a frontend call was never sent (see `FrontendCallNotSentError`). */
138
+ export type FrontendCallNotSentReason =
139
+ /** The turn aborted while the call was parked (or before it was opened). */
140
+ | 'aborted'
141
+ /** No socket that can run it joined the session within the park deadline. */
142
+ | 'park-deadline'
143
+ /** An earlier call of the session hit its park deadline and no socket has joined since. */
144
+ | 'no-browser'
145
+ /** The user's table of calls in flight is full. */
146
+ | 'table-full';
147
+
148
+ /**
149
+ * A frontend call that never reached a browser: a definite failure, so the
150
+ * tool layer releases a write's claim (`isUncertainOutcome` is false for it,
151
+ * even on an aborted turn). Its message never contains the words the tool
152
+ * layer reads as an uncertain outcome.
153
+ */
154
+ export class FrontendCallNotSentError extends Error {
155
+ constructor(
156
+ readonly reason: FrontendCallNotSentReason,
157
+ message: string,
158
+ ) {
159
+ super(message);
160
+ this.name = 'FrontendCallNotSentError';
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Whether `error` is, or wraps (`cause`, as LangChain's middleware errors
166
+ * do), a `FrontendCallNotSentError`.
167
+ */
168
+ export function isFrontendCallNotSent(error: unknown): boolean {
169
+ let current: unknown = error;
170
+ for (let depth = 0; depth < 8 && current instanceof Error; depth += 1) {
171
+ if (current instanceof FrontendCallNotSentError) return true;
172
+ current = current.cause;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ /**
178
+ * The model-facing ending of a call that waited for a browser in vain: the
179
+ * user has to open the chat, and the model must not loop on the tool.
180
+ */
181
+ export const OPEN_CHAT_HINT =
182
+ 'Ask the user to open this chat; do not retry on your own.';
183
+
105
184
  export interface FrontendCallRegistryOptions {
106
185
  now?: () => number;
107
186
  maxPending?: number;
@@ -113,6 +192,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
113
192
  return typeof value === 'object' && value !== null;
114
193
  }
115
194
 
195
+ /** `300000` → `300`, `1500` → `1.5`: the park deadline as the error states it. */
196
+ export function seconds(ms: number): string {
197
+ return String(Number((ms / 1000).toFixed(3)));
198
+ }
199
+
116
200
  function sameExecutor(a: FrontendExecutor, b: FrontendExecutor): boolean {
117
201
  return (
118
202
  a.sid === b.sid && a.sessionId === b.sessionId && a.userDid === b.userDid
@@ -145,12 +229,13 @@ export class FrontendCallRegistry {
145
229
  * Register an invocation BEFORE it is sent, so an immediate answer cannot
146
230
  * be missed, and return the promise its result settles. Throws when the id
147
231
  * was already issued or the table is full — the call is then not sent.
148
- * `signal` (the turn's abort signal) ends it early: rejected as not sent
149
- * before `dispatched`, an unknown outcome after.
232
+ * Until `dispatched` the call is parked on `parkTimeoutMs`; past it the
233
+ * call rejects as never sent. `signal` (the turn's abort signal) ends it
234
+ * early: rejected as not sent before `dispatched`, an unknown outcome after.
150
235
  */
151
236
  open(
152
- call: Omit<PendingFrontendCall, 'startedAt' | 'executorSid'>,
153
- opts: { timeoutMs: number; signal?: AbortSignal },
237
+ call: Omit<PendingFrontendCall, 'startedAt' | 'executorSid' | 'parked'>,
238
+ opts: FrontendCallOpenOptions,
154
239
  ): Promise<unknown> {
155
240
  const id = call.toolCallId;
156
241
  const label = FRONTEND_CALL_LABEL[call.kind];
@@ -158,22 +243,35 @@ export class FrontendCallRegistry {
158
243
  if (this.pending.has(id) || this.completed.has(id))
159
244
  throw new Error(`${label} invocation ${id} was already issued`);
160
245
  if (this.pending.size >= this.maxPending)
161
- throw new Error(
246
+ throw new FrontendCallNotSentError(
247
+ 'table-full',
162
248
  `${label} ${call.toolName} was not sent: ${this.maxPending} frontend calls are already in flight`,
163
249
  );
250
+ const parkTimeoutMs = opts.parkTimeoutMs ?? opts.timeoutMs;
164
251
  return new Promise<unknown>((resolve, reject) => {
252
+ // The park deadline: the call was never sent, so it definitely did not
253
+ // run — worded so the tool layer never reads it as an uncertain outcome.
165
254
  const timer = setTimeout(() => {
166
- if (this.complete(id)) resolve(frontendOutcomeUnknown(id));
167
- }, opts.timeoutMs);
255
+ if (this.complete(id))
256
+ reject(
257
+ new FrontendCallNotSentError(
258
+ 'park-deadline',
259
+ `${label} ${call.toolName} was not sent: no browser joined session ${call.sessionId} within ${seconds(parkTimeoutMs)} s. ${OPEN_CHAT_HINT}`,
260
+ ),
261
+ );
262
+ }, parkTimeoutMs);
168
263
  const entry: Pending = {
169
264
  ...call,
170
265
  startedAt: this.now(),
266
+ timeoutMs: opts.timeoutMs,
171
267
  resolve,
172
268
  reject,
173
269
  timer,
270
+ onDispatched: opts.onDispatched,
174
271
  };
175
272
  this.pending.set(id, entry);
176
- if (opts.signal) {
273
+ const { signal } = opts;
274
+ if (signal) {
177
275
  // The turn ended (Stop, deadline). Before the call was sent it is a
178
276
  // definite failure; once sent, the browser may already have run it.
179
277
  const onAbort = () => {
@@ -182,13 +280,18 @@ export class FrontendCallRegistry {
182
280
  if (entry.executor) resolve(frontendOutcomeUnknown(id));
183
281
  else
184
282
  reject(
185
- new Error(
283
+ new FrontendCallNotSentError(
284
+ 'aborted',
186
285
  `${label} ${call.toolName} was not sent: the turn was aborted`,
187
286
  ),
188
287
  );
189
288
  };
190
- if (opts.signal.aborted) onAbort();
191
- else opts.signal.addEventListener('abort', onAbort, { once: true });
289
+ if (signal.aborted) onAbort();
290
+ else {
291
+ signal.addEventListener('abort', onAbort, { once: true });
292
+ // However the call ends, the turn's signal stops referencing it.
293
+ entry.detach = () => signal.removeEventListener('abort', onAbort);
294
+ }
192
295
  }
193
296
  });
194
297
  }
@@ -198,17 +301,32 @@ export class FrontendCallRegistry {
198
301
  return this.pending.has(toolCallId);
199
302
  }
200
303
 
201
- /** Bind a pending invocation to the one socket it was sent to. */
202
- dispatched(toolCallId: string, executor: FrontendExecutor): void {
304
+ /** Whether `toolCallId` is waiting unsent (parked). */
305
+ isParked(toolCallId: string): boolean {
203
306
  const entry = this.pending.get(toolCallId);
204
- if (entry) entry.executor = executor;
307
+ return entry !== undefined && !entry.executor;
205
308
  }
206
309
 
207
- /** End an invocation that never reached a browser: a definite failure. */
208
- fail(toolCallId: string, error: Error): void {
310
+ /**
311
+ * Bind a pending invocation to the one socket it was sent to; its clock
312
+ * restarts with its own deadline, past which the outcome is unknown.
313
+ */
314
+ dispatched(toolCallId: string, executor: FrontendExecutor): void {
209
315
  const entry = this.pending.get(toolCallId);
210
- if (!entry || !this.complete(toolCallId)) return;
211
- entry.reject(error);
316
+ if (!entry || entry.executor) return;
317
+ entry.executor = executor;
318
+ clearTimeout(entry.timer);
319
+ entry.timer = setTimeout(() => {
320
+ if (this.complete(toolCallId))
321
+ entry.resolve(frontendOutcomeUnknown(toolCallId));
322
+ }, entry.timeoutMs);
323
+ const { onDispatched } = entry;
324
+ entry.onDispatched = undefined;
325
+ try {
326
+ onDispatched?.();
327
+ } catch {
328
+ // The record of the dispatch is best effort; the call is already sent.
329
+ }
212
330
  }
213
331
 
214
332
  /** Deliver a result that arrived on a socket. */
@@ -272,6 +390,7 @@ export class FrontendCallRegistry {
272
390
  toolName,
273
391
  sessionId,
274
392
  startedAt,
393
+ parked: !executor,
275
394
  ...(executor ? { executorSid: executor.sid } : {}),
276
395
  }),
277
396
  )
@@ -293,6 +412,7 @@ export class FrontendCallRegistry {
293
412
  const entry = this.pending.get(id);
294
413
  if (!entry) return false;
295
414
  clearTimeout(entry.timer);
415
+ entry.detach?.();
296
416
  this.pending.delete(id);
297
417
  this.completed.set(id, this.now());
298
418
  this.prune();