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.
- package/AGENTS.md +11 -0
- package/CHANGELOG.md +36 -0
- package/README.md +29 -0
- package/dist/actor-client.d.ts +46 -0
- package/dist/actor-client.d.ts.map +1 -0
- package/dist/actor-client.js +54 -0
- package/dist/actor-client.js.map +1 -0
- package/dist/actor-react.d.ts +54 -0
- package/dist/actor-react.d.ts.map +1 -0
- package/dist/actor-react.js +79 -0
- package/dist/actor-react.js.map +1 -0
- package/dist/actor-shared-BACubf4x.d.ts +136 -0
- package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
- package/dist/actor-shared-DI7J5upy.js +127 -0
- package/dist/actor-shared-DI7J5upy.js.map +1 -0
- package/dist/actor.browser.d.ts +1 -0
- package/dist/actor.browser.js +13 -0
- package/dist/actor.browser.js.map +1 -0
- package/dist/actor.d.ts +176 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +437 -0
- package/dist/actor.js.map +1 -0
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +2 -2
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
- package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
- package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
- package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/react.d.ts +1 -1
- package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
- package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-B2XNevQA.js → server-CBET-jSz.js} +6 -6
- package/dist/server-CBET-jSz.js.map +1 -0
- package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
- package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
- package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +2 -2
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +2 -2
- package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
- package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +2 -2
- package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
- package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +189 -0
- package/docs/actors/02-concurrency.mdx +154 -0
- package/docs/actors/03-timers.mdx +120 -0
- package/docs/actors/04-routes.mdx +352 -0
- package/docs/actors/meta.ts +1 -0
- package/docs/concepts/meta.ts +1 -0
- package/docs/guides/07-examples.mdx +56 -0
- package/docs/guides/meta.ts +1 -0
- package/docs/index.mdx +16 -0
- package/docs/reference/02-errors.mdx +33 -0
- package/docs/reference/meta.ts +1 -0
- package/examples/README.md +15 -0
- package/examples/playground/AGENTS.md +11 -0
- package/examples/playground/DEPLOY.md +106 -0
- package/examples/playground/README.md +19 -0
- package/examples/playground/activity-feed.test.ts +10 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
- package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
- package/examples/playground/app/agent/events/route.ts +4 -0
- package/examples/playground/app/agent/model.ts +3 -0
- package/examples/playground/app/agent/new-agent-session.tsx +98 -0
- package/examples/playground/app/agent/page.tsx +25 -0
- package/examples/playground/app/agent/scheduler/route.ts +5 -0
- package/examples/playground/app/agent/server.ts +153 -0
- package/examples/playground/app/agent/session.ts +13 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
- package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
- package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
- package/examples/playground/app/canvas/events/route.ts +13 -0
- package/examples/playground/app/canvas/model.ts +94 -0
- package/examples/playground/app/canvas/open-canvas.tsx +40 -0
- package/examples/playground/app/canvas/page.tsx +20 -0
- package/examples/playground/app/canvas/server.ts +9 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
- package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
- package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
- package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
- package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
- package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
- package/examples/playground/app/chat/[chatId]/session.ts +7 -0
- package/examples/playground/app/chat/events/route.ts +7 -0
- package/examples/playground/app/chat/model.test.ts +155 -0
- package/examples/playground/app/chat/model.ts +310 -0
- package/examples/playground/app/chat/new-conversation.tsx +16 -0
- package/examples/playground/app/chat/page.tsx +25 -0
- package/examples/playground/app/chat/scheduler/route.ts +5 -0
- package/examples/playground/app/chat/server.ts +184 -0
- package/examples/playground/app/components/activity-feed.tsx +54 -0
- package/examples/playground/app/components/connection-pill.tsx +29 -0
- package/examples/playground/app/counter/counter-client.tsx +72 -0
- package/examples/playground/app/counter/events/route.ts +4 -0
- package/examples/playground/app/counter/model.test.ts +36 -0
- package/examples/playground/app/counter/model.ts +31 -0
- package/examples/playground/app/counter/page.tsx +24 -0
- package/examples/playground/app/counter/server.ts +9 -0
- package/examples/playground/app/counter/session.ts +13 -0
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
- package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
- package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
- package/examples/playground/app/documents/[documentId]/session.ts +7 -0
- package/examples/playground/app/documents/events/route.ts +4 -0
- package/examples/playground/app/documents/model.ts +55 -0
- package/examples/playground/app/documents/open-document.tsx +40 -0
- package/examples/playground/app/documents/page.tsx +22 -0
- package/examples/playground/app/documents/server.ts +9 -0
- package/examples/playground/app/globals.css +2078 -0
- package/examples/playground/app/layout.tsx +44 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
- package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
- package/examples/playground/app/orders/[orderId]/session.ts +11 -0
- package/examples/playground/app/orders/create/route.ts +30 -0
- package/examples/playground/app/orders/events/route.ts +4 -0
- package/examples/playground/app/orders/model.ts +79 -0
- package/examples/playground/app/orders/new-order-form.tsx +98 -0
- package/examples/playground/app/orders/page.tsx +22 -0
- package/examples/playground/app/orders/scheduler/route.ts +5 -0
- package/examples/playground/app/orders/server.ts +50 -0
- package/examples/playground/app/page.tsx +111 -0
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
- package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
- package/examples/playground/app/recovery/events/route.ts +3 -0
- package/examples/playground/app/recovery/model.ts +55 -0
- package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
- package/examples/playground/app/recovery/page.tsx +22 -0
- package/examples/playground/app/recovery/scheduler/route.ts +7 -0
- package/examples/playground/app/recovery/server.ts +53 -0
- package/examples/playground/app/recovery/start/route.ts +41 -0
- package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
- package/examples/playground/app/vault/page.tsx +12 -0
- package/examples/playground/app/vault/server.ts +9 -0
- package/examples/playground/app/vault/vault-client.tsx +124 -0
- package/examples/playground/app/vault/vault.test.ts +147 -0
- package/examples/playground/app/vault/vault.ts +119 -0
- package/examples/playground/css.d.ts +4 -0
- package/examples/playground/lib/store.ts +15 -0
- package/examples/playground/next-env.d.ts +5 -0
- package/examples/playground/next.config.ts +10 -0
- package/examples/playground/package.json +46 -0
- package/examples/playground/tsconfig.json +37 -0
- package/examples/playground/vercel.json +40 -0
- package/package.json +11 -2
- package/src/actor-client.ts +132 -0
- package/src/actor-react.ts +143 -0
- package/src/actor-shared.ts +356 -0
- package/src/actor.browser.ts +12 -0
- package/src/actor.ts +914 -0
- package/src/client.ts +9 -1
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -1
- package/src/server.ts +13 -3
- package/dist/contract-jIfaR085.d.ts.map +0 -1
- 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,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
|
+
}
|