experimental-a2 0.19.0 → 0.20.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/ai-fx.d.ts.map +1 -1
  4. package/dist/ai-fx.js +34 -4
  5. package/dist/ai-fx.js.map +1 -1
  6. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -1
  7. package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-Clbg5ei7.js} +11 -2
  8. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  9. package/dist/ai-pi.js +2 -2
  10. package/dist/ai-server.d.ts.map +1 -1
  11. package/dist/ai-server.js +6 -4
  12. package/dist/ai-server.js.map +1 -1
  13. package/dist/internal-CscSY7K_.js.map +1 -1
  14. package/dist/scheduler-qstash.d.ts.map +1 -1
  15. package/dist/scheduler-qstash.js +5 -5
  16. package/dist/scheduler-qstash.js.map +1 -1
  17. package/dist/scheduler-vercel.d.ts.map +1 -1
  18. package/dist/scheduler-vercel.js +4 -3
  19. package/dist/scheduler-vercel.js.map +1 -1
  20. package/dist/server-DYAATFuT.d.ts.map +1 -1
  21. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  22. package/dist/server-qyb5aJJG.js.map +1 -0
  23. package/dist/server.js +1 -1
  24. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  25. package/dist/store-postgres.d.ts.map +1 -1
  26. package/dist/store-postgres.js +38 -136
  27. package/dist/store-postgres.js.map +1 -1
  28. package/docs/01-quickstart.mdx +7 -4
  29. package/docs/actors/01-introduction.mdx +1 -1
  30. package/docs/actors/meta.ts +1 -1
  31. package/docs/agents/02-tools.mdx +1 -1
  32. package/docs/agents/03-conversations.mdx +1 -1
  33. package/docs/agents/meta.ts +1 -1
  34. package/docs/client/meta.ts +1 -1
  35. package/docs/contract/03-sessions.mdx +1 -1
  36. package/docs/index.mdx +2 -2
  37. package/docs/reference/01-core.mdx +8 -8
  38. package/docs/reference/02-server.mdx +3 -3
  39. package/docs/reference/04-ai.mdx +9 -1
  40. package/docs/reference/05-telemetry.mdx +132 -0
  41. package/docs/reference/06-errors.mdx +1 -1
  42. package/docs/reference/meta.ts +1 -1
  43. package/docs/schedulers/01-overview.mdx +112 -0
  44. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  45. package/docs/schedulers/03-qstash.mdx +123 -0
  46. package/docs/schedulers/meta.ts +1 -0
  47. package/docs/server/01-handlers.mdx +29 -2
  48. package/docs/server/02-timers.mdx +2 -2
  49. package/docs/server/06-guarantees.mdx +6 -3
  50. package/docs/stores/01-overview.mdx +37 -0
  51. package/docs/stores/02-redis.mdx +49 -0
  52. package/docs/stores/03-redis-http.mdx +56 -0
  53. package/docs/stores/04-postgres.mdx +121 -0
  54. package/docs/stores/05-sqlite.mdx +34 -0
  55. package/docs/stores/06-memory.mdx +22 -0
  56. package/docs/stores/meta.ts +1 -0
  57. package/examples/playground/DEPLOY.md +12 -7
  58. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  59. package/examples/playground/app/a0/chat.test.tsx +421 -330
  60. package/examples/playground/app/a0/shell.tsx +1 -1
  61. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  62. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
  63. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  64. package/examples/playground/app/agent/ingress.test.ts +41 -17
  65. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  66. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  67. package/examples/playground/app/chat/server.test.ts +38 -65
  68. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  69. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  70. package/examples/playground/app/initial-state.test.tsx +86 -45
  71. package/examples/playground/package.json +2 -1
  72. package/examples/playground/test/agent.ts +35 -0
  73. package/examples/playground/test/model.ts +149 -0
  74. package/examples/playground/test/serve.ts +184 -0
  75. package/examples/playground/test/server-only.ts +1 -0
  76. package/examples/playground/vitest.config.ts +27 -0
  77. package/package.json +3 -1
  78. package/sql/postgres/notifications.sql +24 -0
  79. package/sql/postgres/schema.sql +137 -0
  80. package/src/ai-agent.ts +5 -4
  81. package/src/ai-fx.ts +37 -3
  82. package/src/ai-harness.ts +18 -0
  83. package/src/errors.ts +1 -1
  84. package/src/internal.ts +2 -1
  85. package/src/postgres-notifications.ts +0 -22
  86. package/src/postgres-resources.ts +6 -9
  87. package/src/postgres-schema.ts +80 -0
  88. package/src/scheduler-qstash.ts +12 -5
  89. package/src/scheduler-vercel.ts +10 -4
  90. package/src/server.ts +66 -32
  91. package/src/store-postgres.ts +5 -123
  92. package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
  93. package/dist/server-D-Y4pqwu.js.map +0 -1
  94. package/docs/reference/05-adapters.mdx +0 -351
  95. package/docs/server/05-production.mdx +0 -323
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The model behind an agent under test. Agents take AI Gateway model IDs;
3
+ * the AI SDK's global provider serves every one of them from this model,
4
+ * whose steps the test writes part by part.
5
+ */
6
+
7
+ import { MockLanguageModelV4 } from 'ai/test'
8
+ import { vi } from 'vitest'
9
+
10
+ export type StreamPart =
11
+ Awaited<
12
+ ReturnType<MockLanguageModelV4['doStream']>
13
+ >['stream'] extends ReadableStream<infer Part>
14
+ ? Part
15
+ : never
16
+
17
+ export type CallOptions = MockLanguageModelV4['doStreamCalls'][number]
18
+
19
+ /** One model step, open until the test ends it or the agent aborts it. */
20
+ export type ModelStep = {
21
+ /** What the agent sent the model for this step. */
22
+ options: CallOptions
23
+ write(...parts: StreamPart[]): void
24
+ /** Ends the step the way a model does: with a finish part. */
25
+ finish(reason?: 'stop' | 'tool-calls'): void
26
+ }
27
+
28
+ const usage = {
29
+ inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
30
+ outputTokens: { total: 1, text: 1, reasoning: 0 },
31
+ }
32
+
33
+ /** A text part, whole. */
34
+ export const say = ({ id, text }: { id: string; text: string }) =>
35
+ [
36
+ { type: 'text-start', id },
37
+ { type: 'text-delta', id, delta: text },
38
+ { type: 'text-end', id },
39
+ ] satisfies StreamPart[]
40
+
41
+ /** A tool call whose input streams in as the given chunks of its JSON. */
42
+ export const toolCall = ({
43
+ id,
44
+ name,
45
+ chunks,
46
+ }: {
47
+ id: string
48
+ name: string
49
+ chunks: string[]
50
+ }) =>
51
+ [
52
+ { type: 'tool-input-start', id, toolName: name },
53
+ ...chunks.map((delta) => ({
54
+ type: 'tool-input-delta' as const,
55
+ id,
56
+ delta,
57
+ })),
58
+ { type: 'tool-input-end', id },
59
+ {
60
+ type: 'tool-call',
61
+ toolCallId: id,
62
+ toolName: name,
63
+ input: chunks.join(''),
64
+ },
65
+ ] satisfies StreamPart[]
66
+
67
+ let models = 0
68
+
69
+ /**
70
+ * Installs a model the test drives step by step, served for `id` alone, so
71
+ * an agent left over from an earlier test can't take its steps. Remove it
72
+ * with `vi.unstubAllGlobals()`.
73
+ */
74
+ export function testModel({
75
+ id = `test-model-${++models}`,
76
+ }: {
77
+ /** The model ID the agent asks for. */
78
+ id?: string
79
+ } = {}): {
80
+ /** The agent's `model`. */
81
+ id: string
82
+ /** Every step the agent asked for, in order. */
83
+ steps: ModelStep[]
84
+ /** The `n`th step (from 0), once the agent asks for it. */
85
+ step(n: number): Promise<ModelStep>
86
+ /** Ends every step still open, and each one asked for from now on. */
87
+ close(): void
88
+ } {
89
+ const steps: ModelStep[] = []
90
+ const arrivals: PromiseWithResolvers<ModelStep>[] = []
91
+ const arrival = (n: number): PromiseWithResolvers<ModelStep> =>
92
+ (arrivals[n] ??= Promise.withResolvers<ModelStep>())
93
+ const ends: (() => void)[] = []
94
+ let closed = false
95
+ const model = new MockLanguageModelV4({
96
+ modelId: id,
97
+ doStream: async (options) => {
98
+ let controller!: ReadableStreamDefaultController<StreamPart>
99
+ let open = true
100
+ const end = (): void => {
101
+ if (!open) return
102
+ open = false
103
+ controller.close()
104
+ }
105
+ const stream = new ReadableStream<StreamPart>({
106
+ start(value) {
107
+ controller = value
108
+ controller.enqueue({ type: 'stream-start', warnings: [] })
109
+ },
110
+ })
111
+ options.abortSignal?.addEventListener('abort', end, { once: true })
112
+ const step: ModelStep = {
113
+ options,
114
+ write(...parts) {
115
+ for (const part of parts) if (open) controller.enqueue(part)
116
+ },
117
+ finish(reason = 'stop') {
118
+ step.write({
119
+ type: 'finish',
120
+ finishReason: { unified: reason, raw: reason },
121
+ usage,
122
+ })
123
+ end()
124
+ },
125
+ }
126
+ ends.push(end)
127
+ arrival(steps.length).resolve(step)
128
+ steps.push(step)
129
+ if (closed) step.finish()
130
+ return { stream }
131
+ },
132
+ })
133
+ vi.stubGlobal('AI_SDK_DEFAULT_PROVIDER', {
134
+ specificationVersion: 'v4',
135
+ languageModel: (requested: string) => {
136
+ if (requested !== id) throw new Error(`no test model '${requested}'`)
137
+ return model
138
+ },
139
+ })
140
+ return {
141
+ id,
142
+ steps,
143
+ step: (n) => arrival(n).promise,
144
+ close: () => {
145
+ closed = true
146
+ for (const end of ends) end()
147
+ },
148
+ }
149
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * A real HTTP server for the app's route handlers, so the browser's clients
3
+ * reach A2 servers over the actual wire format.
4
+ */
5
+
6
+ import { createServer, type ServerResponse } from 'node:http'
7
+ import type { AddressInfo, Socket } from 'node:net'
8
+ import type { A2Operation } from 'experimental-a2/server'
9
+ import { setGlobalOrigin } from 'undici'
10
+
11
+ type Handler = (request: Request) => Response | Promise<Response>
12
+
13
+ export type Served = {
14
+ /** Where the server listens; relative URLs resolve against it until it closes. */
15
+ origin: string
16
+ close(): Promise<void>
17
+ }
18
+
19
+ const writeResponse = async (
20
+ res: ServerResponse,
21
+ response: Response,
22
+ ): Promise<void> => {
23
+ res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
24
+ res.flushHeaders()
25
+ if (!response.body) {
26
+ res.end()
27
+ return
28
+ }
29
+ const reader = response.body.getReader()
30
+ // A disconnecting client must cancel the web stream, which closes the
31
+ // store subscription behind it.
32
+ res.on('close', () => {
33
+ reader.cancel().catch(() => {})
34
+ })
35
+ try {
36
+ for (;;) {
37
+ // oxlint-disable-next-line no-await-in-loop -- stream pump
38
+ const { done, value } = await reader.read()
39
+ if (done) break
40
+ res.write(value)
41
+ }
42
+ res.end()
43
+ } catch {
44
+ res.destroy()
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Serves each route's handler at its path. The page and `fetch` resolve
50
+ * relative URLs, like a module-level client's `api`, against this server
51
+ * until it closes.
52
+ */
53
+ export async function serve({
54
+ routes,
55
+ }: {
56
+ routes: Readonly<Record<string, Handler>>
57
+ }): Promise<Served> {
58
+ let origin = ''
59
+ const server = createServer((req, res) => {
60
+ void (async () => {
61
+ try {
62
+ const url = new URL(req.url ?? '/', origin)
63
+ const handler = routes[url.pathname]
64
+ if (handler === undefined) {
65
+ res.writeHead(404).end()
66
+ return
67
+ }
68
+ const chunks: Buffer[] = []
69
+ for await (const chunk of req) chunks.push(chunk as Buffer)
70
+ const request = new Request(url, {
71
+ method: req.method ?? 'GET',
72
+ headers: Object.entries(req.headers).flatMap(([name, value]) =>
73
+ value === undefined
74
+ ? []
75
+ : (Array.isArray(value) ? value : [value]).map(
76
+ (item): [string, string] => [name, item],
77
+ ),
78
+ ),
79
+ ...(chunks.length === 0 ? {} : { body: Buffer.concat(chunks) }),
80
+ })
81
+ await writeResponse(res, await handler(request))
82
+ } catch (error) {
83
+ // A handler's bug answers as one, not as a dropped connection the
84
+ // client would retry.
85
+ if (res.headersSent) res.destroy()
86
+ else res.writeHead(500).end(String(error))
87
+ }
88
+ })()
89
+ })
90
+ // close() must never wait on a request a test parked.
91
+ const sockets = new Set<Socket>()
92
+ server.on('connection', (socket) => {
93
+ sockets.add(socket)
94
+ socket.on('close', () => sockets.delete(socket))
95
+ })
96
+ // The address clients use: on macOS, another process can bind 127.0.0.1 on
97
+ // a port this server holds on every address, and take its connections.
98
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
99
+ const { port } = server.address() as AddressInfo
100
+ origin = `http://127.0.0.1:${port}`
101
+ setGlobalOrigin(origin)
102
+ // Vitest's jsdom environment exposes its JSDOM, which sets the page's URL.
103
+ ;(
104
+ globalThis as { jsdom?: { reconfigure(settings: { url: string }): void } }
105
+ ).jsdom?.reconfigure({ url: `${origin}/` })
106
+ return {
107
+ origin,
108
+ close: () =>
109
+ new Promise((resolve) => {
110
+ setGlobalOrigin(undefined)
111
+ for (const socket of sockets) socket.destroy()
112
+ server.close(() => resolve())
113
+ }),
114
+ }
115
+ }
116
+
117
+ type Match = (operation: A2Operation) => boolean
118
+
119
+ export type Held = {
120
+ /** The parked operation, once it arrives. */
121
+ arrived: Promise<A2Operation>
122
+ allow(): void
123
+ deny(): void
124
+ }
125
+
126
+ /**
127
+ * An `authorize` for `server.fetch` that records every operation it sees,
128
+ * and can park or deny the next one a test names.
129
+ */
130
+ export function authorizer(): {
131
+ authorize: (operation: A2Operation) => Promise<boolean>
132
+ /** Every operation so far, in arrival order. */
133
+ operations: A2Operation[]
134
+ /** Parks the next matching operation until the test decides it. */
135
+ hold(match: Match): Held
136
+ /** Denies the next matching operation. */
137
+ deny(match: Match): void
138
+ /** Forgets the rules not yet used, and denies what is still parked. */
139
+ release(): void
140
+ } {
141
+ const operations: A2Operation[] = []
142
+ const rules: {
143
+ match: Match
144
+ decide: (operation: A2Operation) => Promise<boolean>
145
+ }[] = []
146
+ const parked = new Set<Held>()
147
+ return {
148
+ operations,
149
+ authorize: async (operation) => {
150
+ operations.push(operation)
151
+ const at = rules.findIndex((rule) => rule.match(operation))
152
+ return at === -1 || rules.splice(at, 1)[0]!.decide(operation)
153
+ },
154
+ hold(match) {
155
+ const arrival = Promise.withResolvers<A2Operation>()
156
+ const decision = Promise.withResolvers<boolean>()
157
+ const decide = (allowed: boolean): void => {
158
+ parked.delete(held)
159
+ decision.resolve(allowed)
160
+ }
161
+ const held: Held = {
162
+ arrived: arrival.promise,
163
+ allow: () => decide(true),
164
+ deny: () => decide(false),
165
+ }
166
+ parked.add(held)
167
+ rules.push({
168
+ match,
169
+ decide: (operation) => {
170
+ arrival.resolve(operation)
171
+ return decision.promise
172
+ },
173
+ })
174
+ return held
175
+ },
176
+ deny(match) {
177
+ rules.push({ match, decide: async () => false })
178
+ },
179
+ release() {
180
+ rules.length = 0
181
+ for (const held of parked) held.deny()
182
+ },
183
+ }
184
+ }
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,27 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { defineConfig } from 'vitest/config'
3
+
4
+ export default defineConfig({
5
+ resolve: {
6
+ tsconfigPaths: true,
7
+ alias: {
8
+ 'server-only': fileURLToPath(
9
+ new URL('./test/server-only.ts', import.meta.url),
10
+ ),
11
+ },
12
+ },
13
+ test: {
14
+ // The app's servers run in-process: with no store URL they use A2's
15
+ // memory store, never a database a dev shell points at, and with no
16
+ // queues their work drains in the background of the append. Without
17
+ // credentials, nothing can reach the AI Gateway.
18
+ env: {
19
+ REDIS_URL: '',
20
+ DATABASE_URL: '',
21
+ VERCEL: '',
22
+ A2_QUEUES: '',
23
+ AI_GATEWAY_API_KEY: '',
24
+ VERCEL_OIDC_TOKEN: '',
25
+ },
26
+ },
27
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Syncs your immutable event log, from server to browser: one contract, handlers, derived state, and a live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,6 +21,7 @@
21
21
  "dist",
22
22
  "docs",
23
23
  "examples",
24
+ "sql",
24
25
  "src",
25
26
  "AGENTS.md",
26
27
  "CHANGELOG.md",
@@ -120,6 +121,7 @@
120
121
  "@types/ws": "^8.18.1",
121
122
  "@vercel/queue": "^0.2.0",
122
123
  "pg": "^8.16.0",
124
+ "postgres": "^3.4.9",
123
125
  "ws": "^8.21.3",
124
126
  "@testing-library/react": "^16.1.0",
125
127
  "@types/react": "^19.0.0",
@@ -0,0 +1,24 @@
1
+ -- Notification triggers for experimental-a2/store-postgres, needed only with
2
+ -- `listenConnectionString`. Apply after schema.sql. They wake live streams
3
+ -- for every write to A2's event and presence tables, including writes from
4
+ -- stores without the option. Safe to re-run.
5
+
6
+ create or replace function a2_notify_events() returns trigger language plpgsql as $$
7
+ begin
8
+ perform pg_notify('a2_events_' || md5(new.session_id), '');
9
+ return null;
10
+ end $$;
11
+ create or replace function a2_notify_presence() returns trigger language plpgsql as $$
12
+ begin
13
+ perform pg_notify('a2_presence_' || md5(case when TG_OP = 'DELETE' then old.ns else new.ns end),
14
+ coalesce(current_setting('a2.presence_source', true), ''));
15
+ return null;
16
+ end $$;
17
+ do $$ begin
18
+ if not exists (select 1 from pg_trigger where tgname = 'a2_notify_events' and tgrelid = 'a2_events'::regclass) then
19
+ create trigger a2_notify_events after insert on a2_events for each row execute function a2_notify_events();
20
+ end if;
21
+ if not exists (select 1 from pg_trigger where tgname = 'a2_notify_presence' and tgrelid = 'a2_presence'::regclass) then
22
+ create trigger a2_notify_presence after insert or update or delete on a2_presence for each row execute function a2_notify_presence();
23
+ end if;
24
+ end $$;
@@ -0,0 +1,137 @@
1
+ -- A2's tables for experimental-a2/store-postgres.
2
+ --
3
+ -- Apply this file once before the first deploy, and again whenever a release
4
+ -- raises the schema version. Every statement creates only what is missing and
5
+ -- leaves existing data alone, so the file is safe to re-run on any A2
6
+ -- database, whichever release created it.
7
+ --
8
+ -- Each index sits behind a catalog check: `create index if not exists` takes
9
+ -- a share lock on its table, and requires owning it, even when the index
10
+ -- exists. The check makes a re-run on a live database wait for no writer.
11
+
12
+ create table if not exists a2_events (
13
+ session_id text not null,
14
+ idx bigint not null,
15
+ event_type text not null,
16
+ payload jsonb not null,
17
+ event_id text not null,
18
+ created_at bigint not null,
19
+ cause jsonb,
20
+ lane text,
21
+ lane_ready boolean not null default false,
22
+ processed_at bigint,
23
+ processed_by_attempt integer,
24
+ returned_event_ids jsonb,
25
+ first_claimed_at bigint,
26
+ last_claimed_at bigint,
27
+ attempt_count integer not null default 0,
28
+ failure_count integer not null default 0,
29
+ last_failed_at bigint,
30
+ last_failed_attempt integer,
31
+ last_error text,
32
+ failed_at bigint,
33
+ claim_holder text,
34
+ claim_expires_at bigint,
35
+ primary key (session_id, idx)
36
+ );
37
+
38
+ do $$ begin
39
+ if to_regclass('a2_events_event_id') is null then
40
+ create unique index if not exists a2_events_event_id on a2_events (event_id);
41
+ end if;
42
+ if to_regclass('a2_events_unprocessed') is null then
43
+ create index if not exists a2_events_unprocessed
44
+ on a2_events (session_id, idx) where processed_at is null;
45
+ end if;
46
+ if to_regclass('a2_events_lane_pending') is null then
47
+ create index if not exists a2_events_lane_pending
48
+ on a2_events (session_id, lane, idx) where processed_at is null;
49
+ end if;
50
+ if to_regclass('a2_events_dispatch_ready') is null then
51
+ create index if not exists a2_events_dispatch_ready
52
+ on a2_events (session_id, idx)
53
+ where lane_ready and processed_at is null and failed_at is null;
54
+ end if;
55
+ if to_regclass('a2_events_claim_expiry') is null then
56
+ create index if not exists a2_events_claim_expiry
57
+ on a2_events (session_id, claim_expires_at)
58
+ where lane_ready and processed_at is null and failed_at is null;
59
+ end if;
60
+ end $$;
61
+
62
+ create table if not exists a2_snapshots (
63
+ session_id text not null,
64
+ reducer_name text not null,
65
+ up_to_index bigint not null,
66
+ state jsonb not null,
67
+ updated_at bigint not null,
68
+ primary key (session_id, reducer_name)
69
+ );
70
+
71
+ create table if not exists a2_snapshot_history (
72
+ session_id text not null,
73
+ reducer_name text not null,
74
+ up_to_index bigint not null,
75
+ state jsonb not null,
76
+ updated_at bigint not null,
77
+ primary key (session_id, reducer_name, up_to_index)
78
+ );
79
+
80
+ create table if not exists a2_snapshot_pins (
81
+ session_id text not null,
82
+ event_index bigint not null,
83
+ reducer_name text not null,
84
+ up_to_index bigint not null,
85
+ primary key (session_id, event_index, reducer_name, up_to_index)
86
+ );
87
+
88
+ do $$ begin
89
+ if to_regclass('a2_snapshot_pins_checkpoint') is null then
90
+ create index if not exists a2_snapshot_pins_checkpoint
91
+ on a2_snapshot_pins (session_id, reducer_name, up_to_index);
92
+ end if;
93
+ end $$;
94
+
95
+ create table if not exists a2_clears (
96
+ session_id text primary key,
97
+ clear_index bigint not null
98
+ );
99
+
100
+ create table if not exists a2_presence (
101
+ ns text not null,
102
+ participant text not null,
103
+ field text not null,
104
+ value jsonb not null,
105
+ seen bigint not null,
106
+ at bigint not null,
107
+ expires_at bigint not null,
108
+ primary key (ns, participant, field)
109
+ );
110
+
111
+ create table if not exists a2_schema (
112
+ only_row boolean primary key default true check (only_row),
113
+ version integer not null
114
+ );
115
+
116
+ -- The version is recorded last, and only once everything above exists, so a
117
+ -- run that skipped a failed statement records nothing, whichever tool ran it.
118
+ do $$ begin
119
+ if to_regclass('a2_events') is null
120
+ or to_regclass('a2_events_event_id') is null
121
+ or to_regclass('a2_events_unprocessed') is null
122
+ or to_regclass('a2_events_lane_pending') is null
123
+ or to_regclass('a2_events_dispatch_ready') is null
124
+ or to_regclass('a2_events_claim_expiry') is null
125
+ or to_regclass('a2_snapshots') is null
126
+ or to_regclass('a2_snapshot_history') is null
127
+ or to_regclass('a2_snapshot_pins') is null
128
+ or to_regclass('a2_snapshot_pins_checkpoint') is null
129
+ or to_regclass('a2_clears') is null
130
+ or to_regclass('a2_presence') is null
131
+ then
132
+ raise exception 'A2 schema incomplete: an earlier statement in schema.sql failed. Fix it and apply the file again.';
133
+ end if;
134
+ insert into a2_schema (version) values (1)
135
+ on conflict (only_row)
136
+ do update set version = greatest(a2_schema.version, excluded.version);
137
+ end $$;
package/src/ai-agent.ts CHANGED
@@ -20,7 +20,6 @@ import type { Reducer } from './reducer.ts'
20
20
  import { aiEventId, AI_EVENT_ID_PREFIX } from './ai-id.ts'
21
21
  import type { ControlCommit, ControlCommand } from './ai-control.ts'
22
22
  import { AsyncLocalStorage } from 'node:async_hooks'
23
- import { createHash } from 'node:crypto'
24
23
  import { convertToModelMessages, isToolUIPart } from 'ai'
25
24
  import type {
26
25
  Experimental_SandboxSession,
@@ -35,6 +34,7 @@ import type {
35
34
  } from 'ai'
36
35
  import { generateAISDKStep, type AISDKStepSettings } from './ai-sdk-step.ts'
37
36
  import {
37
+ gatewaySessionAffinity,
38
38
  harnessDriver,
39
39
  harnessLog,
40
40
  harnessStates,
@@ -627,9 +627,10 @@ async function generateWithAISDK<
627
627
  ? headers
628
628
  : {
629
629
  ...headers,
630
- 'x-session-affinity': createHash('sha256')
631
- .update(JSON.stringify([agentName, context.event.sessionId]))
632
- .digest('hex'),
630
+ 'x-session-affinity': gatewaySessionAffinity({
631
+ agentName,
632
+ sessionId: context.event.sessionId,
633
+ }),
633
634
  },
634
635
  providerOptions: {
635
636
  ...context.generation.providerOptions,
package/src/ai-fx.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  Divergence,
21
21
  gatewayCredential,
22
22
  type GatewayCredential,
23
+ gatewaySessionAffinity,
23
24
  type Harness,
24
25
  type HarnessGeneration,
25
26
  harnessImages,
@@ -244,19 +245,27 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
244
245
  return pending(init.signal)
245
246
  }
246
247
  state.live = true
247
- return live(state, url, init, compaction)
248
+ return live(state, url, { ...init, body }, compaction)
248
249
  }
249
250
 
251
+ const affinity = gatewaySessionAffinity({
252
+ agentName: step.agentName,
253
+ sessionId: step.sessionId,
254
+ })
250
255
  let compactions = 0
251
256
  const live = async (
252
257
  state: TurnState,
253
258
  url: string,
254
- init: RequestInit,
259
+ init: RequestInit & { body: string },
255
260
  compaction: boolean,
256
261
  ): Promise<Response> => {
262
+ const headers = withAuth(init.headers, credential)
263
+ // The kernel's own affinity is new with every rebuild; A2's lasts the session.
264
+ headers.set('x-session-affinity', affinity)
257
265
  const response = await fetch(url, {
258
266
  ...init,
259
- headers: withAuth(init.headers, credential),
267
+ body: withGatewayCaching(init.body),
268
+ headers,
260
269
  signal: network,
261
270
  })
262
271
  // Abandoned before its headers came: the kernel has moved on without it.
@@ -774,6 +783,31 @@ async function loadLibfx(): Promise<typeof import('libfx')> {
774
783
  }
775
784
  }
776
785
 
786
+ /**
787
+ * A model request with Gateway's automatic caching, as the AI SDK harness
788
+ * asks for it; options the request already has take precedence. Every other
789
+ * byte stays as the kernel wrote it, a tool input's spacing included.
790
+ */
791
+ function withGatewayCaching(body: string): string {
792
+ const request = JSON.parse(body) as {
793
+ providerOptions?: Record<string, Record<string, unknown> | undefined>
794
+ }
795
+ const options = request.providerOptions
796
+ if (options === undefined)
797
+ return body.replace(
798
+ /^\{/,
799
+ '{"providerOptions":{"gateway":{"caching":"auto"}},',
800
+ )
801
+ // The kernel writes its own options last; any other shape goes unchanged.
802
+ const own = `"providerOptions":${JSON.stringify(options)}}`
803
+ if (!body.endsWith(own)) return body
804
+ const merged = {
805
+ ...options,
806
+ gateway: { caching: 'auto', ...options['gateway'] },
807
+ }
808
+ return `${body.slice(0, -own.length)}"providerOptions":${JSON.stringify(merged)}}`
809
+ }
810
+
777
811
  function withAuth(
778
812
  headers: HeadersInit | undefined,
779
813
  credential: GatewayCredential,
package/src/ai-harness.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  ReasoningLevel,
15
15
  ToolResultPayload,
16
16
  } from './ai.ts'
17
+ import { createHash } from 'node:crypto'
17
18
  import { STEERED_REASON } from './ai-control.ts'
18
19
  import { boundedCache, type BoundedCache } from './bounded-cache.ts'
19
20
  import type { ContractEvent, EventDefs } from './contract.ts'
@@ -598,6 +599,23 @@ export function gatewayCredential(harness: string): GatewayCredential {
598
599
  throw new Error(`${harness}() needs AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN`)
599
600
  }
600
601
 
602
+ /**
603
+ * The `x-session-affinity` an agent session's Gateway calls carry: stable for
604
+ * the session, so supporting providers can reuse their cache, and free of its
605
+ * raw ID.
606
+ */
607
+ export function gatewaySessionAffinity({
608
+ agentName,
609
+ sessionId,
610
+ }: {
611
+ agentName: string
612
+ sessionId: string
613
+ }): string {
614
+ return createHash('sha256')
615
+ .update(JSON.stringify([agentName, sessionId]))
616
+ .digest('hex')
617
+ }
618
+
601
619
  // Where Vercel Functions keep the current request, as @vercel/oidc reads it.
602
620
  const REQUEST_CONTEXT = Symbol.for('@vercel/request-context')
603
621
 
package/src/errors.ts CHANGED
@@ -7,7 +7,7 @@ export type A2ServerErrorCode =
7
7
  | 'SESSION_CLEARED' // a read or call reached across the session's latest clear
8
8
  | 'CLAIM_EXPIRED' // ctx.signal abort reason: the attempt's lease lapsed without renewal
9
9
  | 'STORE_UNAVAILABLE' // store backend failure — retryable, nothing was written
10
- | 'STORE_NOT_CONFIGURED' // production boot with no `store` passed
10
+ | 'STORE_NOT_CONFIGURED' // no `store` in production, a missing peer dependency, or Postgres lacks A2's tables
11
11
  | 'UNKNOWN_PRESENCE_FIELD' // presence field not in the contract's `presence` map
12
12
  | 'PRESENCE_NOT_SUPPORTED' // contract declares presence but the store lacks the capability
13
13