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
package/src/ai-pi.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  AssistantMessage,
15
15
  AssistantMessageEvent,
16
16
  AssistantMessageEventStream,
17
+ ImageContent,
17
18
  JsonObject,
18
19
  TextContent,
19
20
  ThinkingContent,
@@ -50,6 +51,8 @@ import {
50
51
  gatewayCredential,
51
52
  type Harness,
52
53
  type HarnessGeneration,
54
+ harnessImages,
55
+ type HarnessInput,
53
56
  type HarnessRead,
54
57
  type HarnessSandbox,
55
58
  type HarnessStep,
@@ -130,11 +133,14 @@ type PiRecord =
130
133
  | { call: 'step'; response: StoredResponse }
131
134
  | { call: 'compaction'; response: AssistantMessage }
132
135
 
136
+ /** A user message as Pi takes it. */
137
+ type PiInput = { text: string; images: ImageContent[] }
138
+
133
139
  type RecordedCall = {
134
140
  kind: 'step' | 'compaction'
135
141
  response: AssistantMessage
136
142
  /** Steering to queue while serving this call. */
137
- steers: string[]
143
+ steers: PiInput[]
138
144
  }
139
145
 
140
146
  type Saved = {
@@ -232,7 +238,8 @@ async function runStep(
232
238
  fail(state, diverged(`call ${state.next} is not a ${kind}`))
233
239
  state.next++
234
240
  // Steering queued while a response streams enters after its tools.
235
- for (const text of recorded.steers) await session!.steer(text)
241
+ for (const { text, images } of recorded.steers)
242
+ await session!.steer(text, images.length === 0 ? undefined : images)
236
243
  replay(out, recorded.response)
237
244
  return
238
245
  }
@@ -252,6 +259,9 @@ async function runStep(
252
259
  const upstream = realStream(model, context, {
253
260
  ...options,
254
261
  headers: { ...options?.headers, ...gatewayHeaders(credential) },
262
+ ...(step.speed === 'fast'
263
+ ? { onPayload: fastTier(options?.onPayload) }
264
+ : {}),
255
265
  })
256
266
  const live = kind === 'step' ? liveStep() : undefined
257
267
  for await (const event of upstream) {
@@ -547,9 +557,10 @@ async function runStep(
547
557
  }
548
558
  for (const plan of read.turns) {
549
559
  if (step.signal.aborted) return
560
+ const prompt = await piInput({ input: plan.prompt, signal: step.signal })
550
561
  const state: TurnState = {
551
562
  plan,
552
- calls: recordedTurn(plan),
563
+ calls: await recordedTurn({ turn: plan, signal: step.signal }),
553
564
  next: 0,
554
565
  liveStep: false,
555
566
  over: false,
@@ -557,8 +568,9 @@ async function runStep(
557
568
  compactions: [],
558
569
  }
559
570
  turn = state
560
- const running = session.prompt(plan.prompt, {
571
+ const running = session.prompt(prompt.text, {
561
572
  expandPromptTemplates: false,
573
+ ...(prompt.images.length === 0 ? {} : { images: prompt.images }),
562
574
  })
563
575
  if (plan.end !== 'live') {
564
576
  await running.catch((error: unknown) => {
@@ -622,17 +634,43 @@ async function runStep(
622
634
  // ------------------------------------------------------------ the plan
623
635
 
624
636
  /** A turn's recorded model calls, in the order Pi made them. */
625
- function recordedTurn(turn: HarnessTurn): RecordedCall[] {
637
+ async function recordedTurn({
638
+ turn,
639
+ signal,
640
+ }: {
641
+ turn: HarnessTurn
642
+ signal: AbortSignal
643
+ }): Promise<RecordedCall[]> {
626
644
  const calls: RecordedCall[] = []
627
- turn.generations.forEach((generation, index) => {
645
+ for (const [index, generation] of turn.generations.entries()) {
628
646
  calls.push(...recordedCalls(generation))
629
647
  const steers = turn.steers.get(index)
630
648
  const last = calls.findLast((call) => call.kind === 'step')
631
- if (steers !== undefined && last !== undefined) last.steers.push(...steers)
632
- })
649
+ if (steers !== undefined && last !== undefined)
650
+ for (const input of steers)
651
+ last.steers.push(await piInput({ input, signal }))
652
+ }
633
653
  return calls
634
654
  }
635
655
 
656
+ async function piInput({
657
+ input,
658
+ signal,
659
+ }: {
660
+ input: HarnessInput
661
+ signal: AbortSignal
662
+ }): Promise<PiInput> {
663
+ const images = await harnessImages({ harness: 'pi()', input, signal })
664
+ return {
665
+ text: input.text,
666
+ images: images.map((image) => ({
667
+ type: 'image',
668
+ data: image.data,
669
+ mimeType: image.mediaType,
670
+ })),
671
+ }
672
+ }
673
+
636
674
  function recordedCalls(generation: RecordedGeneration): RecordedCall[] {
637
675
  const texts = new Map<string, string>()
638
676
  const inputs = new Map<string, unknown>()
@@ -937,30 +975,28 @@ async function sessionTools(
937
975
  ) => Promise<AgentToolResult<unknown>>,
938
976
  ): Promise<ToolDefinition[]> {
939
977
  return Promise.all(
940
- Object.entries(tools)
941
- .filter(([, tool]) => tool.type !== 'provider')
942
- .map(async ([name, tool]): Promise<ToolDefinition> => {
943
- const builtin = builtinOf(tools, name)
944
- if (builtin !== undefined)
945
- // Pi's own definition, for its prompt; A2 already ran the call.
946
- return Object.assign(builtinDefinition(modules, builtin, cwd), {
947
- execute: (
948
- toolCallId: string,
949
- _params: unknown,
950
- signal?: AbortSignal,
951
- ) => result(toolCallId, signal, 'pi'),
952
- })
953
- return {
954
- name,
955
- label: name,
956
- description:
957
- typeof tool.description === 'string' ? tool.description : '',
958
- parameters: (await asSchema(tool.inputSchema)
959
- .jsonSchema) as ToolDefinition['parameters'],
960
- execute: (toolCallId, _params, signal) =>
961
- result(toolCallId, signal, 'tool'),
962
- }
963
- }),
978
+ Object.entries(tools).map(async ([name, tool]): Promise<ToolDefinition> => {
979
+ const builtin = builtinOf(tools, name)
980
+ if (builtin !== undefined)
981
+ // Pi's own definition, for its prompt; A2 already ran the call.
982
+ return Object.assign(builtinDefinition(modules, builtin, cwd), {
983
+ execute: (
984
+ toolCallId: string,
985
+ _params: unknown,
986
+ signal?: AbortSignal,
987
+ ) => result(toolCallId, signal, 'pi'),
988
+ })
989
+ return {
990
+ name,
991
+ label: name,
992
+ description:
993
+ typeof tool.description === 'string' ? tool.description : '',
994
+ parameters: (await asSchema(tool.inputSchema)
995
+ .jsonSchema) as ToolDefinition['parameters'],
996
+ execute: (toolCallId, _params, signal) =>
997
+ result(toolCallId, signal, 'tool'),
998
+ }
999
+ }),
964
1000
  )
965
1001
  }
966
1002
 
@@ -1227,6 +1263,26 @@ function thinkingLevel(step: HarnessStep): {
1227
1263
  return { thinkingLevel: step.reasoning === 'none' ? 'off' : step.reasoning }
1228
1264
  }
1229
1265
 
1266
+ type PayloadHook = NonNullable<
1267
+ NonNullable<Parameters<ModelRuntime['streamSimple']>[2]>['onPayload']
1268
+ >
1269
+
1270
+ /** Asks AI Gateway for the model's fast tier, on the payload Pi's own hook returns. */
1271
+ function fastTier(inner: PayloadHook | undefined): PayloadHook {
1272
+ return async (payload, model) => {
1273
+ const body = ((await inner?.(payload, model)) ?? payload) as {
1274
+ providerOptions?: { gateway?: object }
1275
+ }
1276
+ return {
1277
+ ...body,
1278
+ providerOptions: {
1279
+ ...body.providerOptions,
1280
+ gateway: { ...body.providerOptions?.gateway, speed: 'fast' },
1281
+ },
1282
+ }
1283
+ }
1284
+ }
1285
+
1230
1286
  function gatewayHeaders(credential: {
1231
1287
  token: string
1232
1288
  method: string
@@ -211,9 +211,7 @@ const storeState = <S extends AIStoredView>(state: S): AIStoredState => {
211
211
  if (
212
212
  value === undefined ||
213
213
  (typeof value === 'string' && value.length > 80) ||
214
- (field !== undefined &&
215
- payloadFields.has(field) &&
216
- !(field === 'input' && isRecord(value) && 'message' in value)) ||
214
+ (field !== undefined && payloadFields.has(field)) ||
217
215
  (isRecord(value) && Object.hasOwn(value, '$a2Value'))
218
216
  )
219
217
  return intern(value)
package/src/ai.ts CHANGED
@@ -23,7 +23,8 @@ import {
23
23
  } from './ai-client-state.ts'
24
24
  export type { PendingQueueCommand } from './ai-client-state.ts'
25
25
  import { applyControlChanges } from './ai-control-state.ts'
26
- import { initialControlState } from './ai-control.ts'
26
+ import { STEERED_REASON, unexplainedAdmission } from './ai-control.ts'
27
+ import { isJSONCompatible, isRecord, isUIMessage } from './ai-message-shape.ts'
27
28
  import { AI_EVENT_ID_PREFIX } from './ai-id.ts'
28
29
  import type {
29
30
  ControlCommit,
@@ -31,6 +32,7 @@ import type {
31
32
  ControlCommand,
32
33
  ControlReceipt,
33
34
  InboxItem,
35
+ InputRef,
34
36
  ToolExecutionRequest,
35
37
  WorkReport,
36
38
  } from './ai-control.ts'
@@ -39,6 +41,7 @@ export type {
39
41
  ControlCommand,
40
42
  ControlReceipt,
41
43
  InboxItem,
44
+ InputRef,
42
45
  } from './ai-control.ts'
43
46
  import { contract as createContract } from './contract.ts'
44
47
  import type { Contract, ContractEvent, EventDefs } from './contract.ts'
@@ -139,10 +142,15 @@ export type TranscriptMessage<M extends UIMessage> = Omit<M, 'parts'> & {
139
142
 
140
143
  export type SessionCreatedPayload = { metadata?: JSONValue }
141
144
  export type SessionClosedPayload = { reason?: string }
142
- export type MessageCreatedPayload<M extends UIMessage> = {
143
- message: M
144
- /** For user messages, false records passive context. Omission or true schedules. */
145
- generate?: boolean
145
+ /**
146
+ * A message entering the transcript. Its content is the command's that brought
147
+ * it in, which every reader of the transcript has already folded.
148
+ */
149
+ export type MessageCreatedPayload = {
150
+ messageId: string
151
+ role: UIMessage['role']
152
+ /** For user messages, false records passive context. Omission schedules. */
153
+ generate?: false
146
154
  }
147
155
  export type MessageCompletedPayload = { messageId: string }
148
156
  type MessageInterruption = {
@@ -312,7 +320,7 @@ export type AIEventDefs<M extends UIMessage = UIMessage> = {
312
320
  }>
313
321
  'ai.session.created': StandardSchemaV1<SessionCreatedPayload>
314
322
  'ai.session.closed': StandardSchemaV1<SessionClosedPayload>
315
- 'ai.message.created': StandardSchemaV1<MessageCreatedPayload<M>>
323
+ 'ai.message.created': StandardSchemaV1<MessageCreatedPayload>
316
324
  'ai.message.completed': StandardSchemaV1<MessageCompletedPayload>
317
325
  'ai.message.interrupted': StandardSchemaV1<MessageInterruptedPayload>
318
326
  'ai.generation.requested': StandardSchemaV1<GenerationRequestedPayload>
@@ -402,7 +410,7 @@ export type GenerationUsage = {
402
410
  export type AIState<M extends UIMessage = UIMessage> = {
403
411
  inbox: { paused: boolean; items: InboxItem<M>[] }
404
412
  pendingQueueCommands?: PendingQueueCommand<M>[]
405
- active: ActiveTurn<M> | null
413
+ active: ActiveTurn | null
406
414
  starting: boolean
407
415
  receipt: ControlReceipt | null
408
416
  pausedGenerationIds?: Record<string, true>
@@ -433,13 +441,13 @@ export type AIState<M extends UIMessage = UIMessage> = {
433
441
  error: string | null
434
442
  }
435
443
 
436
- /** The `UIMessage` type a contract's `ai.message.created` schema carries. */
444
+ /** The `UIMessage` type a contract's input commands carry. */
437
445
  export type AIMessageOf<D> = D extends {
438
- readonly 'ai.message.created': infer S extends StandardSchemaV1
446
+ readonly 'ai.control.requested': infer S extends StandardSchemaV1
439
447
  }
440
- ? StandardSchemaV1.InferOutput<S> extends MessageCreatedPayload<
441
- infer M extends UIMessage
442
- >
448
+ ? Extract<StandardSchemaV1.InferOutput<S>, { action: 'send' }> extends {
449
+ message: infer M extends UIMessage
450
+ }
443
451
  ? M
444
452
  : UIMessage
445
453
  : UIMessage
@@ -529,33 +537,6 @@ const schema = <T>(
529
537
  },
530
538
  })
531
539
 
532
- const isRecord = (value: unknown): value is Record<string, unknown> =>
533
- typeof value === 'object' && value !== null && !Array.isArray(value)
534
-
535
- const isJSONCompatible = (
536
- value: unknown,
537
- seen: Set<object> = new Set(),
538
- ): boolean => {
539
- if (
540
- value === null ||
541
- typeof value === 'string' ||
542
- typeof value === 'boolean'
543
- ) {
544
- return true
545
- }
546
- if (typeof value === 'number') return Number.isFinite(value)
547
- if (typeof value === 'undefined') return true
548
- if (typeof value !== 'object') return false
549
- if (seen.has(value)) return false
550
- seen.add(value)
551
- const valid = Array.isArray(value)
552
- ? value.every((item) => isJSONCompatible(item, seen))
553
- : Object.getPrototypeOf(value) === Object.prototype &&
554
- Object.values(value).every((item) => isJSONCompatible(item, seen))
555
- seen.delete(value)
556
- return valid
557
- }
558
-
559
540
  const jsonObject = <T extends object>(
560
541
  label: string,
561
542
  check: (value: Record<string, unknown>) => boolean,
@@ -584,45 +565,39 @@ const optionalStringField = (
584
565
  key: string,
585
566
  ): boolean => value[key] === undefined || typeof value[key] === 'string'
586
567
 
587
- const messageValue = (value: unknown): value is UIMessage =>
588
- isRecord(value) &&
589
- typeof value['id'] === 'string' &&
590
- (value['role'] === 'user' ||
591
- value['role'] === 'assistant' ||
592
- value['role'] === 'system') &&
593
- Array.isArray(value['parts']) &&
594
- value['parts'].every(
595
- (part) => isRecord(part) && typeof part['type'] === 'string',
596
- ) &&
597
- isJSONCompatible(value)
598
-
599
568
  const validateMessage = <M extends UIMessage>(
600
569
  value: unknown,
601
570
  messageSchema?: StandardSchemaV1<M>,
602
571
  ): SchemaResult<M> => {
603
- if (!messageValue(value)) return issue('invalid AI SDK UIMessage')
572
+ if (!isUIMessage(value)) return issue('invalid AI SDK UIMessage')
604
573
  if (!messageSchema) return { value: value as M }
605
574
  return validateSync(messageSchema, value, 'the AI message')
606
575
  }
607
576
 
608
- const messagePayloadSchema = <M extends UIMessage>(
609
- messageSchema?: StandardSchemaV1<M>,
610
- ): StandardSchemaV1<MessageCreatedPayload<M>> =>
611
- schema('ai.message.created', (value) => {
612
- if (!isRecord(value)) return issue('invalid ai.message.created payload')
613
- const message = validateMessage(value['message'], messageSchema)
614
- if (message.issues) return { issues: message.issues }
615
- const generate = value['generate']
616
- if (generate !== undefined && typeof generate !== 'boolean') {
617
- return issue('invalid ai.message.created generate flag')
618
- }
577
+ const messageCreatedSchema: StandardSchemaV1<MessageCreatedPayload> = schema(
578
+ 'ai.message.created',
579
+ (value) => {
580
+ if (
581
+ !isRecord(value) ||
582
+ Object.keys(value).some(
583
+ (key) => key !== 'messageId' && key !== 'role' && key !== 'generate',
584
+ ) ||
585
+ !stringField(value, 'messageId') ||
586
+ (value['role'] !== 'user' &&
587
+ value['role'] !== 'assistant' &&
588
+ value['role'] !== 'system') ||
589
+ (value['generate'] !== undefined && value['generate'] !== false)
590
+ )
591
+ return issue('invalid ai.message.created payload')
619
592
  return {
620
593
  value: {
621
- message: message.value,
622
- ...(generate === false ? { generate: false } : {}),
594
+ messageId: value['messageId'] as string,
595
+ role: value['role'],
596
+ ...(value['generate'] === false ? { generate: false as const } : {}),
623
597
  },
624
598
  }
625
- })
599
+ },
600
+ )
626
601
 
627
602
  const progressPayloadSchema: StandardSchemaV1<GenerationProgressPayload> =
628
603
  schema('ai.generation.progress', (value) => {
@@ -731,6 +706,14 @@ export function createEvents<M extends UIMessage = UIMessage>(options?: {
731
706
  'AI command',
732
707
  )
733
708
  if (payload.issues) return { issues: payload.issues }
709
+ if (
710
+ action === 'interrupt' &&
711
+ isRecord(payload.value) &&
712
+ payload.value['reason'] === STEERED_REASON
713
+ )
714
+ return issue(
715
+ `the interruption reason "${STEERED_REASON}" is reserved for steering`,
716
+ )
734
717
  return { value: { action, [key]: payload.value } as ControlCommand<M> }
735
718
  }
736
719
  if (action === 'send' || action === 'edit' || action === 'steer') {
@@ -857,7 +840,7 @@ export function createEvents<M extends UIMessage = UIMessage>(options?: {
857
840
  'ai.session.closed': jsonObject('ai.session.closed', (value) =>
858
841
  optionalStringField(value, 'reason'),
859
842
  ),
860
- 'ai.message.created': messagePayloadSchema(messageSchema),
843
+ 'ai.message.created': messageCreatedSchema,
861
844
  'ai.message.completed': jsonObject('ai.message.completed', (value) =>
862
845
  stringField(value, 'messageId'),
863
846
  ),
@@ -1036,7 +1019,7 @@ export function createEvents<M extends UIMessage = UIMessage>(options?: {
1036
1019
  export const events: AIEventDefs<UIMessage> = createEvents()
1037
1020
 
1038
1021
  const initialState = <M extends UIMessage>(): AIState<M> => ({
1039
- inbox: initialControlState<M>().inbox,
1022
+ inbox: { paused: false, items: [] },
1040
1023
  pendingQueueCommands: [],
1041
1024
  active: null,
1042
1025
  starting: false,
@@ -1153,6 +1136,22 @@ export function reduceAIState<M extends UIMessage>(
1153
1136
  )
1154
1137
  }
1155
1138
 
1139
+ /**
1140
+ * An input's content, while it is queued or about to enter the transcript:
1141
+ * its inbox item's, or its pending send's or steer's, whose command id is the
1142
+ * input's id.
1143
+ */
1144
+ function inputContent<M extends UIMessage>(state: AIState<M>, id: string): M {
1145
+ const queued = state.inbox.items.find((item) => item.id === id)?.message
1146
+ if (queued !== undefined) return queued
1147
+ const command = state.pendingQueueCommands?.find(
1148
+ (item) => item.commandId === id,
1149
+ )?.command
1150
+ if (command?.action === 'send' || command?.action === 'steer')
1151
+ return command.message
1152
+ throw unexplainedAdmission(id)
1153
+ }
1154
+
1156
1155
  function reduceAIEvent<M extends UIMessage>(
1157
1156
  state: AIState<M>,
1158
1157
  event: { type: string; payload: unknown; index: number; id?: string },
@@ -1177,12 +1176,35 @@ function reduceAIEvent<M extends UIMessage>(
1177
1176
  }
1178
1177
  case 'ai.control.committed': {
1179
1178
  const commit = event.payload as ControlCommit
1180
- const visible = applyControlChanges({
1181
- state: { inbox: state.inbox, active: state.active },
1182
- changes: commit.changes.filter(
1183
- (change) => change.path[0] === 'inbox' || change.path[0] === 'active',
1184
- ),
1185
- })
1179
+ const changes = commit.changes.filter(
1180
+ (change) => change.path[0] === 'inbox' || change.path[0] === 'active',
1181
+ )
1182
+ let { inbox, active } = state
1183
+ if (changes.length > 0) {
1184
+ // A commit holds inputs by reference; their content is the commands'.
1185
+ const visible = applyControlChanges({
1186
+ state: {
1187
+ inbox: {
1188
+ ...inbox,
1189
+ items: inbox.items.map(
1190
+ ({ message: _, ...input }): InputRef => input,
1191
+ ),
1192
+ },
1193
+ active,
1194
+ },
1195
+ changes,
1196
+ })
1197
+ const queued = new Map(inbox.items.map((item) => [item.id, item]))
1198
+ inbox = {
1199
+ ...visible.inbox,
1200
+ items: visible.inbox.items.map((input) => ({
1201
+ ...input,
1202
+ message:
1203
+ queued.get(input.id)?.message ?? inputContent(state, input.id),
1204
+ })),
1205
+ }
1206
+ active = visible.active
1207
+ }
1186
1208
  const { phase } = commit.view
1187
1209
  const status: AgentStatus = commit.view.closed
1188
1210
  ? 'closed'
@@ -1199,7 +1221,8 @@ function reduceAIEvent<M extends UIMessage>(
1199
1221
  : 'idle'
1200
1222
  return {
1201
1223
  ...state,
1202
- ...visible,
1224
+ inbox,
1225
+ active,
1203
1226
  status,
1204
1227
  activeRequestId: commit.view.requestId,
1205
1228
  activeRequestReason: commit.view.requestReason,
@@ -1214,9 +1237,24 @@ function reduceAIEvent<M extends UIMessage>(
1214
1237
  const settled = pending.find(
1215
1238
  (item) => item.commandId === receipt.commandId,
1216
1239
  )
1240
+ // An edit's content takes effect at its receipt.
1241
+ const edited =
1242
+ receipt.outcome === 'applied' && settled?.command.action === 'edit'
1243
+ ? settled.command.message
1244
+ : undefined
1217
1245
  return {
1218
1246
  ...state,
1219
1247
  receipt,
1248
+ ...(edited === undefined
1249
+ ? {}
1250
+ : {
1251
+ inbox: {
1252
+ ...state.inbox,
1253
+ items: state.inbox.items.map((item) =>
1254
+ item.id === edited.id ? { ...item, message: edited } : item,
1255
+ ),
1256
+ },
1257
+ }),
1220
1258
  ...(settled === undefined
1221
1259
  ? {}
1222
1260
  : {
@@ -1329,7 +1367,8 @@ function reduceAIEvent<M extends UIMessage>(
1329
1367
  activeProjection: null,
1330
1368
  }
1331
1369
  case 'ai.message.created': {
1332
- const { message } = event.payload as MessageCreatedPayload<M>
1370
+ const { messageId } = event.payload as MessageCreatedPayload
1371
+ const message = inputContent(state, messageId)
1333
1372
  return {
1334
1373
  ...state,
1335
1374
  messages: upsertMessage(state.messages, message),
@@ -2516,7 +2555,7 @@ export function handlerContext<D extends AgentToolEventDefs = AIEventDefs>(
2516
2555
  }
2517
2556
 
2518
2557
  /** The built-in AI reducer's name, and so its checkpoint identity. */
2519
- export const AI_STATE_REDUCER = 'a2.ai.state.v26'
2558
+ export const AI_STATE_REDUCER = 'a2.ai.state.v27'
2520
2559
 
2521
2560
  /**
2522
2561
  * The built-in AI protocol plus application events, as a core contract whose
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
 
package/src/internal.ts CHANGED
@@ -7,7 +7,8 @@ import type { ScheduledEvent } from './scheduler-task.ts'
7
7
  import type { DrainableServer } from './server.ts'
8
8
  import type { Event } from './store.ts'
9
9
 
10
- export type DrainOutcome = 'busy' | 'stalled' | 'settled'
10
+ /** `failed`: a scheduler drain threw after arming its successor. */
11
+ export type DrainOutcome = 'busy' | 'stalled' | 'settled' | 'failed'
11
12
 
12
13
  /**
13
14
  * A null-prototype record for maps keyed by caller-controlled strings
package/src/libfx.d.ts CHANGED
@@ -14,8 +14,15 @@ declare module 'libfx' {
14
14
  cancel(): void
15
15
  }
16
16
 
17
+ export type FxPromptBlock =
18
+ | { type: 'text'; text: string }
19
+ | { type: 'image'; data: string; mimeType: string }
20
+
17
21
  export type FxAgent = {
18
- prompt(input: string, options?: { signal?: AbortSignal }): FxTurn
22
+ prompt(
23
+ input: string | FxPromptBlock[],
24
+ options?: { signal?: AbortSignal },
25
+ ): FxTurn
19
26
  checkpoint(): Promise<Uint8Array>
20
27
  close(): Promise<void>
21
28
  }
@@ -220,25 +220,3 @@ export function postgresNotifications(
220
220
  }
221
221
  return manager
222
222
  }
223
-
224
- export const POSTGRES_NOTIFICATIONS_SQL = `
225
- create or replace function a2_notify_events() returns trigger language plpgsql as $$
226
- begin
227
- perform pg_notify('a2_events_' || md5(new.session_id), '');
228
- return null;
229
- end $$;
230
- create or replace function a2_notify_presence() returns trigger language plpgsql as $$
231
- begin
232
- perform pg_notify('a2_presence_' || md5(case when TG_OP = 'DELETE' then old.ns else new.ns end),
233
- coalesce(current_setting('a2.presence_source', true), ''));
234
- return null;
235
- end $$;
236
- do $$ begin
237
- if not exists (select 1 from pg_trigger where tgname = 'a2_notify_events' and tgrelid = 'a2_events'::regclass) then
238
- create trigger a2_notify_events after insert on a2_events for each row execute function a2_notify_events();
239
- end if;
240
- if not exists (select 1 from pg_trigger where tgname = 'a2_notify_presence' and tgrelid = 'a2_presence'::regclass) then
241
- create trigger a2_notify_presence after insert or update or delete on a2_presence for each row execute function a2_notify_presence();
242
- end if;
243
- end $$;
244
- `
@@ -15,10 +15,7 @@ export type PostgresQueries = {
15
15
  client: PostgresClient
16
16
  raw: PostgresClient
17
17
  exclusive<T>(fn: () => Promise<T>): Promise<T>
18
- initialize(options: {
19
- schema: string
20
- run: () => Promise<void>
21
- }): Promise<void>
18
+ initialize(options: { key: string; run: () => Promise<void> }): Promise<void>
22
19
  close(): Promise<void>
23
20
  }
24
21
 
@@ -128,7 +125,7 @@ export function postgresConnectionKey({
128
125
 
129
126
  const queries = (raw: PostgresClient): PostgresQueries => {
130
127
  let pending: Promise<unknown> = Promise.resolve()
131
- const schemas = new Map<string, Promise<void>>()
128
+ const initialized = new Map<string, Promise<void>>()
132
129
  const exclusive = <T>(fn: () => Promise<T>): Promise<T> => {
133
130
  const result = pending.then(fn)
134
131
  pending = result.catch(() => {})
@@ -140,13 +137,13 @@ const queries = (raw: PostgresClient): PostgresQueries => {
140
137
  ? raw
141
138
  : { query: (text, values) => exclusive(() => raw.query(text, values)) },
142
139
  exclusive,
143
- initialize({ schema, run }) {
144
- const existing = schemas.get(schema)
140
+ initialize({ key, run }) {
141
+ const existing = initialized.get(key)
145
142
  if (existing) return existing
146
143
  const attempt = run()
147
- schemas.set(schema, attempt)
144
+ initialized.set(key, attempt)
148
145
  void attempt.catch(() => {
149
- if (schemas.get(schema) === attempt) schemas.delete(schema)
146
+ if (initialized.get(key) === attempt) initialized.delete(key)
150
147
  })
151
148
  return attempt
152
149
  },