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.
- package/CHANGELOG.md +16 -0
- package/dist/actor.js +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +34 -4
- package/dist/ai-fx.js.map +1 -1
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -1
- package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-Clbg5ei7.js} +11 -2
- package/dist/ai-harness-Clbg5ei7.js.map +1 -0
- package/dist/ai-pi.js +2 -2
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +6 -4
- package/dist/ai-server.js.map +1 -1
- package/dist/internal-CscSY7K_.js.map +1 -1
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +5 -5
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +4 -3
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/server-DYAATFuT.d.ts.map +1 -1
- package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
- package/dist/server-qyb5aJJG.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/session-clear-DGI4jTQ5.js.map +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +38 -136
- package/dist/store-postgres.js.map +1 -1
- package/docs/01-quickstart.mdx +7 -4
- package/docs/actors/01-introduction.mdx +1 -1
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/02-tools.mdx +1 -1
- package/docs/agents/03-conversations.mdx +1 -1
- package/docs/agents/meta.ts +1 -1
- package/docs/client/meta.ts +1 -1
- package/docs/contract/03-sessions.mdx +1 -1
- package/docs/index.mdx +2 -2
- package/docs/reference/01-core.mdx +8 -8
- package/docs/reference/02-server.mdx +3 -3
- package/docs/reference/04-ai.mdx +9 -1
- package/docs/reference/05-telemetry.mdx +132 -0
- package/docs/reference/06-errors.mdx +1 -1
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +112 -0
- package/docs/schedulers/02-vercel-queues.mdx +87 -0
- package/docs/schedulers/03-qstash.mdx +123 -0
- package/docs/schedulers/meta.ts +1 -0
- package/docs/server/01-handlers.mdx +29 -2
- package/docs/server/02-timers.mdx +2 -2
- package/docs/server/06-guarantees.mdx +6 -3
- package/docs/stores/01-overview.mdx +37 -0
- package/docs/stores/02-redis.mdx +49 -0
- package/docs/stores/03-redis-http.mdx +56 -0
- package/docs/stores/04-postgres.mdx +121 -0
- package/docs/stores/05-sqlite.mdx +34 -0
- package/docs/stores/06-memory.mdx +22 -0
- package/docs/stores/meta.ts +1 -0
- package/examples/playground/DEPLOY.md +12 -7
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/chat.test.tsx +421 -330
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
- package/examples/playground/app/agent/ingress.test.ts +41 -17
- package/examples/playground/app/agent/model-input.test.tsx +92 -43
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
- package/examples/playground/app/chat/server.test.ts +38 -65
- package/examples/playground/app/components/activity-history.test.tsx +60 -18
- package/examples/playground/app/components/fast-link.test.tsx +3 -5
- package/examples/playground/app/initial-state.test.tsx +86 -45
- package/examples/playground/package.json +2 -1
- package/examples/playground/test/agent.ts +35 -0
- package/examples/playground/test/model.ts +149 -0
- package/examples/playground/test/serve.ts +184 -0
- package/examples/playground/test/server-only.ts +1 -0
- package/examples/playground/vitest.config.ts +27 -0
- package/package.json +3 -1
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +137 -0
- package/src/ai-agent.ts +5 -4
- package/src/ai-fx.ts +37 -3
- package/src/ai-harness.ts +18 -0
- package/src/errors.ts +1 -1
- package/src/internal.ts +2 -1
- package/src/postgres-notifications.ts +0 -22
- package/src/postgres-resources.ts +6 -9
- package/src/postgres-schema.ts +80 -0
- package/src/scheduler-qstash.ts +12 -5
- package/src/scheduler-vercel.ts +10 -4
- package/src/server.ts +66 -32
- package/src/store-postgres.ts +5 -123
- package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
- package/dist/server-D-Y4pqwu.js.map +0 -1
- package/docs/reference/05-adapters.mdx +0 -351
- 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.
|
|
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':
|
|
631
|
-
|
|
632
|
-
.
|
|
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
|
-
|
|
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' //
|
|
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
|
|