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
@@ -1,5 +1,5 @@
1
1
  'use client'
2
- import type { KeyboardEvent, ReactNode } from 'react'
2
+ import type { KeyboardEvent, ReactNode, RefObject } from 'react'
3
3
  import {
4
4
  useEffect,
5
5
  useEffectEvent,
@@ -7,27 +7,38 @@ import {
7
7
  useRef,
8
8
  useState,
9
9
  } from 'react'
10
+ import { Check } from 'lucide-react'
10
11
 
11
12
  export type MenuItem = {
12
13
  label: string
13
14
  onSelect: () => void
14
15
  danger?: boolean
16
+ /** One of a set of choices; a checked one is the current choice. */
17
+ checked?: boolean
15
18
  }
16
19
 
20
+ const ITEMS = '[role="menuitem"], [role="menuitemradio"]'
21
+
17
22
  // Keeps the menu this far inside the window's edges.
18
23
  const MARGIN = 8
19
24
 
20
25
  /**
21
- * A menu at a point, as a right-click opens one. It takes focus, moves with
22
- * the arrow keys, and closes on Escape, a press outside, or a pick; Escape
23
- * and a pick hand focus back through `onClose`.
26
+ * A menu at a point, as a right-click opens one. The point is the menu's top
27
+ * left corner, or its bottom right for a button near the window's foot. It
28
+ * takes focus, moves with the arrow keys, and closes on Escape, a press
29
+ * outside, or a pick; Escape and a pick hand focus back through `onClose`.
24
30
  */
25
31
  export function ContextMenu({
26
32
  at,
33
+ corner = 'top-left',
34
+ trigger,
27
35
  items,
28
36
  onClose,
29
37
  }: {
30
38
  at: { x: number; y: number }
39
+ corner?: 'top-left' | 'bottom-right'
40
+ /** The button that opens the menu; a press on it is its own, not outside. */
41
+ trigger?: RefObject<HTMLElement | null>
31
42
  items: MenuItem[]
32
43
  onClose: (closed: { returnFocus: boolean }) => void
33
44
  }): ReactNode {
@@ -39,19 +50,26 @@ export function ContextMenu({
39
50
  const element = menu.current
40
51
  if (!element) return
41
52
  const { width, height } = element.getBoundingClientRect()
53
+ const x = corner === 'top-left' ? at.x : at.x - width
54
+ const y = corner === 'top-left' ? at.y : at.y - height
42
55
  setPosition({
43
- x: Math.max(MARGIN, Math.min(at.x, innerWidth - width - MARGIN)),
44
- y: Math.max(MARGIN, Math.min(at.y, innerHeight - height - MARGIN)),
56
+ x: Math.max(MARGIN, Math.min(x, innerWidth - width - MARGIN)),
57
+ y: Math.max(MARGIN, Math.min(y, innerHeight - height - MARGIN)),
45
58
  })
46
- element.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
47
- }, [at])
59
+ element.querySelector<HTMLElement>(ITEMS)?.focus()
60
+ }, [at, corner])
48
61
 
49
62
  const dismiss = useEffectEvent((returnFocus: boolean) =>
50
63
  onClose({ returnFocus }),
51
64
  )
52
65
  useEffect(() => {
53
66
  const outside = (event: PointerEvent): void => {
54
- if (!menu.current?.contains(event.target as Node)) dismiss(false)
67
+ const target = event.target as Node
68
+ if (
69
+ !menu.current?.contains(target) &&
70
+ !trigger?.current?.contains(target)
71
+ )
72
+ dismiss(false)
55
73
  }
56
74
  const away = (): void => dismiss(false)
57
75
  document.addEventListener('pointerdown', outside, true)
@@ -66,7 +84,7 @@ export function ContextMenu({
66
84
 
67
85
  const move = (event: KeyboardEvent<HTMLDivElement>): void => {
68
86
  const buttons = [
69
- ...event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitem"]'),
87
+ ...event.currentTarget.querySelectorAll<HTMLElement>(ITEMS),
70
88
  ]
71
89
  const focused = buttons.indexOf(document.activeElement as HTMLElement)
72
90
  const next =
@@ -101,13 +119,22 @@ export function ContextMenu({
101
119
  <button
102
120
  key={item.label}
103
121
  type="button"
104
- role="menuitem"
122
+ role={item.checked === undefined ? 'menuitem' : 'menuitemradio'}
123
+ aria-checked={item.checked}
105
124
  data-danger={item.danger || undefined}
106
125
  onClick={() => {
107
126
  onClose({ returnFocus: true })
108
127
  item.onSelect()
109
128
  }}
110
129
  >
130
+ {item.checked !== undefined && (
131
+ <Check
132
+ size={12}
133
+ className="a0-menu-check"
134
+ visibility={item.checked ? 'visible' : 'hidden'}
135
+ aria-hidden
136
+ />
137
+ )}
111
138
  {item.label}
112
139
  </button>
113
140
  ))}
@@ -20,12 +20,10 @@ const gap = (ms: number): string =>
20
20
  export function DevTools({
21
21
  appId,
22
22
  deployed,
23
- crashesAllowed,
24
23
  }: {
25
24
  appId: string
26
25
  /** Function limits and crashes only mean something on Vercel. */
27
26
  deployed: boolean
28
- crashesAllowed: boolean
29
27
  }): ReactNode {
30
28
  const { state, events, index, history, hydrated, loadHistory } = useSession()
31
29
  const short = useSearchParams().get('limit') === '5s'
@@ -83,12 +81,8 @@ export function DevTools({
83
81
  <button
84
82
  type="button"
85
83
  className="a0-ghost a0-devtools-crash"
86
- disabled={!crashesAllowed || crashes.state}
87
- title={
88
- crashesAllowed
89
- ? 'Ends the process running the agent, right now'
90
- : 'Crashes need a preview deployment, or A0_CRASHES=on'
91
- }
84
+ disabled={crashes.state}
85
+ title="Ends the process running the agent, right now"
92
86
  onClick={crash}
93
87
  >
94
88
  <Zap size={13} aria-hidden />
@@ -71,3 +71,31 @@ it("reads the app's harness from its URL, the AI SDK's by default", () => {
71
71
  'a0-pi',
72
72
  ])
73
73
  })
74
+
75
+ const sendWithFile = (url: string) =>
76
+ appAgent.events['ai.control.requested']['~standard'].validate({
77
+ action: 'send',
78
+ message: {
79
+ id: 'u1',
80
+ role: 'user',
81
+ parts: [
82
+ { type: 'file', mediaType: 'image/png', url },
83
+ { type: 'text', text: 'Like this.' },
84
+ ],
85
+ },
86
+ })
87
+
88
+ it("only takes a user's files from the playground's Blob store", () => {
89
+ expect(
90
+ sendWithFile(
91
+ 'https://store.public.blob.vercel-storage.com/a0/app/shot-x1.png',
92
+ ),
93
+ ).not.toHaveProperty('issues')
94
+ for (const url of [
95
+ 'data:image/png;base64,iVBORw0KGgo=',
96
+ 'http://store.public.blob.vercel-storage.com/shot.png',
97
+ 'http://169.254.169.254/latest/meta-data',
98
+ 'https://example.com/shot.png',
99
+ ])
100
+ expect(sendWithFile(url)).toHaveProperty('issues')
101
+ })
@@ -35,6 +35,35 @@ export const defaultModelChoice: ModelChoice = {
35
35
 
36
36
  export type AppMessage = UIMessage
37
37
 
38
+ /** A file the user attached, uploaded to the playground's public Blob store. */
39
+ export function isAttachmentUrl(value: string): boolean {
40
+ try {
41
+ const { protocol, hostname } = new URL(value)
42
+ return (
43
+ protocol === 'https:' &&
44
+ hostname.endsWith('.public.blob.vercel-storage.com')
45
+ )
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ /**
52
+ * A user's files must be attachments. Whatever hands them to the model
53
+ * fetches them by URL, so the URL can't point anywhere else, and a message
54
+ * never holds a file's bytes.
55
+ */
56
+ const messageSchema = z
57
+ .custom<AppMessage>()
58
+ .refine(
59
+ (message) =>
60
+ message.role !== 'user' ||
61
+ message.parts.every(
62
+ (part) => part.type !== 'file' || isAttachmentUrl(part.url),
63
+ ),
64
+ "A message's files must be uploaded attachments",
65
+ )
66
+
38
67
  /**
39
68
  * The loop an app's agent runs: the AI SDK's, FX's, or Pi's. An
40
69
  * app picks it before its first message and keeps it.
@@ -56,6 +85,7 @@ export const harnessFrom = (value: unknown): Harness =>
56
85
  const appContract = (name: string) =>
57
86
  agentContract({
58
87
  name,
88
+ messageSchema,
59
89
  events: { 'model.chosen': modelChoiceSchema },
60
90
  reducer: {
61
91
  name: 'a0.model-choice.v1',
@@ -5,6 +5,7 @@ import { handlerContext } from 'experimental-a2/ai'
5
5
  import { fx } from 'experimental-a2/ai/fx'
6
6
  import { pi } from 'experimental-a2/ai/pi'
7
7
  import { agent } from 'experimental-a2/ai/server'
8
+ import { otel } from 'experimental-a2/otel'
8
9
  import { createServer } from 'experimental-a2/server'
9
10
  import { vercelQueues } from 'experimental-a2/scheduler-vercel'
10
11
  import { z } from 'zod'
@@ -33,6 +34,7 @@ const instructions = (
33
34
  - If the preview stops answering, read /tmp/next.log, then start next dev with \`${START_DEV}\`; it does nothing while one is already running.
34
35
  - next.config allows the preview's *.vercel.run host (allowedDevOrigins); keep that when you edit it.
35
36
  - Background anything long-running; a bash call stops after its timeout.
37
+ - The user can attach files. Images come with their message; any other file is a "name: URL" line in it. Download one with curl when you need its contents.
36
38
  - Keep replies short and in plain text, without Markdown: what you changed.`
37
39
 
38
40
  /** A tool's result, with how long it took; the chat shows it. */
@@ -106,24 +108,25 @@ function chosenModel({
106
108
  }) {
107
109
  // Every step starts here, in the process that runs it, which is what
108
110
  // the dev tools' crash has to end.
109
- if (crashesAllowed) {
110
- const appId = event.sessionId
111
- waitUntil(
112
- crashOnRequest({
113
- appId,
114
- app: agentServers[harness].session(appId),
115
- log: crashServer.session(crashLogId(appId)),
116
- }).catch(() => {}),
117
- )
118
- }
111
+ const appId = event.sessionId
112
+ waitUntil(
113
+ crashOnRequest({
114
+ appId,
115
+ app: agentServers[harness].session(appId),
116
+ log: crashServer.session(crashLogId(appId)),
117
+ }).catch(() => {}),
118
+ )
119
119
  const { model, reasoning, fast } = state.modelChoice
120
120
  return { model, reasoning, speed: fast ? 'fast' : 'standard' } as const
121
121
  }
122
122
 
123
+ const telemetry = otel()
124
+
123
125
  const shared = (harness: Harness) => ({
124
126
  ...(store ? { store } : {}),
125
127
  ...(appSchedulers[harness] ? { scheduler: appSchedulers[harness] } : {}),
126
128
  contract: harnessAgents[harness],
129
+ telemetry,
127
130
  maxSteps: 80,
128
131
  })
129
132
 
@@ -193,20 +196,11 @@ export const crashServer = createServer({
193
196
  contract: crashLog,
194
197
  })
195
198
 
196
- /**
197
- * A crash ends the function instance, and anything else it's serving, so
198
- * only previews allow it, behind Deployment Protection, unless A0_CRASHES
199
- * is on. Under next dev it would take the dev server down.
200
- */
201
- export const crashesAllowed =
202
- process.env.VERCEL_ENV === 'preview' || process.env.A0_CRASHES === 'on'
203
-
204
199
  /** The browser can only ask for a crash; the process it ends records it. */
205
200
  export function crashEvents(request: Request): Promise<Response> {
206
201
  return crashServer.fetch(request, {
207
202
  authorize: (operation) =>
208
203
  isCrashLogId(operation.sessionId) &&
209
- (crashesAllowed || operation.type !== 'push') &&
210
204
  (operation.type !== 'push' ||
211
205
  operation.events.every((event) => event.type === 'crash.requested')),
212
206
  })
@@ -0,0 +1,29 @@
1
+ import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
2
+ import { isAppId } from '../apps'
3
+
4
+ /** Tokens for the browser to upload an app's attachments straight to Blob. */
5
+ export async function POST(request: Request): Promise<Response> {
6
+ const body = (await request.json()) as HandleUploadBody
7
+ try {
8
+ return Response.json(
9
+ await handleUpload({
10
+ request,
11
+ body,
12
+ onBeforeGenerateToken: async (pathname, appId) => {
13
+ if (
14
+ appId === null ||
15
+ !isAppId(appId) ||
16
+ !pathname.startsWith(`a0/${appId}/`)
17
+ )
18
+ throw new Error("An attachment goes in its app's folder")
19
+ return { addRandomSuffix: true }
20
+ },
21
+ }),
22
+ )
23
+ } catch (cause) {
24
+ return Response.json(
25
+ { error: cause instanceof Error ? cause.message : String(cause) },
26
+ { status: 400 },
27
+ )
28
+ }
29
+ }
@@ -393,10 +393,11 @@ export function AgentClient({
393
393
  >
394
394
  Stop
395
395
  </button>
396
+ {/* With no turn to steer, steering would only send. */}
396
397
  <button
397
398
  type="button"
398
399
  className="secondary"
399
- disabled={!canSend || !prompt.trim()}
400
+ disabled={!canSend || !prompt.trim() || !state.active}
400
401
  onClick={() => void send(undefined, true)}
401
402
  >
402
403
  Steer
@@ -14,6 +14,7 @@ const queue = (edit: Edit) =>
14
14
  items={[
15
15
  {
16
16
  id: 'queued',
17
+ role: 'user',
17
18
  message: {
18
19
  id: 'queued',
19
20
  role: 'user',
@@ -29,9 +29,18 @@ it('folds compaction settings beside the AI state', () => {
29
29
  )
30
30
  state = reducer.fold(
31
31
  state,
32
- event(2, 'ai.message.created', {
33
- message: { id: 'user', role: 'user', parts: [] },
34
- }),
32
+ // A send's id is its message's.
33
+ {
34
+ ...event(2, 'ai.control.requested', {
35
+ action: 'send',
36
+ message: { id: 'user', role: 'user', parts: [] },
37
+ }),
38
+ id: 'user',
39
+ },
40
+ )
41
+ state = reducer.fold(
42
+ state,
43
+ event(3, 'ai.message.created', { messageId: 'user', role: 'user' }),
35
44
  )
36
45
  expect(state.compactionSettings).toEqual({ mode: 'disabled' })
37
46
  expect(state.compactionSettingsId).toBe('event-1')
@@ -19,11 +19,7 @@ function running() {
19
19
  active: {
20
20
  turnId: 'turn',
21
21
  phase: 'generating',
22
- input: {
23
- id: 'input',
24
- generate: true,
25
- message: { id: 'input', role: 'user', parts: [] },
26
- },
22
+ input: { id: 'input', role: 'user', generate: true },
27
23
  },
28
24
  }
29
25
  const append = (event: { type: string; payload: unknown; id?: string }) => {
@@ -6,13 +6,16 @@ import type { NextConfig } from 'next'
6
6
 
7
7
  const nextConfig: NextConfig = {
8
8
  // libfx loads a native binary for the platform, falling back to its
9
- // WebAssembly, by paths the bundler cannot follow; Node loads both from
10
- // node_modules at runtime.
11
- serverExternalPackages: ['libfx'],
9
+ // WebAssembly, and photon, which Pi resizes images with, loads its
10
+ // WebAssembly, by paths the bundler cannot follow; Node loads them from
11
+ // node_modules at runtime. Pi stays bundled (externalized, it makes
12
+ // Turbopack panic on its WebAssembly), so it finds photon through this
13
+ // app: package.json depends on photon at the version Pi pins, and the two
14
+ // move together.
15
+ serverExternalPackages: ['libfx', '@silvia-odwyer/photon-node'],
12
16
  turbopack: {
13
17
  // WebAssembly Pi's code reaches that the bundler cannot trace.
14
18
  resolveAlias: {
15
- '@silvia-odwyer/photon-node': './app/a0/no-photon.cjs',
16
19
  'quickjs-wasi': './app/a0/no-quickjs.cjs',
17
20
  'quickjs-wasi/quickjs.wasm': './app/a0/no-quickjs.cjs',
18
21
  },
@@ -22,13 +22,15 @@
22
22
  "@earendil-works/pi-ai": "1.0.3",
23
23
  "@earendil-works/pi-coding-agent": "1.0.2",
24
24
  "@opentelemetry/api": "^1.9.0",
25
+ "@silvia-odwyer/photon-node": "0.3.4",
26
+ "@vercel/blob": "^2.8.0",
25
27
  "@vercel/functions": "^3.9.3",
26
28
  "@vercel/otel": "^2.1.0",
27
29
  "@vercel/queue": "^0.2.0",
28
30
  "@vercel/sandbox": "^3.0.0",
29
31
  "ai": "^7.0.58",
30
32
  "codemirror": "^6.0.2",
31
- "experimental-a2": "0.18.0",
33
+ "experimental-a2": "0.19.0",
32
34
  "geist": "^1.7.2",
33
35
  "ioredis": "^5.9.0",
34
36
  "libfx": "0.0.12",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Syncs your immutable event log, from server to browser: one contract, handlers, derived state, and a live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai-agent.ts CHANGED
@@ -680,7 +680,15 @@ async function* consumeGeneration(options: {
680
680
  chunks: mergeDeltas(pending.splice(0)),
681
681
  })
682
682
  const reader = options.source.stream.getReader()
683
- let pendingChunk = reader.read()
683
+ // The next read starts before a batch is yielded, and the consumer may
684
+ // take a while to ask again: a stream that fails meanwhile must not leave
685
+ // that read rejected with no handler, which ends the process.
686
+ const readAhead = (): Promise<StreamRead<UIMessageChunk>> => {
687
+ const read = reader.read()
688
+ read.catch(() => {})
689
+ return read
690
+ }
691
+ let pendingChunk = readAhead()
684
692
  try {
685
693
  for (;;) {
686
694
  let result: StreamRead<UIMessageChunk>
@@ -701,7 +709,7 @@ async function* consumeGeneration(options: {
701
709
  pending.push(chunk)
702
710
  if (chunk.type === 'finish') streamedFinishReason = chunk.finishReason
703
711
  if (chunk.type === 'error') streamedError = chunk.errorText
704
- pendingChunk = reader.read()
712
+ pendingChunk = readAhead()
705
713
 
706
714
  if (pending.length >= maxChunks || isBoundaryChunk(pending.at(-1))) {
707
715
  yield batch()
@@ -712,7 +720,6 @@ async function* consumeGeneration(options: {
712
720
  if (pending.length > 0) yield batch()
713
721
  throw error
714
722
  } finally {
715
- void pendingChunk.catch(() => {})
716
723
  try {
717
724
  await reader.cancel()
718
725
  } catch {
@@ -1275,6 +1282,10 @@ export function createHandlers<
1275
1282
  throw new TypeError(
1276
1283
  `a harness runs tools without approval; '${name}' sets needsApproval`,
1277
1284
  )
1285
+ if (tool.type === 'provider')
1286
+ throw new TypeError(
1287
+ `a harness runs only tools A2 executes; '${name}' is provider-defined`,
1288
+ )
1278
1289
  }
1279
1290
  }
1280
1291
 
@@ -1,6 +1,11 @@
1
1
  import type { UIMessage } from 'ai'
2
2
  import type { AIState } from './ai.ts'
3
- import type { ControlCommand, InboxItem } from './ai-control.ts'
3
+ import {
4
+ steeringEnd,
5
+ steersOn,
6
+ type ControlCommand,
7
+ type InboxItem,
8
+ } from './ai-control.ts'
4
9
  import {
5
10
  interruptGenerationProjection,
6
11
  settleAbandonedCompaction,
@@ -92,10 +97,10 @@ export function projectAIClientState<M extends UIMessage>(
92
97
  const admit = (): void => {
93
98
  if (turnId !== null || paused || items.length === 0) return
94
99
  let count = 0
95
- for (const item of items) {
100
+ for (const [index, item] of items.entries()) {
96
101
  count += 1
97
102
  show(item.message)
98
- if (item.generate) {
103
+ if (item.generate && !steersOn(item, items.slice(index + 1))) {
99
104
  turnId = item.id
100
105
  starting = true
101
106
  suspended = false
@@ -118,18 +123,21 @@ export function projectAIClientState<M extends UIMessage>(
118
123
  ? { afterStepOf: command.turnId }
119
124
  : {}),
120
125
  id: command.message.id,
126
+ role: command.message.role,
121
127
  message: command.message,
122
128
  generate:
123
129
  command.message.role === 'user' &&
124
130
  (command.action === 'steer' || command.generate !== false),
125
131
  }
126
- items = command.action === 'steer' ? [item, ...items] : [...items, item]
132
+ if (command.action === 'steer') {
133
+ const at = steeringEnd(items, command.turnId)
134
+ items = [...items.slice(0, at), item, ...items.slice(at)]
135
+ } else items = [...items, item]
127
136
  break
128
137
  }
129
138
  case 'edit':
130
139
  items = items.map((item) =>
131
- item.id === command.message.id &&
132
- item.message.role === command.message.role
140
+ item.id === command.message.id && item.role === command.message.role
133
141
  ? { ...item, message: command.message }
134
142
  : item,
135
143
  )
@@ -147,20 +155,24 @@ export function projectAIClientState<M extends UIMessage>(
147
155
  case 'send-now': {
148
156
  if (turnId !== command.turnId) break
149
157
  const selected = items.find((item) => item.id === command.inputId)
150
- if (!selected || selected.message.role !== 'user') break
158
+ if (!selected || selected.role !== 'user') break
159
+ const { afterStepOf: _, ...promoted } = selected
151
160
  items = items.map((item) =>
152
161
  item.id === selected.id
153
162
  ? {
154
- ...item,
163
+ ...promoted,
155
164
  generate: true,
156
165
  ...(turnId === null ? {} : { afterStepOf: turnId }),
157
166
  }
158
167
  : item,
159
168
  )
169
+ const others = items.filter((item) => item.id !== selected.id)
160
170
  items = moveItem({
161
171
  items,
162
172
  inputId: command.inputId,
163
- beforeId: items[0]?.id ?? null,
173
+ beforeId:
174
+ others[turnId === null ? 0 : steeringEnd(others, turnId)]?.id ??
175
+ null,
164
176
  })
165
177
  break
166
178
  }
@@ -1,6 +1,7 @@
1
1
  import { assertMessageBase, restoreMessageBase } from './ai-message-base.ts'
2
2
  import type { UIMessage } from 'ai'
3
3
  import { events } from './ai.ts'
4
+ import { isUIMessage } from './ai-message-shape.ts'
4
5
  import type { AIContextState } from './ai-context.ts'
5
6
  import type { StandardSchemaV1 } from './standard-schema.ts'
6
7
  import { validateSync } from './validate.ts'
@@ -14,13 +15,34 @@ const id = (value: unknown): value is string =>
14
15
  const nullableId = (value: unknown): boolean => value === null || id(value)
15
16
  const matches = (schema: StandardSchemaV1<unknown>, value: unknown): boolean =>
16
17
  validateSync(schema, value, 'AI context checkpoint').issues === undefined
17
- const isMessage = (value: unknown): value is UIMessage =>
18
- matches(events['ai.message.created'], { message: value })
19
18
  const indexes = (value: unknown, messages: readonly UIMessage[]): boolean =>
20
19
  record(value) &&
21
20
  Object.values(value).every(index) &&
22
21
  messages.every((message) => Object.hasOwn(value, message.id))
23
22
 
23
+ const inputs = (value: unknown): boolean =>
24
+ record(value) &&
25
+ Object.keys(value).length === 2 &&
26
+ Array.isArray(value['commands']) &&
27
+ value['commands'].every(
28
+ (held) =>
29
+ record(held) &&
30
+ Object.keys(held).every((key) =>
31
+ ['commandId', 'inputId', 'message'].includes(key),
32
+ ) &&
33
+ id(held['commandId']) &&
34
+ id(held['inputId']) &&
35
+ (held['message'] === undefined || isUIMessage(held['message'])),
36
+ ) &&
37
+ Array.isArray(value['waiting']) &&
38
+ value['waiting'].every(
39
+ (input) =>
40
+ record(input) &&
41
+ Object.keys(input).length === 2 &&
42
+ id(input['inputId']) &&
43
+ isUIMessage(input['message']),
44
+ )
45
+
24
46
  const compaction = (value: unknown): boolean =>
25
47
  value === null ||
26
48
  (record(value) &&
@@ -109,7 +131,7 @@ const restorableBase = (options: {
109
131
  current: UIMessage | undefined
110
132
  }): boolean => {
111
133
  assertMessageBase(options.base)
112
- return isMessage(
134
+ return isUIMessage(
113
135
  restoreMessageBase({ current: options.current, base: options.base }),
114
136
  )
115
137
  }
@@ -230,6 +252,7 @@ export const contextSchema = <M extends UIMessage>(
230
252
  'compaction',
231
253
  'modelMetadata',
232
254
  'closed',
255
+ 'inputs',
233
256
  'authority',
234
257
  'generation',
235
258
  'activeProjection',
@@ -249,9 +272,10 @@ export const contextSchema = <M extends UIMessage>(
249
272
  const current = value['activeProjection']
250
273
  if (
251
274
  !Array.isArray(messages) ||
252
- !messages.every(isMessage) ||
275
+ !messages.every(isUIMessage) ||
253
276
  !compaction(value['compaction']) ||
254
277
  typeof value['closed'] !== 'boolean' ||
278
+ !inputs(value['inputs']) ||
255
279
  !authority(value['authority']) ||
256
280
  !record(metadata) ||
257
281
  !Object.entries(metadata).every(