@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +2003 -274
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +562 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -3,6 +3,7 @@ import { tool } from '../../plugin-api/tool-helper';
|
|
|
3
3
|
import type { PluginTool, RuntimeContext } from '../../plugin-api/types';
|
|
4
4
|
import { vfsBearer } from './vfs-auth';
|
|
5
5
|
import {
|
|
6
|
+
hasGlobWildcard,
|
|
6
7
|
VfsClient,
|
|
7
8
|
type VfsBatchItemResult,
|
|
8
9
|
type VfsSearchHit,
|
|
@@ -191,6 +192,31 @@ async function withConflictRetry<T>(op: () => Promise<T>): Promise<T> {
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/** Appended wherever a path resolved to no file — folders never resolve. */
|
|
196
|
+
const FILES_ONLY_NOTE =
|
|
197
|
+
'Paths must name files exactly — folders and wildcard patterns are not accepted (find files with `vfs_glob`).';
|
|
198
|
+
|
|
199
|
+
/** The answer for a move/delete source that is not a file. */
|
|
200
|
+
function notAFileMessage(path: string, verb: string): string {
|
|
201
|
+
return `No file at \`${path}\` to ${verb}. ${FILES_ONLY_NOTE}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Map `items` through `fn` with at most `width` calls in flight, preserving
|
|
206
|
+
* order. Each batch settles before the next starts.
|
|
207
|
+
*/
|
|
208
|
+
async function mapInBatches<T, R>(
|
|
209
|
+
items: readonly T[],
|
|
210
|
+
width: number,
|
|
211
|
+
fn: (item: T) => Promise<R>,
|
|
212
|
+
): Promise<R[]> {
|
|
213
|
+
const out: R[] = [];
|
|
214
|
+
for (let i = 0; i < items.length; i += width) {
|
|
215
|
+
out.push(...(await Promise.all(items.slice(i, i + width).map(fn))));
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
194
220
|
export function invalidArgs(err: z.ZodError): string {
|
|
195
221
|
return `Invalid arguments: ${err.issues
|
|
196
222
|
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
@@ -291,16 +317,32 @@ const editSchema = z.object({
|
|
|
291
317
|
});
|
|
292
318
|
|
|
293
319
|
const moveSchema = z.object({
|
|
294
|
-
from: z.string().describe('Absolute
|
|
320
|
+
from: z.string().describe('Absolute path of the file to move.'),
|
|
295
321
|
to: z.string().describe('Absolute destination path.'),
|
|
296
322
|
});
|
|
297
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Most paths one `vfs_delete` call accepts. Each path costs one `/glob`
|
|
326
|
+
* lookup (plus, on a cold delegation cache, a UCAN-store fetch), so the cap
|
|
327
|
+
* keeps a call well inside the per-invocation subrequest budget.
|
|
328
|
+
*/
|
|
329
|
+
export const VFS_DELETE_MAX_PATHS = 50;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Path lookups `vfs_delete` keeps in flight at once — the Workers limit on
|
|
333
|
+
* simultaneous open connections per invocation. Requests beyond it would
|
|
334
|
+
* queue inside `fetch` while their timeout is already running.
|
|
335
|
+
*/
|
|
336
|
+
export const VFS_PATH_LOOKUP_CONCURRENCY = 6;
|
|
337
|
+
|
|
298
338
|
const deleteSchema = z.object({
|
|
299
339
|
paths: z
|
|
300
340
|
.array(z.string())
|
|
301
341
|
.min(1)
|
|
302
|
-
.max(
|
|
303
|
-
.describe(
|
|
342
|
+
.max(VFS_DELETE_MAX_PATHS)
|
|
343
|
+
.describe(
|
|
344
|
+
`Absolute paths of the files to move to trash (at most ${VFS_DELETE_MAX_PATHS} per call).`,
|
|
345
|
+
),
|
|
304
346
|
});
|
|
305
347
|
|
|
306
348
|
const shareSchema = z.object({
|
|
@@ -529,13 +571,7 @@ export function createVfsTools(deps: CreateVfsToolsDeps): PluginTool[] {
|
|
|
529
571
|
withConflictRetry(async () => {
|
|
530
572
|
// The worker addresses moves by file id, not source path — resolve it.
|
|
531
573
|
const stat = await c.statByPath(from);
|
|
532
|
-
if (!stat)
|
|
533
|
-
throw new VfsHttpError({
|
|
534
|
-
status: 404,
|
|
535
|
-
message: `No such file at ${from}`,
|
|
536
|
-
raw: '',
|
|
537
|
-
});
|
|
538
|
-
}
|
|
574
|
+
if (!stat) return notAFileMessage(from, 'move');
|
|
539
575
|
const results = await c.move([{ id: stat.id, destinationPath: to }]);
|
|
540
576
|
const failed = results.find((r) => !r.ok);
|
|
541
577
|
if (failed) {
|
|
@@ -548,7 +584,7 @@ export function createVfsTools(deps: CreateVfsToolsDeps): PluginTool[] {
|
|
|
548
584
|
{
|
|
549
585
|
name: 'vfs_move',
|
|
550
586
|
description:
|
|
551
|
-
'Move or rename a file
|
|
587
|
+
'Move or rename one file (`from` must be the exact path of a file; wildcards and folders are not accepted). To move a folder, find its files with `vfs_glob` and move each. Confirm destructive actions with the user first.',
|
|
552
588
|
schema: moveSchema,
|
|
553
589
|
},
|
|
554
590
|
);
|
|
@@ -561,33 +597,32 @@ export function createVfsTools(deps: CreateVfsToolsDeps): PluginTool[] {
|
|
|
561
597
|
const err = validatePath(p);
|
|
562
598
|
if (err) return `Invalid path "${p}": ${err}`;
|
|
563
599
|
}
|
|
600
|
+
const paths = [...new Set(parsed.data.paths)];
|
|
564
601
|
const c = client(ctx);
|
|
565
602
|
return guard(ctx, {}, async () => {
|
|
566
|
-
const resolved = await
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
})),
|
|
603
|
+
const resolved = await mapInBatches(
|
|
604
|
+
paths,
|
|
605
|
+
VFS_PATH_LOOKUP_CONCURRENCY,
|
|
606
|
+
async (p) => ({ path: p, stat: await c.statByPath(p) }),
|
|
571
607
|
);
|
|
572
608
|
const missing = resolved
|
|
573
609
|
.filter((x) => x.stat === null)
|
|
574
610
|
.map((x) => x.path);
|
|
575
611
|
const ids = resolved.flatMap((x) => (x.stat ? [x.stat.id] : []));
|
|
576
612
|
if (ids.length === 0) {
|
|
577
|
-
return `No such file(s): ${
|
|
613
|
+
return `No such file(s): ${paths.join(', ')}. ${FILES_ONLY_NOTE}`;
|
|
578
614
|
}
|
|
579
615
|
const results = await c.trash(ids);
|
|
580
616
|
let summary = summarizeBatch(results, 'Moved to trash', ids.length);
|
|
581
617
|
if (missing.length > 0) {
|
|
582
|
-
summary += `\nNot found (skipped): ${missing.join(', ')}
|
|
618
|
+
summary += `\nNot found (skipped): ${missing.join(', ')}. ${FILES_ONLY_NOTE}`;
|
|
583
619
|
}
|
|
584
620
|
return summary;
|
|
585
621
|
});
|
|
586
622
|
},
|
|
587
623
|
{
|
|
588
624
|
name: 'vfs_delete',
|
|
589
|
-
description:
|
|
590
|
-
'Move a file or folder to trash (recoverable). Confirm destructive actions with the user first. Deletes go to trash, not permanent.',
|
|
625
|
+
description: `Move files to trash (recoverable), up to ${VFS_DELETE_MAX_PATHS} per call. Each path must be the exact path of a file; wildcards and folders are not accepted — find a folder's files with \`vfs_glob\` and pass them. Confirm destructive actions with the user first. Deletes go to trash, not permanent.`,
|
|
591
626
|
schema: deleteSchema,
|
|
592
627
|
},
|
|
593
628
|
);
|
|
@@ -603,6 +638,11 @@ export function createVfsTools(deps: CreateVfsToolsDeps): PluginTool[] {
|
|
|
603
638
|
const c = client(ctx);
|
|
604
639
|
return guard(ctx, { path }, async () => {
|
|
605
640
|
const stat = await c.statByPath(path);
|
|
641
|
+
// A path that is not a file is published as a folder; a wildcard
|
|
642
|
+
// pattern is neither, so it is refused rather than sent on.
|
|
643
|
+
if (!stat && hasGlobWildcard(path)) {
|
|
644
|
+
return `No file at \`${path}\`. \`*\` and \`?\` are not wildcards here — give the exact path of one file or folder (find it with \`vfs_glob\`).`;
|
|
645
|
+
}
|
|
606
646
|
const result = stat
|
|
607
647
|
? await c.setFilePublic(stat.id, pub)
|
|
608
648
|
: await c.setFolderPublic(path, pub);
|
|
@@ -1,111 +1,191 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* from the user's browser
|
|
4
|
-
*
|
|
5
|
-
* socket delivers `browser_tool_result` / `action_call_result` for the same
|
|
6
|
-
* `toolCallId`.
|
|
2
|
+
* Frontend (browser-tool / AG-UI action) invocations of one user object:
|
|
3
|
+
* the calls waiting for an answer from the user's browser, and a bounded
|
|
4
|
+
* record of the ones already finished.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
* - `
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
6
|
+
* The contract (see docs/frontend-bridge.md):
|
|
7
|
+
* - every invocation has its own id (`frontendInvocationId`), and an id is
|
|
8
|
+
* issued once — never reused while it is pending or remembered;
|
|
9
|
+
* - an invocation is sent to ONE socket (`dispatched`), and only a result
|
|
10
|
+
* arriving on that socket — same engine.io sid, same session, same
|
|
11
|
+
* authenticated DID — settles it. Another tab of the same session, a
|
|
12
|
+
* reconnected tab (a new sid) or another session cannot;
|
|
13
|
+
* - a settled invocation is remembered (bounded by count and age), so a
|
|
14
|
+
* 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
|
|
18
|
+
* passed, or its socket went — resolves with `FRONTEND_OUTCOME_UNKNOWN`:
|
|
19
|
+
* the browser may have performed the write, so it is neither a success
|
|
20
|
+
* nor a failure, and it is never sent again.
|
|
13
21
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
22
|
+
* Settlement rules for a matching result:
|
|
23
|
+
* - `error` set → reject(Error(error))
|
|
24
|
+
* - AG-UI, `result.success === false`, and not an
|
|
25
|
+
* unknown outcome the client reported → reject(Error(result.error ?? 'Action failed'))
|
|
26
|
+
* - otherwise → resolve(result)
|
|
27
|
+
*
|
|
28
|
+
* Purely in-memory, which is exact on Workers: a pending call holds a timer,
|
|
29
|
+
* 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.
|
|
16
34
|
*/
|
|
35
|
+
import {
|
|
36
|
+
frontendOutcomeUnknown,
|
|
37
|
+
reportsUnknownOutcome,
|
|
38
|
+
} from '@ixo/common/ai/frontend-bridge';
|
|
17
39
|
|
|
18
40
|
export type FrontendCallKind = 'browser' | 'agui';
|
|
19
41
|
|
|
42
|
+
/** The socket an invocation was sent to, as its attachment identifies it. */
|
|
43
|
+
export interface FrontendExecutor {
|
|
44
|
+
/** engine.io sid of the socket (stable across hibernation, new on reconnect). */
|
|
45
|
+
sid: string;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
/** The DID the socket's CONNECT authenticated. */
|
|
48
|
+
userDid: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
20
51
|
export interface FrontendCallResult {
|
|
52
|
+
/** The invocation id the client echoes back. */
|
|
21
53
|
toolCallId: string;
|
|
54
|
+
/** The socket the result arrived on (never the client's claims). */
|
|
55
|
+
from: FrontendExecutor;
|
|
22
56
|
result?: unknown;
|
|
23
57
|
error?: string;
|
|
24
58
|
}
|
|
25
59
|
|
|
60
|
+
/** Why a result settled nothing (the call, if any, keeps waiting). */
|
|
61
|
+
export type FrontendResultRejection =
|
|
62
|
+
/** No such invocation was issued (or its record has expired). */
|
|
63
|
+
| 'not-issued'
|
|
64
|
+
/** The invocation already ended: a duplicate, replayed or late result. */
|
|
65
|
+
| 'already-settled'
|
|
66
|
+
/** The result came from a socket other than the one the call was sent to. */
|
|
67
|
+
| 'wrong-socket'
|
|
68
|
+
/** A browser-tool result for an AG-UI invocation, or the reverse. */
|
|
69
|
+
| 'wrong-kind';
|
|
70
|
+
|
|
71
|
+
export type SettleOutcome =
|
|
72
|
+
| { settled: true }
|
|
73
|
+
| { settled: false; reason: FrontendResultRejection };
|
|
74
|
+
|
|
26
75
|
export interface PendingFrontendCall {
|
|
27
76
|
kind: FrontendCallKind;
|
|
77
|
+
/** The invocation id (what the browser received as `toolCallId`). */
|
|
28
78
|
toolCallId: string;
|
|
29
79
|
toolName: string;
|
|
30
80
|
sessionId: string;
|
|
31
81
|
startedAt: number;
|
|
82
|
+
/** sid of the socket executing it (unset only before it is sent). */
|
|
83
|
+
executorSid?: string;
|
|
32
84
|
}
|
|
33
85
|
|
|
34
|
-
interface Pending extends PendingFrontendCall {
|
|
86
|
+
interface Pending extends Omit<PendingFrontendCall, 'executorSid'> {
|
|
87
|
+
executor?: FrontendExecutor;
|
|
35
88
|
resolve: (value: unknown) => void;
|
|
36
89
|
reject: (error: Error) => void;
|
|
37
90
|
timer: ReturnType<typeof setTimeout>;
|
|
38
91
|
}
|
|
39
92
|
|
|
40
|
-
|
|
93
|
+
/** Finished invocations kept to recognise replays: at most this many… */
|
|
94
|
+
export const MAX_COMPLETED_FRONTEND_CALLS = 1024;
|
|
95
|
+
/** …for at most this long. */
|
|
96
|
+
export const COMPLETED_FRONTEND_CALL_TTL_MS = 30 * 60_000;
|
|
97
|
+
/** Calls in flight at once for one user; beyond it new calls are refused. */
|
|
98
|
+
export const MAX_PENDING_FRONTEND_CALLS = 256;
|
|
99
|
+
|
|
100
|
+
export const FRONTEND_CALL_LABEL: Record<FrontendCallKind, string> = {
|
|
41
101
|
browser: 'Browser tool',
|
|
42
102
|
agui: 'AG-UI action',
|
|
43
103
|
};
|
|
44
104
|
|
|
105
|
+
export interface FrontendCallRegistryOptions {
|
|
106
|
+
now?: () => number;
|
|
107
|
+
maxPending?: number;
|
|
108
|
+
maxCompleted?: number;
|
|
109
|
+
completedTtlMs?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
45
112
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
113
|
return typeof value === 'object' && value !== null;
|
|
47
114
|
}
|
|
48
115
|
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!isRecord(result)) return {};
|
|
54
|
-
const record = result;
|
|
55
|
-
return {
|
|
56
|
-
...(typeof record.success === 'boolean' ? { success: record.success } : {}),
|
|
57
|
-
...(typeof record.error === 'string' ? { error: record.error } : {}),
|
|
58
|
-
};
|
|
116
|
+
function sameExecutor(a: FrontendExecutor, b: FrontendExecutor): boolean {
|
|
117
|
+
return (
|
|
118
|
+
a.sid === b.sid && a.sessionId === b.sessionId && a.userDid === b.userDid
|
|
119
|
+
);
|
|
59
120
|
}
|
|
60
121
|
|
|
61
122
|
export class FrontendCallRegistry {
|
|
62
123
|
private readonly pending = new Map<string, Pending>();
|
|
63
124
|
|
|
64
|
-
|
|
65
|
-
|
|
125
|
+
/** Invocation id → when it finished; insertion order is completion order. */
|
|
126
|
+
private readonly completed = new Map<string, number>();
|
|
127
|
+
|
|
128
|
+
private readonly now: () => number;
|
|
129
|
+
|
|
130
|
+
private readonly maxPending: number;
|
|
131
|
+
|
|
132
|
+
private readonly maxCompleted: number;
|
|
133
|
+
|
|
134
|
+
private readonly completedTtlMs: number;
|
|
135
|
+
|
|
136
|
+
constructor(options: FrontendCallRegistryOptions = {}) {
|
|
137
|
+
this.now = options.now ?? (() => Date.now());
|
|
138
|
+
this.maxPending = options.maxPending ?? MAX_PENDING_FRONTEND_CALLS;
|
|
139
|
+
this.maxCompleted = options.maxCompleted ?? MAX_COMPLETED_FRONTEND_CALLS;
|
|
140
|
+
this.completedTtlMs =
|
|
141
|
+
options.completedTtlMs ?? COMPLETED_FRONTEND_CALL_TTL_MS;
|
|
66
142
|
}
|
|
67
143
|
|
|
68
144
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
145
|
+
* Register an invocation BEFORE it is sent, so an immediate answer cannot
|
|
146
|
+
* be missed, and return the promise its result settles. Throws when the id
|
|
147
|
+
* 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.
|
|
72
150
|
*/
|
|
73
|
-
|
|
74
|
-
call: Omit<PendingFrontendCall, 'startedAt'>,
|
|
151
|
+
open(
|
|
152
|
+
call: Omit<PendingFrontendCall, 'startedAt' | 'executorSid'>,
|
|
75
153
|
opts: { timeoutMs: number; signal?: AbortSignal },
|
|
76
154
|
): Promise<unknown> {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
155
|
+
const id = call.toolCallId;
|
|
156
|
+
const label = FRONTEND_CALL_LABEL[call.kind];
|
|
157
|
+
this.prune();
|
|
158
|
+
if (this.pending.has(id) || this.completed.has(id))
|
|
159
|
+
throw new Error(`${label} invocation ${id} was already issued`);
|
|
160
|
+
if (this.pending.size >= this.maxPending)
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${label} ${call.toolName} was not sent: ${this.maxPending} frontend calls are already in flight`,
|
|
83
163
|
);
|
|
84
|
-
}
|
|
85
164
|
return new Promise<unknown>((resolve, reject) => {
|
|
86
165
|
const timer = setTimeout(() => {
|
|
87
|
-
this.
|
|
88
|
-
reject(
|
|
89
|
-
new Error(
|
|
90
|
-
`${KIND_LABEL[call.kind]} timeout after ${opts.timeoutMs}ms: ${call.toolName}`,
|
|
91
|
-
),
|
|
92
|
-
);
|
|
166
|
+
if (this.complete(id)) resolve(frontendOutcomeUnknown(id));
|
|
93
167
|
}, opts.timeoutMs);
|
|
94
168
|
const entry: Pending = {
|
|
95
169
|
...call,
|
|
96
|
-
startedAt:
|
|
170
|
+
startedAt: this.now(),
|
|
97
171
|
resolve,
|
|
98
172
|
reject,
|
|
99
173
|
timer,
|
|
100
174
|
};
|
|
101
|
-
this.pending.set(
|
|
175
|
+
this.pending.set(id, entry);
|
|
102
176
|
if (opts.signal) {
|
|
177
|
+
// The turn ended (Stop, deadline). Before the call was sent it is a
|
|
178
|
+
// definite failure; once sent, the browser may already have run it.
|
|
103
179
|
const onAbort = () => {
|
|
104
|
-
if (this.pending.get(
|
|
105
|
-
this.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
180
|
+
if (this.pending.get(id) !== entry) return;
|
|
181
|
+
this.complete(id);
|
|
182
|
+
if (entry.executor) resolve(frontendOutcomeUnknown(id));
|
|
183
|
+
else
|
|
184
|
+
reject(
|
|
185
|
+
new Error(
|
|
186
|
+
`${label} ${call.toolName} was not sent: the turn was aborted`,
|
|
187
|
+
),
|
|
188
|
+
);
|
|
109
189
|
};
|
|
110
190
|
if (opts.signal.aborted) onAbort();
|
|
111
191
|
else opts.signal.addEventListener('abort', onAbort, { once: true });
|
|
@@ -113,40 +193,88 @@ export class FrontendCallRegistry {
|
|
|
113
193
|
});
|
|
114
194
|
}
|
|
115
195
|
|
|
196
|
+
/** Whether `toolCallId` is still waiting (an already-aborted call is not). */
|
|
197
|
+
isPending(toolCallId: string): boolean {
|
|
198
|
+
return this.pending.has(toolCallId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Bind a pending invocation to the one socket it was sent to. */
|
|
202
|
+
dispatched(toolCallId: string, executor: FrontendExecutor): void {
|
|
203
|
+
const entry = this.pending.get(toolCallId);
|
|
204
|
+
if (entry) entry.executor = executor;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** End an invocation that never reached a browser: a definite failure. */
|
|
208
|
+
fail(toolCallId: string, error: Error): void {
|
|
209
|
+
const entry = this.pending.get(toolCallId);
|
|
210
|
+
if (!entry || !this.complete(toolCallId)) return;
|
|
211
|
+
entry.reject(error);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Deliver a result that arrived on a socket. */
|
|
215
|
+
settle(kind: FrontendCallKind, data: FrontendCallResult): SettleOutcome {
|
|
216
|
+
const id = data.toolCallId;
|
|
217
|
+
const entry = this.pending.get(id);
|
|
218
|
+
if (!entry) {
|
|
219
|
+
this.prune();
|
|
220
|
+
return {
|
|
221
|
+
settled: false,
|
|
222
|
+
reason: this.completed.has(id) ? 'already-settled' : 'not-issued',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (entry.kind !== kind) return { settled: false, reason: 'wrong-kind' };
|
|
226
|
+
if (!entry.executor || !sameExecutor(entry.executor, data.from))
|
|
227
|
+
return { settled: false, reason: 'wrong-socket' };
|
|
228
|
+
this.complete(id);
|
|
229
|
+
const result = data.result;
|
|
230
|
+
if (data.error) entry.reject(new Error(data.error));
|
|
231
|
+
else if (
|
|
232
|
+
kind === 'agui' &&
|
|
233
|
+
isRecord(result) &&
|
|
234
|
+
result.success === false &&
|
|
235
|
+
!reportsUnknownOutcome(result)
|
|
236
|
+
)
|
|
237
|
+
entry.reject(
|
|
238
|
+
new Error(
|
|
239
|
+
typeof result.error === 'string' && result.error
|
|
240
|
+
? result.error
|
|
241
|
+
: 'Action failed',
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
else entry.resolve(result);
|
|
245
|
+
return { settled: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
116
248
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
249
|
+
* The socket `sid` is gone (closed, errored, dropped by the heartbeat).
|
|
250
|
+
* Its pending invocations can never be answered — a reconnect is a new
|
|
251
|
+
* socket — and are never re-sent elsewhere: they resolve with an unknown
|
|
252
|
+
* outcome now instead of at their deadline. Returns how many there were.
|
|
119
253
|
*/
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const entry
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return true;
|
|
254
|
+
executorGone(sid: string): number {
|
|
255
|
+
let ended = 0;
|
|
256
|
+
for (const [id, entry] of [...this.pending]) {
|
|
257
|
+
if (entry.executor?.sid !== sid) continue;
|
|
258
|
+
this.complete(id);
|
|
259
|
+
entry.resolve(frontendOutcomeUnknown(id));
|
|
260
|
+
ended += 1;
|
|
128
261
|
}
|
|
129
|
-
|
|
130
|
-
const flags = readSuccessFlag(data.result);
|
|
131
|
-
if (flags.success === false) {
|
|
132
|
-
entry.reject(new Error(flags.error || 'Action failed'));
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
entry.resolve(data.result);
|
|
137
|
-
return true;
|
|
262
|
+
return ended;
|
|
138
263
|
}
|
|
139
264
|
|
|
140
265
|
/** Pending calls, oldest first (diagnostics / status). */
|
|
141
266
|
list(): PendingFrontendCall[] {
|
|
142
267
|
return [...this.pending.values()]
|
|
143
|
-
.map(
|
|
144
|
-
kind,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
268
|
+
.map(
|
|
269
|
+
({ kind, toolCallId, toolName, sessionId, startedAt, executor }) => ({
|
|
270
|
+
kind,
|
|
271
|
+
toolCallId,
|
|
272
|
+
toolName,
|
|
273
|
+
sessionId,
|
|
274
|
+
startedAt,
|
|
275
|
+
...(executor ? { executorSid: executor.sid } : {}),
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
150
278
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
151
279
|
}
|
|
152
280
|
|
|
@@ -154,10 +282,29 @@ export class FrontendCallRegistry {
|
|
|
154
282
|
return this.pending.size;
|
|
155
283
|
}
|
|
156
284
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
285
|
+
/** Finished invocations currently remembered. */
|
|
286
|
+
get completedCount(): number {
|
|
287
|
+
this.prune();
|
|
288
|
+
return this.completed.size;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Move a pending invocation to the finished records; false if it was not pending. */
|
|
292
|
+
private complete(id: string): boolean {
|
|
293
|
+
const entry = this.pending.get(id);
|
|
294
|
+
if (!entry) return false;
|
|
160
295
|
clearTimeout(entry.timer);
|
|
161
|
-
this.pending.delete(
|
|
296
|
+
this.pending.delete(id);
|
|
297
|
+
this.completed.set(id, this.now());
|
|
298
|
+
this.prune();
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Drop finished records past their age, then the oldest beyond the bound. */
|
|
303
|
+
private prune(): void {
|
|
304
|
+
const cutoff = this.now() - this.completedTtlMs;
|
|
305
|
+
for (const [id, at] of this.completed) {
|
|
306
|
+
if (at > cutoff && this.completed.size <= this.maxCompleted) break;
|
|
307
|
+
this.completed.delete(id);
|
|
308
|
+
}
|
|
162
309
|
}
|
|
163
310
|
}
|