experimental-a2 0.18.1 → 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 (143) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
  4. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  5. package/dist/ai-client.d.ts +1 -1
  6. package/dist/ai-client.js +1 -1
  7. package/dist/ai-control-CvLbW10B.js +46 -0
  8. package/dist/ai-control-CvLbW10B.js.map +1 -0
  9. package/dist/ai-fx.d.ts +1 -1
  10. package/dist/ai-fx.d.ts.map +1 -1
  11. package/dist/ai-fx.js +63 -13
  12. package/dist/ai-fx.js.map +1 -1
  13. package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  14. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  15. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
  16. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  17. package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
  18. package/dist/ai-mMiWul1T.js.map +1 -0
  19. package/dist/ai-pi.d.ts +1 -1
  20. package/dist/ai-pi.d.ts.map +1 -1
  21. package/dist/ai-pi.js +56 -11
  22. package/dist/ai-pi.js.map +1 -1
  23. package/dist/ai-react.d.ts +1 -1
  24. package/dist/ai-react.js +1 -1
  25. package/dist/ai-server.d.ts +2 -2
  26. package/dist/ai-server.d.ts.map +1 -1
  27. package/dist/ai-server.js +123 -53
  28. package/dist/ai-server.js.map +1 -1
  29. package/dist/ai.d.ts +2 -2
  30. package/dist/ai.js +1 -1
  31. package/dist/internal-CscSY7K_.js.map +1 -1
  32. package/dist/scheduler-qstash.d.ts.map +1 -1
  33. package/dist/scheduler-qstash.js +5 -5
  34. package/dist/scheduler-qstash.js.map +1 -1
  35. package/dist/scheduler-vercel.d.ts.map +1 -1
  36. package/dist/scheduler-vercel.js +5 -7
  37. package/dist/scheduler-vercel.js.map +1 -1
  38. package/dist/server-DYAATFuT.d.ts.map +1 -1
  39. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  40. package/dist/server-qyb5aJJG.js.map +1 -0
  41. package/dist/server.js +1 -1
  42. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  43. package/dist/store-postgres.d.ts.map +1 -1
  44. package/dist/store-postgres.js +38 -136
  45. package/dist/store-postgres.js.map +1 -1
  46. package/docs/01-quickstart.mdx +7 -4
  47. package/docs/actors/01-introduction.mdx +1 -1
  48. package/docs/actors/meta.ts +1 -1
  49. package/docs/agents/02-tools.mdx +1 -1
  50. package/docs/agents/03-conversations.mdx +26 -14
  51. package/docs/agents/04-harnesses.mdx +51 -5
  52. package/docs/agents/meta.ts +1 -1
  53. package/docs/client/meta.ts +1 -1
  54. package/docs/contract/03-sessions.mdx +2 -2
  55. package/docs/index.mdx +2 -2
  56. package/docs/reference/01-core.mdx +8 -8
  57. package/docs/reference/02-server.mdx +3 -3
  58. package/docs/reference/04-ai.mdx +36 -19
  59. package/docs/reference/05-telemetry.mdx +132 -0
  60. package/docs/reference/06-errors.mdx +1 -1
  61. package/docs/reference/meta.ts +1 -1
  62. package/docs/schedulers/01-overview.mdx +112 -0
  63. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  64. package/docs/schedulers/03-qstash.mdx +123 -0
  65. package/docs/schedulers/meta.ts +1 -0
  66. package/docs/server/01-handlers.mdx +29 -2
  67. package/docs/server/02-timers.mdx +3 -3
  68. package/docs/server/06-guarantees.mdx +6 -3
  69. package/docs/stores/01-overview.mdx +37 -0
  70. package/docs/stores/02-redis.mdx +49 -0
  71. package/docs/stores/03-redis-http.mdx +56 -0
  72. package/docs/stores/04-postgres.mdx +121 -0
  73. package/docs/stores/05-sqlite.mdx +34 -0
  74. package/docs/stores/06-memory.mdx +22 -0
  75. package/docs/stores/meta.ts +1 -0
  76. package/examples/playground/DEPLOY.md +19 -5
  77. package/examples/playground/app/a0/a0.css +244 -5
  78. package/examples/playground/app/a0/app-view.tsx +2 -1
  79. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  80. package/examples/playground/app/a0/attachments.ts +133 -0
  81. package/examples/playground/app/a0/chat.test.tsx +595 -0
  82. package/examples/playground/app/a0/chat.tsx +623 -63
  83. package/examples/playground/app/a0/context-menu.tsx +38 -11
  84. package/examples/playground/app/a0/model.test.ts +28 -0
  85. package/examples/playground/app/a0/model.ts +30 -0
  86. package/examples/playground/app/a0/server.ts +5 -0
  87. package/examples/playground/app/a0/shell.tsx +1 -1
  88. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  89. package/examples/playground/app/a0/uploads/route.ts +29 -0
  90. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  91. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
  92. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  93. package/examples/playground/app/agent/ingress.test.ts +41 -17
  94. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  95. package/examples/playground/app/agent/model.test.ts +12 -3
  96. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  97. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  98. package/examples/playground/app/chat/server.test.ts +38 -65
  99. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  100. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  101. package/examples/playground/app/initial-state.test.tsx +86 -45
  102. package/examples/playground/next.config.ts +7 -4
  103. package/examples/playground/package.json +4 -1
  104. package/examples/playground/test/agent.ts +35 -0
  105. package/examples/playground/test/model.ts +149 -0
  106. package/examples/playground/test/serve.ts +184 -0
  107. package/examples/playground/test/server-only.ts +1 -0
  108. package/examples/playground/vitest.config.ts +27 -0
  109. package/package.json +3 -1
  110. package/sql/postgres/notifications.sql +24 -0
  111. package/sql/postgres/schema.sql +137 -0
  112. package/src/ai-agent.ts +9 -4
  113. package/src/ai-client-state.ts +21 -9
  114. package/src/ai-context-schema.ts +28 -4
  115. package/src/ai-context.ts +83 -3
  116. package/src/ai-control-server.ts +70 -43
  117. package/src/ai-control-state.ts +3 -4
  118. package/src/ai-control.ts +59 -10
  119. package/src/ai-coordinator.ts +7 -6
  120. package/src/ai-fx.ts +95 -23
  121. package/src/ai-harness.ts +171 -29
  122. package/src/ai-message-shape.ts +41 -0
  123. package/src/ai-pi.ts +88 -32
  124. package/src/ai-stored-state.ts +1 -3
  125. package/src/ai.ts +116 -77
  126. package/src/errors.ts +1 -1
  127. package/src/internal.ts +2 -1
  128. package/src/libfx.d.ts +8 -1
  129. package/src/postgres-notifications.ts +0 -22
  130. package/src/postgres-resources.ts +6 -9
  131. package/src/postgres-schema.ts +80 -0
  132. package/src/scheduler-qstash.ts +12 -5
  133. package/src/scheduler-vercel.ts +11 -16
  134. package/src/server.ts +66 -32
  135. package/src/store-postgres.ts +5 -123
  136. package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
  137. package/dist/ai-D6mGlO8j.js.map +0 -1
  138. package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
  139. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  140. package/dist/server-D-Y4pqwu.js.map +0 -1
  141. package/docs/reference/05-adapters.mdx +0 -351
  142. package/docs/server/05-production.mdx +0 -323
  143. package/examples/playground/app/a0/no-photon.cjs +0 -4
@@ -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.18.1",
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,
@@ -1282,6 +1283,10 @@ export function createHandlers<
1282
1283
  throw new TypeError(
1283
1284
  `a harness runs tools without approval; '${name}' sets needsApproval`,
1284
1285
  )
1286
+ if (tool.type === 'provider')
1287
+ throw new TypeError(
1288
+ `a harness runs only tools A2 executes; '${name}' is provider-defined`,
1289
+ )
1285
1290
  }
1286
1291
  }
1287
1292
 
@@ -1,6 +1,11 @@
1
1
  import type { UIMessage } from 'ai'
2
2
  import type { AIState } from './ai.ts'
3
- import type { ControlCommand, InboxItem } from './ai-control.ts'
3
+ import {
4
+ steeringEnd,
5
+ steersOn,
6
+ type ControlCommand,
7
+ type InboxItem,
8
+ } from './ai-control.ts'
4
9
  import {
5
10
  interruptGenerationProjection,
6
11
  settleAbandonedCompaction,
@@ -92,10 +97,10 @@ export function projectAIClientState<M extends UIMessage>(
92
97
  const admit = (): void => {
93
98
  if (turnId !== null || paused || items.length === 0) return
94
99
  let count = 0
95
- for (const item of items) {
100
+ for (const [index, item] of items.entries()) {
96
101
  count += 1
97
102
  show(item.message)
98
- if (item.generate) {
103
+ if (item.generate && !steersOn(item, items.slice(index + 1))) {
99
104
  turnId = item.id
100
105
  starting = true
101
106
  suspended = false
@@ -118,18 +123,21 @@ export function projectAIClientState<M extends UIMessage>(
118
123
  ? { afterStepOf: command.turnId }
119
124
  : {}),
120
125
  id: command.message.id,
126
+ role: command.message.role,
121
127
  message: command.message,
122
128
  generate:
123
129
  command.message.role === 'user' &&
124
130
  (command.action === 'steer' || command.generate !== false),
125
131
  }
126
- items = command.action === 'steer' ? [item, ...items] : [...items, item]
132
+ if (command.action === 'steer') {
133
+ const at = steeringEnd(items, command.turnId)
134
+ items = [...items.slice(0, at), item, ...items.slice(at)]
135
+ } else items = [...items, item]
127
136
  break
128
137
  }
129
138
  case 'edit':
130
139
  items = items.map((item) =>
131
- item.id === command.message.id &&
132
- item.message.role === command.message.role
140
+ item.id === command.message.id && item.role === command.message.role
133
141
  ? { ...item, message: command.message }
134
142
  : item,
135
143
  )
@@ -147,20 +155,24 @@ export function projectAIClientState<M extends UIMessage>(
147
155
  case 'send-now': {
148
156
  if (turnId !== command.turnId) break
149
157
  const selected = items.find((item) => item.id === command.inputId)
150
- if (!selected || selected.message.role !== 'user') break
158
+ if (!selected || selected.role !== 'user') break
159
+ const { afterStepOf: _, ...promoted } = selected
151
160
  items = items.map((item) =>
152
161
  item.id === selected.id
153
162
  ? {
154
- ...item,
163
+ ...promoted,
155
164
  generate: true,
156
165
  ...(turnId === null ? {} : { afterStepOf: turnId }),
157
166
  }
158
167
  : item,
159
168
  )
169
+ const others = items.filter((item) => item.id !== selected.id)
160
170
  items = moveItem({
161
171
  items,
162
172
  inputId: command.inputId,
163
- beforeId: items[0]?.id ?? null,
173
+ beforeId:
174
+ others[turnId === null ? 0 : steeringEnd(others, turnId)]?.id ??
175
+ null,
164
176
  })
165
177
  break
166
178
  }
@@ -1,6 +1,7 @@
1
1
  import { assertMessageBase, restoreMessageBase } from './ai-message-base.ts'
2
2
  import type { UIMessage } from 'ai'
3
3
  import { events } from './ai.ts'
4
+ import { isUIMessage } from './ai-message-shape.ts'
4
5
  import type { AIContextState } from './ai-context.ts'
5
6
  import type { StandardSchemaV1 } from './standard-schema.ts'
6
7
  import { validateSync } from './validate.ts'
@@ -14,13 +15,34 @@ const id = (value: unknown): value is string =>
14
15
  const nullableId = (value: unknown): boolean => value === null || id(value)
15
16
  const matches = (schema: StandardSchemaV1<unknown>, value: unknown): boolean =>
16
17
  validateSync(schema, value, 'AI context checkpoint').issues === undefined
17
- const isMessage = (value: unknown): value is UIMessage =>
18
- matches(events['ai.message.created'], { message: value })
19
18
  const indexes = (value: unknown, messages: readonly UIMessage[]): boolean =>
20
19
  record(value) &&
21
20
  Object.values(value).every(index) &&
22
21
  messages.every((message) => Object.hasOwn(value, message.id))
23
22
 
23
+ const inputs = (value: unknown): boolean =>
24
+ record(value) &&
25
+ Object.keys(value).length === 2 &&
26
+ Array.isArray(value['commands']) &&
27
+ value['commands'].every(
28
+ (held) =>
29
+ record(held) &&
30
+ Object.keys(held).every((key) =>
31
+ ['commandId', 'inputId', 'message'].includes(key),
32
+ ) &&
33
+ id(held['commandId']) &&
34
+ id(held['inputId']) &&
35
+ (held['message'] === undefined || isUIMessage(held['message'])),
36
+ ) &&
37
+ Array.isArray(value['waiting']) &&
38
+ value['waiting'].every(
39
+ (input) =>
40
+ record(input) &&
41
+ Object.keys(input).length === 2 &&
42
+ id(input['inputId']) &&
43
+ isUIMessage(input['message']),
44
+ )
45
+
24
46
  const compaction = (value: unknown): boolean =>
25
47
  value === null ||
26
48
  (record(value) &&
@@ -109,7 +131,7 @@ const restorableBase = (options: {
109
131
  current: UIMessage | undefined
110
132
  }): boolean => {
111
133
  assertMessageBase(options.base)
112
- return isMessage(
134
+ return isUIMessage(
113
135
  restoreMessageBase({ current: options.current, base: options.base }),
114
136
  )
115
137
  }
@@ -230,6 +252,7 @@ export const contextSchema = <M extends UIMessage>(
230
252
  'compaction',
231
253
  'modelMetadata',
232
254
  'closed',
255
+ 'inputs',
233
256
  'authority',
234
257
  'generation',
235
258
  'activeProjection',
@@ -249,9 +272,10 @@ export const contextSchema = <M extends UIMessage>(
249
272
  const current = value['activeProjection']
250
273
  if (
251
274
  !Array.isArray(messages) ||
252
- !messages.every(isMessage) ||
275
+ !messages.every(isUIMessage) ||
253
276
  !compaction(value['compaction']) ||
254
277
  typeof value['closed'] !== 'boolean' ||
278
+ !inputs(value['inputs']) ||
255
279
  !authority(value['authority']) ||
256
280
  !record(metadata) ||
257
281
  !Object.entries(metadata).every(