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,310 @@
1
+ import type { FinishReason, LanguageModelUsage, UIMessage } from 'ai'
2
+ import { agent, type AIState } from 'experimental-a2/ai'
3
+ import { z } from 'zod'
4
+
5
+ export type ChatAgentStep = {
6
+ generationId: string
7
+ model: string
8
+ finishReason: FinishReason
9
+ rawFinishReason?: string
10
+ usage: LanguageModelUsage
11
+ completedAt: string
12
+ }
13
+
14
+ export type ChatParticipantMode = 'ambient' | 'mention' | 'passive'
15
+ export type ChatAgentMode = Exclude<ChatParticipantMode, 'passive'>
16
+
17
+ export const chatPostInputSchema = z
18
+ .object({ message: z.string().min(1).max(2000) })
19
+ .strict()
20
+
21
+ const agentPostEventSchema = chatPostInputSchema
22
+ .extend({
23
+ generationId: z.string().min(1),
24
+ responseMessageId: z.string().min(1),
25
+ toolCallId: z.string().min(1),
26
+ })
27
+ .strict()
28
+
29
+ export type ChatMessageMetadata =
30
+ | {
31
+ kind: 'participant'
32
+ author: string
33
+ text: string
34
+ sentAt: string
35
+ mode: ChatParticipantMode
36
+ }
37
+ | {
38
+ kind: 'agent'
39
+ author: 'agent'
40
+ mode: ChatAgentMode
41
+ steps: ChatAgentStep[]
42
+ }
43
+
44
+ export type ChatAgentTools = {
45
+ post: {
46
+ input: z.output<typeof chatPostInputSchema>
47
+ output: { posted: true }
48
+ }
49
+ }
50
+
51
+ export type ChatAgentMessage = UIMessage<
52
+ ChatMessageMetadata,
53
+ Record<never, never>,
54
+ ChatAgentTools
55
+ >
56
+
57
+ const usageSchema = z.object({
58
+ inputTokens: z.number().optional(),
59
+ inputTokenDetails: z.object({
60
+ noCacheTokens: z.number().optional(),
61
+ cacheReadTokens: z.number().optional(),
62
+ cacheWriteTokens: z.number().optional(),
63
+ }),
64
+ outputTokens: z.number().optional(),
65
+ outputTokenDetails: z.object({
66
+ textTokens: z.number().optional(),
67
+ reasoningTokens: z.number().optional(),
68
+ }),
69
+ totalTokens: z.number().optional(),
70
+ raw: z.record(z.string(), z.unknown()).optional(),
71
+ })
72
+
73
+ const participantMetadataSchema = z
74
+ .object({
75
+ kind: z.literal('participant'),
76
+ author: z.string().min(1).max(40),
77
+ text: z.string().min(1).max(2000),
78
+ sentAt: z.iso.datetime(),
79
+ mode: z.enum(['ambient', 'mention', 'passive']),
80
+ })
81
+ .strict()
82
+
83
+ const agentMetadataSchema = z.object({
84
+ kind: z.literal('agent'),
85
+ author: z.literal('agent'),
86
+ mode: z.enum(['ambient', 'mention']),
87
+ steps: z.array(
88
+ z.object({
89
+ generationId: z.string(),
90
+ model: z.string(),
91
+ finishReason: z.enum([
92
+ 'stop',
93
+ 'length',
94
+ 'content-filter',
95
+ 'tool-calls',
96
+ 'error',
97
+ 'other',
98
+ ]),
99
+ rawFinishReason: z.string().optional(),
100
+ usage: usageSchema,
101
+ completedAt: z.iso.datetime(),
102
+ }),
103
+ ),
104
+ })
105
+
106
+ const chatMessageMetadataSchema = z.discriminatedUnion('kind', [
107
+ participantMetadataSchema,
108
+ agentMetadataSchema,
109
+ ])
110
+
111
+ const participantMessageValueSchema = z
112
+ .object({
113
+ id: z.string().min(1),
114
+ role: z.literal('user'),
115
+ metadata: participantMetadataSchema,
116
+ parts: z.tuple([
117
+ z
118
+ .object({
119
+ type: z.literal('text'),
120
+ text: z.string(),
121
+ })
122
+ .strict(),
123
+ ]),
124
+ })
125
+ .strict()
126
+ .refine(
127
+ (message) =>
128
+ message.parts[0].text ===
129
+ `${message.metadata.author}: ${message.metadata.text}`,
130
+ { message: 'participant metadata must match the model text' },
131
+ )
132
+
133
+ const trustedMessageValueSchema = z.object({
134
+ id: z.string().min(1),
135
+ role: z.enum(['system', 'assistant']),
136
+ metadata: chatMessageMetadataSchema.optional(),
137
+ parts: z.array(z.object({ type: z.string() }).passthrough()),
138
+ })
139
+
140
+ const chatMessageValueSchema = z.union([
141
+ participantMessageValueSchema,
142
+ trustedMessageValueSchema,
143
+ ])
144
+
145
+ const chatMessageSchema = z.custom<ChatAgentMessage>(
146
+ (value) => chatMessageValueSchema.safeParse(value).success,
147
+ 'invalid chat UI message',
148
+ )
149
+
150
+ const chatEvents = {
151
+ 'message.sent': z.object({
152
+ author: z.string().min(1).max(40),
153
+ text: z.string().min(1).max(2000),
154
+ }),
155
+ typed: z.object({ author: z.string().min(1).max(40) }),
156
+ 'agent.ambient.changed': z.object({
157
+ enabled: z.boolean(),
158
+ author: z.string().min(1).max(40),
159
+ }),
160
+ 'agent.message.posted': agentPostEventSchema,
161
+ }
162
+
163
+ export const chatAgent = agent<ChatAgentMessage, typeof chatEvents>({
164
+ name: 'chat',
165
+ events: chatEvents,
166
+ messageSchema: chatMessageSchema,
167
+ reducerName: 'a2.playground.chat.ai.v1',
168
+ })
169
+
170
+ export const chat = chatAgent.contract
171
+
172
+ export type ChatMessage = {
173
+ id: string
174
+ index: number
175
+ author: string
176
+ text: string
177
+ at: string
178
+ }
179
+
180
+ export type AgentPost = {
181
+ generationId: string
182
+ responseMessageId: string
183
+ toolCallId: string
184
+ index: number
185
+ text: string
186
+ at: string
187
+ }
188
+
189
+ export type ChatState = {
190
+ ai: AIState<ChatAgentMessage>
191
+ messages: ChatMessage[]
192
+ posts: AgentPost[]
193
+ lastTypingAt: Record<string, string>
194
+ ambient: {
195
+ enabled: boolean
196
+ changedBy: string | null
197
+ changedAt: string | null
198
+ }
199
+ }
200
+
201
+ const initialChatState: ChatState = {
202
+ ai: chatAgent.reducer.initialState,
203
+ messages: [],
204
+ posts: [],
205
+ lastTypingAt: {},
206
+ ambient: { enabled: false, changedBy: null, changedAt: null },
207
+ }
208
+
209
+ export const chatReducer = chat
210
+ .reducer({
211
+ name: 'a2.playground.chat.v1',
212
+ initialState: initialChatState,
213
+ })
214
+ .fold((state, event) => {
215
+ if (
216
+ event.type === 'ai.tool.called' &&
217
+ event.payload.toolName === 'post' &&
218
+ (state.ai.activeGeneration?.generationId !== event.payload.generationId ||
219
+ state.ai.activeGeneration.responseMessageId !== event.payload.messageId)
220
+ ) {
221
+ return state
222
+ }
223
+
224
+ const ai = chatAgent.reducer.fold(state.ai, event)
225
+ const base = ai === state.ai ? state : { ...state, ai }
226
+
227
+ switch (event.type) {
228
+ case 'message.sent': {
229
+ const stillTyping = { ...base.lastTypingAt }
230
+ delete stillTyping[event.payload.author]
231
+ return {
232
+ ...base,
233
+ messages: [
234
+ ...base.messages,
235
+ {
236
+ id: event.id,
237
+ index: event.index,
238
+ author: event.payload.author,
239
+ text: event.payload.text,
240
+ at: event.createdAt.toISOString(),
241
+ },
242
+ ],
243
+ lastTypingAt: stillTyping,
244
+ }
245
+ }
246
+ case 'typed':
247
+ return {
248
+ ...base,
249
+ lastTypingAt: {
250
+ ...base.lastTypingAt,
251
+ [event.payload.author]: event.createdAt.toISOString(),
252
+ },
253
+ }
254
+ case 'agent.ambient.changed':
255
+ return {
256
+ ...base,
257
+ ambient: {
258
+ enabled: event.payload.enabled,
259
+ changedBy: event.payload.author,
260
+ changedAt: event.createdAt.toISOString(),
261
+ },
262
+ }
263
+ case 'agent.message.posted': {
264
+ const payload = event.payload
265
+ const running = state.ai.tools.some(
266
+ (activity) =>
267
+ activity.generationId === payload.generationId &&
268
+ activity.messageId === payload.responseMessageId &&
269
+ activity.toolCallId === payload.toolCallId &&
270
+ activity.toolName === 'post' &&
271
+ activity.status === 'running',
272
+ )
273
+ if (!running) return base
274
+ return {
275
+ ...base,
276
+ posts: [
277
+ ...base.posts,
278
+ {
279
+ generationId: payload.generationId,
280
+ responseMessageId: payload.responseMessageId,
281
+ toolCallId: payload.toolCallId,
282
+ index: event.index,
283
+ text: payload.message,
284
+ at: event.createdAt.toISOString(),
285
+ },
286
+ ],
287
+ }
288
+ }
289
+ case 'ai.message.interrupted': {
290
+ const cutoff = event.payload.lastSeenIndex
291
+ if (ai === state.ai || cutoff === undefined) return base
292
+ return {
293
+ ...base,
294
+ posts: base.posts.filter(
295
+ (post) =>
296
+ post.responseMessageId !== event.payload.messageId ||
297
+ (event.payload.generationId !== undefined &&
298
+ post.generationId !== event.payload.generationId) ||
299
+ post.index <= cutoff,
300
+ ),
301
+ }
302
+ }
303
+ default:
304
+ return base
305
+ }
306
+ })
307
+
308
+ const agentMention = /(?:^|\s)@agent(?![\w-])/i
309
+
310
+ export const mentionsAgent = (text: string): boolean => agentMention.test(text)
@@ -0,0 +1,16 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+
5
+ export function NewConversation(): ReactNode {
6
+ const router = useRouter()
7
+ return (
8
+ <p>
9
+ <button
10
+ onClick={() => router.push(`/chat/${crypto.randomUUID().slice(0, 8)}`)}
11
+ >
12
+ New conversation
13
+ </button>
14
+ </p>
15
+ )
16
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react'
2
+ import { NewConversation } from './new-conversation'
3
+
4
+ export default function ChatPage(): ReactNode {
5
+ return (
6
+ <article>
7
+ <h1>Multiplayer chat, with an agent in the room</h1>
8
+ <p className="lede">
9
+ Messages, presence, and a real AI agent all live in one log. By default,
10
+ only an <code>@agent</code> mention calls the model; a durable room-wide
11
+ switch can make every message ambient. The agent can stay silent, or
12
+ call <code>post</code> to add one visible message to the room.
13
+ </p>
14
+ <NewConversation />
15
+ <p className="hint">
16
+ Each conversation is one session. Open the same URL in two tabs:
17
+ messages converge, “is typing…” rides throttled <code>typed</code>{' '}
18
+ extension events, the ambient setting is an event too, and AI SDK
19
+ progress, tools, metadata, and usage are durable AI events. Private
20
+ model tokens render as a thinking or writing indicator, not an inline
21
+ reply.
22
+ </p>
23
+ </article>
24
+ )
25
+ }
@@ -0,0 +1,5 @@
1
+ import { chatScheduler, chatServer } from '../server'
2
+
3
+ export const POST =
4
+ chatScheduler?.handler(chatServer) ??
5
+ (() => new Response('chat scheduler is not configured', { status: 503 }))
@@ -0,0 +1,184 @@
1
+ import 'server-only'
2
+ import { tool } from 'ai'
3
+ import { handlerContext, inputs } from 'experimental-a2/ai'
4
+ import { createHandlers } from 'experimental-a2/ai/server'
5
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
6
+ import { createServer, type A2Operation } from 'experimental-a2/server'
7
+ import { store } from '@/lib/store'
8
+ import {
9
+ chatAgent,
10
+ chatPostInputSchema,
11
+ mentionsAgent,
12
+ type ChatAgentMessage,
13
+ type ChatAgentMode,
14
+ type ChatParticipantMode,
15
+ } from './model'
16
+
17
+ const tools = {
18
+ post: tool({
19
+ description:
20
+ 'Post a visible message to the multiplayer chat. Call it once for each chat bubble you want to send; this tool is the only way to speak in the room.',
21
+ inputSchema: chatPostInputSchema,
22
+ execute: async ({ message }, { toolCallId }) => {
23
+ const context = handlerContext(chatAgent)
24
+ if (context.event.type !== 'ai.tool.called') {
25
+ throw new Error('The chat post tool must execute automatically')
26
+ }
27
+ context.signal.throwIfAborted()
28
+ await context.session.append('post', {
29
+ type: 'agent.message.posted',
30
+ payload: {
31
+ message,
32
+ generationId: context.event.payload.generationId,
33
+ responseMessageId: context.event.payload.messageId,
34
+ toolCallId,
35
+ },
36
+ })
37
+ return { posted: true as const }
38
+ },
39
+ }),
40
+ }
41
+
42
+ const aiHandlers = createHandlers({
43
+ agent: chatAgent,
44
+ model: 'openai/gpt-5.4',
45
+ tools,
46
+ instructions: `You are "agent", a participant in a multiplayer chat.
47
+ The latest participant message invited you either by mentioning @agent or by enabling the room's ambient mode. Messages are formatted as "name: message"; earlier passive messages are useful conversation context.
48
+ Sound like a sharp, relaxed person in the chat, not a customer-support bot. Match the room's language and energy. Keep messages specific and conversational; avoid canned acknowledgements, assistant-y disclaimers, and explanations of your own mechanics.
49
+ Only contribute when you have something useful, relevant, or friendly to add. It is fine to stay silent or stop whenever you have nothing else to say.
50
+ To speak, call post once for each visible chat bubble. Never communicate through ordinary assistant text because it is private and is not shown in the room.
51
+ Use as many post calls as the moment naturally calls for. Several short messages are welcome when they create better rhythm, separate distinct thoughts, or fulfill a request for a sequence. A single message is fine too. Do not spam. After post results, continue only when another message genuinely improves the conversation.`,
52
+ generation: {
53
+ maxOutputTokens: 500,
54
+ temperature: 0.4,
55
+ },
56
+ progress: {
57
+ maxChunks: 4,
58
+ maxDelayMs: 30,
59
+ },
60
+ messageMetadata: (context) => {
61
+ const previous = context.state.messages.find(
62
+ (message) => message.id === context.responseMessageId,
63
+ )?.metadata
64
+ const trigger = context.state.messages.find(
65
+ (message) => message.id === context.request.payload.messageId,
66
+ )?.metadata
67
+ const steps = previous?.kind === 'agent' ? previous.steps : []
68
+ const mode: ChatAgentMode | undefined =
69
+ previous?.kind === 'agent'
70
+ ? previous.mode
71
+ : trigger?.kind === 'participant' && trigger.mode !== 'passive'
72
+ ? trigger.mode
73
+ : undefined
74
+ if (mode === undefined) return undefined
75
+ const metadata = {
76
+ kind: 'agent' as const,
77
+ author: 'agent' as const,
78
+ mode,
79
+ steps,
80
+ }
81
+ if (context.part.type === 'start') return metadata
82
+ if (context.part.type !== 'finish') return undefined
83
+ return {
84
+ ...metadata,
85
+ steps: [
86
+ ...steps,
87
+ {
88
+ generationId: context.generationId,
89
+ model: context.state.activeGeneration?.model ?? 'custom',
90
+ finishReason: context.part.finishReason,
91
+ ...(context.part.rawFinishReason === undefined
92
+ ? {}
93
+ : { rawFinishReason: context.part.rawFinishReason }),
94
+ usage: context.part.totalUsage,
95
+ completedAt: new Date().toISOString(),
96
+ },
97
+ ],
98
+ }
99
+ },
100
+ })
101
+
102
+ export const authorizeChatOperation = (
103
+ operation: A2Operation<(typeof chatAgent.contract)['events']>,
104
+ ): boolean => {
105
+ if (operation.type !== 'push') return true
106
+ const serverOnly = operation.events.find(
107
+ (event) =>
108
+ event.type === 'ai.message.created' ||
109
+ event.type === 'agent.message.posted',
110
+ )
111
+ if (serverOnly !== undefined) return false
112
+ const rejected = operation.events.find((event) => {
113
+ if (
114
+ event.type === 'message.sent' ||
115
+ event.type === 'typed' ||
116
+ event.type === 'agent.ambient.changed'
117
+ ) {
118
+ return false
119
+ }
120
+ return !(
121
+ event.type === 'ai.approval.responded' ||
122
+ event.type === 'ai.input.responded' ||
123
+ event.type === 'ai.message.interrupted' ||
124
+ event.type === 'ai.retry.requested'
125
+ )
126
+ })
127
+ return rejected === undefined
128
+ }
129
+
130
+ const toolCalledHandler = aiHandlers['ai.tool.called']
131
+ if (toolCalledHandler === undefined) {
132
+ throw new Error('A2 AI handlers must include ai.tool.called')
133
+ }
134
+ const serializedToolCalledHandler =
135
+ typeof toolCalledHandler === 'function'
136
+ ? { handler: toolCalledHandler, lane: 'a2.chat.agent-tools' }
137
+ : { ...toolCalledHandler, lane: 'a2.chat.agent-tools' }
138
+
139
+ export const chatScheduler =
140
+ process.env.VERCEL || process.env.A2_QUEUES
141
+ ? vercelQueues({ topic: 'a2-chat' })
142
+ : undefined
143
+
144
+ export const chatServer = createServer({
145
+ ...(store ? { store } : {}),
146
+ ...(chatScheduler ? { scheduler: chatScheduler } : {}),
147
+ contract: chatAgent.contract,
148
+ handlers: {
149
+ ...aiHandlers,
150
+ 'ai.tool.called': serializedToolCalledHandler,
151
+ 'message.sent': {
152
+ lane: 'a2.chat.ingress',
153
+ handler: async (context) => {
154
+ let ambient = false
155
+ for (const event of await context.session.history()) {
156
+ if (event.index > context.event.index) break
157
+ if (event.type === 'agent.ambient.changed') {
158
+ ambient = event.payload.enabled
159
+ }
160
+ }
161
+
162
+ const { author, text } = context.event.payload
163
+ const mode: ChatParticipantMode = ambient
164
+ ? 'ambient'
165
+ : mentionsAgent(text)
166
+ ? 'mention'
167
+ : 'passive'
168
+ const message: ChatAgentMessage = {
169
+ id: context.event.id,
170
+ role: 'user',
171
+ metadata: {
172
+ kind: 'participant',
173
+ author,
174
+ text,
175
+ sentAt: context.event.createdAt.toISOString(),
176
+ mode,
177
+ },
178
+ parts: [{ type: 'text', text: `${author}: ${text}` }],
179
+ }
180
+ return inputs.message(message, { generate: mode !== 'passive' })
181
+ },
182
+ },
183
+ },
184
+ })
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /** The structural shape every machine's events share — enough for a
4
+ * feed that shows the log itself. */
5
+ export type FeedEvent = {
6
+ id: string
7
+ index: number
8
+ type: string
9
+ payload: unknown
10
+ createdAt: Date
11
+ }
12
+
13
+ export function formatActivityTime(createdAt: Date): string {
14
+ return `${createdAt.toISOString().slice(11, 19)} UTC`
15
+ }
16
+
17
+ export function ActivityFeed({
18
+ events,
19
+ index,
20
+ status,
21
+ }: {
22
+ events: readonly FeedEvent[]
23
+ index: number
24
+ status?: ReactNode
25
+ }): ReactNode {
26
+ return (
27
+ <section>
28
+ <h2>Activity</h2>
29
+ {events.length === 0 ? (
30
+ <p className="hint">
31
+ Nothing observed yet — events appear here the moment they land.
32
+ </p>
33
+ ) : (
34
+ <ol className="feed">
35
+ {events.map((event) => (
36
+ <li key={event.id}>
37
+ <span className="idx">#{event.index}</span>
38
+ <span className="type">{event.type}</span>
39
+ <span className="payload">{JSON.stringify(event.payload)}</span>
40
+ <time dateTime={event.createdAt.toISOString()}>
41
+ {formatActivityTime(event.createdAt)}
42
+ </time>
43
+ </li>
44
+ ))}
45
+ </ol>
46
+ )}
47
+ {status ? <p className="activity-status">{status}</p> : null}
48
+ <p className="frontier">
49
+ Stream frontier: <code>{index}</code> — the last server-confirmed log
50
+ position this browser has folded.
51
+ </p>
52
+ </section>
53
+ )
54
+ }
@@ -0,0 +1,29 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import type { Connection } from 'experimental-a2/client'
4
+
5
+ export function ConnectionPill({
6
+ connection,
7
+ }: {
8
+ connection: Connection
9
+ }): ReactNode {
10
+ const label =
11
+ connection.status === 'connecting'
12
+ ? connection.reconnects > 0
13
+ ? 'reconnecting…'
14
+ : 'connecting…'
15
+ : connection.status
16
+
17
+ return (
18
+ <span
19
+ className={`conn ${connection.status}`}
20
+ title={
21
+ connection.status === 'connecting'
22
+ ? connection.error?.message
23
+ : undefined
24
+ }
25
+ >
26
+ {label}
27
+ </span>
28
+ )
29
+ }
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import { useState } from 'react'
4
+ import { ActivityFeed } from '@/app/components/activity-feed'
5
+ import { ConnectionPill } from '@/app/components/connection-pill'
6
+ import { useSession } from './session'
7
+
8
+ type CounterEventType = 'incremented' | 'decremented'
9
+ const ACTIVITY_LIMIT = 50
10
+
11
+ export function CounterClient(): ReactNode {
12
+ const { state, push, events, index, connection } = useSession()
13
+ const [error, setError] = useState<string | null>(null)
14
+ const visibleEvents = events.slice(-ACTIVITY_LIMIT)
15
+
16
+ const change = async (type: CounterEventType): Promise<void> => {
17
+ setError(null)
18
+ try {
19
+ const event =
20
+ type === 'incremented'
21
+ ? { type: 'incremented' as const, payload: {} }
22
+ : { type: 'decremented' as const, payload: {} }
23
+ await push(event)
24
+ } catch (err) {
25
+ setError(err instanceof Error ? err.message : String(err))
26
+ }
27
+ }
28
+
29
+ return (
30
+ <article>
31
+ <header className="session-header">
32
+ <h1>Counter</h1>
33
+ <ConnectionPill connection={connection} />
34
+ <span className="badge live">shared</span>
35
+ </header>
36
+ <p className="lede">
37
+ Every click appends one fact to a durable log. This page folds those
38
+ facts into a value on the server for the first paint, then keeps folding
39
+ the live stream in your browser.
40
+ </p>
41
+
42
+ <section className="counter-panel" aria-label="Shared counter controls">
43
+ <output className="counter-value" aria-live="polite">
44
+ {state.value}
45
+ </output>
46
+ <div className="counter-actions">
47
+ <button
48
+ type="button"
49
+ className="secondary"
50
+ onClick={() => void change('decremented')}
51
+ >
52
+ −1
53
+ </button>
54
+ <button type="button" onClick={() => void change('incremented')}>
55
+ +1
56
+ </button>
57
+ </div>
58
+ </section>
59
+
60
+ {error ? <p className="error">{error}</p> : null}
61
+ <p className="hint">
62
+ Pushes apply optimistically, then reconcile with the server. Open this
63
+ page in another tab and both counters converge on the same shared log.
64
+ </p>
65
+
66
+ <ActivityFeed events={visibleEvents} index={index} />
67
+ {events.length > ACTIVITY_LIMIT ? (
68
+ <p className="hint">Showing the latest {ACTIVITY_LIMIT} events.</p>
69
+ ) : null}
70
+ </article>
71
+ )
72
+ }