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.
- package/CHANGELOG.md +44 -0
- package/dist/actor.js +1 -1
- package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
- package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +1 -1
- package/dist/ai-control-CvLbW10B.js +46 -0
- package/dist/ai-control-CvLbW10B.js.map +1 -0
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +63 -13
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
- package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
- package/dist/ai-harness-Clbg5ei7.js.map +1 -0
- package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
- package/dist/ai-mMiWul1T.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +56 -11
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +1 -1
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +123 -53
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +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 +5 -7
- 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 +26 -14
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/agents/meta.ts +1 -1
- package/docs/client/meta.ts +1 -1
- package/docs/contract/03-sessions.mdx +2 -2
- 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 +36 -19
- 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 +3 -3
- 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 +19 -5
- package/examples/playground/app/a0/a0.css +244 -5
- package/examples/playground/app/a0/app-view.tsx +2 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/attachments.ts +133 -0
- package/examples/playground/app/a0/chat.test.tsx +595 -0
- package/examples/playground/app/a0/chat.tsx +623 -63
- package/examples/playground/app/a0/context-menu.tsx +38 -11
- package/examples/playground/app/a0/model.test.ts +28 -0
- package/examples/playground/app/a0/model.ts +30 -0
- package/examples/playground/app/a0/server.ts +5 -0
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- package/examples/playground/app/a0/uploads/route.ts +29 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
- 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/agent/model.test.ts +12 -3
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
- 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/next.config.ts +7 -4
- package/examples/playground/package.json +4 -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 +9 -4
- package/src/ai-client-state.ts +21 -9
- package/src/ai-context-schema.ts +28 -4
- package/src/ai-context.ts +83 -3
- package/src/ai-control-server.ts +70 -43
- package/src/ai-control-state.ts +3 -4
- package/src/ai-control.ts +59 -10
- package/src/ai-coordinator.ts +7 -6
- package/src/ai-fx.ts +95 -23
- package/src/ai-harness.ts +171 -29
- package/src/ai-message-shape.ts +41 -0
- package/src/ai-pi.ts +88 -32
- package/src/ai-stored-state.ts +1 -3
- package/src/ai.ts +116 -77
- package/src/errors.ts +1 -1
- package/src/internal.ts +2 -1
- package/src/libfx.d.ts +8 -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 +11 -16
- package/src/server.ts +66 -32
- package/src/store-postgres.ts +5 -123
- package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
- package/dist/ai-D6mGlO8j.js.map +0 -1
- package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
- package/dist/ai-harness-BuLh_EYi.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
- package/examples/playground/app/a0/no-photon.cjs +0 -4
package/src/ai-fx.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type ToolSet,
|
|
13
13
|
type UIMessageChunk,
|
|
14
14
|
} from 'ai'
|
|
15
|
-
import type { FxAgent, FxHostTool, FxTurn } from 'libfx'
|
|
15
|
+
import type { FxAgent, FxHostTool, FxPromptBlock, FxTurn } from 'libfx'
|
|
16
16
|
import {
|
|
17
17
|
type Deferred,
|
|
18
18
|
defineHarness,
|
|
@@ -20,8 +20,11 @@ import {
|
|
|
20
20
|
Divergence,
|
|
21
21
|
gatewayCredential,
|
|
22
22
|
type GatewayCredential,
|
|
23
|
+
gatewaySessionAffinity,
|
|
23
24
|
type Harness,
|
|
24
25
|
type HarnessGeneration,
|
|
26
|
+
harnessImages,
|
|
27
|
+
type HarnessInput,
|
|
25
28
|
type HarnessRead,
|
|
26
29
|
type HarnessStep,
|
|
27
30
|
type HarnessTurn,
|
|
@@ -48,6 +51,8 @@ export function fx(): Harness<'fx', never> {
|
|
|
48
51
|
|
|
49
52
|
const SNAPSHOT_VERSION = 1
|
|
50
53
|
const CATALOG_TTL_MS = 60 * 60 * 1000
|
|
54
|
+
/** The Gateway model catalog libfx reads for each new kernel. */
|
|
55
|
+
const CATALOG_PATH = '/coding-agent/v1/models'
|
|
51
56
|
|
|
52
57
|
type StreamPart = { type: string } & Record<string, unknown>
|
|
53
58
|
/** A recorded tool call, with the input the kernel reads from its text. */
|
|
@@ -62,7 +67,7 @@ type RecordedCall = {
|
|
|
62
67
|
|
|
63
68
|
/** One FX turn to replay: its prompt, recorded calls, and how it ended. */
|
|
64
69
|
type TurnPlan = {
|
|
65
|
-
prompt: string
|
|
70
|
+
prompt: string | FxPromptBlock[]
|
|
66
71
|
calls: RecordedCall[]
|
|
67
72
|
/** Steering to queue while serving the call at that index. */
|
|
68
73
|
steers: Map<number, string[]>
|
|
@@ -188,7 +193,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
188
193
|
try {
|
|
189
194
|
checkpoint = await agent!.checkpoint()
|
|
190
195
|
} catch {
|
|
191
|
-
//
|
|
196
|
+
// Later steps replay from the previous snapshot.
|
|
192
197
|
return
|
|
193
198
|
}
|
|
194
199
|
await saveHarness({
|
|
@@ -206,8 +211,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
206
211
|
init: RequestInit = {},
|
|
207
212
|
): Promise<Response> => {
|
|
208
213
|
const url = input instanceof Request ? input.url : String(input)
|
|
209
|
-
|
|
214
|
+
const method = (init.method ?? 'GET').toUpperCase()
|
|
215
|
+
if (method === 'GET' && new URL(url).pathname === CATALOG_PATH)
|
|
210
216
|
return catalog(url, credential, withAuth(init.headers, credential))
|
|
217
|
+
if (method === 'GET') return fetch(input, init)
|
|
211
218
|
const state = turn!
|
|
212
219
|
if (state.failure) throw state.failure
|
|
213
220
|
const body = decodeBody(init.body)
|
|
@@ -238,19 +245,27 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
238
245
|
return pending(init.signal)
|
|
239
246
|
}
|
|
240
247
|
state.live = true
|
|
241
|
-
return live(state, url, init, compaction)
|
|
248
|
+
return live(state, url, { ...init, body }, compaction)
|
|
242
249
|
}
|
|
243
250
|
|
|
251
|
+
const affinity = gatewaySessionAffinity({
|
|
252
|
+
agentName: step.agentName,
|
|
253
|
+
sessionId: step.sessionId,
|
|
254
|
+
})
|
|
244
255
|
let compactions = 0
|
|
245
256
|
const live = async (
|
|
246
257
|
state: TurnState,
|
|
247
258
|
url: string,
|
|
248
|
-
init: RequestInit,
|
|
259
|
+
init: RequestInit & { body: string },
|
|
249
260
|
compaction: boolean,
|
|
250
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)
|
|
251
265
|
const response = await fetch(url, {
|
|
252
266
|
...init,
|
|
253
|
-
|
|
267
|
+
body: withGatewayCaching(init.body),
|
|
268
|
+
headers,
|
|
254
269
|
signal: network,
|
|
255
270
|
})
|
|
256
271
|
// Abandoned before its headers came: the kernel has moved on without it.
|
|
@@ -328,7 +343,8 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
328
343
|
return fail(state, diverged(`no recorded result for ${toolName}`))
|
|
329
344
|
})
|
|
330
345
|
|
|
331
|
-
|
|
346
|
+
const plans = (turns: HarnessTurn[]): Promise<TurnPlan[]> =>
|
|
347
|
+
Promise.all(turns.map((recorded) => turnPlan(recorded, step.signal)))
|
|
332
348
|
let results = read.results
|
|
333
349
|
let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
|
|
334
350
|
let closed = false
|
|
@@ -364,11 +380,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
364
380
|
if (read.saved === undefined) throw error
|
|
365
381
|
// An unusable checkpoint: replay the whole log instead.
|
|
366
382
|
read = await replayAll()
|
|
367
|
-
turns = read.turns.map(turnPlan)
|
|
368
383
|
results = read.results
|
|
369
384
|
agent = await create(undefined)
|
|
370
385
|
}
|
|
371
|
-
await run(streamController, turns)
|
|
386
|
+
await run(streamController, await plans(read.turns))
|
|
372
387
|
closed = true
|
|
373
388
|
streamController.close()
|
|
374
389
|
} catch (error) {
|
|
@@ -396,11 +411,14 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
396
411
|
|
|
397
412
|
// ------------------------------------------------------------ the plan
|
|
398
413
|
|
|
399
|
-
function turnPlan(
|
|
414
|
+
async function turnPlan(
|
|
415
|
+
turn: HarnessTurn,
|
|
416
|
+
signal: AbortSignal,
|
|
417
|
+
): Promise<TurnPlan> {
|
|
400
418
|
const calls: RecordedCall[] = []
|
|
401
419
|
const steers = new Map<number, string[]>()
|
|
402
|
-
const steer = (at: number,
|
|
403
|
-
if (
|
|
420
|
+
const steer = (at: number, inputs: HarnessInput[] | undefined) => {
|
|
421
|
+
if (inputs !== undefined) steers.set(Math.max(0, at), inputs.map(steerText))
|
|
404
422
|
}
|
|
405
423
|
steer(0, turn.generations.length === 0 ? turn.steers.get(0) : undefined)
|
|
406
424
|
turn.generations.forEach((generation, index) => {
|
|
@@ -408,7 +426,38 @@ function turnPlan(turn: HarnessTurn): TurnPlan {
|
|
|
408
426
|
// A generation's step is its last call.
|
|
409
427
|
steer(calls.length - 1, turn.steers.get(index))
|
|
410
428
|
})
|
|
411
|
-
|
|
429
|
+
const images = await harnessImages({
|
|
430
|
+
harness: 'fx()',
|
|
431
|
+
input: turn.prompt,
|
|
432
|
+
signal,
|
|
433
|
+
})
|
|
434
|
+
return {
|
|
435
|
+
prompt:
|
|
436
|
+
images.length === 0
|
|
437
|
+
? turn.prompt.text
|
|
438
|
+
: [
|
|
439
|
+
...images.map((image): FxPromptBlock => ({
|
|
440
|
+
type: 'image',
|
|
441
|
+
data: image.data,
|
|
442
|
+
mimeType: image.mediaType,
|
|
443
|
+
})),
|
|
444
|
+
...(turn.prompt.text === ''
|
|
445
|
+
? []
|
|
446
|
+
: [{ type: 'text' as const, text: turn.prompt.text }]),
|
|
447
|
+
],
|
|
448
|
+
calls,
|
|
449
|
+
steers,
|
|
450
|
+
end: turn.end,
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** libfx steers with text alone, so a steer's files fail the step. */
|
|
455
|
+
function steerText(input: HarnessInput): string {
|
|
456
|
+
if (input.files.length > 0)
|
|
457
|
+
throw new Error(
|
|
458
|
+
'fx() steers with text only: send files in a message of their own',
|
|
459
|
+
)
|
|
460
|
+
return input.text
|
|
412
461
|
}
|
|
413
462
|
|
|
414
463
|
/** A completed generation's model calls: its compactions, then its step. */
|
|
@@ -734,6 +783,31 @@ async function loadLibfx(): Promise<typeof import('libfx')> {
|
|
|
734
783
|
}
|
|
735
784
|
}
|
|
736
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
|
+
|
|
737
811
|
function withAuth(
|
|
738
812
|
headers: HeadersInit | undefined,
|
|
739
813
|
credential: GatewayCredential,
|
|
@@ -764,15 +838,13 @@ async function kernelTools(
|
|
|
764
838
|
execute: (toolName: string, input: unknown) => unknown,
|
|
765
839
|
): Promise<FxHostTool[]> {
|
|
766
840
|
return Promise.all(
|
|
767
|
-
Object.entries(tools)
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
execute: (input: unknown) => execute(toolName, input),
|
|
775
|
-
})),
|
|
841
|
+
Object.entries(tools).map(async ([toolName, tool]) => ({
|
|
842
|
+
name: toolName,
|
|
843
|
+
description:
|
|
844
|
+
typeof tool.description === 'string' ? tool.description : toolName,
|
|
845
|
+
inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
|
|
846
|
+
execute: (input: unknown) => execute(toolName, input),
|
|
847
|
+
})),
|
|
776
848
|
)
|
|
777
849
|
}
|
|
778
850
|
|
package/src/ai-harness.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import {
|
|
2
|
+
createDownload,
|
|
3
|
+
type Experimental_SandboxSession,
|
|
4
|
+
type FileUIPart,
|
|
5
|
+
type FinishReason,
|
|
6
|
+
type LanguageModelUsage,
|
|
7
|
+
type ToolSet,
|
|
8
|
+
type UIMessage,
|
|
9
|
+
type UIMessageChunk,
|
|
8
10
|
} from 'ai'
|
|
9
11
|
import type {
|
|
10
12
|
GenerationCompletedPayload,
|
|
13
|
+
MessageCreatedPayload,
|
|
11
14
|
ReasoningLevel,
|
|
12
15
|
ToolResultPayload,
|
|
13
16
|
} from './ai.ts'
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { STEERED_REASON } from './ai-control.ts'
|
|
14
19
|
import { boundedCache, type BoundedCache } from './bounded-cache.ts'
|
|
15
20
|
import type { ContractEvent, EventDefs } from './contract.ts'
|
|
16
21
|
|
|
@@ -307,21 +312,21 @@ function savedEnvelope(value: unknown): SavedEnvelope | undefined {
|
|
|
307
312
|
|
|
308
313
|
// ------------------------------------------------- the log as harness turns
|
|
309
314
|
|
|
310
|
-
// A2's reason on `ai.message.interrupted` when a steer ends a response.
|
|
311
|
-
const STEERED = 'steered after step'
|
|
312
|
-
|
|
313
315
|
/** A completed generation as the log recorded it. */
|
|
314
316
|
export type RecordedGeneration = {
|
|
315
317
|
chunks: UIMessageChunk[]
|
|
316
318
|
completed: GenerationCompletedPayload
|
|
317
319
|
}
|
|
318
320
|
|
|
321
|
+
/** What user messages hand a harness: their text, and the files they attach. */
|
|
322
|
+
export type HarnessInput = { text: string; files: FileUIPart[] }
|
|
323
|
+
|
|
319
324
|
/** One harness turn to rebuild: its prompt, completed generations, and end. */
|
|
320
325
|
export type HarnessTurn = {
|
|
321
|
-
prompt:
|
|
326
|
+
prompt: HarnessInput
|
|
322
327
|
generations: RecordedGeneration[]
|
|
323
328
|
/** Steering that entered after the generation at that index. */
|
|
324
|
-
steers: Map<number,
|
|
329
|
+
steers: Map<number, HarnessInput[]>
|
|
325
330
|
end: 'stop' | 'interrupted' | 'live'
|
|
326
331
|
}
|
|
327
332
|
|
|
@@ -342,15 +347,18 @@ export function harnessTurns(
|
|
|
342
347
|
}
|
|
343
348
|
type Response = {
|
|
344
349
|
id: string
|
|
345
|
-
prompt:
|
|
350
|
+
prompt: HarnessInput[]
|
|
346
351
|
generations: Generation[]
|
|
347
352
|
end?: 'stop' | 'steered' | 'interrupted'
|
|
348
353
|
}
|
|
349
|
-
//
|
|
350
|
-
|
|
354
|
+
// Prompts take their content from the transcript: a harness agent never
|
|
355
|
+
// runs A2's compaction, so the step's messages hold every input admitted by
|
|
356
|
+
// its read, before the snapshot or after it. A session another loop
|
|
357
|
+
// compacted can't move to a harness.
|
|
358
|
+
const userInput = new Map<string, HarnessInput>(
|
|
351
359
|
step.messages
|
|
352
360
|
.filter((message) => message.role === 'user')
|
|
353
|
-
.map((message) => [message.id,
|
|
361
|
+
.map((message) => [message.id, inputOf(message)]),
|
|
354
362
|
)
|
|
355
363
|
let passive: string[] = []
|
|
356
364
|
const responses = new Map<string, Response>()
|
|
@@ -362,10 +370,9 @@ export function harnessTurns(
|
|
|
362
370
|
const payload = event.payload as Record<string, unknown>
|
|
363
371
|
switch (event.type) {
|
|
364
372
|
case 'ai.message.created': {
|
|
365
|
-
const
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
if (payload['generate'] === false) passive.push(message.id)
|
|
373
|
+
const admitted = event.payload as MessageCreatedPayload
|
|
374
|
+
if (admitted.role === 'user' && admitted.generate === false)
|
|
375
|
+
passive.push(admitted.messageId)
|
|
369
376
|
break
|
|
370
377
|
}
|
|
371
378
|
case 'ai.generation.requested': {
|
|
@@ -376,7 +383,7 @@ export function harnessTurns(
|
|
|
376
383
|
passive = []
|
|
377
384
|
responses.set(id, {
|
|
378
385
|
id,
|
|
379
|
-
prompt: ids.flatMap((p) =>
|
|
386
|
+
prompt: ids.flatMap((p) => userInput.get(p) ?? []),
|
|
380
387
|
generations: [],
|
|
381
388
|
})
|
|
382
389
|
break
|
|
@@ -420,7 +427,7 @@ export function harnessTurns(
|
|
|
420
427
|
const response = responses.get(payload['messageId'] as string)
|
|
421
428
|
if (response)
|
|
422
429
|
response.end =
|
|
423
|
-
payload['reason'] ===
|
|
430
|
+
payload['reason'] === STEERED_REASON ? 'steered' : 'interrupted'
|
|
424
431
|
break
|
|
425
432
|
}
|
|
426
433
|
}
|
|
@@ -429,13 +436,16 @@ export function harnessTurns(
|
|
|
429
436
|
const turns: HarnessTurn[] = []
|
|
430
437
|
let steered = false
|
|
431
438
|
for (const response of responses.values()) {
|
|
432
|
-
const
|
|
439
|
+
const input: HarnessInput = {
|
|
440
|
+
text: response.prompt.map((prompt) => prompt.text).join('\n\n'),
|
|
441
|
+
files: response.prompt.flatMap((prompt) => prompt.files),
|
|
442
|
+
}
|
|
433
443
|
let turn = turns.at(-1)
|
|
434
444
|
if (steered && turn !== undefined) {
|
|
435
445
|
const at = Math.max(0, turn.generations.length - 1)
|
|
436
|
-
turn.steers.set(at, [...(turn.steers.get(at) ?? []),
|
|
446
|
+
turn.steers.set(at, [...(turn.steers.get(at) ?? []), input])
|
|
437
447
|
} else {
|
|
438
|
-
turn = { prompt:
|
|
448
|
+
turn = { prompt: input, generations: [], steers: new Map(), end: 'live' }
|
|
439
449
|
turns.push(turn)
|
|
440
450
|
}
|
|
441
451
|
for (const generation of response.generations)
|
|
@@ -455,10 +465,125 @@ export function harnessTurns(
|
|
|
455
465
|
return { turns, results }
|
|
456
466
|
}
|
|
457
467
|
|
|
458
|
-
function
|
|
459
|
-
return
|
|
460
|
-
|
|
461
|
-
|
|
468
|
+
function inputOf(message: UIMessage): HarnessInput {
|
|
469
|
+
return {
|
|
470
|
+
text: message.parts
|
|
471
|
+
.flatMap((part) => (part.type === 'text' ? [part.text] : []))
|
|
472
|
+
.join('\n'),
|
|
473
|
+
files: message.parts.filter(
|
|
474
|
+
(part): part is FileUIPart => part.type === 'file',
|
|
475
|
+
),
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ------------------------------------------------------------- attachments
|
|
480
|
+
|
|
481
|
+
/** An attached image as an engine takes it: its bytes in base64. */
|
|
482
|
+
export type HarnessImage = { mediaType: string; data: string }
|
|
483
|
+
|
|
484
|
+
// What one message's files hold in a step's memory, at most.
|
|
485
|
+
const MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024
|
|
486
|
+
|
|
487
|
+
type Attachment = { data: string; bytes: number }
|
|
488
|
+
|
|
489
|
+
// Every step of a response replays its turn, and a URL's file doesn't change,
|
|
490
|
+
// so the process keeps what it fetched.
|
|
491
|
+
const attachments = boundedCache<string, Attachment>({
|
|
492
|
+
maxEntries: 256,
|
|
493
|
+
maxBytes: 64 * 1024 * 1024,
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The images an input attaches, as its engine takes them: engines take
|
|
498
|
+
* bytes, not URLs. A file it can't attach fails the step, naming the file,
|
|
499
|
+
* and is never left out: the failure may pass, and the step is retried like
|
|
500
|
+
* any other.
|
|
501
|
+
*/
|
|
502
|
+
export async function harnessImages({
|
|
503
|
+
harness,
|
|
504
|
+
input,
|
|
505
|
+
signal,
|
|
506
|
+
}: {
|
|
507
|
+
/** As the error names it: `fx()` or `pi()`. */
|
|
508
|
+
harness: string
|
|
509
|
+
input: HarnessInput
|
|
510
|
+
signal: AbortSignal
|
|
511
|
+
}): Promise<HarnessImage[]> {
|
|
512
|
+
const images: HarnessImage[] = []
|
|
513
|
+
let budget = MAX_ATTACHMENT_BYTES
|
|
514
|
+
// One at a time, so the budget bounds what the step holds.
|
|
515
|
+
for (const file of input.files) {
|
|
516
|
+
try {
|
|
517
|
+
if (!file.mediaType.startsWith('image/'))
|
|
518
|
+
throw new Error(`it takes images only, not ${file.mediaType}`)
|
|
519
|
+
const attached = await attachment({ url: file.url, budget, signal })
|
|
520
|
+
budget -= attached.bytes
|
|
521
|
+
images.push({
|
|
522
|
+
mediaType: file.mediaType,
|
|
523
|
+
data: attached.data,
|
|
524
|
+
})
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (signal.aborted) throw error
|
|
527
|
+
throw new Error(
|
|
528
|
+
`${harness} couldn't attach ${file.filename ?? 'a file'}: ${
|
|
529
|
+
error instanceof Error ? error.message : String(error)
|
|
530
|
+
}`,
|
|
531
|
+
{ cause: error },
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return images
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function attachment({
|
|
539
|
+
url,
|
|
540
|
+
budget,
|
|
541
|
+
signal,
|
|
542
|
+
}: {
|
|
543
|
+
url: string
|
|
544
|
+
budget: number
|
|
545
|
+
signal: AbortSignal
|
|
546
|
+
}): Promise<Attachment> {
|
|
547
|
+
const tooLarge = () =>
|
|
548
|
+
new Error(
|
|
549
|
+
`a message's files may hold ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MiB in a step`,
|
|
550
|
+
)
|
|
551
|
+
const cached = attachments.get(url)
|
|
552
|
+
if (cached !== undefined) {
|
|
553
|
+
if (cached.bytes > budget) throw tooLarge()
|
|
554
|
+
return cached
|
|
555
|
+
}
|
|
556
|
+
const { protocol } = new URL(url)
|
|
557
|
+
if (protocol !== 'https:' && protocol !== 'data:')
|
|
558
|
+
throw new Error(`attachments must be https: or data: URLs, not ${protocol}`)
|
|
559
|
+
// AI SDK's download checks an https: URL's address and its redirects,
|
|
560
|
+
// which a message's sender chooses; a data: URL holds its bytes.
|
|
561
|
+
const bytes =
|
|
562
|
+
protocol === 'data:'
|
|
563
|
+
? dataBytes(url)
|
|
564
|
+
: (
|
|
565
|
+
await createDownload({ maxBytes: budget })({
|
|
566
|
+
url: new URL(url),
|
|
567
|
+
abortSignal: signal,
|
|
568
|
+
})
|
|
569
|
+
).data
|
|
570
|
+
if (bytes.byteLength > budget) throw tooLarge()
|
|
571
|
+
const attached: Attachment = {
|
|
572
|
+
data: Buffer.from(bytes).toString('base64'),
|
|
573
|
+
bytes: bytes.byteLength,
|
|
574
|
+
}
|
|
575
|
+
if (protocol === 'https:')
|
|
576
|
+
attachments.set(url, attached, attached.data.length)
|
|
577
|
+
return attached
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function dataBytes(url: string): Uint8Array {
|
|
581
|
+
const comma = url.indexOf(',')
|
|
582
|
+
if (comma === -1) throw new Error('its data: URL has no data')
|
|
583
|
+
const body = url.slice(comma + 1)
|
|
584
|
+
return url.slice(0, comma).endsWith(';base64')
|
|
585
|
+
? Buffer.from(body, 'base64')
|
|
586
|
+
: Buffer.from(decodeURIComponent(body))
|
|
462
587
|
}
|
|
463
588
|
|
|
464
589
|
// ------------------------------------------------------------- Gateway
|
|
@@ -474,6 +599,23 @@ export function gatewayCredential(harness: string): GatewayCredential {
|
|
|
474
599
|
throw new Error(`${harness}() needs AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN`)
|
|
475
600
|
}
|
|
476
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
|
+
|
|
477
619
|
// Where Vercel Functions keep the current request, as @vercel/oidc reads it.
|
|
478
620
|
const REQUEST_CONTEXT = Symbol.for('@vercel/request-context')
|
|
479
621
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { UIMessage } from 'ai'
|
|
2
|
+
|
|
3
|
+
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
4
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
5
|
+
|
|
6
|
+
export const isJSONCompatible = (
|
|
7
|
+
value: unknown,
|
|
8
|
+
seen: Set<object> = new Set(),
|
|
9
|
+
): boolean => {
|
|
10
|
+
if (
|
|
11
|
+
value === null ||
|
|
12
|
+
typeof value === 'string' ||
|
|
13
|
+
typeof value === 'boolean'
|
|
14
|
+
) {
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === 'number') return Number.isFinite(value)
|
|
18
|
+
if (typeof value === 'undefined') return true
|
|
19
|
+
if (typeof value !== 'object') return false
|
|
20
|
+
if (seen.has(value)) return false
|
|
21
|
+
seen.add(value)
|
|
22
|
+
const valid = Array.isArray(value)
|
|
23
|
+
? value.every((item) => isJSONCompatible(item, seen))
|
|
24
|
+
: Object.getPrototypeOf(value) === Object.prototype &&
|
|
25
|
+
Object.values(value).every((item) => isJSONCompatible(item, seen))
|
|
26
|
+
seen.delete(value)
|
|
27
|
+
return valid
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether a value has an AI SDK `UIMessage`'s shape. */
|
|
31
|
+
export const isUIMessage = (value: unknown): value is UIMessage =>
|
|
32
|
+
isRecord(value) &&
|
|
33
|
+
typeof value['id'] === 'string' &&
|
|
34
|
+
(value['role'] === 'user' ||
|
|
35
|
+
value['role'] === 'assistant' ||
|
|
36
|
+
value['role'] === 'system') &&
|
|
37
|
+
Array.isArray(value['parts']) &&
|
|
38
|
+
value['parts'].every(
|
|
39
|
+
(part) => isRecord(part) && typeof part['type'] === 'string',
|
|
40
|
+
) &&
|
|
41
|
+
isJSONCompatible(value)
|