experimental-a2 0.8.0 → 0.9.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 (182) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +29 -0
  4. package/dist/actor-client.d.ts +46 -0
  5. package/dist/actor-client.d.ts.map +1 -0
  6. package/dist/actor-client.js +54 -0
  7. package/dist/actor-client.js.map +1 -0
  8. package/dist/actor-react.d.ts +54 -0
  9. package/dist/actor-react.d.ts.map +1 -0
  10. package/dist/actor-react.js +79 -0
  11. package/dist/actor-react.js.map +1 -0
  12. package/dist/actor-shared-BACubf4x.d.ts +136 -0
  13. package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
  14. package/dist/actor-shared-DI7J5upy.js +127 -0
  15. package/dist/actor-shared-DI7J5upy.js.map +1 -0
  16. package/dist/actor.browser.d.ts +1 -0
  17. package/dist/actor.browser.js +13 -0
  18. package/dist/actor.browser.js.map +1 -0
  19. package/dist/actor.d.ts +176 -0
  20. package/dist/actor.d.ts.map +1 -0
  21. package/dist/actor.js +437 -0
  22. package/dist/actor.js.map +1 -0
  23. package/dist/ai-server.d.ts +2 -2
  24. package/dist/ai-server.js +2 -2
  25. package/dist/ai.d.ts +2 -2
  26. package/dist/client.d.ts +1 -1
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +4 -4
  29. package/dist/client.js.map +1 -1
  30. package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
  31. package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
  32. package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
  33. package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
  34. package/dist/index.d.ts +16 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/react.d.ts +1 -1
  38. package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
  39. package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
  40. package/dist/scheduler-qstash.d.ts +2 -2
  41. package/dist/scheduler-qstash.js +2 -2
  42. package/dist/scheduler-vercel.d.ts +2 -2
  43. package/dist/scheduler-vercel.js +1 -1
  44. package/dist/{server-B2XNevQA.js → server-CBET-jSz.js} +6 -6
  45. package/dist/server-CBET-jSz.js.map +1 -0
  46. package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
  47. package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
  48. package/dist/server.d.ts +3 -3
  49. package/dist/server.js +1 -1
  50. package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
  51. package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
  52. package/dist/store-memory.d.ts +1 -1
  53. package/dist/store-memory.js +2 -2
  54. package/dist/store-postgres.d.ts +1 -1
  55. package/dist/store-postgres.js +2 -2
  56. package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
  57. package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
  58. package/dist/store-redis-http.d.ts +1 -1
  59. package/dist/store-redis-http.js +2 -2
  60. package/dist/store-redis.d.ts +1 -1
  61. package/dist/store-redis.js +2 -2
  62. package/dist/store-sqlite.d.ts +1 -1
  63. package/dist/store-sqlite.js +2 -2
  64. package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
  65. package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
  66. package/docs/actors/01-introduction.mdx +189 -0
  67. package/docs/actors/02-concurrency.mdx +154 -0
  68. package/docs/actors/03-timers.mdx +120 -0
  69. package/docs/actors/04-routes.mdx +352 -0
  70. package/docs/actors/meta.ts +1 -0
  71. package/docs/concepts/meta.ts +1 -0
  72. package/docs/guides/07-examples.mdx +56 -0
  73. package/docs/guides/meta.ts +1 -0
  74. package/docs/index.mdx +16 -0
  75. package/docs/reference/02-errors.mdx +33 -0
  76. package/docs/reference/meta.ts +1 -0
  77. package/examples/README.md +15 -0
  78. package/examples/playground/AGENTS.md +11 -0
  79. package/examples/playground/DEPLOY.md +106 -0
  80. package/examples/playground/README.md +19 -0
  81. package/examples/playground/activity-feed.test.ts +10 -0
  82. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  83. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  84. package/examples/playground/app/agent/events/route.ts +4 -0
  85. package/examples/playground/app/agent/model.ts +3 -0
  86. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  87. package/examples/playground/app/agent/page.tsx +25 -0
  88. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  89. package/examples/playground/app/agent/server.ts +153 -0
  90. package/examples/playground/app/agent/session.ts +13 -0
  91. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  92. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  93. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  94. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  95. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  96. package/examples/playground/app/canvas/events/route.ts +13 -0
  97. package/examples/playground/app/canvas/model.ts +94 -0
  98. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  99. package/examples/playground/app/canvas/page.tsx +20 -0
  100. package/examples/playground/app/canvas/server.ts +9 -0
  101. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  102. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  103. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  104. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  105. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  106. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  107. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  108. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  109. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  110. package/examples/playground/app/chat/events/route.ts +7 -0
  111. package/examples/playground/app/chat/model.test.ts +155 -0
  112. package/examples/playground/app/chat/model.ts +310 -0
  113. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  114. package/examples/playground/app/chat/page.tsx +25 -0
  115. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  116. package/examples/playground/app/chat/server.ts +184 -0
  117. package/examples/playground/app/components/activity-feed.tsx +54 -0
  118. package/examples/playground/app/components/connection-pill.tsx +29 -0
  119. package/examples/playground/app/counter/counter-client.tsx +72 -0
  120. package/examples/playground/app/counter/events/route.ts +4 -0
  121. package/examples/playground/app/counter/model.test.ts +36 -0
  122. package/examples/playground/app/counter/model.ts +31 -0
  123. package/examples/playground/app/counter/page.tsx +24 -0
  124. package/examples/playground/app/counter/server.ts +9 -0
  125. package/examples/playground/app/counter/session.ts +13 -0
  126. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  127. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  128. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  129. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  130. package/examples/playground/app/documents/events/route.ts +4 -0
  131. package/examples/playground/app/documents/model.ts +55 -0
  132. package/examples/playground/app/documents/open-document.tsx +40 -0
  133. package/examples/playground/app/documents/page.tsx +22 -0
  134. package/examples/playground/app/documents/server.ts +9 -0
  135. package/examples/playground/app/globals.css +2078 -0
  136. package/examples/playground/app/layout.tsx +44 -0
  137. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  138. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  139. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  140. package/examples/playground/app/orders/create/route.ts +30 -0
  141. package/examples/playground/app/orders/events/route.ts +4 -0
  142. package/examples/playground/app/orders/model.ts +79 -0
  143. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  144. package/examples/playground/app/orders/page.tsx +22 -0
  145. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  146. package/examples/playground/app/orders/server.ts +50 -0
  147. package/examples/playground/app/page.tsx +111 -0
  148. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  149. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  150. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  151. package/examples/playground/app/recovery/events/route.ts +3 -0
  152. package/examples/playground/app/recovery/model.ts +55 -0
  153. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  154. package/examples/playground/app/recovery/page.tsx +22 -0
  155. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  156. package/examples/playground/app/recovery/server.ts +53 -0
  157. package/examples/playground/app/recovery/start/route.ts +41 -0
  158. package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
  159. package/examples/playground/app/vault/page.tsx +12 -0
  160. package/examples/playground/app/vault/server.ts +9 -0
  161. package/examples/playground/app/vault/vault-client.tsx +124 -0
  162. package/examples/playground/app/vault/vault.test.ts +147 -0
  163. package/examples/playground/app/vault/vault.ts +119 -0
  164. package/examples/playground/css.d.ts +4 -0
  165. package/examples/playground/lib/store.ts +15 -0
  166. package/examples/playground/next-env.d.ts +5 -0
  167. package/examples/playground/next.config.ts +10 -0
  168. package/examples/playground/package.json +46 -0
  169. package/examples/playground/tsconfig.json +37 -0
  170. package/examples/playground/vercel.json +40 -0
  171. package/package.json +11 -2
  172. package/src/actor-client.ts +132 -0
  173. package/src/actor-react.ts +143 -0
  174. package/src/actor-shared.ts +356 -0
  175. package/src/actor.browser.ts +12 -0
  176. package/src/actor.ts +914 -0
  177. package/src/client.ts +9 -1
  178. package/src/errors.ts +15 -0
  179. package/src/index.ts +1 -1
  180. package/src/server.ts +13 -3
  181. package/dist/contract-jIfaR085.d.ts.map +0 -1
  182. package/dist/server-B2XNevQA.js.map +0 -1
@@ -0,0 +1,376 @@
1
+ 'use client'
2
+ import type {
3
+ FormEvent,
4
+ KeyboardEvent as ReactKeyboardEvent,
5
+ ReactNode,
6
+ } from 'react'
7
+ import { useEffect, useRef, useState } from 'react'
8
+ import type { UIMessage } from 'ai'
9
+ import { inputs } from 'experimental-a2/ai'
10
+ import { ActivityFeed } from '@/app/components/activity-feed'
11
+ import { ConnectionPill } from '@/app/components/connection-pill'
12
+ import { useSession } from '../session'
13
+
14
+ type MessagePart = UIMessage['parts'][number]
15
+ type ToolPartView = {
16
+ name: string
17
+ state: string
18
+ input: unknown
19
+ output?: unknown
20
+ approved?: boolean
21
+ }
22
+
23
+ const STATUS_LABEL = {
24
+ idle: 'idle',
25
+ generating: 'working…',
26
+ waiting: 'approval needed',
27
+ failed: 'failed',
28
+ closed: 'closed',
29
+ } as const
30
+
31
+ const toolTitle = (name: string): string =>
32
+ name === 'inspectService'
33
+ ? 'Inspect service'
34
+ : name === 'restartService'
35
+ ? 'Restart service'
36
+ : name === 'setReminder'
37
+ ? 'Set reminder'
38
+ : name === 'bash'
39
+ ? 'Bash'
40
+ : name
41
+
42
+ const toolState = (part: ToolPartView): string => {
43
+ switch (part.state) {
44
+ case 'approval-requested':
45
+ return 'waiting for approval'
46
+ case 'approval-responded':
47
+ return part.approved ? 'approved' : 'denied'
48
+ case 'output-available':
49
+ return 'completed'
50
+ case 'output-error':
51
+ return 'failed'
52
+ case 'output-denied':
53
+ return 'denied'
54
+ default:
55
+ return 'running'
56
+ }
57
+ }
58
+
59
+ const toolPartView = (part: MessagePart): ToolPartView | null => {
60
+ if (part.type !== 'dynamic-tool' && !part.type.startsWith('tool-'))
61
+ return null
62
+ const candidate = part as unknown as Record<string, unknown>
63
+ const name =
64
+ part.type === 'dynamic-tool'
65
+ ? candidate['toolName']
66
+ : part.type.slice('tool-'.length)
67
+ if (typeof name !== 'string' || typeof candidate['state'] !== 'string') {
68
+ return null
69
+ }
70
+ const approval = candidate['approval']
71
+ return {
72
+ name,
73
+ state: candidate['state'],
74
+ input: candidate['input'],
75
+ ...(candidate['output'] === undefined
76
+ ? {}
77
+ : { output: candidate['output'] }),
78
+ ...(typeof approval === 'object' &&
79
+ approval !== null &&
80
+ 'approved' in approval &&
81
+ typeof approval.approved === 'boolean'
82
+ ? { approved: approval.approved }
83
+ : {}),
84
+ }
85
+ }
86
+
87
+ function ToolCard({ part }: { part: ToolPartView }): ReactNode {
88
+ return (
89
+ <div className={`tool-card ${part.state}`}>
90
+ <div>
91
+ <strong>{toolTitle(part.name)}</strong>
92
+ <span>{toolState(part)}</span>
93
+ </div>
94
+ <code>{JSON.stringify(part.input)}</code>
95
+ {part.output === undefined ? null : (
96
+ <pre>{JSON.stringify(part.output, null, 2)}</pre>
97
+ )}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function MessagePartView({ part }: { part: MessagePart }): ReactNode {
103
+ if (part.type === 'text') return part.text ? <p>{part.text}</p> : null
104
+ const tool = toolPartView(part)
105
+ if (tool) return <ToolCard part={tool} />
106
+ return null
107
+ }
108
+
109
+ const hasVisibleParts = (message: UIMessage): boolean =>
110
+ message.parts.some(
111
+ (part) =>
112
+ (part.type === 'text' && part.text.length > 0) ||
113
+ toolPartView(part) !== null,
114
+ )
115
+
116
+ function Thinking(): ReactNode {
117
+ return (
118
+ <p className="agent-thinking" role="status">
119
+ <span aria-hidden="true" />
120
+ Thinking…
121
+ </p>
122
+ )
123
+ }
124
+
125
+ function AgentMessage({
126
+ message,
127
+ thinking,
128
+ }: {
129
+ message: UIMessage
130
+ thinking: boolean
131
+ }): ReactNode {
132
+ const role =
133
+ message.role === 'user'
134
+ ? message.id.startsWith('reminder:')
135
+ ? 'Reminder'
136
+ : 'You'
137
+ : 'Agent'
138
+
139
+ return (
140
+ <div className={`agent-message ${message.role}`}>
141
+ <span className="agent-role">{role}</span>
142
+ <div>
143
+ {message.parts.map((part, partIndex) => (
144
+ <MessagePartView key={`${message.id}:${partIndex}`} part={part} />
145
+ ))}
146
+ {thinking ? <Thinking /> : null}
147
+ </div>
148
+ </div>
149
+ )
150
+ }
151
+
152
+ function submitOnEnter(event: ReactKeyboardEvent<HTMLTextAreaElement>): void {
153
+ if (
154
+ event.key !== 'Enter' ||
155
+ event.shiftKey ||
156
+ event.nativeEvent.isComposing
157
+ ) {
158
+ return
159
+ }
160
+ event.preventDefault()
161
+ event.currentTarget.form?.requestSubmit()
162
+ }
163
+
164
+ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
165
+ const { state, push, events, index, connection } = useSession()
166
+ const promptRef = useRef<HTMLTextAreaElement>(null)
167
+ const [prompt, setPrompt] = useState('')
168
+ const [error, setError] = useState<string | null>(null)
169
+ const [responding, setResponding] = useState(false)
170
+
171
+ const generating = state.status === 'generating'
172
+ const canSend = state.status === 'idle' || state.status === 'failed'
173
+
174
+ useEffect(() => {
175
+ if (canSend) promptRef.current?.focus()
176
+ }, [canSend])
177
+
178
+ const send = async (event: FormEvent): Promise<void> => {
179
+ event.preventDefault()
180
+ const submittedPrompt = prompt
181
+ const text = prompt.trim()
182
+ if (!text || !canSend) return
183
+ setPrompt('')
184
+ setError(null)
185
+ try {
186
+ const message: UIMessage = {
187
+ id: crypto.randomUUID(),
188
+ role: 'user',
189
+ parts: [{ type: 'text', text }],
190
+ }
191
+ await push(...inputs.message(message))
192
+ } catch (err) {
193
+ setPrompt((current) => (current === '' ? submittedPrompt : current))
194
+ setError(err instanceof Error ? err.message : String(err))
195
+ }
196
+ }
197
+
198
+ const answerApproval = async (
199
+ messageId: string,
200
+ generationId: string,
201
+ approvalId: string,
202
+ approved: boolean,
203
+ ): Promise<void> => {
204
+ setResponding(true)
205
+ setError(null)
206
+ try {
207
+ await push(
208
+ ...inputs.approval({
209
+ messageId,
210
+ generationId,
211
+ approvalId,
212
+ approved,
213
+ ...(!approved ? { reason: 'operator denied the restart' } : {}),
214
+ }),
215
+ )
216
+ } catch (err) {
217
+ setError(err instanceof Error ? err.message : String(err))
218
+ } finally {
219
+ setResponding(false)
220
+ }
221
+ }
222
+
223
+ const stop = async (): Promise<void> => {
224
+ const active = state.activeGeneration
225
+ if (!active) return
226
+ setError(null)
227
+ try {
228
+ await push(
229
+ ...inputs.interrupt({
230
+ messageId: active.responseMessageId,
231
+ generationId: active.generationId,
232
+ reason: 'operator stopped the agent',
233
+ lastSeenIndex: index,
234
+ }),
235
+ )
236
+ } catch (err) {
237
+ setError(err instanceof Error ? err.message : String(err))
238
+ }
239
+ }
240
+
241
+ return (
242
+ <article>
243
+ <header className="session-header">
244
+ <h1>
245
+ Agent <code>{agentId}</code>
246
+ </h1>
247
+ <ConnectionPill connection={connection} />
248
+ <span
249
+ className={`badge${generating ? ' live' : ''}${
250
+ state.status === 'waiting' ? ' waiting' : ''
251
+ }${state.status === 'failed' ? ' cut' : ''}`}
252
+ >
253
+ {STATUS_LABEL[state.status]}
254
+ </span>
255
+ </header>
256
+
257
+ <section className="agent-conversation" aria-label="Conversation">
258
+ <div className="agent-transcript" aria-live="polite">
259
+ {state.messages.length === 0 ? (
260
+ <p className="agent-empty">
261
+ Ask the agent to investigate an incident, run a Bash command, or
262
+ set a reminder for itself. Its tools and decisions will appear
263
+ here as they enter the log.
264
+ </p>
265
+ ) : (
266
+ state.messages.map((message) => (
267
+ <AgentMessage
268
+ key={message.id}
269
+ message={message}
270
+ thinking={
271
+ generating &&
272
+ message.role === 'assistant' &&
273
+ !hasVisibleParts(message)
274
+ }
275
+ />
276
+ ))
277
+ )}
278
+ {generating && state.messages.at(-1)?.role === 'user' ? (
279
+ <div className="agent-message assistant">
280
+ <span className="agent-role">Agent</span>
281
+ <div>
282
+ <Thinking />
283
+ </div>
284
+ </div>
285
+ ) : null}
286
+ </div>
287
+
288
+ {state.pendingApprovals.map((approval) => (
289
+ <section className="approval-card" key={approval.approvalId}>
290
+ <div>
291
+ <p className="eyebrow">Human approval</p>
292
+ <h2>Restart checkout-api?</h2>
293
+ <p>
294
+ The agent inspected a 12.4% error rate. The runbook recommends a
295
+ restart after a sustained 10%.
296
+ </p>
297
+ </div>
298
+ <div className="approval-actions">
299
+ <button
300
+ type="button"
301
+ disabled={responding}
302
+ onClick={() =>
303
+ void answerApproval(
304
+ approval.messageId,
305
+ approval.generationId,
306
+ approval.approvalId,
307
+ true,
308
+ )
309
+ }
310
+ >
311
+ Approve restart
312
+ </button>
313
+ <button
314
+ type="button"
315
+ className="secondary"
316
+ disabled={responding}
317
+ onClick={() =>
318
+ void answerApproval(
319
+ approval.messageId,
320
+ approval.generationId,
321
+ approval.approvalId,
322
+ false,
323
+ )
324
+ }
325
+ >
326
+ Deny
327
+ </button>
328
+ </div>
329
+ </section>
330
+ ))}
331
+
332
+ {state.error ? (
333
+ <p className="error agent-generation-error" role="alert">
334
+ {state.error}
335
+ </p>
336
+ ) : null}
337
+
338
+ <form className="agent-form" onSubmit={(event) => void send(event)}>
339
+ <label>
340
+ Message
341
+ <textarea
342
+ ref={promptRef}
343
+ autoFocus
344
+ rows={3}
345
+ value={prompt}
346
+ onChange={(event) => setPrompt(event.target.value)}
347
+ onKeyDown={submitOnEnter}
348
+ disabled={!canSend}
349
+ />
350
+ </label>
351
+ <div className="agent-form-actions">
352
+ <button type="submit" disabled={!canSend || !prompt.trim()}>
353
+ Send to agent
354
+ </button>
355
+ <button
356
+ type="button"
357
+ className="danger"
358
+ disabled={!generating}
359
+ onClick={() => void stop()}
360
+ >
361
+ Stop
362
+ </button>
363
+ </div>
364
+ </form>
365
+ </section>
366
+ {error ? <p className="error">{error}</p> : null}
367
+ <p className="hint">
368
+ The browser only appends inputs. <code>createAgentServer()</code>{' '}
369
+ records the generation, progress snapshots, tool activity, and pause;
370
+ approving or delivering a reminder wakes the same durable session.
371
+ </p>
372
+
373
+ <ActivityFeed events={events} index={index} />
374
+ </article>
375
+ )
376
+ }
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from 'react'
2
+ import { demoAgent } from '../model'
3
+ import { agentServer } from '../server'
4
+ import { SessionProvider } from '../session'
5
+ import { AgentClient } from './agent-client'
6
+
7
+ export default async function AgentSessionPage({
8
+ params,
9
+ }: {
10
+ params: Promise<{ agentId: string }>
11
+ }): Promise<ReactNode> {
12
+ const { agentId } = await params
13
+ const session = agentServer.session(agentId)
14
+ const { state, index } = await session.state(demoAgent.reducer)
15
+ const events = (await session.history()).filter(
16
+ (event) => event.index <= index,
17
+ )
18
+
19
+ return (
20
+ <SessionProvider
21
+ sessionId={agentId}
22
+ initialState={state}
23
+ initialIndex={index}
24
+ initialEvents={events}
25
+ >
26
+ <AgentClient agentId={agentId} />
27
+ </SessionProvider>
28
+ )
29
+ }
@@ -0,0 +1,4 @@
1
+ import { agentServer } from '../server'
2
+
3
+ export const GET = agentServer.fetch
4
+ export const POST = agentServer.fetch
@@ -0,0 +1,3 @@
1
+ import { agent } from 'experimental-a2/ai'
2
+
3
+ export const demoAgent = agent({ name: 'agent' })
@@ -0,0 +1,98 @@
1
+ 'use client'
2
+ import type {
3
+ FormEvent,
4
+ KeyboardEvent as ReactKeyboardEvent,
5
+ ReactNode,
6
+ } from 'react'
7
+ import { useState } from 'react'
8
+ import { useRouter } from 'next/navigation'
9
+ import type { UIMessage } from 'ai'
10
+ import { inputs } from 'experimental-a2/ai'
11
+ import { agentClient } from './session'
12
+
13
+ const REMINDER_PROMPT =
14
+ 'Set a reminder for yourself in 37 seconds to check checkout-api again.'
15
+ const INCIDENT_PROMPT =
16
+ 'Investigate the checkout errors and take the safest action.'
17
+
18
+ function submitOnEnter(event: ReactKeyboardEvent<HTMLTextAreaElement>): void {
19
+ if (
20
+ event.key !== 'Enter' ||
21
+ event.shiftKey ||
22
+ event.nativeEvent.isComposing
23
+ ) {
24
+ return
25
+ }
26
+ event.preventDefault()
27
+ event.currentTarget.form?.requestSubmit()
28
+ }
29
+
30
+ export function NewAgentSession({
31
+ remindersEnabled,
32
+ }: {
33
+ remindersEnabled: boolean
34
+ }): ReactNode {
35
+ const router = useRouter()
36
+ const [prompt, setPrompt] = useState(
37
+ remindersEnabled ? REMINDER_PROMPT : INCIDENT_PROMPT,
38
+ )
39
+ const [submitting, setSubmitting] = useState(false)
40
+ const [error, setError] = useState<string | null>(null)
41
+
42
+ const start = async (event: FormEvent): Promise<void> => {
43
+ event.preventDefault()
44
+ const submittedPrompt = prompt
45
+ const text = prompt.trim()
46
+ if (!text || submitting) return
47
+
48
+ const agentId = crypto.randomUUID().slice(0, 8)
49
+ const message: UIMessage = {
50
+ id: crypto.randomUUID(),
51
+ role: 'user',
52
+ parts: [{ type: 'text', text }],
53
+ }
54
+
55
+ setPrompt('')
56
+ setSubmitting(true)
57
+ setError(null)
58
+ try {
59
+ await agentClient.session(agentId).push(...inputs.message(message))
60
+ router.push(`/agent/${agentId}`)
61
+ } catch (err) {
62
+ setPrompt((current) => (current === '' ? submittedPrompt : current))
63
+ setSubmitting(false)
64
+ setError(err instanceof Error ? err.message : String(err))
65
+ }
66
+ }
67
+
68
+ return (
69
+ <section className="agent-conversation agent-new" aria-label="New agent">
70
+ <div className="agent-transcript">
71
+ <p className="agent-empty">
72
+ Ask the agent to investigate an incident or set a reminder for itself.
73
+ The conversation starts immediately while the durable events are sent
74
+ in the background.
75
+ </p>
76
+ </div>
77
+ <form className="agent-form" onSubmit={(event) => void start(event)}>
78
+ <label>
79
+ Message
80
+ <textarea
81
+ autoFocus
82
+ rows={3}
83
+ value={prompt}
84
+ onChange={(event) => setPrompt(event.target.value)}
85
+ onKeyDown={submitOnEnter}
86
+ disabled={submitting}
87
+ />
88
+ </label>
89
+ <div className="agent-form-actions">
90
+ <button type="submit" disabled={submitting || !prompt.trim()}>
91
+ {submitting ? 'Opening…' : 'Send to agent'}
92
+ </button>
93
+ </div>
94
+ </form>
95
+ {error ? <p className="error agent-new-error">{error}</p> : null}
96
+ </section>
97
+ )
98
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react'
2
+ import { agentScheduler } from './server'
3
+ import { NewAgentSession } from './new-agent-session'
4
+
5
+ export default function AgentPage(): ReactNode {
6
+ return (
7
+ <article>
8
+ <h1>Agent</h1>
9
+ <p className="lede">
10
+ One agent turn becomes a durable trail of messages, model progress, tool
11
+ calls, sandboxed Bash commands, human approval, and scheduled reminders.
12
+ The agent can stop at a decision or timer and resume later because its
13
+ state lives in the log, not the request.
14
+ </p>
15
+ <NewAgentSession remindersEnabled={agentScheduler !== undefined} />
16
+ <p className="hint">
17
+ Each durable generation runs one AI SDK <code>streamText</code> step
18
+ with <code>openai/gpt-5.6-luna</code> through Vercel AI Gateway. Local
19
+ development needs Gateway credentials and a linked Vercel project for
20
+ Sandbox; Vercel deployments use their OIDC token. Reminder delivery also
21
+ needs the configured scheduler.
22
+ </p>
23
+ </article>
24
+ )
25
+ }
@@ -0,0 +1,5 @@
1
+ import { agentScheduler, agentServer } from '../server'
2
+
3
+ export const POST =
4
+ agentScheduler?.handler(agentServer) ??
5
+ (() => new Response('agent scheduler is not configured', { status: 503 }))
@@ -0,0 +1,153 @@
1
+ import 'server-only'
2
+ import { Buffer } from 'node:buffer'
3
+ import { createHash } from 'node:crypto'
4
+ import { Sandbox } from '@vercel/sandbox'
5
+ import { tool } from 'ai'
6
+ import { handlerContext, inputs } from 'experimental-a2/ai'
7
+ import { createAgentServer } from 'experimental-a2/ai/server'
8
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
9
+ import { z } from 'zod'
10
+ import { store } from '@/lib/store'
11
+ import { demoAgent } from './model'
12
+
13
+ const maxReminderDelaySeconds = 6 * 24 * 60 * 60
14
+ const bashTimeoutMs = 120_000
15
+ const maxBashOutputBytes = 64 * 1024
16
+
17
+ const sandboxName = (sessionId: string): string =>
18
+ `a2-playground-agent-${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`
19
+
20
+ const tools = {
21
+ inspectService: tool({
22
+ description: 'Inspect the current health and runbook for a service.',
23
+ inputSchema: z.object({ service: z.string() }),
24
+ execute: ({ service }) => ({
25
+ service,
26
+ errorRate: '12.4%',
27
+ runbook: 'restart after sustained 10%',
28
+ }),
29
+ }),
30
+ restartService: tool({
31
+ description: 'Restart every instance of a service.',
32
+ inputSchema: z.object({ service: z.string() }),
33
+ execute: ({ service }) => ({
34
+ service,
35
+ status: 'restarted',
36
+ instances: 3,
37
+ }),
38
+ }),
39
+ bash: tool({
40
+ description:
41
+ "Execute a Bash command in this session's persistent Vercel Sandbox. Files created by one command are available to later commands in the same session.",
42
+ inputSchema: z.object({
43
+ command: z.string().min(1).describe('The Bash command to execute.'),
44
+ }),
45
+ execute: async ({ command }) => {
46
+ const context = handlerContext(demoAgent)
47
+ const sandbox = await Sandbox.getOrCreate({
48
+ name: sandboxName(context.session.id),
49
+ persistent: true,
50
+ signal: context.signal,
51
+ })
52
+ const running = await sandbox.runCommand({
53
+ cmd: '/bin/bash',
54
+ args: ['-lc', command],
55
+ detached: true,
56
+ signal: context.signal,
57
+ timeoutMs: bashTimeoutMs,
58
+ })
59
+ const logs = running.logs({ signal: context.signal })
60
+ let stdout = ''
61
+ let stderr = ''
62
+ let remainingBytes = maxBashOutputBytes
63
+ let truncated = false
64
+ try {
65
+ for await (const log of logs) {
66
+ const bytes = Buffer.from(log.data)
67
+ const retained = bytes.subarray(0, remainingBytes)
68
+ const text =
69
+ retained.byteLength === bytes.byteLength
70
+ ? log.data
71
+ : new TextDecoder().decode(retained, { stream: true })
72
+ if (log.stream === 'stdout') stdout += text
73
+ else stderr += text
74
+ remainingBytes -= retained.byteLength
75
+ if (retained.byteLength < bytes.byteLength) {
76
+ truncated = true
77
+ break
78
+ }
79
+ }
80
+ } catch (error) {
81
+ try {
82
+ await running.kill('SIGKILL')
83
+ } catch {}
84
+ throw error
85
+ } finally {
86
+ logs.close()
87
+ }
88
+ if (truncated) {
89
+ try {
90
+ await running.kill('SIGKILL')
91
+ } catch {}
92
+ }
93
+ const result = await running.wait({ signal: context.signal })
94
+ return { exitCode: result.exitCode, stdout, stderr, truncated }
95
+ },
96
+ }),
97
+ setReminder: tool({
98
+ description:
99
+ 'Set a reminder for yourself after any delay up to six days. When it is due, it arrives as a new user message.',
100
+ inputSchema: z.object({
101
+ delaySeconds: z
102
+ .number()
103
+ .int()
104
+ .min(1)
105
+ .max(maxReminderDelaySeconds)
106
+ .describe('Seconds from now, from 1 through 518400'),
107
+ reminder: z.string().min(1).max(500),
108
+ }),
109
+ execute: async ({ delaySeconds, reminder }, { toolCallId }) => {
110
+ const context = handlerContext(demoAgent)
111
+ await context.session.schedule(
112
+ `reminder:${toolCallId}`,
113
+ { delay: `${delaySeconds}s` },
114
+ ...inputs.message({
115
+ id: `reminder:${context.event.id}`,
116
+ role: 'user',
117
+ parts: [{ type: 'text', text: `Reminder: ${reminder}` }],
118
+ }),
119
+ )
120
+ return { scheduled: true, delaySeconds, reminder }
121
+ },
122
+ }),
123
+ }
124
+
125
+ export const agentScheduler =
126
+ process.env.VERCEL || process.env.A2_QUEUES
127
+ ? vercelQueues({ topic: 'a2-agent' })
128
+ : undefined
129
+
130
+ export const agentServer = createAgentServer({
131
+ ...(store ? { store } : {}),
132
+ ...(agentScheduler ? { scheduler: agentScheduler } : {}),
133
+ agent: demoAgent,
134
+ model: 'openai/gpt-5.6-luna',
135
+ tools,
136
+ instructions: `You are an incident-response agent for the playground.
137
+ When responding to an incident, inspect checkout-api before taking action. Its
138
+ runbook says to restart when the error rate remains above 10%. If it does,
139
+ immediately call restartService. Do not ask for approval in prose: that tool
140
+ automatically creates the built-in approval. After the operator responds,
141
+ continue and report the result.
142
+
143
+ When the operator asks for a reminder, always call setReminder and only say it
144
+ was scheduled after the tool succeeds. A user message beginning with
145
+ "Reminder:" is a reminder that is now due: respond to it, and do not schedule
146
+ it again.
147
+
148
+ When the operator asks you to run a shell command, call bash. Make one bash call
149
+ at a time and wait for its result before calling it again. Be concise.`,
150
+ generation: {
151
+ toolApproval: { restartService: 'user-approval' },
152
+ },
153
+ })