@ixo/oracle-runtime-workers 0.21.1 → 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 +1 -1
- 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 +32 -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
|
@@ -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
|
-
|
|
102
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
27
|
+
import {
|
|
28
|
+
declaredToolEffect,
|
|
29
|
+
describeDropped,
|
|
30
|
+
sanitizeDeclaredTools,
|
|
31
|
+
} from './declared-tools';
|
|
28
32
|
|
|
29
33
|
const manifest: PluginManifest = {
|
|
30
34
|
title: 'Portal',
|
|
@@ -54,6 +58,13 @@ const manifest: PluginManifest = {
|
|
|
54
58
|
|
|
55
59
|
export const BROWSER_TOOL_TIMEOUT_MS = 15_000;
|
|
56
60
|
|
|
61
|
+
const HUMAN_DECISION_TOOLS = new Set([
|
|
62
|
+
'ask_user_question',
|
|
63
|
+
'propose_domain_creation',
|
|
64
|
+
'write_domain_files',
|
|
65
|
+
'publish_recipe_release',
|
|
66
|
+
]);
|
|
67
|
+
|
|
57
68
|
/**
|
|
58
69
|
* The Portal's Topic write tool. Its commands can take a while to settle in
|
|
59
70
|
* the browser (several Matrix writes), so it gets a longer window before the
|
|
@@ -72,6 +83,7 @@ const BROWSER_TOOL_SHAPE = z.object({
|
|
|
72
83
|
name: z.string(),
|
|
73
84
|
description: z.string(),
|
|
74
85
|
schema: z.record(z.string(), z.unknown()),
|
|
86
|
+
effect: z.unknown().optional(),
|
|
75
87
|
});
|
|
76
88
|
|
|
77
89
|
const ARGS_RECORD_SHAPE = z.record(z.string(), z.unknown());
|
|
@@ -85,7 +97,9 @@ export function parseBrowserTools(value: unknown): BrowserToolCall[] {
|
|
|
85
97
|
const parsed = BROWSER_TOOL_SHAPE.safeParse(entry);
|
|
86
98
|
if (!parsed.success || names.has(parsed.data.name)) continue;
|
|
87
99
|
names.add(parsed.data.name);
|
|
88
|
-
|
|
100
|
+
const { effect: rawEffect, ...declaration } = parsed.data;
|
|
101
|
+
const effect = declaredToolEffect(rawEffect);
|
|
102
|
+
out.push(effect ? { ...declaration, effect } : declaration);
|
|
89
103
|
}
|
|
90
104
|
return out;
|
|
91
105
|
}
|
|
@@ -163,11 +177,8 @@ export function buildBrowserTool(
|
|
|
163
177
|
`Browser tool ${descriptor.name} needs the realtime channel, which this host does not provide.`,
|
|
164
178
|
);
|
|
165
179
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
`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.`,
|
|
169
|
-
);
|
|
170
|
-
}
|
|
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).
|
|
171
182
|
const args = withRuntimeSuppliedArgs(
|
|
172
183
|
parseArgs(input),
|
|
173
184
|
descriptor,
|
|
@@ -182,6 +193,7 @@ export function buildBrowserTool(
|
|
|
182
193
|
args,
|
|
183
194
|
timeoutMs: browserToolTimeoutMs(descriptor.name),
|
|
184
195
|
signal: ctx.abortSignal,
|
|
196
|
+
...(ctx.markDispatched ? { onDispatched: ctx.markDispatched } : {}),
|
|
185
197
|
onInvocation: (id) => {
|
|
186
198
|
invocationId = id;
|
|
187
199
|
},
|
|
@@ -194,7 +206,19 @@ export function buildBrowserTool(
|
|
|
194
206
|
description: descriptor.description,
|
|
195
207
|
schema,
|
|
196
208
|
// A browser step (scroll, page, click) repeated is a new step.
|
|
197
|
-
repeatable:
|
|
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.
|
|
217
|
+
...(HUMAN_DECISION_TOOLS.has(descriptor.name)
|
|
218
|
+
? { effect: 'write' as const }
|
|
219
|
+
: descriptor.effect
|
|
220
|
+
? { effect: descriptor.effect }
|
|
221
|
+
: {}),
|
|
198
222
|
},
|
|
199
223
|
);
|
|
200
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
|
|
16
|
-
* refuses new calls instead;
|
|
17
|
-
* -
|
|
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
|
|
31
|
-
*
|
|
32
|
-
* the
|
|
33
|
-
* never issued
|
|
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
|
-
/**
|
|
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
|
-
* `
|
|
149
|
-
*
|
|
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:
|
|
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
|
|
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))
|
|
167
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
191
|
-
else
|
|
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
|
-
/**
|
|
202
|
-
|
|
304
|
+
/** Whether `toolCallId` is waiting unsent (parked). */
|
|
305
|
+
isParked(toolCallId: string): boolean {
|
|
203
306
|
const entry = this.pending.get(toolCallId);
|
|
204
|
-
|
|
307
|
+
return entry !== undefined && !entry.executor;
|
|
205
308
|
}
|
|
206
309
|
|
|
207
|
-
/**
|
|
208
|
-
|
|
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 ||
|
|
211
|
-
entry.
|
|
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();
|