experimental-a2 0.17.0 → 0.18.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 (211) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-Bjce9_5U.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-Bjce9_5U.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-UrWfKeuv.js → ai-BpOGm-zB.js} +2 -2
  11. package/dist/{ai-UrWfKeuv.js.map → ai-BpOGm-zB.js.map} +1 -1
  12. package/dist/{ai-vpGtcaCB.d.ts → ai-DrUaMyRt.d.ts} +3 -3
  13. package/dist/{ai-vpGtcaCB.d.ts.map → ai-DrUaMyRt.d.ts.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +634 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-BuLh_EYi.js +273 -0
  24. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  25. package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
  26. package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +904 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +106 -37
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai.d.ts +1 -1
  41. package/dist/ai.js +1 -1
  42. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  43. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  44. package/dist/{client-COaiaDub.js → client-BReKqwZC.js} +3 -3
  45. package/dist/{client-COaiaDub.js.map → client-BReKqwZC.js.map} +1 -1
  46. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  47. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  48. package/dist/client.d.ts +3 -3
  49. package/dist/client.js +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  52. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  53. package/dist/{internal-CbVvEwad.js → internal-B2T3U4Rq.js} +8 -2
  54. package/dist/internal-B2T3U4Rq.js.map +1 -0
  55. package/dist/otel.d.ts +1 -1
  56. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  57. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/reducer-BAXc8JyF.js.map +1 -1
  61. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  62. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  63. package/dist/scheduler-qstash.d.ts +2 -2
  64. package/dist/scheduler-qstash.js +2 -2
  65. package/dist/scheduler-vercel.d.ts +2 -2
  66. package/dist/scheduler-vercel.js +2 -2
  67. package/dist/{server-Rj64fNvX.js → server-Dw_k_7kO.js} +93 -60
  68. package/dist/server-Dw_k_7kO.js.map +1 -0
  69. package/dist/{server-CGM8WzRG.d.ts → server-En7cbi3v.d.ts} +36 -7
  70. package/dist/server-En7cbi3v.d.ts.map +1 -0
  71. package/dist/server.d.ts +4 -4
  72. package/dist/server.js +1 -1
  73. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-Bo-XTFEy.d.ts} +2 -2
  74. package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
  75. package/dist/{store-Gpkq7WBo.d.ts → store-Djdv0bnZ.d.ts} +31 -2
  76. package/dist/store-Djdv0bnZ.d.ts.map +1 -0
  77. package/dist/store-N8PXxDAS.js.map +1 -1
  78. package/dist/store-memory.d.ts +1 -1
  79. package/dist/store-memory.js +32 -6
  80. package/dist/store-memory.js.map +1 -1
  81. package/dist/{store-polling-4_y8lETm.js → store-polling-B5FcwJfo.js} +2 -2
  82. package/dist/{store-polling-4_y8lETm.js.map → store-polling-B5FcwJfo.js.map} +1 -1
  83. package/dist/store-postgres.d.ts +1 -1
  84. package/dist/store-postgres.js +54 -3
  85. package/dist/store-postgres.js.map +1 -1
  86. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-BAwS8RWn.js} +3 -3
  87. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
  88. package/dist/store-redis-http.d.ts +1 -1
  89. package/dist/store-redis-http.js +3 -3
  90. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-BRITti_o.js} +187 -68
  91. package/dist/store-redis-notify-BRITti_o.js.map +1 -0
  92. package/dist/store-redis.d.ts +1 -1
  93. package/dist/store-redis.js +1 -1
  94. package/dist/store-sqlite.d.ts +1 -1
  95. package/dist/store-sqlite.js +32 -2
  96. package/dist/store-sqlite.js.map +1 -1
  97. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  98. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  99. package/dist/{wire-DjfHOvdW.js → wire-rSUglYo8.js} +2 -2
  100. package/dist/{wire-DjfHOvdW.js.map → wire-rSUglYo8.js.map} +1 -1
  101. package/docs/01-quickstart.mdx +49 -83
  102. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  103. package/docs/actors/01-introduction.mdx +48 -86
  104. package/docs/actors/02-concurrency.mdx +43 -70
  105. package/docs/actors/03-timers.mdx +28 -42
  106. package/docs/actors/04-routes.mdx +110 -150
  107. package/docs/actors/meta.ts +1 -1
  108. package/docs/agents/01-introduction.mdx +257 -0
  109. package/docs/agents/02-tools.mdx +247 -0
  110. package/docs/agents/03-conversations.mdx +538 -0
  111. package/docs/agents/04-harnesses.mdx +136 -0
  112. package/docs/agents/meta.ts +1 -0
  113. package/docs/client/01-react.mdx +294 -0
  114. package/docs/client/02-transports.mdx +86 -0
  115. package/docs/client/03-presence.mdx +224 -0
  116. package/docs/client/meta.ts +1 -0
  117. package/docs/contract/01-events.mdx +57 -0
  118. package/docs/contract/02-state.mdx +142 -0
  119. package/docs/contract/03-sessions.mdx +175 -0
  120. package/docs/index.mdx +108 -144
  121. package/docs/reference/01-core.mdx +139 -0
  122. package/docs/reference/02-server.mdx +855 -0
  123. package/docs/reference/03-client.mdx +301 -0
  124. package/docs/reference/04-ai.mdx +722 -0
  125. package/docs/reference/05-adapters.mdx +351 -0
  126. package/docs/reference/06-errors.mdx +138 -0
  127. package/docs/reference/meta.ts +1 -1
  128. package/docs/server/01-handlers.mdx +200 -0
  129. package/docs/server/02-timers.mdx +130 -0
  130. package/docs/server/03-cancellation.mdx +141 -0
  131. package/docs/server/04-routes.mdx +243 -0
  132. package/docs/server/05-production.mdx +322 -0
  133. package/docs/server/06-guarantees.mdx +123 -0
  134. package/examples/playground/DEPLOY.md +1 -1
  135. package/examples/playground/app/a0/[appId]/page.tsx +11 -6
  136. package/examples/playground/app/a0/app-list.tsx +9 -2
  137. package/examples/playground/app/a0/app-view.tsx +9 -1
  138. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  139. package/examples/playground/app/a0/apps.test.ts +12 -0
  140. package/examples/playground/app/a0/apps.ts +22 -7
  141. package/examples/playground/app/a0/chat.tsx +47 -1
  142. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  143. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  144. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  145. package/examples/playground/app/a0/model.test.ts +18 -1
  146. package/examples/playground/app/a0/model.ts +47 -10
  147. package/examples/playground/app/a0/no-photon.cjs +4 -0
  148. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  149. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  150. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  153. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  154. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  155. package/examples/playground/app/a0/server.ts +100 -45
  156. package/examples/playground/app/a0/session.ts +17 -5
  157. package/examples/playground/app/a0/shell.tsx +11 -3
  158. package/examples/playground/app/agent/ingress.test.ts +6 -3
  159. package/examples/playground/app/agent/server.ts +9 -6
  160. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  161. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  162. package/examples/playground/app/chat/server.test.ts +30 -26
  163. package/examples/playground/app/chat/server.ts +46 -46
  164. package/examples/playground/next.config.ts +17 -5
  165. package/examples/playground/package.json +4 -1
  166. package/examples/playground/vercel.json +18 -0
  167. package/package.json +27 -3
  168. package/src/ai-agent.ts +261 -87
  169. package/src/ai-fx.browser.ts +5 -0
  170. package/src/ai-fx.ts +833 -0
  171. package/src/ai-harness.ts +518 -0
  172. package/src/ai-model-metadata.ts +11 -6
  173. package/src/ai-pi.browser.ts +5 -0
  174. package/src/ai-pi.ts +1263 -0
  175. package/src/ai-sdk-step.ts +2 -2
  176. package/src/ai-server.ts +3 -1
  177. package/src/internal.ts +7 -0
  178. package/src/libfx.d.ts +34 -0
  179. package/src/reducer.ts +1 -1
  180. package/src/server.ts +177 -19
  181. package/src/store-memory.ts +42 -7
  182. package/src/store-postgres.ts +80 -0
  183. package/src/store-redis-core.ts +210 -79
  184. package/src/store-redis-notify.ts +2 -0
  185. package/src/store-sqlite.ts +60 -0
  186. package/src/store.ts +33 -0
  187. package/src/telemetry.ts +1 -0
  188. package/dist/internal-CbVvEwad.js.map +0 -1
  189. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  190. package/dist/server-Rj64fNvX.js.map +0 -1
  191. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  192. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  193. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  194. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  195. package/docs/concepts/01-contracts.mdx +0 -237
  196. package/docs/concepts/02-handlers.mdx +0 -284
  197. package/docs/concepts/03-durability.mdx +0 -314
  198. package/docs/concepts/04-state.mdx +0 -209
  199. package/docs/guides/01-timers.mdx +0 -193
  200. package/docs/guides/02-cancellation.mdx +0 -161
  201. package/docs/guides/03-react.mdx +0 -324
  202. package/docs/guides/04-local-first.mdx +0 -47
  203. package/docs/guides/05-production.mdx +0 -534
  204. package/docs/guides/06-ai-agents.mdx +0 -1408
  205. package/docs/guides/08-application-data.mdx +0 -113
  206. package/docs/guides/09-presence.mdx +0 -262
  207. package/docs/guides/10-transports.mdx +0 -221
  208. package/docs/reference/01-api.mdx +0 -2358
  209. package/docs/reference/02-errors.mdx +0 -150
  210. /package/docs/{concepts → contract}/meta.ts +0 -0
  211. /package/docs/{guides → server}/meta.ts +0 -0
@@ -3,8 +3,8 @@ import { notFound } from 'next/navigation'
3
3
  import { AppView } from '../app-view'
4
4
  import { isAppId } from '../apps'
5
5
  import { DevTools } from '../devtools'
6
- import { logPage } from '../model'
7
- import { agentServer, crashesAllowed } from '../server'
6
+ import { harnessFrom, logPage } from '../model'
7
+ import { agentServers, crashesAllowed } from '../server'
8
8
  import { SessionProvider } from '../session'
9
9
 
10
10
  /** An app, from its current state; the provider follows its log from there. */
@@ -17,24 +17,29 @@ export default async function A0App({
17
17
  }): Promise<ReactNode> {
18
18
  const { appId } = await params
19
19
  if (!isAppId(appId)) notFound()
20
- const initial = await agentServer.session(appId).state()
20
+ const query = await searchParams
21
+ const harness = harnessFrom(query.harness)
22
+ const session = agentServers[harness].session(appId)
23
+ const initial = await session.state()
21
24
  // Open dev tools show the log itself, starting with its latest page.
22
25
  const events =
23
- (await searchParams).devtools !== undefined && initial.index > 0
24
- ? await agentServer.session(appId).history({
26
+ query.devtools !== undefined && initial.index > 0
27
+ ? await session.history({
25
28
  gte: Math.max(1, initial.index - logPage + 1),
26
29
  lte: initial.index,
27
30
  })
28
31
  : undefined
29
32
  return (
30
33
  <SessionProvider
31
- key={appId}
34
+ // Another harness is another log, followed over its own route.
35
+ key={`${harness}:${appId}`}
32
36
  sessionId={appId}
33
37
  initial={initial}
34
38
  initialEvents={events}
35
39
  >
36
40
  <AppView
37
41
  appId={appId}
42
+ harness={harness}
38
43
  devtools={
39
44
  <DevTools
40
45
  appId={appId}
@@ -35,9 +35,16 @@ export function AppList({
35
35
  onDelete: (app: App) => void
36
36
  }): ReactNode {
37
37
  const now = useNow(renderedAt)
38
- // Links keep the dev tools' state from app to app.
38
+ // Links keep the dev tools' state from app to app. A new app starts with
39
+ // the open app's harness; a listed one keeps its own.
39
40
  const params = useSearchParams()
40
41
  const query = queryString(new URLSearchParams(params))
42
+ const appQuery = (app: App): string => {
43
+ const own = new URLSearchParams(params)
44
+ own.delete('harness')
45
+ if (app.harness) own.set('harness', app.harness)
46
+ return queryString(own)
47
+ }
41
48
  // Open dev tools also read each page's log, so they don't prefetch every
42
49
  // app's in full.
43
50
  const prefetch = params.has('devtools') ? null : true
@@ -63,7 +70,7 @@ export function AppList({
63
70
  app={app}
64
71
  when={timeAgo({ at: app.promptedAt, now })}
65
72
  current={app.id === currentId}
66
- href={`/a0/${app.id}${query}`}
73
+ href={`/a0/${app.id}${appQuery(app)}`}
67
74
  prefetch={prefetch}
68
75
  onRename={(name) => onRename({ id: app.id, name })}
69
76
  onDelete={() => onDelete(app)}
@@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation'
5
5
  import { titleFromPrompt } from './apps'
6
6
  import { Chat } from './chat'
7
7
  import { ColumnEdge } from './columns'
8
+ import type { Harness } from './model'
8
9
  import { checkApp, PreviewPane } from './preview-pane'
9
10
  import { useSession } from './session'
10
11
  import { useApps } from './shell'
@@ -12,9 +13,11 @@ import { useApps } from './shell'
12
13
  /** One app: its chat with the agent, and the app it builds. */
13
14
  export function AppView({
14
15
  appId,
16
+ harness,
15
17
  devtools,
16
18
  }: {
17
19
  appId: string
20
+ harness: Harness
18
21
  /** Shown while the query has `devtools`. */
19
22
  devtools: ReactNode
20
23
  }): ReactNode {
@@ -41,8 +44,13 @@ export function AppView({
41
44
  <section aria-label="Chat" className="a0-chat">
42
45
  <h1 className="a0-title">{name}</h1>
43
46
  <Chat
47
+ harness={harness}
44
48
  onPrompt={(text) =>
45
- prompted({ id: appId, name: titleFromPrompt(prompt ?? text) })
49
+ prompted({
50
+ id: appId,
51
+ name: titleFromPrompt(prompt ?? text),
52
+ harness,
53
+ })
46
54
  }
47
55
  autoFocus={!app}
48
56
  />
@@ -1,7 +1,7 @@
1
1
  import { inputs } from 'experimental-a2/ai'
2
2
  import { isAppId } from '../../apps'
3
3
  import { deleteAppSandbox } from '../../sandbox'
4
- import { agentServer } from '../../server'
4
+ import { agentServers } from '../../server'
5
5
 
6
6
  /**
7
7
  * Stops the agent's current turn, so no later tool call brings the sandbox
@@ -14,10 +14,15 @@ export async function DELETE(
14
14
  const { appId } = await context.params
15
15
  if (!isAppId(appId))
16
16
  return Response.json({ error: 'Unknown app.' }, { status: 404 })
17
- const session = agentServer.session(appId)
18
- const { state } = await session.state()
19
- if (state.active)
20
- await session.append(...inputs.stop({ turnId: state.active.turnId }))
17
+ // The app's agent is whichever harness's agent has its log.
18
+ await Promise.all(
19
+ Object.values(agentServers).map(async (server) => {
20
+ const session = server.session(appId)
21
+ const { state } = await session.state()
22
+ if (state.active)
23
+ await session.append(...inputs.stop({ turnId: state.active.turnId }))
24
+ }),
25
+ )
21
26
  await deleteAppSandbox({ appId })
22
27
  return Response.json({ ok: true })
23
28
  }
@@ -114,3 +114,15 @@ it('reads the widths someone dragged to, and ignores bad ones', () => {
114
114
  chat: 480,
115
115
  })
116
116
  })
117
+
118
+ it("keeps the harness an app's agent runs, for its link", () => {
119
+ expect(
120
+ fold(
121
+ ['app.created', { appId: first, name: 'todo app', harness: 'fx' }, 1_000],
122
+ ['app.created', { appId: second, name: 'counter' }, 2_000],
123
+ ),
124
+ ).toEqual([
125
+ { id: first, name: 'todo app', promptedAt: 1_000, harness: 'fx' },
126
+ { id: second, name: 'counter', promptedAt: 2_000 },
127
+ ])
128
+ })
@@ -1,14 +1,21 @@
1
1
  import { contract } from 'experimental-a2'
2
2
  import { z } from 'zod'
3
+ import { type Harness, harnessSchema } from './model'
3
4
 
4
5
  export const isAppId = (value: string): boolean =>
5
6
  z.uuid().safeParse(value).success
6
7
 
7
8
  /**
8
9
  * An app is one conversation with the coding agent and one sandbox. It's
9
- * listed with the time of its latest prompt, as the server recorded it.
10
+ * listed with the time of its latest prompt, as the server recorded it, and
11
+ * the harness its agent runs when that's not the AI SDK's.
10
12
  */
11
- export type App = { id: string; name: string; promptedAt: number }
13
+ export type App = {
14
+ id: string
15
+ name: string
16
+ promptedAt: number
17
+ harness?: Harness
18
+ }
12
19
 
13
20
  const appId = z.uuid()
14
21
  const name = z.string().trim().min(1).max(80)
@@ -21,7 +28,11 @@ const name = z.string().trim().min(1).max(80)
21
28
  export const appList = contract({
22
29
  name: 'a0-apps',
23
30
  events: {
24
- 'app.created': z.object({ appId, name }),
31
+ 'app.created': z.object({
32
+ appId,
33
+ name,
34
+ harness: harnessSchema.optional(),
35
+ }),
25
36
  'app.prompted': z.object({ appId }),
26
37
  'app.renamed': z.object({ appId, name }),
27
38
  'app.deleted': z.object({ appId }),
@@ -33,10 +44,14 @@ export const appList = contract({
33
44
  const { appId: id } = event.payload
34
45
  const promptedAt = event.createdAt.getTime()
35
46
  switch (event.type) {
36
- case 'app.created':
37
- return apps.some((app) => app.id === id)
38
- ? apps
39
- : [...apps, { id, name: event.payload.name, promptedAt }]
47
+ case 'app.created': {
48
+ if (apps.some((app) => app.id === id)) return apps
49
+ const { name: created, harness } = event.payload
50
+ return [
51
+ ...apps,
52
+ { id, name: created, promptedAt, ...(harness ? { harness } : {}) },
53
+ ]
54
+ }
40
55
  case 'app.prompted':
41
56
  return apps.map((app) =>
42
57
  app.id === id ? { ...app, promptedAt } : app,
@@ -1,10 +1,15 @@
1
1
  'use client'
2
2
  import type { ReactNode } from 'react'
3
3
  import { useEffect, useRef, useState } from 'react'
4
+ import { useRouter, useSearchParams } from 'next/navigation'
4
5
  import { Asterisk, LoaderCircle, Zap } from 'lucide-react'
5
6
  import { inputs } from 'experimental-a2/ai'
7
+ import { queryString } from './flags'
6
8
  import {
7
9
  type AppMessage,
10
+ type Harness,
11
+ harnessLabels,
12
+ harnessSchema,
8
13
  type ModelChoice,
9
14
  reasoningSchema,
10
15
  toolLabels,
@@ -22,13 +27,25 @@ const suggestions = [
22
27
 
23
28
  /** The coding agent's conversation: its words, and one line per tool step. */
24
29
  export function Chat({
30
+ harness,
25
31
  onPrompt,
26
32
  autoFocus,
27
33
  }: {
34
+ harness: Harness
28
35
  onPrompt: (prompt: string) => void
29
36
  autoFocus: boolean
30
37
  }): ReactNode {
31
38
  const { state, push } = useSession()
39
+ const router = useRouter()
40
+ const params = useSearchParams()
41
+ // Each harness's agent has its own log, so an app picks one before its
42
+ // first message: the URL names it, and the page follows that agent.
43
+ const pickHarness = (next: Harness): void => {
44
+ const query = new URLSearchParams(params)
45
+ if (next === 'ai-sdk') query.delete('harness')
46
+ else query.set('harness', next)
47
+ router.replace(`${location.pathname}${queryString(query)}`)
48
+ }
32
49
  const [error, setError] = useState<string | null>(null)
33
50
  // A change here applies when the next message goes out with it, so typing a
34
51
  // model id doesn't record a choice per keystroke. Until then, the composer
@@ -133,6 +150,8 @@ export function Chat({
133
150
  queued={state.inbox.items.length}
134
151
  settings={settings}
135
152
  onSettings={setChosen}
153
+ harness={harness}
154
+ onHarness={state.messages.length ? null : pickHarness}
136
155
  autoFocus={autoFocus}
137
156
  />
138
157
  </>
@@ -179,7 +198,10 @@ function MessagePart({
179
198
  const name = part.type.slice('tool-'.length)
180
199
  const done = part.state === 'output-available'
181
200
  const failed = part.state === 'output-error'
182
- const detail = (part as { input?: { command?: unknown } }).input?.command
201
+ // A command, or the file one of Pi's tools works on.
202
+ const input = (part as { input?: { command?: unknown; path?: unknown } })
203
+ .input
204
+ const detail = input?.command ?? input?.path
183
205
  const took = (part as { output?: { durationMs?: unknown } }).output
184
206
  ?.durationMs
185
207
  const label =
@@ -220,6 +242,8 @@ function Composer({
220
242
  queued,
221
243
  settings,
222
244
  onSettings,
245
+ harness,
246
+ onHarness,
223
247
  autoFocus,
224
248
  }: {
225
249
  onSend: (text: string) => Promise<void>
@@ -228,6 +252,9 @@ function Composer({
228
252
  queued: number
229
253
  settings: ModelChoice
230
254
  onSettings: (settings: ModelChoice) => void
255
+ harness: Harness
256
+ /** Until the app's first message; it keeps its harness after that. */
257
+ onHarness: ((harness: Harness) => void) | null
231
258
  autoFocus: boolean
232
259
  }): ReactNode {
233
260
  const [text, setText] = useState('')
@@ -297,6 +324,25 @@ function Composer({
297
324
  />
298
325
  <div className="a0-composer-bar">
299
326
  <div className="a0-composer-model">
327
+ <select
328
+ aria-label="Harness"
329
+ title={
330
+ onHarness
331
+ ? "The agent's loop: the AI SDK's, FX's, or Pi's"
332
+ : 'The agent loop this app runs'
333
+ }
334
+ value={harness}
335
+ disabled={!onHarness}
336
+ onChange={(event) =>
337
+ onHarness?.(harnessSchema.parse(event.target.value))
338
+ }
339
+ >
340
+ {harnessSchema.options.map((option) => (
341
+ <option key={option} value={option}>
342
+ {harnessLabels[option]}
343
+ </option>
344
+ ))}
345
+ </select>
300
346
  <input
301
347
  aria-label="Model"
302
348
  title="The Gateway model the next request runs with"
@@ -0,0 +1,9 @@
1
+ import { harnessEvents } from '../../server'
2
+
3
+ /** The events of apps whose agent runs FX's loop. */
4
+ const events = harnessEvents('fx')
5
+
6
+ export { events as GET, events as POST }
7
+
8
+ // Agent work started by a push runs after the response, up to this long.
9
+ export const maxDuration = 300
@@ -0,0 +1,7 @@
1
+ import { agentServers, appSchedulers } from '../../server'
2
+
3
+ export const POST =
4
+ appSchedulers.fx?.handler(agentServers.fx) ??
5
+ (() => new Response('a0 fx scheduler is not configured', { status: 503 }))
6
+
7
+ export const maxDuration = 300
@@ -0,0 +1,11 @@
1
+ import { harnessEvents } from '../../../server'
2
+
3
+ /**
4
+ * Pushes from the dev tools' 5-second switch. Agent work they start stops
5
+ * with the function, and the scheduler resumes it in a full-length one.
6
+ */
7
+ const events = harnessEvents('fx')
8
+
9
+ export { events as POST }
10
+
11
+ export const maxDuration = 5
@@ -1,6 +1,11 @@
1
1
  import type { ContractEvent } from 'experimental-a2'
2
2
  import { expect, it } from 'vitest'
3
- import { appAgent, defaultModelChoice } from './model'
3
+ import {
4
+ appAgent,
5
+ defaultModelChoice,
6
+ harnessAgents,
7
+ harnessFrom,
8
+ } from './model'
4
9
 
5
10
  const chosen = (payload: unknown): ContractEvent<typeof appAgent.events> =>
6
11
  ({
@@ -54,3 +59,15 @@ it('fills in defaults and rejects choices a browser could write badly', () => {
54
59
  ])
55
60
  expect(validate(payload)).toHaveProperty('issues')
56
61
  })
62
+
63
+ it("reads the app's harness from its URL, the AI SDK's by default", () => {
64
+ expect(harnessFrom('fx')).toBe('fx')
65
+ expect(harnessFrom(null)).toBe('ai-sdk')
66
+ expect(harnessFrom('other')).toBe('ai-sdk')
67
+ // Each harness's agent keeps its own logs.
68
+ expect(Object.values(harnessAgents).map((agent) => agent.name)).toEqual([
69
+ 'a0',
70
+ 'a0-fx',
71
+ 'a0-pi',
72
+ ])
73
+ })
@@ -35,24 +35,61 @@ export const defaultModelChoice: ModelChoice = {
35
35
 
36
36
  export type AppMessage = UIMessage
37
37
 
38
+ /**
39
+ * The loop an app's agent runs: the AI SDK's, FX's, or Pi's. An
40
+ * app picks it before its first message and keeps it.
41
+ */
42
+ export const harnessSchema = z.enum(['ai-sdk', 'fx', 'pi'])
43
+
44
+ export type Harness = z.output<typeof harnessSchema>
45
+
46
+ export const harnessLabels: Record<Harness, string> = {
47
+ 'ai-sdk': 'AI SDK',
48
+ fx: 'FX',
49
+ pi: 'Pi',
50
+ }
51
+
52
+ /** The harness an app's URL names, `?harness=fx`; the AI SDK's without one. */
53
+ export const harnessFrom = (value: unknown): Harness =>
54
+ harnessSchema.safeParse(value).data ?? 'ai-sdk'
55
+
56
+ const appContract = (name: string) =>
57
+ agentContract({
58
+ name,
59
+ events: { 'model.chosen': modelChoiceSchema },
60
+ reducer: {
61
+ name: 'a0.model-choice.v1',
62
+ initialState: { modelChoice: defaultModelChoice },
63
+ fold: (state, event) =>
64
+ event.type === 'model.chosen' ? { modelChoice: event.payload } : state,
65
+ },
66
+ })
67
+
38
68
  /** The coding agent's contract, shared by the server and the browser. */
39
- export const appAgent = agentContract({
40
- name: 'a0',
41
- events: { 'model.chosen': modelChoiceSchema },
42
- reducer: {
43
- name: 'a0.model-choice.v1',
44
- initialState: { modelChoice: defaultModelChoice },
45
- fold: (state, event) =>
46
- event.type === 'model.chosen' ? { modelChoice: event.payload } : state,
47
- },
48
- })
69
+ export const appAgent = appContract('a0')
70
+
71
+ /**
72
+ * Each harness's agent keeps its own logs. They differ only in name, so the
73
+ * browser follows any of them with `appAgent`.
74
+ */
75
+ export const harnessAgents = {
76
+ 'ai-sdk': appAgent,
77
+ fx: appContract('a0-fx'),
78
+ pi: appContract('a0-pi'),
79
+ } satisfies Record<Harness, typeof appAgent>
49
80
 
50
81
  export const toolLabels: Record<string, string> = {
51
82
  bash: 'Running a command',
52
83
  show_preview: 'Showing the app',
84
+ // Pi's own tools.
85
+ read: 'Reading a file',
86
+ write: 'Writing a file',
87
+ edit: 'Editing a file',
53
88
  }
54
89
 
55
90
  /** While the model is still writing a call's input. */
56
91
  export const writingLabels: Record<string, string> = {
57
92
  bash: 'Writing a command',
93
+ write: 'Writing a file',
94
+ edit: 'Writing an edit',
58
95
  }
@@ -0,0 +1,4 @@
1
+ // Stands in for @silvia-odwyer/photon-node, whose WebAssembly the playground's
2
+ // bundler cannot trace. Pi treats a photon that fails to load as no image
3
+ // processing.
4
+ throw new Error('photon is not available in the playground')
@@ -0,0 +1,3 @@
1
+ // Stands in for quickjs-wasi, whose WebAssembly the playground's bundler
2
+ // cannot trace. Only Pi's codemode extension imports it, and A0 never starts it.
3
+ module.exports = {}
@@ -0,0 +1,9 @@
1
+ import { harnessEvents } from '../../server'
2
+
3
+ /** The events of apps whose agent runs Pi's loop. */
4
+ const events = harnessEvents('pi')
5
+
6
+ export { events as GET, events as POST }
7
+
8
+ // Agent work started by a push runs after the response, up to this long.
9
+ export const maxDuration = 300
@@ -0,0 +1,7 @@
1
+ import { agentServers, appSchedulers } from '../../server'
2
+
3
+ export const POST =
4
+ appSchedulers.pi?.handler(agentServers.pi) ??
5
+ (() => new Response('a0 pi scheduler is not configured', { status: 503 }))
6
+
7
+ export const maxDuration = 300
@@ -0,0 +1,11 @@
1
+ import { harnessEvents } from '../../../server'
2
+
3
+ /**
4
+ * Pushes from the dev tools' 5-second switch. Agent work they start stops
5
+ * with the function, and the scheduler resumes it in a full-length one.
6
+ */
7
+ const events = harnessEvents('pi')
8
+
9
+ export { events as POST }
10
+
11
+ export const maxDuration = 5
@@ -6,7 +6,7 @@ import { z } from 'zod'
6
6
  import { store } from '@/lib/store'
7
7
  import { isAppId } from './apps'
8
8
  import { deleteAppSandbox } from './sandbox'
9
- import { agentServer } from './server'
9
+ import { agentServers } from './server'
10
10
 
11
11
  // How long a new app's sandbox waits for a first prompt.
12
12
  const IDLE_GRACE_MS = 10 * 60 * 1000
@@ -38,9 +38,15 @@ export const sandboxServer = createServer({
38
38
  handlers: {
39
39
  'sandbox.idle.checked': async ({ event }) => {
40
40
  const { appId } = event.payload
41
- // A prompt since the sandbox opened makes it a real app's.
42
- const { state } = await agentServer.session(appId).state()
43
- if (state.messages.length === 0) await deleteAppSandbox({ appId })
41
+ // A prompt since the sandbox opened makes it a real app's, whichever
42
+ // harness's agent got it.
43
+ const reads = await Promise.all(
44
+ Object.values(agentServers).map((server) =>
45
+ server.session(appId).state(),
46
+ ),
47
+ )
48
+ if (reads.every(({ state }) => state.messages.length === 0))
49
+ await deleteAppSandbox({ appId })
44
50
  },
45
51
  },
46
52
  })
@@ -0,0 +1,152 @@
1
+ import 'server-only'
2
+ import type { Sandbox } from '@vercel/sandbox'
3
+ import type { Experimental_SandboxSession } from 'ai'
4
+ import { appDir } from './sandbox'
5
+
6
+ // A job the command backgrounds, like `next dev &`, keeps whatever output it
7
+ // started with, and a read of a pipe would wait on it forever. Output goes to
8
+ // a file instead; background jobs live on.
9
+ const CAPTURE = `out=$(mktemp); bash -lc "$0" > "$out" 2>&1 < /dev/null; code=$?; cat "$out"; rm -f "$out"; exit $code`
10
+
11
+ type Run = Parameters<Experimental_SandboxSession['run']>[0]
12
+
13
+ /**
14
+ * The app's Vercel Sandbox as an AI SDK sandbox session, for harnesses whose
15
+ * own tools need one. Commands run in the app's directory.
16
+ */
17
+ export function sandboxSession(sandbox: Sandbox): Experimental_SandboxSession {
18
+ const params = ({ command, workingDirectory, env, abortSignal }: Run) => ({
19
+ cmd: 'bash',
20
+ args: ['-c', CAPTURE, command],
21
+ cwd: workingDirectory ?? appDir,
22
+ ...(env ? { env } : {}),
23
+ ...(abortSignal ? { signal: abortSignal } : {}),
24
+ })
25
+ const readBinaryFile = async ({
26
+ path,
27
+ abortSignal,
28
+ }: {
29
+ path: string
30
+ abortSignal?: AbortSignal
31
+ }): Promise<Uint8Array | null> => {
32
+ const buffer = await sandbox.readFileToBuffer(
33
+ { path, cwd: appDir },
34
+ abortSignal ? { signal: abortSignal } : {},
35
+ )
36
+ return buffer && new Uint8Array(buffer)
37
+ }
38
+ // The sandbox extracts written files at its root, creating their directories.
39
+ const write = (path: string, content: Uint8Array, signal?: AbortSignal) =>
40
+ sandbox.writeFiles(
41
+ [{ path: path.startsWith('/') ? path : `${appDir}/${path}`, content }],
42
+ signal ? { signal } : {},
43
+ )
44
+
45
+ return {
46
+ description: `A Vercel Sandbox. The app is in ${appDir}.`,
47
+ // Streams, so a reader that stops early stops the download.
48
+ readFile: async ({ path, abortSignal }) => {
49
+ const stream = await sandbox.readFile(
50
+ { path, cwd: appDir },
51
+ abortSignal ? { signal: abortSignal } : {},
52
+ )
53
+ if (stream === null) return null
54
+ const chunks = stream[Symbol.asyncIterator]()
55
+ return new ReadableStream<Uint8Array>({
56
+ pull: async (controller) => {
57
+ const { done, value } = await chunks.next()
58
+ if (done) controller.close()
59
+ else
60
+ controller.enqueue(
61
+ typeof value === 'string'
62
+ ? new TextEncoder().encode(value)
63
+ : value,
64
+ )
65
+ },
66
+ cancel: async () => {
67
+ await chunks.return?.()
68
+ },
69
+ })
70
+ },
71
+ readBinaryFile,
72
+ readTextFile: async ({ encoding, startLine, endLine, ...options }) => {
73
+ const bytes = await readBinaryFile(options)
74
+ if (bytes === null) return null
75
+ const text = new TextDecoder(encoding).decode(bytes)
76
+ if (startLine === undefined && endLine === undefined) return text
77
+ return text
78
+ .split('\n')
79
+ .slice((startLine ?? 1) - 1, endLine)
80
+ .join('\n')
81
+ },
82
+ writeFile: async ({ path, content, abortSignal }) =>
83
+ write(
84
+ path,
85
+ new Uint8Array(await new Response(content).arrayBuffer()),
86
+ abortSignal,
87
+ ),
88
+ writeBinaryFile: ({ path, content, abortSignal }) =>
89
+ write(path, content, abortSignal),
90
+ writeTextFile: ({ path, content, abortSignal }) =>
91
+ write(path, new TextEncoder().encode(content), abortSignal),
92
+ run: async (options) => {
93
+ const finished = await sandbox.runCommand(params(options))
94
+ return {
95
+ exitCode: finished.exitCode,
96
+ stdout: await finished.stdout(),
97
+ stderr: await finished.stderr(),
98
+ }
99
+ },
100
+ spawn: async (options) => {
101
+ const { abortSignal } = options
102
+ const command = await sandbox.runCommand({
103
+ ...params(options),
104
+ detached: true,
105
+ })
106
+ // For a detached command the signal only cancels its start; aborting
107
+ // kills it, as the AI SDK's sandbox contract says.
108
+ const kill = () => void command.kill().catch(() => {})
109
+ if (abortSignal?.aborted) kill()
110
+ abortSignal?.addEventListener('abort', kill, { once: true })
111
+ const encoder = new TextEncoder()
112
+ let stdout!: ReadableStreamDefaultController<Uint8Array>
113
+ let stderr!: ReadableStreamDefaultController<Uint8Array>
114
+ const streams = {
115
+ stdout: new ReadableStream<Uint8Array>({
116
+ start: (c) => void (stdout = c),
117
+ }),
118
+ stderr: new ReadableStream<Uint8Array>({
119
+ start: (c) => void (stderr = c),
120
+ }),
121
+ }
122
+ // The command's log ends when it exits, and closes both streams.
123
+ void (async () => {
124
+ try {
125
+ for await (const line of command.logs())
126
+ (line.stream === 'stdout' ? stdout : stderr).enqueue(
127
+ encoder.encode(line.data),
128
+ )
129
+ stdout.close()
130
+ stderr.close()
131
+ } catch (error) {
132
+ stdout.error(error)
133
+ stderr.error(error)
134
+ }
135
+ })()
136
+ return {
137
+ ...streams,
138
+ wait: async () => {
139
+ try {
140
+ const finished = await command.wait(
141
+ abortSignal ? { signal: abortSignal } : {},
142
+ )
143
+ return { exitCode: finished.exitCode }
144
+ } finally {
145
+ abortSignal?.removeEventListener('abort', kill)
146
+ }
147
+ },
148
+ kill: () => command.kill(),
149
+ }
150
+ },
151
+ }
152
+ }