@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.
- package/README.md +1 -0
- package/package.json +3 -3
- package/src/core/env.ts +18 -0
- package/src/core/index.ts +8 -2
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +24 -0
- package/src/core/middlewares/tool-execution.ts +87 -46
- package/src/core/middlewares/tool-marks.ts +54 -19
- package/src/core/runtime-context.ts +15 -1
- package/src/core/state.ts +2 -0
- package/src/core/tool-scheduler.ts +129 -24
- package/src/do/active-runs.ts +171 -0
- package/src/do/contracts.ts +47 -3
- package/src/do/run-coordinator.ts +30 -0
- package/src/do/run-request.ts +15 -2
- package/src/do/run-store-test-do.ts +10 -0
- package/src/do/run-store.ts +66 -13
- package/src/do/turn-body.ts +13 -1
- package/src/do/user-oracle-do.ts +167 -10
- package/src/plugin-api/tool-helper.ts +6 -0
- package/src/plugin-api/types.ts +63 -7
- package/src/plugins/agui/agui.plugin.ts +7 -5
- package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
- package/src/plugins/portal/declared-tools.ts +12 -0
- package/src/plugins/portal/portal.plugin.ts +22 -8
- package/src/realtime/frontend-call-registry.ts +147 -27
- package/src/realtime/realtime-endpoint.ts +335 -41
- package/src/realtime/session-socket-hub.ts +90 -19
- package/src/shell/app.ts +24 -2
- package/src/sqlite/sessions-store.ts +22 -0
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* → the shell routes the upgrade to this user's object (`?userDid`);
|
|
9
9
|
* the object accepts the socket (Hibernatable WebSockets API) and
|
|
10
10
|
* answers the engine.io OPEN handshake.
|
|
11
|
-
* 40{"invocation":"…","ucanDelegation":"…"}
|
|
11
|
+
* 40{"invocation":"…","ucanDelegation":"…"[,"executor":"background","tools":[…]]}
|
|
12
12
|
* → the CONNECT packet carries the same UCAN material the HTTP routes
|
|
13
13
|
* take in headers; it is validated with the shell's `authenticate`
|
|
14
14
|
* and the validated DID must be the routed one. Success: `40{sid}` +
|
|
15
15
|
* a `connected` event. Failure: `44{message}` and the socket closes.
|
|
16
|
+
* `executor: "background"` declares a socket that only runs the
|
|
17
|
+
* browser tools it names and renders nothing (no fan-out); any other
|
|
18
|
+
* socket is a foreground executor.
|
|
16
19
|
* 42["ping"] / 42["status"] / 42["list-events"]
|
|
17
20
|
* → Node's diagnostic events, same payloads.
|
|
18
21
|
* 42["tool_result",{toolCallId,result,error?}] / 42["action_call_result",…]
|
|
@@ -21,15 +24,16 @@
|
|
|
21
24
|
*
|
|
22
25
|
* Outbound, every event the runtime emits for a session (`ctx.emit.*`,
|
|
23
26
|
* `tool_call`, `render_component`, …) is fanned out to the session's
|
|
24
|
-
* authenticated sockets — the router's tap.
|
|
27
|
+
* authenticated foreground sockets — the router's tap.
|
|
25
28
|
*
|
|
26
29
|
* Frontend calls (`ctx.frontend.callBrowserTool` / `callAgAction`) are not
|
|
27
30
|
* fanned out: each invocation gets its own id, is sent to ONE socket of its
|
|
28
|
-
* session
|
|
29
|
-
* settles it.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* session that can run it (`SessionSocketHub.executorFor`), and only that
|
|
32
|
+
* socket's answer settles it. With no such socket the call is parked until
|
|
33
|
+
* one passes CONNECT (or the park deadline / the turn's abort ends it as
|
|
34
|
+
* never sent). When the answer of a sent call cannot come — deadline, or
|
|
35
|
+
* that socket went — the call resolves with `FRONTEND_OUTCOME_UNKNOWN` and
|
|
36
|
+
* is never re-sent. See docs/frontend-bridge.md.
|
|
33
37
|
*
|
|
34
38
|
* Heartbeat: engine.io's server-initiated ping, driven by the OBJECT'S
|
|
35
39
|
* ALARM rather than a timer. A timer would keep the object resident (and
|
|
@@ -47,17 +51,31 @@ import type { EventSink, SessionEventRouter } from '../do/ambient';
|
|
|
47
51
|
import type {
|
|
48
52
|
FrontendCallParams,
|
|
49
53
|
FrontendCallSurface,
|
|
54
|
+
FrontendWaitingFor,
|
|
50
55
|
} from '../plugin-api/types';
|
|
51
56
|
import type { RawEventPayload } from '../core/runtime-context';
|
|
52
57
|
import { frontendInvocationId } from '@ixo/common/ai/frontend-bridge';
|
|
58
|
+
import {
|
|
59
|
+
baseEnvSchema,
|
|
60
|
+
FRONTEND_PARK_TIMEOUT_MS_DEFAULT,
|
|
61
|
+
MAX_TIMER_MS,
|
|
62
|
+
} from '../core/env';
|
|
63
|
+
import { DECLARED_TOOL_NAME_PATTERN } from '../plugins/portal/declared-tools';
|
|
53
64
|
import {
|
|
54
65
|
FRONTEND_CALL_LABEL,
|
|
66
|
+
FrontendCallNotSentError,
|
|
55
67
|
FrontendCallRegistry,
|
|
68
|
+
OPEN_CHAT_HINT,
|
|
69
|
+
seconds,
|
|
56
70
|
type FrontendCallKind,
|
|
57
71
|
type FrontendResultRejection,
|
|
58
72
|
type PendingFrontendCall,
|
|
59
73
|
} from './frontend-call-registry';
|
|
60
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
canRunTool,
|
|
76
|
+
SessionSocketHub,
|
|
77
|
+
type SocketAttachment,
|
|
78
|
+
} from './session-socket-hub';
|
|
61
79
|
import {
|
|
62
80
|
decodeClientFrame,
|
|
63
81
|
encodeAck,
|
|
@@ -82,6 +100,20 @@ export const MAX_PAYLOAD_BYTES = 1_000_000;
|
|
|
82
100
|
export const HANDSHAKE_DEADLINE_MS = 10_000;
|
|
83
101
|
export const BROWSER_TOOL_DEFAULT_TIMEOUT_MS = 15_000;
|
|
84
102
|
export const AG_ACTION_DEFAULT_TIMEOUT_MS = 10_000;
|
|
103
|
+
/** Tool names a background executor's CONNECT may declare; further names are dropped. */
|
|
104
|
+
export const MAX_BACKGROUND_EXECUTOR_TOOLS = 24;
|
|
105
|
+
/**
|
|
106
|
+
* Workers refuses a WebSocket attachment over 2,048 bytes serialized
|
|
107
|
+
* (structured clone). A background executor's tool list is cut until the
|
|
108
|
+
* attachment's UTF-8 JSON, with the heartbeat fields at their widest, fits
|
|
109
|
+
* this budget: for these fields JSON is never smaller than the structured
|
|
110
|
+
* clone (quotes and separators against tags and lengths, 16 digits against
|
|
111
|
+
* 9 bytes per number, at least as many bytes per character), and the
|
|
112
|
+
* margin covers the clone's header. With 51-character DIDs and a 36- or
|
|
113
|
+
* 44-character session id, 24 names of 64 characters fit (about 1,990 JSON
|
|
114
|
+
* bytes, 1,930 cloned); longer identifiers cost names, never the CONNECT.
|
|
115
|
+
*/
|
|
116
|
+
export const SOCKET_ATTACHMENT_BUDGET_BYTES = 2_000;
|
|
85
117
|
|
|
86
118
|
/** Header the shell sets on the forwarded upgrade: the DID it routed for. */
|
|
87
119
|
export const ROUTED_USER_HEADER = 'x-routed-user-did';
|
|
@@ -173,13 +205,53 @@ export interface RealtimeEndpointDeps {
|
|
|
173
205
|
* accepted or re-adopted. The object calls `pingTick` from its alarm.
|
|
174
206
|
*/
|
|
175
207
|
requestAlarm?: (at: number) => void;
|
|
208
|
+
/**
|
|
209
|
+
* How long a frontend call may wait unsent for a socket that can run it
|
|
210
|
+
* (`FRONTEND_PARK_TIMEOUT_MS`, default 300 s).
|
|
211
|
+
*/
|
|
212
|
+
parkTimeoutMs?: number;
|
|
176
213
|
now?: () => number;
|
|
177
214
|
}
|
|
178
215
|
|
|
216
|
+
/**
|
|
217
|
+
* `FRONTEND_PARK_TIMEOUT_MS` through the base env schema's own field (the
|
|
218
|
+
* rule the boot validates: a whole number of ms, 1 to the 2^31−1 a timer
|
|
219
|
+
* takes), so the endpoint never sees anything else; a malformed var keeps
|
|
220
|
+
* the default, with a warning.
|
|
221
|
+
*/
|
|
222
|
+
export function frontendParkTimeoutMs(
|
|
223
|
+
env: { FRONTEND_PARK_TIMEOUT_MS?: string | number },
|
|
224
|
+
log: Pick<Console, 'warn'> = console,
|
|
225
|
+
): number {
|
|
226
|
+
const parsed = baseEnvSchema.shape.FRONTEND_PARK_TIMEOUT_MS.safeParse(
|
|
227
|
+
env.FRONTEND_PARK_TIMEOUT_MS,
|
|
228
|
+
);
|
|
229
|
+
if (parsed.success) return parsed.data;
|
|
230
|
+
log.warn(
|
|
231
|
+
`[realtime] FRONTEND_PARK_TIMEOUT_MS=${JSON.stringify(env.FRONTEND_PARK_TIMEOUT_MS)} is not a whole number of ms between 1 and ${MAX_TIMER_MS}; using ${FRONTEND_PARK_TIMEOUT_MS_DEFAULT}`,
|
|
232
|
+
);
|
|
233
|
+
return FRONTEND_PARK_TIMEOUT_MS_DEFAULT;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A frontend call to send; parked while no socket of its session can run it. */
|
|
237
|
+
interface OutgoingFrontendCall {
|
|
238
|
+
toolCallId: string;
|
|
239
|
+
kind: FrontendCallKind;
|
|
240
|
+
eventName: 'browser_tool_call' | 'action_call';
|
|
241
|
+
payload: RawEventPayload;
|
|
242
|
+
sessionId: string;
|
|
243
|
+
toolName: string;
|
|
244
|
+
parkedAt: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
179
247
|
export interface RealtimeSocketStatus {
|
|
180
248
|
sid: string;
|
|
181
249
|
sessionId: string;
|
|
182
250
|
authenticated: boolean;
|
|
251
|
+
/** `background` for a socket that declared itself a background executor. */
|
|
252
|
+
executor: 'foreground' | 'background';
|
|
253
|
+
/** How many tool names a background executor kept (absent in the foreground). */
|
|
254
|
+
tools?: number;
|
|
183
255
|
openedAt: number;
|
|
184
256
|
lastPingAt: number | null;
|
|
185
257
|
lastPongAt: number;
|
|
@@ -190,6 +262,8 @@ export interface RealtimeStatus {
|
|
|
190
262
|
authenticated: number;
|
|
191
263
|
sessions: number;
|
|
192
264
|
pendingCalls: PendingFrontendCall[];
|
|
265
|
+
/** Of `pendingCalls`, the ones parked (not sent yet). */
|
|
266
|
+
parkedCalls: number;
|
|
193
267
|
/** Finished invocations remembered to reject replays (bounded). */
|
|
194
268
|
completedCalls: number;
|
|
195
269
|
pingIntervalMs: number;
|
|
@@ -222,6 +296,52 @@ function readString(
|
|
|
222
296
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
223
297
|
}
|
|
224
298
|
|
|
299
|
+
/**
|
|
300
|
+
* The executor kind a CONNECT declares. Only `executor: "background"` makes
|
|
301
|
+
* a background executor, and only then do `tools` count: each a valid tool
|
|
302
|
+
* name, de-duplicated, at most `MAX_BACKGROUND_EXECUTOR_TOOLS` kept. Only
|
|
303
|
+
* the first `MAX_BACKGROUND_EXECUTOR_TOOLS * 4` entries are looked at (a
|
|
304
|
+
* frame may carry up to `MAX_PAYLOAD_BYTES` of names); the rest count as
|
|
305
|
+
* over the cap unread.
|
|
306
|
+
*/
|
|
307
|
+
function readExecutor(record: Record<string, unknown>): {
|
|
308
|
+
patch: Pick<SocketAttachment, 'executor' | 'tools'>;
|
|
309
|
+
invalid: number;
|
|
310
|
+
overCap: number;
|
|
311
|
+
} {
|
|
312
|
+
if (record.executor !== 'background')
|
|
313
|
+
return { patch: {}, invalid: 0, overCap: 0 };
|
|
314
|
+
const all: unknown[] = Array.isArray(record.tools) ? record.tools : [];
|
|
315
|
+
const declared = all.slice(0, MAX_BACKGROUND_EXECUTOR_TOOLS * 4);
|
|
316
|
+
const tools: string[] = [];
|
|
317
|
+
let invalid = 0;
|
|
318
|
+
let overCap = all.length - declared.length;
|
|
319
|
+
for (const name of declared) {
|
|
320
|
+
if (typeof name !== 'string' || !DECLARED_TOOL_NAME_PATTERN.test(name))
|
|
321
|
+
invalid += 1;
|
|
322
|
+
else if (tools.includes(name)) continue;
|
|
323
|
+
else if (tools.length >= MAX_BACKGROUND_EXECUTOR_TOOLS) overCap += 1;
|
|
324
|
+
else tools.push(name);
|
|
325
|
+
}
|
|
326
|
+
return { patch: { executor: 'background', tools }, invalid, overCap };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* UTF-8 bytes of the attachment's JSON with its heartbeat fields at their
|
|
331
|
+
* widest: the measure `SOCKET_ATTACHMENT_BUDGET_BYTES` bounds.
|
|
332
|
+
*/
|
|
333
|
+
export function socketAttachmentBytes(attachment: SocketAttachment): number {
|
|
334
|
+
const widest = Number.MAX_SAFE_INTEGER;
|
|
335
|
+
return new TextEncoder().encode(
|
|
336
|
+
JSON.stringify({
|
|
337
|
+
...attachment,
|
|
338
|
+
lastPongAt: widest,
|
|
339
|
+
lastPingAt: widest,
|
|
340
|
+
lastActiveAt: widest,
|
|
341
|
+
}),
|
|
342
|
+
).byteLength;
|
|
343
|
+
}
|
|
344
|
+
|
|
225
345
|
export class RealtimeEndpoint {
|
|
226
346
|
readonly hub: SessionSocketHub<WebSocket>;
|
|
227
347
|
|
|
@@ -239,8 +359,22 @@ export class RealtimeEndpoint {
|
|
|
239
359
|
|
|
240
360
|
private readonly now: () => number;
|
|
241
361
|
|
|
362
|
+
private readonly parkTimeoutMs: number;
|
|
363
|
+
|
|
364
|
+
/** Parked calls by session, oldest first (see `call`). */
|
|
365
|
+
private readonly parked = new Map<string, OutgoingFrontendCall[]>();
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Sessions whose last parked call reached its park deadline, until a
|
|
369
|
+
* socket of the session passes CONNECT: their calls fail fast instead of
|
|
370
|
+
* waiting again. In memory only (lost when the object hibernates, after
|
|
371
|
+
* which a call simply parks again).
|
|
372
|
+
*/
|
|
373
|
+
private readonly parkExpired = new Set<string>();
|
|
374
|
+
|
|
242
375
|
constructor(private readonly deps: RealtimeEndpointDeps) {
|
|
243
376
|
this.now = deps.now ?? (() => Date.now());
|
|
377
|
+
this.parkTimeoutMs = deps.parkTimeoutMs ?? FRONTEND_PARK_TIMEOUT_MS_DEFAULT;
|
|
244
378
|
this.calls = new FrontendCallRegistry({ now: this.now });
|
|
245
379
|
this.hub = new SessionSocketHub<WebSocket>({
|
|
246
380
|
now: this.now,
|
|
@@ -261,6 +395,9 @@ export class RealtimeEndpoint {
|
|
|
261
395
|
callAgAction: (params) =>
|
|
262
396
|
this.call('agui', 'action_call', params, AG_ACTION_DEFAULT_TIMEOUT_MS),
|
|
263
397
|
hasClient: (sessionId) => this.hub.hasSession(sessionId),
|
|
398
|
+
canDispatch: (sessionId, kind, toolName) =>
|
|
399
|
+
this.hub.executorFor(sessionId, kind, toolName) !== undefined,
|
|
400
|
+
parkedFor: (sessionId) => this.parkedFor(sessionId),
|
|
264
401
|
};
|
|
265
402
|
// Sockets that survived hibernation or a restart.
|
|
266
403
|
const restored = this.hub.restore(deps.ctx.getWebSockets());
|
|
@@ -415,11 +552,13 @@ export class RealtimeEndpoint {
|
|
|
415
552
|
}
|
|
416
553
|
|
|
417
554
|
status(): RealtimeStatus {
|
|
555
|
+
const pendingCalls = this.calls.list();
|
|
418
556
|
return {
|
|
419
557
|
sockets: this.hub.size,
|
|
420
558
|
authenticated: this.hub.connectionCount(),
|
|
421
559
|
sessions: this.hub.sessionCount(),
|
|
422
|
-
pendingCalls
|
|
560
|
+
pendingCalls,
|
|
561
|
+
parkedCalls: pendingCalls.filter((c) => c.parked).length,
|
|
423
562
|
completedCalls: this.calls.completedCount,
|
|
424
563
|
pingIntervalMs: PING_INTERVAL_MS,
|
|
425
564
|
pingTimeoutMs: PING_TIMEOUT_MS,
|
|
@@ -428,6 +567,8 @@ export class RealtimeEndpoint {
|
|
|
428
567
|
sid: attachment.sid,
|
|
429
568
|
sessionId: attachment.sessionId,
|
|
430
569
|
authenticated: Boolean(attachment.userDid),
|
|
570
|
+
executor: attachment.executor ?? 'foreground',
|
|
571
|
+
...(attachment.tools ? { tools: attachment.tools.length } : {}),
|
|
431
572
|
openedAt: attachment.openedAt,
|
|
432
573
|
lastPingAt: attachment.lastPingAt ?? null,
|
|
433
574
|
lastPongAt: attachment.lastPongAt,
|
|
@@ -551,21 +692,57 @@ export class RealtimeEndpoint {
|
|
|
551
692
|
clearTimeout(timer);
|
|
552
693
|
this.handshakeTimers.delete(ws);
|
|
553
694
|
}
|
|
554
|
-
this.
|
|
695
|
+
const executor = this.executorPatch(meta, record, outcome.auth.userDid);
|
|
696
|
+
this.hub.update(ws, { userDid: outcome.auth.userDid, ...executor });
|
|
555
697
|
this.hub.send(ws, encodeConnectAck(meta.sid));
|
|
556
698
|
this.hub.send(
|
|
557
699
|
ws,
|
|
558
700
|
encodeEvent('connected', {
|
|
559
701
|
message: 'Connected successfully',
|
|
560
702
|
sessionId: meta.sessionId,
|
|
703
|
+
// The executor kind this runtime accepted: a client that asked for
|
|
704
|
+
// `background` and does not read it back is on a runtime that
|
|
705
|
+
// ignores the field (and treats the socket as a foreground tab).
|
|
706
|
+
executor: executor.executor ?? 'foreground',
|
|
561
707
|
timestamp: new Date(this.now()).toISOString(),
|
|
562
708
|
}),
|
|
563
709
|
);
|
|
564
710
|
this.deps.logger.log(
|
|
565
|
-
`[realtime] ${outcome.auth.userDid} connected to session ${meta.sessionId} (${meta.sid})`,
|
|
711
|
+
`[realtime] ${outcome.auth.userDid} connected to session ${meta.sessionId} (${meta.sid}${executor.executor ? `, background executor of ${executor.tools?.length ?? 0} tool(s)` : ''})`,
|
|
566
712
|
);
|
|
567
713
|
// From here the heartbeat (the object's alarm) keeps the socket honest.
|
|
568
714
|
this.scheduleHeartbeat();
|
|
715
|
+
this.dispatchParked(ws);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The executor fields a CONNECT sets on the attachment: none for a
|
|
720
|
+
* foreground socket; for a background one its tools, cut further when the
|
|
721
|
+
* attachment would not fit `SOCKET_ATTACHMENT_BUDGET_BYTES`. One warning
|
|
722
|
+
* names how many names were dropped and why (never the names).
|
|
723
|
+
*/
|
|
724
|
+
private executorPatch(
|
|
725
|
+
meta: SocketAttachment,
|
|
726
|
+
record: Record<string, unknown>,
|
|
727
|
+
userDid: string,
|
|
728
|
+
): Pick<SocketAttachment, 'executor' | 'tools'> {
|
|
729
|
+
const { patch, invalid, overCap } = readExecutor(record);
|
|
730
|
+
const tools = patch.tools;
|
|
731
|
+
if (!tools) return patch;
|
|
732
|
+
let overBudget = 0;
|
|
733
|
+
while (
|
|
734
|
+
tools.length > 0 &&
|
|
735
|
+
socketAttachmentBytes({ ...meta, userDid, ...patch }) >
|
|
736
|
+
SOCKET_ATTACHMENT_BUDGET_BYTES
|
|
737
|
+
) {
|
|
738
|
+
tools.pop();
|
|
739
|
+
overBudget += 1;
|
|
740
|
+
}
|
|
741
|
+
if (invalid + overCap + overBudget > 0)
|
|
742
|
+
this.deps.logger.warn(
|
|
743
|
+
`[realtime] background executor ${meta.sid} of session ${meta.sessionId}: kept ${tools.length} tool name(s), dropped ${invalid} invalid, ${overCap} over the cap of ${MAX_BACKGROUND_EXECUTOR_TOOLS}, ${overBudget} over the attachment budget`,
|
|
744
|
+
);
|
|
745
|
+
return patch;
|
|
569
746
|
}
|
|
570
747
|
|
|
571
748
|
private refuse(ws: WebSocket, message: string): void {
|
|
@@ -694,10 +871,11 @@ export class RealtimeEndpoint {
|
|
|
694
871
|
|
|
695
872
|
/**
|
|
696
873
|
* One frontend invocation: a fresh id, registered before it is sent (an
|
|
697
|
-
* immediate answer cannot be missed), sent to the
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
874
|
+
* immediate answer cannot be missed), sent to the socket of the session
|
|
875
|
+
* that runs it (`executorFor`) and bound to it. With no such socket it is
|
|
876
|
+
* parked, FIFO per session, until one passes CONNECT; a parked call that
|
|
877
|
+
* reaches its park deadline (or whose turn aborts) is a definite failure,
|
|
878
|
+
* a sent one whose answer never comes is an unknown outcome.
|
|
701
879
|
*/
|
|
702
880
|
private call(
|
|
703
881
|
kind: FrontendCallKind,
|
|
@@ -707,6 +885,18 @@ export class RealtimeEndpoint {
|
|
|
707
885
|
): Promise<unknown> {
|
|
708
886
|
const toolCallId = frontendInvocationId(params.toolCallId);
|
|
709
887
|
const label = FRONTEND_CALL_LABEL[kind];
|
|
888
|
+
// An earlier call of this session waited its whole park deadline and no
|
|
889
|
+
// socket has joined since: fail at once instead of waiting again.
|
|
890
|
+
if (
|
|
891
|
+
this.parkExpired.has(params.sessionId) &&
|
|
892
|
+
!this.hub.executorFor(params.sessionId, kind, params.toolName)
|
|
893
|
+
)
|
|
894
|
+
return Promise.reject(
|
|
895
|
+
new FrontendCallNotSentError(
|
|
896
|
+
'no-browser',
|
|
897
|
+
`${label} ${params.toolName} was not sent: no browser has joined session ${params.sessionId} since an earlier call waited ${seconds(this.parkTimeoutMs)} s for one. ${OPEN_CHAT_HINT}`,
|
|
898
|
+
),
|
|
899
|
+
);
|
|
710
900
|
let pending: Promise<unknown>;
|
|
711
901
|
try {
|
|
712
902
|
pending = this.calls.open(
|
|
@@ -718,7 +908,9 @@ export class RealtimeEndpoint {
|
|
|
718
908
|
},
|
|
719
909
|
{
|
|
720
910
|
timeoutMs: params.timeoutMs ?? defaultTimeoutMs,
|
|
911
|
+
parkTimeoutMs: this.parkTimeoutMs,
|
|
721
912
|
...(params.signal ? { signal: params.signal } : {}),
|
|
913
|
+
...(params.onDispatched ? { onDispatched: params.onDispatched } : {}),
|
|
722
914
|
},
|
|
723
915
|
);
|
|
724
916
|
} catch (err) {
|
|
@@ -737,34 +929,131 @@ export class RealtimeEndpoint {
|
|
|
737
929
|
args: params.args,
|
|
738
930
|
...(kind === 'agui' ? { status: 'isRunning' } : {}),
|
|
739
931
|
};
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
this.
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
932
|
+
const call: OutgoingFrontendCall = {
|
|
933
|
+
toolCallId,
|
|
934
|
+
kind,
|
|
935
|
+
eventName,
|
|
936
|
+
payload,
|
|
937
|
+
sessionId: params.sessionId,
|
|
938
|
+
toolName: params.toolName,
|
|
939
|
+
parkedAt: this.now(),
|
|
940
|
+
};
|
|
941
|
+
if (this.dispatch(call)) return pending;
|
|
942
|
+
const queue = this.parked.get(call.sessionId) ?? [];
|
|
943
|
+
queue.push(call);
|
|
944
|
+
this.parked.set(call.sessionId, queue);
|
|
945
|
+
// However it ends (dispatched, park deadline, abort), it leaves the
|
|
946
|
+
// queue. A park deadline marks the session: later calls fail fast until
|
|
947
|
+
// a socket of it passes CONNECT (`dispatchParked`).
|
|
948
|
+
pending.then(
|
|
949
|
+
() => this.unpark(call),
|
|
950
|
+
(error: unknown) => {
|
|
951
|
+
this.unpark(call);
|
|
952
|
+
if (
|
|
953
|
+
error instanceof FrontendCallNotSentError &&
|
|
954
|
+
error.reason === 'park-deadline'
|
|
955
|
+
)
|
|
956
|
+
this.parkExpired.add(call.sessionId);
|
|
957
|
+
},
|
|
958
|
+
);
|
|
959
|
+
this.deps.logger.log(
|
|
960
|
+
`[realtime] ${label} ${call.toolName} (${toolCallId}) parked: no socket of session ${call.sessionId} can run it`,
|
|
961
|
+
);
|
|
962
|
+
return pending;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Send `call` to the socket of its session that runs it and bind it there.
|
|
967
|
+
* A socket whose send throws is dropped by the hub, and the next eligible
|
|
968
|
+
* one is tried. False when none is left (the call stays parked).
|
|
969
|
+
*/
|
|
970
|
+
private dispatch(call: OutgoingFrontendCall): boolean {
|
|
971
|
+
const frame = encodeEvent(call.eventName, call.payload);
|
|
972
|
+
for (;;) {
|
|
973
|
+
const target = this.hub.executorFor(
|
|
974
|
+
call.sessionId,
|
|
975
|
+
call.kind,
|
|
976
|
+
call.toolName,
|
|
752
977
|
);
|
|
753
|
-
|
|
978
|
+
const userDid = target?.attachment.userDid;
|
|
979
|
+
if (!target || !userDid) return false;
|
|
980
|
+
if (!this.hub.send(target.socket, frame)) continue;
|
|
981
|
+
this.bind(call, target.attachment, userDid);
|
|
982
|
+
return true;
|
|
754
983
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** Record `call` as sent to the socket of `attachment`. */
|
|
987
|
+
private bind(
|
|
988
|
+
call: OutgoingFrontendCall,
|
|
989
|
+
attachment: SocketAttachment,
|
|
990
|
+
userDid: string,
|
|
991
|
+
): void {
|
|
992
|
+
this.calls.dispatched(call.toolCallId, {
|
|
993
|
+
sid: attachment.sid,
|
|
994
|
+
sessionId: call.sessionId,
|
|
758
995
|
userDid,
|
|
759
996
|
});
|
|
760
997
|
// A browser tool call is shown on the session's SSE stream too (session
|
|
761
|
-
// sinks; the socket tap skips frontend calls, see `fanOut`)
|
|
762
|
-
// invocation is not: the SSE stream already
|
|
763
|
-
// `action_call` under the model's call id and
|
|
764
|
-
// ends, and a second frame under the invocation
|
|
765
|
-
// closed — the client would show a card spinning
|
|
766
|
-
|
|
767
|
-
|
|
998
|
+
// sinks; the socket tap skips frontend calls, see `fanOut`), when it is
|
|
999
|
+
// actually sent. An AG-UI invocation is not: the SSE stream already
|
|
1000
|
+
// reports the action as an `action_call` under the model's call id and
|
|
1001
|
+
// closes it when the tool ends, and a second frame under the invocation
|
|
1002
|
+
// id would never be closed — the client would show a card spinning
|
|
1003
|
+
// forever.
|
|
1004
|
+
if (call.kind === 'browser')
|
|
1005
|
+
this.deps.router.emit(call.eventName, call.payload);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* A socket of the session just passed CONNECT: every parked call it can
|
|
1010
|
+
* run is sent to it, oldest first (a foreground socket takes them all, a
|
|
1011
|
+
* background executor the tools it declared). A send that fails stops
|
|
1012
|
+
* the round; the rest stay parked.
|
|
1013
|
+
*/
|
|
1014
|
+
private dispatchParked(ws: WebSocket): void {
|
|
1015
|
+
const attachment = this.hub.get(ws);
|
|
1016
|
+
const userDid = attachment?.userDid;
|
|
1017
|
+
if (!attachment || !userDid) return;
|
|
1018
|
+
// A browser of the session is back: its calls may park again.
|
|
1019
|
+
this.parkExpired.delete(attachment.sessionId);
|
|
1020
|
+
const queue = this.parked.get(attachment.sessionId);
|
|
1021
|
+
if (!queue) return;
|
|
1022
|
+
for (const call of [...queue]) {
|
|
1023
|
+
if (!this.calls.isParked(call.toolCallId)) {
|
|
1024
|
+
this.unpark(call);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
if (!canRunTool(attachment, call.kind, call.toolName)) continue;
|
|
1028
|
+
if (!this.hub.send(ws, encodeEvent(call.eventName, call.payload))) return;
|
|
1029
|
+
this.bind(call, attachment, userDid);
|
|
1030
|
+
this.unpark(call);
|
|
1031
|
+
this.deps.logger.log(
|
|
1032
|
+
`[realtime] parked ${FRONTEND_CALL_LABEL[call.kind]} ${call.toolName} (${call.toolCallId}) sent to ${attachment.sid} of session ${attachment.sessionId}`,
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
private unpark(call: OutgoingFrontendCall): void {
|
|
1038
|
+
const queue = this.parked.get(call.sessionId);
|
|
1039
|
+
if (!queue) return;
|
|
1040
|
+
const index = queue.indexOf(call);
|
|
1041
|
+
if (index >= 0) queue.splice(index, 1);
|
|
1042
|
+
if (queue.length === 0) this.parked.delete(call.sessionId);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** The session's oldest call still parked, if any. */
|
|
1046
|
+
private parkedFor(sessionId: string): FrontendWaitingFor | null {
|
|
1047
|
+
const call = this.parked
|
|
1048
|
+
.get(sessionId)
|
|
1049
|
+
?.find((c) => this.calls.isParked(c.toolCallId));
|
|
1050
|
+
return call
|
|
1051
|
+
? {
|
|
1052
|
+
kind: call.kind,
|
|
1053
|
+
toolName: call.toolName,
|
|
1054
|
+
since: new Date(call.parkedAt).toISOString(),
|
|
1055
|
+
}
|
|
1056
|
+
: null;
|
|
768
1057
|
}
|
|
769
1058
|
|
|
770
1059
|
/** The socket `sid` is gone: its invocations end as unknown, never re-sent. */
|
|
@@ -797,9 +1086,14 @@ export class RealtimeEndpoint {
|
|
|
797
1086
|
const attachment = this.hub.get(ws);
|
|
798
1087
|
this.hub.remove(ws);
|
|
799
1088
|
if (attachment) this.executorGone(attachment.sid);
|
|
800
|
-
// Only an authenticated socket
|
|
801
|
-
// that never passed CONNECT
|
|
802
|
-
|
|
1089
|
+
// Only an authenticated foreground socket (a tab showing the chat) ever
|
|
1090
|
+
// counted for its session's drain; a socket that never passed CONNECT,
|
|
1091
|
+
// or a background executor, leaves nothing to drain.
|
|
1092
|
+
if (
|
|
1093
|
+
attachment?.userDid &&
|
|
1094
|
+
attachment.executor !== 'background' &&
|
|
1095
|
+
!this.hub.hasForegroundSession(attachment.sessionId)
|
|
1096
|
+
) {
|
|
803
1097
|
this.deps.onSessionDrained?.(attachment.sessionId, attachment.userDid);
|
|
804
1098
|
}
|
|
805
1099
|
}
|