experimental-a2 0.4.0 → 0.5.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/CHANGELOG.md +51 -0
- package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +4 -3
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +4 -2
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +3 -1
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +2 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.js +2 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -0
- package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/contract-48bUMgcL.js +2 -0
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/contract-jIfaR085.d.ts +2 -1
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +2 -1
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +2 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +2 -0
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +2 -1
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +2 -0
- package/dist/devtools-server.js.map +1 -0
- package/dist/errors-BQuJpe82.js +2 -0
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/errors-W6nwJ-fm.d.ts +2 -1
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +121 -72
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +503 -178
- package/dist/http.js.map +1 -0
- package/dist/idempotent-replay-DuqEkYA7.js +2 -0
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspection-DaxB5jM2.js +2 -0
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +2 -1
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +2 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +12 -3
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +5 -1
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +4 -3
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +4 -2
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +2 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -3
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +4 -2
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js +2 -0
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +2 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +3 -2
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +19 -11
- package/dist/store-memory.js.map +1 -0
- package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/store-postgres.d.ts +3 -2
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/store-postgres.js +57 -1
- package/dist/store-postgres.js.map +1 -0
- package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/store-redis-http.d.ts +3 -2
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/store-redis-http.js +4 -2
- package/dist/store-redis-http.js.map +1 -0
- package/dist/store-redis.d.ts +3 -2
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/store-redis.js +5 -3
- package/dist/store-redis.js.map +1 -0
- package/dist/store-sqlite.d.ts +3 -2
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/store-sqlite.js +3 -1
- package/dist/store-sqlite.js.map +1 -0
- package/dist/telemetry-BjYHTfh2.d.ts +2 -1
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.js +2 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +2 -1
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/guides/03-react.mdx +59 -39
- package/docs/guides/06-ai-agents.mdx +5 -27
- package/docs/guides/09-presence.mdx +19 -40
- package/docs/guides/10-transports.mdx +49 -40
- package/docs/reference/01-api.mdx +107 -26
- package/docs/reference/02-errors.mdx +4 -2
- package/package.json +2 -1
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
|
@@ -123,8 +123,8 @@ Server-only by construction: `experimental-a2/server` is the only entry point th
|
|
|
123
123
|
can reach a store backend, and its exports map resolves to a loud error
|
|
124
124
|
under the browser condition.
|
|
125
125
|
|
|
126
|
-
`validatePush(context)` runs only for input that
|
|
127
|
-
once per plane. The events plane invokes it with `{ sessionId, events }` before
|
|
126
|
+
`validatePush(context)` runs only for input that arrived over the wire
|
|
127
|
+
through `handle`'s push lane, once per plane. The events plane invokes it with `{ sessionId, events }` before
|
|
128
128
|
contract schema validation and before the store append, so throwing rejects the
|
|
129
129
|
complete push without writing anything. The presence plane invokes it with
|
|
130
130
|
`{ sessionId, events: [], presence }`, the whole pushed patch with its
|
|
@@ -132,7 +132,7 @@ participant, before field validation and the broadcast, so authorizing the
|
|
|
132
132
|
participant id (and applying any size or cardinality policy) happens at the
|
|
133
133
|
same seam. The patch arrives frozen: authorize, don't rewrite (a mutation
|
|
134
134
|
attempt throws and fails the push). Direct trusted server appends, handler appends, and server-side
|
|
135
|
-
`setPresence` bypass it. `
|
|
135
|
+
`setPresence` bypass it. The envelope parser inside `handle` creates
|
|
136
136
|
the runtime provenance brand after reading the envelope; a caller-supplied
|
|
137
137
|
field with the same name is ignored, and the brand is not stored in the log.
|
|
138
138
|
|
|
@@ -428,8 +428,8 @@ The only way to move a session forward. Payloads are validated against
|
|
|
428
428
|
the contract's schemas before anything is written. A multi-event append
|
|
429
429
|
is atomic: all-or-nothing, consecutive positions, one transaction. Pass
|
|
430
430
|
`id` to make an append idempotent across retries; re-sending an
|
|
431
|
-
identical batch returns the original rows. (Events
|
|
432
|
-
`
|
|
431
|
+
identical batch returns the original rows. (Events that arrived through
|
|
432
|
+
`handle`'s push lane are accepted directly.) See
|
|
433
433
|
[Durability](/concepts/durability).
|
|
434
434
|
|
|
435
435
|
After the write, A2 dispatches only event types with registered handlers.
|
|
@@ -537,14 +537,14 @@ session.stream(options: {
|
|
|
537
537
|
|
|
538
538
|
A live feed of the session's events. `startAfter` is a non-negative safe integer;
|
|
539
539
|
`startAfter: 20` begins with event 21.
|
|
540
|
-
Server-side only;
|
|
541
|
-
dispatches handlers.
|
|
540
|
+
Server-side only; `handle` exposes it over SSE, and over the multiplexed
|
|
541
|
+
socket when `upgrade` is set. Subscribing never dispatches handlers.
|
|
542
542
|
|
|
543
543
|
With `presence: true`, the feed yields one `PresenceSnapshot` first:
|
|
544
544
|
`{ snapshot }`, the current pruned map with each field's own `value`,
|
|
545
545
|
`seen`, and `at` stamp. Live presence patches then interleave with
|
|
546
|
-
events.
|
|
547
|
-
don't know them skip them. The return type widens only under the
|
|
546
|
+
events. The stream response sends both as named SSE frames, so clients
|
|
547
|
+
that don't know them skip them. The return type widens only under the
|
|
548
548
|
literal `presence: true`; without it, existing consumers keep
|
|
549
549
|
`AsyncIterable<Event>`. The option itself exists only on sessions of
|
|
550
550
|
contracts that declare `presence`; elsewhere it is a type error, not a
|
|
@@ -580,9 +580,9 @@ by backend TTL when a participant goes silent (default 60 seconds; set
|
|
|
580
580
|
the storage clock, not the sender stamp, so a hostile stamp can only
|
|
581
581
|
vandalize its own field and still expires on schedule.
|
|
582
582
|
|
|
583
|
-
The patch is structurally the `presence`
|
|
584
|
-
|
|
585
|
-
API mirrors the wire.
|
|
583
|
+
The patch is structurally the `presence` sibling of the push envelope;
|
|
584
|
+
`handle`'s push lane forwards it whole to `session.setPresence(presence)`.
|
|
585
|
+
The API mirrors the wire.
|
|
586
586
|
|
|
587
587
|
The handler-scoped form `ctx.session.setPresence(...)` is the same
|
|
588
588
|
operation; `seen` defaults to the triggering event's `index`.
|
|
@@ -666,16 +666,32 @@ not affect state hydration or stream resumption.
|
|
|
666
666
|
### `useSession()`
|
|
667
667
|
|
|
668
668
|
```ts
|
|
669
|
-
const { state, push, events, index,
|
|
670
|
-
useSession()
|
|
669
|
+
const { state, push, events, index, loadHistory, history, connection,
|
|
670
|
+
presence, setPresence } = useSession()
|
|
671
671
|
```
|
|
672
672
|
|
|
673
673
|
`state` starts from the initial snapshot and folds live events through the
|
|
674
|
-
shared reducer. `events` is the raw
|
|
675
|
-
|
|
674
|
+
shared reducer. `events` is the raw event feed: observed, explicitly
|
|
675
|
+
seeded, or backscrolled. Unless `initialEvents` seeds earlier entries or
|
|
676
|
+
`loadHistory` fetches them, it begins after `initialIndex`. `index` is
|
|
676
677
|
the stream frontier, the `lastSeenIndex` for
|
|
677
678
|
[cancellation](/guides/cancellation).
|
|
678
679
|
|
|
680
|
+
`loadHistory({ before?, limit? })` backscrolls: it fetches a bounded
|
|
681
|
+
slice of the log from below the frontier (the same route, `gte`/`lte`
|
|
682
|
+
query parameters) and merges it into `events`, deduped, ordered, and
|
|
683
|
+
shared across every handle of the session. It resolves with the events
|
|
684
|
+
in the requested range. Defaults walk backward 50 at a time from the
|
|
685
|
+
oldest loaded event; `before` is an exclusive upper bound. After a
|
|
686
|
+
hydrate jump (returning to a session whose frontier advanced while
|
|
687
|
+
away), default paging still continues from the oldest loaded event;
|
|
688
|
+
pass an explicit `before` to fill the gap between the old feed and the
|
|
689
|
+
new frontier. It never touches `state` or the optimistic overlay. Calls
|
|
690
|
+
serialize per session and already-loaded ranges are not refetched.
|
|
691
|
+
`history` is the progress: `{ loading, complete, oldestLoaded }`, where
|
|
692
|
+
`complete` means the feed reaches index 1 (or the log is empty). The `ws` api has no history lane; there `loadHistory`
|
|
693
|
+
throws a `TypeError`.
|
|
694
|
+
|
|
679
695
|
`presence` is the replicated ephemeral map,
|
|
680
696
|
`Record<participantId, { [field]: { value, seen, at } }>`, including
|
|
681
697
|
this client. `setPresence(values)` is fire-and-forget: validated
|
|
@@ -1032,10 +1048,10 @@ subscription with frontier resume and reconnection, the optimistic push
|
|
|
1032
1048
|
queue with ack/rollback, and the local fold. `client.session(id, {
|
|
1033
1049
|
initialState?, initialIndex?, initialEvents?, participant? })` returns a
|
|
1034
1050
|
handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
|
|
1035
|
-
contract), `push()`, `connect()`, and `close()`.
|
|
1036
|
-
`state`, `events`, `index`, and `connection`
|
|
1037
|
-
`useSession` exposes), and `push` returns the same
|
|
1038
|
-
result. On contracts that declare `presence` the handle also carries
|
|
1051
|
+
contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
|
|
1052
|
+
Snapshots carry `state`, `events`, `index`, `history`, and `connection`
|
|
1053
|
+
(the same fields `useSession` exposes), and `push` returns the same
|
|
1054
|
+
ack-then-`confirmed` result. On contracts that declare `presence` the handle also carries
|
|
1039
1055
|
`setPresence()` and snapshots carry the `presence` map, exactly like
|
|
1040
1056
|
the hook; `participant` is the identity `setPresence` sends under. Use
|
|
1041
1057
|
it directly from any other framework, or none.
|
|
@@ -1051,13 +1067,77 @@ across route transitions, while IndexedDB preserves the replica across reloads.
|
|
|
1051
1067
|
|
|
1052
1068
|
## `experimental-a2/http`
|
|
1053
1069
|
|
|
1070
|
+
### `handle(server, options?)`
|
|
1071
|
+
|
|
1072
|
+
```ts
|
|
1073
|
+
handle(server: A2Server, options?: {
|
|
1074
|
+
before?(args: { request: Request; intent: A2Intent }):
|
|
1075
|
+
Response | undefined | void | Promise<Response | undefined | void>
|
|
1076
|
+
after?(args: { request: Request; intent: A2Intent; outcome: A2Outcome; response: Response }):
|
|
1077
|
+
Response | undefined | void | Promise<Response | undefined | void>
|
|
1078
|
+
upgrade?: UpgradeFn // e.g. (attach) => experimental_upgradeWebSocket(attach)
|
|
1079
|
+
presence?: boolean // interleave presence on every stream lane
|
|
1080
|
+
deadline?: number // epoch ms: close sockets cleanly before it
|
|
1081
|
+
}): { GET(req: Request): Promise<Response>; POST(req: Request): Promise<Response> }
|
|
1082
|
+
|
|
1083
|
+
type A2Intent =
|
|
1084
|
+
| { type: 'ws-upgrade' }
|
|
1085
|
+
| { type: 'stream'; sessionId: string; startAfter: number; transport: 'sse' | 'ws' }
|
|
1086
|
+
| { type: 'history'; sessionId: string; gte: number; lte: number }
|
|
1087
|
+
| { type: 'push'; sessionId: string; events: PushedEvent[]; presence?: PushedPresence; transport: 'http' | 'ws' }
|
|
1088
|
+
|
|
1089
|
+
type A2Outcome =
|
|
1090
|
+
| { type: 'stream' }
|
|
1091
|
+
| { type: 'history'; covered: boolean; events: Event[] }
|
|
1092
|
+
| { type: 'push'; appended: Event[] }
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
The session route pair as one call: `export const { GET, POST } =
|
|
1096
|
+
handle(server)` in a route module (any framework speaking
|
|
1097
|
+
`(req: Request) => Promise<Response>`). A plain `GET` is the live SSE
|
|
1098
|
+
stream, resumed after the `index` query parameter, with a `: connected`
|
|
1099
|
+
prelude, a `: ping` heartbeat every 15s, and a clean close one second
|
|
1100
|
+
before an ambient Vercel invocation deadline when available; presence
|
|
1101
|
+
patches ride as named frames when `presence: true`. A `GET` with
|
|
1102
|
+
`gte`/`lte` query parameters is a history slice: the closed log range
|
|
1103
|
+
as JSON wire events, the read `loadHistory` rides. `POST` is the push
|
|
1104
|
+
envelope `{ sessionId, events, presence? }`, answered with the appended
|
|
1105
|
+
events. A `GET` carrying an upgrade header becomes the multiplexed
|
|
1106
|
+
WebSocket when `options.upgrade` is present, and answers `426` when it
|
|
1107
|
+
is not.
|
|
1108
|
+
|
|
1109
|
+
Parsing is protocol, hooks are policy. A request that fails to parse
|
|
1110
|
+
(missing `sessionId`, malformed bounds, a bad push envelope) answers
|
|
1111
|
+
`INVALID_PAYLOAD` on the wire before any hook runs. `before` sees every
|
|
1112
|
+
parsed intent, HTTP requests and socket frames alike; over the socket,
|
|
1113
|
+
each subscribe arrives as a `stream` intent and each push as a `push`
|
|
1114
|
+
intent, with `request` always the original upgrade Request. Returning a
|
|
1115
|
+
Response short-circuits: over HTTP it is the response, verbatim; over
|
|
1116
|
+
the socket it is translated into the wire's own vocabulary (a denied
|
|
1117
|
+
subscribe answers `unsubscribed`, a denied push a non-retryable error
|
|
1118
|
+
ack), because a Response cannot cross a socket.
|
|
1119
|
+
|
|
1120
|
+
`after` runs only where the library produced an HTTP response: never
|
|
1121
|
+
after a short-circuit, never for `ws-upgrade` or socket frames. It may
|
|
1122
|
+
mutate `response.headers` in place or return a replacement Response.
|
|
1123
|
+
`outcome.covered` on a history read means the closed range came back
|
|
1124
|
+
fully covered (`events.length === lte - gte + 1`): an immutable slice
|
|
1125
|
+
of the append-only log, safe to cache under whatever policy your
|
|
1126
|
+
`after` applies. The history response carries no cache headers of its
|
|
1127
|
+
own.
|
|
1128
|
+
|
|
1129
|
+
The socket is one connection for all of a client's sessions:
|
|
1130
|
+
`subscribe`/`unsubscribe` frames open and close per-session lanes at
|
|
1131
|
+
their own resume frontiers, `sessionId` tags route pushes, presence,
|
|
1132
|
+
and acks, and a lane ending or failing answers `unsubscribed` without
|
|
1133
|
+
taking the socket down. See [Transports](/guides/transports).
|
|
1134
|
+
|
|
1135
|
+
### The rest of the entry
|
|
1136
|
+
|
|
1054
1137
|
| Helper | What it does |
|
|
1055
1138
|
| ----------------------- | ------------------------------------------------------------------------------ |
|
|
1056
1139
|
| `schedulerHandler(...servers)` | returns the delivery route after synchronously verifying one shared scheduler |
|
|
1057
|
-
| `
|
|
1058
|
-
| `sseResponse(iterable)` | pipes a `session.stream()` iterable into an SSE `Response`, with a `: connected` prelude, a `: ping` heartbeat every 15s, and a clean close one second before an ambient Vercel invocation deadline when available; presence patches ride as named `presence` frames |
|
|
1059
|
-
| `sessionSocket(session, socket, options?)` | speaks the A2 wire over any `ws`-shaped socket: the stream pumps down as JSON frames, pushes and presence come up with the same validation and `validatePush` seam as the POST route; `options` carries `startAfter`, `presence`, and an optional `deadline` for clean pre-deadline closes. See [Transports](/guides/transports) |
|
|
1060
|
-
| `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status |
|
|
1140
|
+
| `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status; the natural return value of a refusing `before` hook |
|
|
1061
1141
|
| `deserializeError(body)` | rebuilds an `A2Error` from a wire body, or `null` if the body isn't one |
|
|
1062
1142
|
|
|
1063
1143
|
`schedulerHandler(...servers)` is the application-facing scheduler route. It
|
|
@@ -1071,8 +1151,9 @@ Different scheduler instances use different routes. Match each QStash route to
|
|
|
1071
1151
|
that instance's resolved `url`; additional QStash routes pass an explicit
|
|
1072
1152
|
`url`. Match each Vercel Queues route and trigger to that instance's `topic`.
|
|
1073
1153
|
|
|
1074
|
-
|
|
1075
|
-
push
|
|
1154
|
+
`errorResponse` and `deserializeError` are the `A2Error` wire format
|
|
1155
|
+
that `push` and the push lane share. See
|
|
1156
|
+
[Errors](/reference/errors#over-the-wire).
|
|
1076
1157
|
|
|
1077
1158
|
## `experimental-a2/cache-indexeddb`
|
|
1078
1159
|
|
|
@@ -78,5 +78,7 @@ with a mapped status: `INVALID_PAYLOAD`, `UNKNOWN_EVENT_TYPE`, and
|
|
|
78
78
|
|
|
79
79
|
The client's `push` deserializes the body back into an `A2Error`, so client
|
|
80
80
|
and server code branch on identical codes. `push` auto-retries only
|
|
81
|
-
`STORE_UNAVAILABLE`. The serializer pair
|
|
82
|
-
`
|
|
81
|
+
`STORE_UNAVAILABLE`. The serializer pair, `errorResponse` and
|
|
82
|
+
`deserializeError`, ships in `experimental-a2/http` alongside `handle`;
|
|
83
|
+
a `before` hook that wants the wire's own error shapes returns
|
|
84
|
+
`errorResponse(new A2Error(...))`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "experimental-a2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"dist",
|
|
25
25
|
"docs",
|
|
26
|
+
"src",
|
|
26
27
|
"CHANGELOG.md"
|
|
27
28
|
],
|
|
28
29
|
"exports": {
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { UIMessage } from 'ai'
|
|
2
|
+
import type {
|
|
3
|
+
AIEventDefs,
|
|
4
|
+
ApprovalRequestedPayload,
|
|
5
|
+
ApprovalRespondedPayload,
|
|
6
|
+
GenerationCompletedPayload,
|
|
7
|
+
GenerationFailedPayload,
|
|
8
|
+
GenerationRequestedPayload,
|
|
9
|
+
GenerationStartedPayload,
|
|
10
|
+
InputRequestedPayload,
|
|
11
|
+
MessageCreatedPayload,
|
|
12
|
+
MessageInterruptedPayload,
|
|
13
|
+
ToolCalledPayload,
|
|
14
|
+
ToolResultPayload,
|
|
15
|
+
} from './ai.ts'
|
|
16
|
+
import type { Contract, ContractEvent, EventDefs } from './contract.ts'
|
|
17
|
+
import type { Reducer } from './reducer.ts'
|
|
18
|
+
|
|
19
|
+
export type CoordinatorCall = {
|
|
20
|
+
index: number
|
|
21
|
+
call: ToolCalledPayload
|
|
22
|
+
approval?: ApprovalRequestedPayload
|
|
23
|
+
response?: ApprovalRespondedPayload
|
|
24
|
+
responseIndex?: number
|
|
25
|
+
terminal: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type CoordinatorResponse = {
|
|
29
|
+
rootMessageId: string
|
|
30
|
+
responseMessageId: string
|
|
31
|
+
status: 'requested' | 'generating' | 'waiting' | 'failed'
|
|
32
|
+
activeRequestId?: string
|
|
33
|
+
generation?: GenerationStartedPayload
|
|
34
|
+
completion?: GenerationCompletedPayload
|
|
35
|
+
failure?: GenerationFailedPayload
|
|
36
|
+
calls: CoordinatorCall[]
|
|
37
|
+
inputs: InputRequestedPayload[]
|
|
38
|
+
inputResponse?: {
|
|
39
|
+
index: number
|
|
40
|
+
generationId: string
|
|
41
|
+
inputId: string
|
|
42
|
+
name: string
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type AICoordinatorState = {
|
|
47
|
+
closed: boolean
|
|
48
|
+
queued: Array<{ index: number; messageId: string; generate: boolean }>
|
|
49
|
+
response?: CoordinatorResponse
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const updateResponse = (
|
|
53
|
+
state: AICoordinatorState,
|
|
54
|
+
update: (response: CoordinatorResponse) => CoordinatorResponse,
|
|
55
|
+
): AICoordinatorState =>
|
|
56
|
+
state.response === undefined
|
|
57
|
+
? state
|
|
58
|
+
: { ...state, response: update(state.response) }
|
|
59
|
+
|
|
60
|
+
const foldCoordinator = <M extends UIMessage, D extends EventDefs>(
|
|
61
|
+
state: AICoordinatorState,
|
|
62
|
+
event: ContractEvent<D>,
|
|
63
|
+
): AICoordinatorState => {
|
|
64
|
+
switch (event.type) {
|
|
65
|
+
case 'ai.session.closed':
|
|
66
|
+
return { closed: true, queued: [] }
|
|
67
|
+
case 'ai.message.created': {
|
|
68
|
+
const payload = event.payload as MessageCreatedPayload<M>
|
|
69
|
+
const message = payload.message
|
|
70
|
+
if (message.role !== 'user') return state
|
|
71
|
+
return {
|
|
72
|
+
...state,
|
|
73
|
+
queued: [
|
|
74
|
+
...state.queued.filter((item) => item.messageId !== message.id),
|
|
75
|
+
{
|
|
76
|
+
index: event.index,
|
|
77
|
+
messageId: message.id,
|
|
78
|
+
generate: payload.generate !== false,
|
|
79
|
+
},
|
|
80
|
+
].toSorted((left, right) => left.index - right.index),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
case 'ai.generation.requested': {
|
|
84
|
+
const request = event.payload as GenerationRequestedPayload
|
|
85
|
+
if (request.reason === 'message') {
|
|
86
|
+
const responseMessageId =
|
|
87
|
+
request.responseMessageId ?? `${request.messageId}:assistant`
|
|
88
|
+
const requested = state.queued.find(
|
|
89
|
+
(item) => item.messageId === request.messageId,
|
|
90
|
+
)
|
|
91
|
+
return {
|
|
92
|
+
...state,
|
|
93
|
+
queued:
|
|
94
|
+
requested === undefined
|
|
95
|
+
? state.queued.filter(
|
|
96
|
+
(item) => item.messageId !== request.messageId,
|
|
97
|
+
)
|
|
98
|
+
: state.queued.filter((item) => item.index > requested.index),
|
|
99
|
+
response: {
|
|
100
|
+
rootMessageId: request.messageId,
|
|
101
|
+
responseMessageId,
|
|
102
|
+
status: 'requested',
|
|
103
|
+
activeRequestId: event.id,
|
|
104
|
+
calls: [],
|
|
105
|
+
inputs: [],
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
state.response === undefined ||
|
|
111
|
+
request.responseMessageId !== state.response.responseMessageId
|
|
112
|
+
) {
|
|
113
|
+
return state
|
|
114
|
+
}
|
|
115
|
+
if (
|
|
116
|
+
(request.reason === 'tool' &&
|
|
117
|
+
(!continuationReady(state) ||
|
|
118
|
+
event.id !==
|
|
119
|
+
`ai.generate:tools:${state.response.generation?.generationId}`)) ||
|
|
120
|
+
(request.reason === 'retry' && state.response.status !== 'failed') ||
|
|
121
|
+
(request.reason === 'input' &&
|
|
122
|
+
state.response.inputResponse === undefined)
|
|
123
|
+
) {
|
|
124
|
+
return state
|
|
125
|
+
}
|
|
126
|
+
return updateResponse(state, (response) => ({
|
|
127
|
+
rootMessageId: response.rootMessageId,
|
|
128
|
+
responseMessageId: response.responseMessageId,
|
|
129
|
+
status: 'requested',
|
|
130
|
+
activeRequestId: event.id,
|
|
131
|
+
calls: [],
|
|
132
|
+
inputs: [],
|
|
133
|
+
}))
|
|
134
|
+
}
|
|
135
|
+
case 'ai.generation.started': {
|
|
136
|
+
const generation = event.payload as GenerationStartedPayload
|
|
137
|
+
if (
|
|
138
|
+
state.response?.responseMessageId !== generation.responseMessageId ||
|
|
139
|
+
state.response.activeRequestId !== generation.requestId ||
|
|
140
|
+
(state.response.generation?.requestId === generation.requestId &&
|
|
141
|
+
state.response.generation.attempt >= generation.attempt)
|
|
142
|
+
) {
|
|
143
|
+
return state
|
|
144
|
+
}
|
|
145
|
+
return updateResponse(state, (response) => ({
|
|
146
|
+
rootMessageId: response.rootMessageId,
|
|
147
|
+
responseMessageId: response.responseMessageId,
|
|
148
|
+
status: 'generating',
|
|
149
|
+
generation,
|
|
150
|
+
activeRequestId: generation.requestId,
|
|
151
|
+
calls: [],
|
|
152
|
+
inputs: [],
|
|
153
|
+
}))
|
|
154
|
+
}
|
|
155
|
+
case 'ai.generation.completed': {
|
|
156
|
+
const completion = event.payload as GenerationCompletedPayload
|
|
157
|
+
if (
|
|
158
|
+
state.response?.generation?.generationId !== completion.generationId
|
|
159
|
+
) {
|
|
160
|
+
return state
|
|
161
|
+
}
|
|
162
|
+
return updateResponse(state, (response) => ({
|
|
163
|
+
...response,
|
|
164
|
+
status: 'waiting',
|
|
165
|
+
completion,
|
|
166
|
+
}))
|
|
167
|
+
}
|
|
168
|
+
case 'ai.generation.failed': {
|
|
169
|
+
const failure = event.payload as GenerationFailedPayload
|
|
170
|
+
const response = state.response
|
|
171
|
+
const ownsActiveGeneration =
|
|
172
|
+
response?.generation?.generationId === failure.generationId
|
|
173
|
+
const ownsActiveStepLimit =
|
|
174
|
+
failure.stepLimit === true &&
|
|
175
|
+
response?.activeRequestId ===
|
|
176
|
+
`ai.generate:tools:${failure.generationId}` &&
|
|
177
|
+
response.responseMessageId === failure.responseMessageId
|
|
178
|
+
if (!ownsActiveGeneration && !ownsActiveStepLimit) {
|
|
179
|
+
return state
|
|
180
|
+
}
|
|
181
|
+
return updateResponse(state, (current) => ({
|
|
182
|
+
...current,
|
|
183
|
+
status: 'failed',
|
|
184
|
+
failure,
|
|
185
|
+
}))
|
|
186
|
+
}
|
|
187
|
+
case 'ai.message.completed': {
|
|
188
|
+
const messageId = (event.payload as { messageId: string }).messageId
|
|
189
|
+
return state.response?.responseMessageId === messageId
|
|
190
|
+
? { closed: state.closed, queued: state.queued }
|
|
191
|
+
: state
|
|
192
|
+
}
|
|
193
|
+
case 'ai.message.interrupted': {
|
|
194
|
+
const interruption = event.payload as MessageInterruptedPayload
|
|
195
|
+
const response = state.response
|
|
196
|
+
return response?.responseMessageId === interruption.messageId &&
|
|
197
|
+
(interruption.generationId === response.generation?.generationId ||
|
|
198
|
+
interruption.requestId === response.activeRequestId)
|
|
199
|
+
? { closed: state.closed, queued: state.queued }
|
|
200
|
+
: state
|
|
201
|
+
}
|
|
202
|
+
case 'ai.tool.called': {
|
|
203
|
+
const call = event.payload as ToolCalledPayload
|
|
204
|
+
if (state.response?.generation?.generationId !== call.generationId) {
|
|
205
|
+
return state
|
|
206
|
+
}
|
|
207
|
+
return updateResponse(state, (response) => ({
|
|
208
|
+
...response,
|
|
209
|
+
calls: [
|
|
210
|
+
...response.calls.filter(
|
|
211
|
+
(candidate) => candidate.call.toolCallId !== call.toolCallId,
|
|
212
|
+
),
|
|
213
|
+
{ index: event.index, call, terminal: false },
|
|
214
|
+
],
|
|
215
|
+
}))
|
|
216
|
+
}
|
|
217
|
+
case 'ai.approval.requested': {
|
|
218
|
+
const approval = event.payload as ApprovalRequestedPayload
|
|
219
|
+
if (state.response?.generation?.generationId !== approval.generationId) {
|
|
220
|
+
return state
|
|
221
|
+
}
|
|
222
|
+
return updateResponse(state, (response) => ({
|
|
223
|
+
...response,
|
|
224
|
+
calls: response.calls.map((candidate) =>
|
|
225
|
+
candidate.call.toolCallId === approval.toolCallId
|
|
226
|
+
? { ...candidate, approval }
|
|
227
|
+
: candidate,
|
|
228
|
+
),
|
|
229
|
+
}))
|
|
230
|
+
}
|
|
231
|
+
case 'ai.approval.responded': {
|
|
232
|
+
const approval = event.payload as ApprovalRespondedPayload
|
|
233
|
+
if (
|
|
234
|
+
state.response?.responseMessageId !== approval.messageId ||
|
|
235
|
+
state.response.generation?.generationId !== approval.generationId
|
|
236
|
+
) {
|
|
237
|
+
return state
|
|
238
|
+
}
|
|
239
|
+
return updateResponse(state, (response) => ({
|
|
240
|
+
...response,
|
|
241
|
+
calls: response.calls.map((candidate) =>
|
|
242
|
+
candidate.approval?.approvalId === approval.approvalId
|
|
243
|
+
? { ...candidate, response: approval, responseIndex: event.index }
|
|
244
|
+
: candidate,
|
|
245
|
+
),
|
|
246
|
+
}))
|
|
247
|
+
}
|
|
248
|
+
case 'ai.tool.result': {
|
|
249
|
+
const result = event.payload as ToolResultPayload
|
|
250
|
+
if (
|
|
251
|
+
result.preliminary === true ||
|
|
252
|
+
state.response?.generation?.generationId !== result.generationId
|
|
253
|
+
) {
|
|
254
|
+
return state
|
|
255
|
+
}
|
|
256
|
+
return updateResponse(state, (response) => ({
|
|
257
|
+
...response,
|
|
258
|
+
calls: response.calls.map((candidate) =>
|
|
259
|
+
candidate.call.toolCallId === result.toolCallId
|
|
260
|
+
? { ...candidate, terminal: true }
|
|
261
|
+
: candidate,
|
|
262
|
+
),
|
|
263
|
+
}))
|
|
264
|
+
}
|
|
265
|
+
case 'ai.input.requested': {
|
|
266
|
+
const input = event.payload as InputRequestedPayload
|
|
267
|
+
if (
|
|
268
|
+
state.response?.responseMessageId !== input.messageId ||
|
|
269
|
+
state.response.generation?.generationId !== input.generationId
|
|
270
|
+
) {
|
|
271
|
+
return state
|
|
272
|
+
}
|
|
273
|
+
return updateResponse(state, (response) => ({
|
|
274
|
+
...response,
|
|
275
|
+
inputs: [
|
|
276
|
+
...response.inputs.filter(
|
|
277
|
+
(candidate) =>
|
|
278
|
+
candidate.messageId !== input.messageId ||
|
|
279
|
+
candidate.generationId !== input.generationId ||
|
|
280
|
+
candidate.inputId !== input.inputId,
|
|
281
|
+
),
|
|
282
|
+
input,
|
|
283
|
+
],
|
|
284
|
+
}))
|
|
285
|
+
}
|
|
286
|
+
case 'ai.input.responded': {
|
|
287
|
+
const input = event.payload as {
|
|
288
|
+
messageId: string
|
|
289
|
+
generationId: string
|
|
290
|
+
inputId: string
|
|
291
|
+
name: string
|
|
292
|
+
}
|
|
293
|
+
const requested = state.response?.inputs.find(
|
|
294
|
+
(candidate) =>
|
|
295
|
+
candidate.messageId === input.messageId &&
|
|
296
|
+
candidate.generationId === input.generationId &&
|
|
297
|
+
candidate.inputId === input.inputId &&
|
|
298
|
+
candidate.name === input.name,
|
|
299
|
+
)
|
|
300
|
+
if (
|
|
301
|
+
state.response?.responseMessageId !== input.messageId ||
|
|
302
|
+
state.response.generation?.generationId !== input.generationId ||
|
|
303
|
+
requested === undefined
|
|
304
|
+
) {
|
|
305
|
+
return state
|
|
306
|
+
}
|
|
307
|
+
return updateResponse(state, (response) => ({
|
|
308
|
+
...response,
|
|
309
|
+
inputs: response.inputs.filter(
|
|
310
|
+
(candidate) => candidate.inputId !== input.inputId,
|
|
311
|
+
),
|
|
312
|
+
inputResponse: {
|
|
313
|
+
index: event.index,
|
|
314
|
+
generationId: input.generationId,
|
|
315
|
+
inputId: input.inputId,
|
|
316
|
+
name: requested.name,
|
|
317
|
+
},
|
|
318
|
+
}))
|
|
319
|
+
}
|
|
320
|
+
default:
|
|
321
|
+
return state
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export const aiCoordinatorReducer = <
|
|
326
|
+
M extends UIMessage,
|
|
327
|
+
D extends AIEventDefs<M> & EventDefs,
|
|
328
|
+
>(
|
|
329
|
+
contract: Contract<D>,
|
|
330
|
+
): Reducer<D, AICoordinatorState> =>
|
|
331
|
+
contract
|
|
332
|
+
.reducer({
|
|
333
|
+
name: 'a2.ai.coordinator.v3',
|
|
334
|
+
initialState: {
|
|
335
|
+
closed: false,
|
|
336
|
+
queued: [],
|
|
337
|
+
} as AICoordinatorState,
|
|
338
|
+
})
|
|
339
|
+
.fold((state, event) => foldCoordinator<M, D>(state, event))
|
|
340
|
+
|
|
341
|
+
export const continuationReady = (state: AICoordinatorState): boolean => {
|
|
342
|
+
const response = state.response
|
|
343
|
+
return (
|
|
344
|
+
!state.closed &&
|
|
345
|
+
response?.completion?.finishReason === 'tool-calls' &&
|
|
346
|
+
response.generation?.generationId === response.completion.generationId &&
|
|
347
|
+
response.failure === undefined &&
|
|
348
|
+
response.calls.length > 0 &&
|
|
349
|
+
response.calls.every(
|
|
350
|
+
(call) =>
|
|
351
|
+
call.terminal ||
|
|
352
|
+
(call.call.providerExecuted === true &&
|
|
353
|
+
call.call.supportsDeferredResults !== true &&
|
|
354
|
+
call.approval !== undefined &&
|
|
355
|
+
call.response !== undefined),
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
}
|