experimental-a2 0.18.0 → 0.19.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 (152) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
  3. package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor.d.ts +1 -1
  8. package/dist/actor.js +2 -2
  9. package/dist/{ai-DrUaMyRt.d.ts → ai-Cz3hqVhk.d.ts} +34 -17
  10. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  11. package/dist/ai-client.d.ts +1 -1
  12. package/dist/ai-client.js +2 -2
  13. package/dist/ai-control-CvLbW10B.js +46 -0
  14. package/dist/ai-control-CvLbW10B.js.map +1 -0
  15. package/dist/ai-fx.d.ts +1 -1
  16. package/dist/ai-fx.d.ts.map +1 -1
  17. package/dist/ai-fx.js +122 -44
  18. package/dist/ai-fx.js.map +1 -1
  19. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
  20. package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
  21. package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  22. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  23. package/dist/{ai-BpOGm-zB.js → ai-mMiWul1T.js} +117 -78
  24. package/dist/ai-mMiWul1T.js.map +1 -0
  25. package/dist/ai-pi.d.ts +1 -1
  26. package/dist/ai-pi.d.ts.map +1 -1
  27. package/dist/ai-pi.js +114 -23
  28. package/dist/ai-pi.js.map +1 -1
  29. package/dist/ai-react.d.ts +1 -1
  30. package/dist/ai-react.js +2 -2
  31. package/dist/ai-server.d.ts +3 -3
  32. package/dist/ai-server.d.ts.map +1 -1
  33. package/dist/ai-server.js +127 -55
  34. package/dist/ai-server.js.map +1 -1
  35. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  36. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +1 -1
  39. package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
  40. package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
  41. package/dist/client.js +1 -1
  42. package/dist/index.d.ts +2 -2
  43. package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
  44. package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
  45. package/dist/react.js +1 -1
  46. package/dist/scheduler-qstash.d.ts +2 -2
  47. package/dist/scheduler-qstash.js +3 -3
  48. package/dist/scheduler-qstash.js.map +1 -1
  49. package/dist/scheduler-vercel.d.ts +7 -7
  50. package/dist/scheduler-vercel.d.ts.map +1 -1
  51. package/dist/scheduler-vercel.js +10 -14
  52. package/dist/scheduler-vercel.js.map +1 -1
  53. package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
  54. package/dist/server-D-Y4pqwu.js.map +1 -0
  55. package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
  56. package/dist/server-DYAATFuT.d.ts.map +1 -0
  57. package/dist/server.d.ts +2 -2
  58. package/dist/server.js +1 -1
  59. package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  60. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  61. package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
  62. package/dist/store-CyLUy86v.d.ts.map +1 -0
  63. package/dist/store-N8PXxDAS.js.map +1 -1
  64. package/dist/store-memory.d.ts +1 -1
  65. package/dist/store-memory.js +25 -6
  66. package/dist/store-memory.js.map +1 -1
  67. package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
  68. package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  69. package/dist/store-postgres.d.ts +1 -1
  70. package/dist/store-postgres.d.ts.map +1 -1
  71. package/dist/store-postgres.js +55 -25
  72. package/dist/store-postgres.js.map +1 -1
  73. package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
  74. package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  75. package/dist/store-redis-http.d.ts +1 -1
  76. package/dist/store-redis-http.js +3 -3
  77. package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
  78. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  79. package/dist/store-redis.d.ts +1 -1
  80. package/dist/store-redis.js +1 -1
  81. package/dist/store-sqlite.d.ts +1 -1
  82. package/dist/store-sqlite.js +35 -20
  83. package/dist/store-sqlite.js.map +1 -1
  84. package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
  85. package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
  86. package/docs/agents/03-conversations.mdx +27 -15
  87. package/docs/agents/04-harnesses.mdx +51 -5
  88. package/docs/contract/03-sessions.mdx +1 -1
  89. package/docs/index.mdx +3 -3
  90. package/docs/reference/02-server.mdx +12 -7
  91. package/docs/reference/04-ai.mdx +41 -23
  92. package/docs/reference/05-adapters.mdx +6 -6
  93. package/docs/server/01-handlers.mdx +1 -1
  94. package/docs/server/02-timers.mdx +1 -1
  95. package/docs/server/03-cancellation.mdx +3 -2
  96. package/docs/server/05-production.mdx +4 -3
  97. package/docs/server/06-guarantees.mdx +20 -11
  98. package/examples/playground/DEPLOY.md +11 -4
  99. package/examples/playground/app/a0/[appId]/page.tsx +2 -8
  100. package/examples/playground/app/a0/a0.css +244 -5
  101. package/examples/playground/app/a0/app-view.tsx +2 -1
  102. package/examples/playground/app/a0/attachments.ts +133 -0
  103. package/examples/playground/app/a0/chat.test.tsx +504 -0
  104. package/examples/playground/app/a0/chat.tsx +623 -63
  105. package/examples/playground/app/a0/context-menu.tsx +38 -11
  106. package/examples/playground/app/a0/devtools.tsx +2 -8
  107. package/examples/playground/app/a0/model.test.ts +28 -0
  108. package/examples/playground/app/a0/model.ts +30 -0
  109. package/examples/playground/app/a0/server.ts +13 -19
  110. package/examples/playground/app/a0/uploads/route.ts +29 -0
  111. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  112. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
  113. package/examples/playground/app/agent/model.test.ts +12 -3
  114. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  115. package/examples/playground/next.config.ts +7 -4
  116. package/examples/playground/package.json +3 -1
  117. package/package.json +1 -1
  118. package/src/ai-agent.ts +14 -3
  119. package/src/ai-client-state.ts +21 -9
  120. package/src/ai-context-schema.ts +28 -4
  121. package/src/ai-context.ts +83 -3
  122. package/src/ai-control-server.ts +70 -43
  123. package/src/ai-control-state.ts +3 -4
  124. package/src/ai-control.ts +59 -10
  125. package/src/ai-coordinator.ts +7 -6
  126. package/src/ai-fx.ts +184 -67
  127. package/src/ai-harness.ts +153 -29
  128. package/src/ai-message-shape.ts +41 -0
  129. package/src/ai-pi.ts +161 -46
  130. package/src/ai-stored-state.ts +1 -3
  131. package/src/ai-tool-input.ts +45 -0
  132. package/src/ai.ts +116 -77
  133. package/src/internal.ts +6 -0
  134. package/src/libfx.d.ts +8 -1
  135. package/src/scheduler-qstash.ts +5 -5
  136. package/src/scheduler-vercel.ts +15 -27
  137. package/src/server.ts +58 -12
  138. package/src/store-memory.ts +50 -9
  139. package/src/store-postgres.ts +79 -28
  140. package/src/store-redis-core.ts +112 -31
  141. package/src/store-sqlite.ts +61 -28
  142. package/src/store.ts +22 -3
  143. package/dist/ai-BpOGm-zB.js.map +0 -1
  144. package/dist/ai-DrUaMyRt.d.ts.map +0 -1
  145. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  146. package/dist/ai-harness-DvsPeJeB.d.ts.map +0 -1
  147. package/dist/server-Dw_k_7kO.js.map +0 -1
  148. package/dist/server-En7cbi3v.d.ts.map +0 -1
  149. package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
  150. package/dist/store-Djdv0bnZ.d.ts.map +0 -1
  151. package/dist/store-redis-notify-BRITti_o.js.map +0 -1
  152. package/examples/playground/app/a0/no-photon.cjs +0 -4
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/internal.ts CHANGED
@@ -274,12 +274,18 @@ export const DRAIN_TIMINGS: {
274
274
  watchdogGraceMs: number
275
275
  /** Maximum time a top-level append waits for its initial scheduler arm. */
276
276
  schedulerArmTimeoutMs: number
277
+ /** First retry delay after a caught failure, doubling per failure. */
278
+ failureRetryBaseMs: number
279
+ /** Ceiling of the caught-failure retry delay. */
280
+ failureRetryMaxMs: number
277
281
  } = {
278
282
  claimTtlMs: 5_000,
279
283
  claimHeartbeatMs: 2_000,
280
284
  renewRetryMs: 400,
281
285
  watchdogGraceMs: 1_000,
282
286
  schedulerArmTimeoutMs: 2_000,
287
+ failureRetryBaseMs: 1_000,
288
+ failureRetryMaxMs: 30_000,
283
289
  }
284
290
 
285
291
  /**
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
  }
@@ -912,7 +912,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
912
912
  }
913
913
  if (result.settled) return response.ok()
914
914
 
915
- if (result.outcome === 'busy') {
915
+ if (result.outcome === 'busy' || result.outcome === 'stalled') {
916
916
  if (result.schedulerArm) await result.schedulerArm
917
917
  else {
918
918
  await sendTask({
@@ -926,10 +926,10 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
926
926
  return response.ok()
927
927
  }
928
928
 
929
- // QStash's per-message retry default can be lower than A2's failure
930
- // budget. Hand stalled work to a fresh durable message so recovery
931
- // continues until the store reports success or dead-letter. If this
932
- // publish fails, the throw keeps the current delivery retryable.
929
+ // A drain whose outcome is unknown hands its work to a fresh durable
930
+ // message: QStash's per-message retry default can be lower than A2's
931
+ // failure budget. If this publish fails, the throw keeps the current
932
+ // delivery retryable.
933
933
  await sendTask({
934
934
  version: 1,
935
935
  kind: 'drain',
@@ -14,8 +14,7 @@
14
14
  * the configured deployment scope for sending and receiving.
15
15
  * - `handler(...servers)` is the delivery route: look up the server for
16
16
  * the contract named in the message and drain it. Settled drains ack;
17
- * contention arms a later watchdog before acking; handler failure keeps
18
- * the current message and redelivers with backoff.
17
+ * contention and handler failure arm a later watchdog before acking.
19
18
  *
20
19
  * `@vercel/queue` is an optional peer dependency, imported lazily. The
21
20
  * `transport` option is the injection seam — tests run a real little
@@ -44,12 +43,12 @@ import {
44
43
  } from './server.ts'
45
44
 
46
45
  /**
47
- * The "not settled yet" signal for a stalled handler — an expected state,
48
- * not a transport failure. The default transport classifies it in
49
- * `@vercel/queue`'s `retry` option and reschedules the delivery quietly
46
+ * The "not settled yet" signal for a drain whose outcome is unknown — an
47
+ * expected state, not a transport failure. The default transport classifies
48
+ * it in `@vercel/queue`'s `retry` option and reschedules the delivery quietly
50
49
  * (`{ afterSeconds }` → visibility change → 200 response, no error log)
51
- * instead of letting it surface as a thrown 500. Busy deliveries use a
52
- * fresh watchdog instead. Custom transports should treat this class the
50
+ * instead of letting it surface as a thrown 500. Busy and stalled deliveries
51
+ * use a fresh watchdog instead. Custom transports should treat this class the
53
52
  * same way when their protocol has a polite reschedule.
54
53
  */
55
54
  export class DrainUnsettled extends Error {}
@@ -101,12 +100,9 @@ export type VercelQueuesOptions = {
101
100
  /** Vercel Queues rejects shorter visibility changes. */
102
101
  const MIN_VISIBILITY_SECONDS = 30
103
102
 
104
- /** Vercel Queues' maximum message retention and delivery delay. */
103
+ /** Vercel Queues' maximum message retention; its default is a day. */
105
104
  const MAX_RETENTION_SECONDS = 7 * 24 * 60 * 60
106
105
 
107
- /** Leave the final retention day for delivery and retries. */
108
- const MAX_DELAY_SECONDS = 6 * 24 * 60 * 60
109
-
110
106
  const TOPIC_PATTERN = /^[A-Za-z0-9_-]+$/
111
107
  const RETRYABLE_HTTP_CLIENT_STATUSES = new Set([408, 409, 425, 429])
112
108
  /** The SDK's send path maps these statuses to name-only error instances. */
@@ -258,14 +254,6 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
258
254
  0,
259
255
  Math.ceil((deliveryTargetAt - nowMs) / 1_000),
260
256
  )
261
- if (deliveryDelaySeconds > MAX_DELAY_SECONDS) {
262
- throw classifySchedulerSendFailure(
263
- new RangeError(
264
- 'vercelQueues() tasks cannot be delayed more than 6 days',
265
- ),
266
- 'terminal',
267
- )
268
- }
269
257
  const message: SchedulerTask = { ...task, dueAt }
270
258
  const idempotencyKey =
271
259
  task.kind === 'drain'
@@ -354,11 +342,12 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
354
342
  }
355
343
  if (result.settled) return
356
344
 
357
- if (result.outcome === 'busy') {
358
- // The live owner normally arms its own claim windows.
359
- // Ensure a later slot here as well before relinquishing this
360
- // durable delivery. If the queue is down, the throw leaves the
361
- // current message unacked for visibility-timeout redelivery.
345
+ if (result.outcome === 'busy' || result.outcome === 'stalled') {
346
+ // The live owner arms its own claim windows, and the store holds
347
+ // a failed event until its retry time. Ensure a later slot here
348
+ // as well before relinquishing this durable delivery. If the queue is down, the
349
+ // throw leaves the current message unacked for visibility-timeout
350
+ // redelivery.
362
351
  if (result.schedulerArm) await result.schedulerArm
363
352
  else {
364
353
  await sendTask({
@@ -372,9 +361,8 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
372
361
  return
373
362
  }
374
363
 
375
- // Handler failure keeps the current durable message and uses the
376
- // transport retry path. Dead-lettered sessions count as settled
377
- // above, so retries stop there.
364
+ // A drain whose outcome is unknown keeps the current durable message
365
+ // and uses the transport retry path.
378
366
  throw new DrainUnsettled(
379
367
  `a2 scheduler: session '${task.sessionId}' of '${task.contract}' not settled yet (${result.outcome}) — rescheduled for redelivery`,
380
368
  )
package/src/server.ts CHANGED
@@ -205,9 +205,15 @@ export type DeadLetter<D extends EventDefs> = ContractEvent<D> & {
205
205
  lane: string | null
206
206
  /** Durable dispatch claims, including claims abandoned by hard kills. */
207
207
  attemptCount: number
208
- /** Caught handler failures since the event was appended or last retried. */
208
+ /**
209
+ * Failed attempts since the event was appended or last retried: caught
210
+ * handler failures, and attempts that ended before they finished.
211
+ */
209
212
  failureCount: number
210
- /** The last handler failure, stringified. */
213
+ /**
214
+ * The last error a handler threw, stringified; null when every failed
215
+ * attempt ended before it finished.
216
+ */
211
217
  lastError: string | null
212
218
  failedAt: Date
213
219
  }
@@ -499,6 +505,13 @@ export type ServerOptions<
499
505
 
500
506
  const MAX_FAILURES = 10
501
507
 
508
+ /** How long an event waits after its `failureCount`th failed attempt. */
509
+ const failureRetryDelayMs = (failureCount: number): number =>
510
+ Math.min(
511
+ DRAIN_TIMINGS.failureRetryBaseMs * 2 ** Math.max(failureCount - 1, 0),
512
+ DRAIN_TIMINGS.failureRetryMaxMs,
513
+ )
514
+
502
515
  /** Abort a lost attempt's handler and release its drain slot. */
503
516
  function endSuperseded(
504
517
  entry: {
@@ -527,6 +540,7 @@ type ClaimWindow = {
527
540
  expiresAtMs: number
528
541
  watchdogAtMs: number
529
542
  deadlineCapped: boolean
543
+ pastDeadline: boolean
530
544
  }
531
545
 
532
546
  type DrainSignal = {
@@ -601,9 +615,11 @@ function pruneAbortHub(hub: AbortHub): void {
601
615
  }
602
616
 
603
617
  /**
604
- * Deadlines tighten the claim/watchdog window; they never decide whether a
605
- * handler starts. A platform timeout therefore follows the same path as any
606
- * other process death: the in-flight event remains pending and is retried.
618
+ * Deadlines tighten the claim/watchdog window, and a passed deadline ends
619
+ * claiming: a claim then could not run, only lapse and be claimed again until
620
+ * the platform ends the process. A platform timeout otherwise follows the
621
+ * same path as any other process death: the in-flight event remains pending
622
+ * and a fresh invocation retries it.
607
623
  */
608
624
  function nextClaimWindow(): ClaimWindow {
609
625
  const nowMs = Date.now()
@@ -619,6 +635,7 @@ function nextClaimWindow(): ClaimWindow {
619
635
  expiresAtMs,
620
636
  watchdogAtMs: expiresAtMs + DRAIN_TIMINGS.watchdogGraceMs,
621
637
  deadlineCapped,
638
+ pastDeadline: deadlineMs !== null && deadlineTtlMs <= 0,
622
639
  }
623
640
  }
624
641
 
@@ -3068,6 +3085,25 @@ export function createServer<
3068
3085
  return tracked
3069
3086
  }
3070
3087
 
3088
+ /** Records an event the store dead-lettered on takeover; it never runs. */
3089
+ const reportExhausted = (event: StoredEvent): Promise<void> =>
3090
+ telemetry.span(
3091
+ 'a2.event',
3092
+ {
3093
+ 'a2.contract': name,
3094
+ 'a2.session_id': sessionId,
3095
+ 'a2.event.type': event.type,
3096
+ 'a2.event.index': event.index,
3097
+ 'a2.event.id': event.id,
3098
+ 'a2.event.attempt': event.attemptCount,
3099
+ 'a2.event.handled': handlers.has(event.type),
3100
+ ...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
3101
+ },
3102
+ async (eventSpan) => {
3103
+ eventSpan.setAttribute('a2.event.outcome', 'dead_lettered')
3104
+ },
3105
+ )
3106
+
3071
3107
  const runEvent = async (
3072
3108
  event: StoredEvent,
3073
3109
  supersedeSignal: AbortSignal,
@@ -3127,9 +3163,8 @@ export function createServer<
3127
3163
  if (supersedeSignal.aborted && err === supersedeSignal.reason) {
3128
3164
  // The handler surrendered to its own abort — identity, not
3129
3165
  // code, so it cannot arrive from another context. A lapse
3130
- // is not a handler failure: no completion, no failure
3131
- // budget spent; the claim simply expires and recovery
3132
- // retries.
3166
+ // is not a handler failure: no completion; the claim simply
3167
+ // expires, and the claim that takes it over counts it.
3133
3168
  eventSpan.setAttribute('a2.event.outcome', 'surrendered')
3134
3169
  return {
3135
3170
  status: 'surrendered',
@@ -3162,6 +3197,7 @@ export function createServer<
3162
3197
  // budget before dead-lettering.
3163
3198
  maxFailures:
3164
3199
  err instanceof NonRetriableError ? 1 : MAX_FAILURES,
3200
+ retryAfterMs: failureRetryDelayMs(event.failureCount + 1),
3165
3201
  })
3166
3202
  commitSpan.setAttribute('a2.commit.outcome', result.outcome)
3167
3203
  return result
@@ -3293,6 +3329,10 @@ export function createServer<
3293
3329
  let claim: StoreClaimAvailableResult | undefined
3294
3330
  if (shouldClaim) {
3295
3331
  const window = nextClaimWindow()
3332
+ if (window.pastDeadline) {
3333
+ outcome = 'busy'
3334
+ break
3335
+ }
3296
3336
  claim = await telemetry.span(
3297
3337
  'a2.claim',
3298
3338
  { 'a2.contract': name, 'a2.session_id': sessionId },
@@ -3301,6 +3341,7 @@ export function createServer<
3301
3341
  sessionId: ns,
3302
3342
  holder,
3303
3343
  ttlMs: window.ttlMs,
3344
+ maxFailures: MAX_FAILURES,
3304
3345
  ...(window.deadlineCapped
3305
3346
  ? { expiresAtMs: window.expiresAtMs }
3306
3347
  : {}),
@@ -3317,11 +3358,16 @@ export function createServer<
3317
3358
  },
3318
3359
  )
3319
3360
  if (claim.outcome === 'claimed') {
3320
- requestWatchdog(window.watchdogAtMs)
3361
+ // A takeover that spent the last failed attempt comes back
3362
+ // dead-lettered instead of claimed.
3363
+ const runnable = claim.events.filter(
3364
+ (event) => event.failedAt === null,
3365
+ )
3366
+ for (const event of claim.events)
3367
+ if (event.failedAt !== null) await reportExhausted(event)
3368
+ if (runnable.length > 0) requestWatchdog(window.watchdogAtMs)
3321
3369
  if (window.deadlineCapped) stopRenewing = true
3322
- for (const event of claim.events) {
3323
- start(event, window.expiresAtMs)
3324
- }
3370
+ for (const event of runnable) start(event, window.expiresAtMs)
3325
3371
  shouldClaim = false
3326
3372
  }
3327
3373
  }