experimental-a2 0.7.0 → 0.8.1
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 +27 -0
- package/README.md +29 -0
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +13 -11
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-286j79Mt.js → server-B2XNevQA.js} +123 -53
- package/dist/server-B2XNevQA.js.map +1 -0
- package/dist/{server-DgXmORIq.d.ts → server-DjPhHnbI.d.ts} +7 -4
- package/dist/server-DjPhHnbI.d.ts.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-flRz1OWh.d.ts → store-RJO35BMj.d.ts} +25 -8
- package/dist/store-RJO35BMj.d.ts.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +79 -19
- package/dist/store-memory.js.map +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +230 -101
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-redis-core-DEYO8Ryv.js → store-redis-core-DT01r4GZ.js} +167 -29
- package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +103 -19
- package/dist/store-sqlite.js.map +1 -1
- package/docs/concepts/02-handlers.mdx +4 -0
- package/docs/concepts/04-state.mdx +57 -9
- package/docs/guides/06-ai-agents.mdx +2 -1
- package/docs/guides/07-examples.mdx +56 -0
- package/docs/index.mdx +13 -0
- package/docs/reference/01-api.mdx +39 -16
- 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 +102 -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/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 +5 -2
- package/src/ai-server.ts +27 -10
- package/src/server.ts +242 -87
- package/src/store-memory.ts +138 -20
- package/src/store-postgres.ts +355 -138
- package/src/store-redis-core.ts +201 -27
- package/src/store-redis-http.ts +1 -1
- package/src/store-redis.ts +1 -1
- package/src/store-sqlite.ts +191 -34
- package/src/store.ts +27 -9
- package/dist/server-286j79Mt.js.map +0 -1
- package/dist/server-DgXmORIq.d.ts.map +0 -1
- package/dist/store-flRz1OWh.d.ts.map +0 -1
- package/dist/store-redis-core-DEYO8Ryv.js.map +0 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Deploying the playground
|
|
2
|
+
|
|
3
|
+
The event log runs with zero setup in development (SQLite at `.a2/dev.db`,
|
|
4
|
+
no scheduler). The Agent demo also needs AI Gateway and Vercel Sandbox
|
|
5
|
+
credentials. Production needs the deliberate pieces from
|
|
6
|
+
`node_modules/experimental-a2/docs/guides/05-production.mdx`.
|
|
7
|
+
|
|
8
|
+
## 1. A store
|
|
9
|
+
|
|
10
|
+
Two options — set one of:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
REDIS_URL=rediss://… # push-native streams (Upstash; recommended here)
|
|
14
|
+
DATABASE_URL=postgres://… # any Postgres (Neon, Supabase, RDS)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`lib/store.ts` prefers Redis when both are set — the chat, document,
|
|
18
|
+
and canvas demos are stream-latency-sensitive, and the Redis backend
|
|
19
|
+
delivers to parked subscribers in single-digit milliseconds (Postgres
|
|
20
|
+
streams poll adaptively, 25–250ms). The canvas demo's presence plane
|
|
21
|
+
(cursors, drag ghosts) also rides the backend's delivery tier: push on
|
|
22
|
+
Redis, poll cadence elsewhere. Upstash is durable by default; use its TCP
|
|
23
|
+
`rediss://` URL, not the REST one. Each feature creates its own server with
|
|
24
|
+
the shared store; storage remains namespaced per contract. Without either variable, a
|
|
25
|
+
production boot fails fast with `STORE_NOT_CONFIGURED` (by design).
|
|
26
|
+
Schemas/keys are created lazily on first use; no migration step.
|
|
27
|
+
|
|
28
|
+
To verify a provider before leaning on it, run the full conformance
|
|
29
|
+
kit against it (throwaway database; the suite isolates by key prefix
|
|
30
|
+
and sweeps after itself):
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
TEST_REDIS_URL=rediss://… pnpm --filter a2 exec vitest run test/store-redis.conformance.test.ts
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`pg` and `ioredis` are optional peer dependencies of `experimental-a2` and direct
|
|
37
|
+
dependencies of this app.
|
|
38
|
+
|
|
39
|
+
## 2. Vercel Queues scheduler
|
|
40
|
+
|
|
41
|
+
Queues need no configuration beyond the triggers in `vercel.json`. Orders,
|
|
42
|
+
Chat, Agent, and Recovery each own a scheduler route and queue topic alongside
|
|
43
|
+
their contract and server. Each topic is created on first send, and its trigger
|
|
44
|
+
makes the scheduler route private (no public URL). Schedulers turn on
|
|
45
|
+
automatically when `VERCEL` is set (every deployment).
|
|
46
|
+
|
|
47
|
+
To exercise the scheduler locally against a linked project:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
vercel link
|
|
51
|
+
vercel env pull # OIDC credentials for the @vercel/queue SDK
|
|
52
|
+
A2_QUEUES=1 pnpm dev
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 3. AI Gateway
|
|
56
|
+
|
|
57
|
+
The Agent demo uses the AI SDK with `openai/gpt-5.6-luna`, while the Chat demo
|
|
58
|
+
uses `openai/gpt-5.4`. Both resolve through Vercel AI Gateway. Vercel
|
|
59
|
+
deployments use their OIDC token automatically. For local generation, link the
|
|
60
|
+
project and pull its environment or set an `AI_GATEWAY_API_KEY`:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
vercel link
|
|
64
|
+
vercel env pull
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
New Chat rooms invoke the model only for `@agent` mentions. Participants can
|
|
68
|
+
enable ambient mode for the room to invoke it for every subsequent message.
|
|
69
|
+
|
|
70
|
+
The Counter, Orders, Canvas, and Documents demos do not call a model.
|
|
71
|
+
|
|
72
|
+
## 4. Vercel Sandbox
|
|
73
|
+
|
|
74
|
+
The Agent demo's Bash tool executes commands in a named, persistent Vercel
|
|
75
|
+
Sandbox. Every A2 agent session gets its own sandbox, so files remain available
|
|
76
|
+
to later Bash calls in that conversation. Vercel deployments authenticate with
|
|
77
|
+
OIDC automatically; the `vercel link` and `vercel env pull` commands above
|
|
78
|
+
supply the same credentials during local development.
|
|
79
|
+
|
|
80
|
+
## 5. WebSockets for the canvas
|
|
81
|
+
|
|
82
|
+
Deployed, the canvas demo upgrades its route to a WebSocket
|
|
83
|
+
(`experimental_upgradeWebSocket` from `@vercel/functions`, with `ws`
|
|
84
|
+
as the server-side socket — both direct dependencies of this app).
|
|
85
|
+
Vercel WebSockets need Fluid compute, which is the default on new
|
|
86
|
+
projects. Under `next dev` the upgrade never reaches the route (the
|
|
87
|
+
dev server drops the handshake), so the demo's session module picks
|
|
88
|
+
the SSE + POST pair in development — same route, same protocol
|
|
89
|
+
semantics, different wire.
|
|
90
|
+
|
|
91
|
+
## 6. Deploy
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
vercel deploy
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The Recovery lab tests a real function timeout. Its dedicated trigger and
|
|
98
|
+
queue consumer both export `maxDuration = 20`. The pipeline has four 8-second
|
|
99
|
+
handlers, so every handler fits in a fresh invocation but the 32-second chain
|
|
100
|
+
does not. A2 keeps dispatching events. Vercel stops the first invocation while
|
|
101
|
+
the third handler is running, leaving that event without a processed marker.
|
|
102
|
+
The deadline-capped claim expires, its watchdog retries the same event in a
|
|
103
|
+
fresh function, and already completed events do not run again. The dedicated
|
|
104
|
+
`a2-recovery` topic keeps this artificial budget away from the
|
|
105
|
+
playground's other recovery workloads. This example is disabled under
|
|
106
|
+
`next dev`, which supplies neither a Vercel deadline nor queue delivery.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# A2 playground
|
|
2
|
+
|
|
3
|
+
This is a standalone snapshot of the A2 playground included with the
|
|
4
|
+
`experimental-a2` npm package. It uses only public package entry points.
|
|
5
|
+
|
|
6
|
+
## Run it
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pnpm install
|
|
10
|
+
pnpm dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Each directory under `app/` owns its contract, reducer, server, routes, and
|
|
14
|
+
scheduler when one is needed. Start with `app/counter` for the smallest
|
|
15
|
+
example, then read `app/orders`, `app/chat`, or `app/agent` for complete
|
|
16
|
+
handler-driven applications.
|
|
17
|
+
|
|
18
|
+
The canonical source lives in `apps/playground` in the A2 repository. This
|
|
19
|
+
copy is generated from it when the package is published.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { formatActivityTime } from './app/components/activity-feed'
|
|
3
|
+
|
|
4
|
+
describe('activity feed', () => {
|
|
5
|
+
it('formats event times identically on the server and client', () => {
|
|
6
|
+
expect(formatActivityTime(new Date('2026-08-18T01:16:09.123Z'))).toBe(
|
|
7
|
+
'01:16:09 UTC',
|
|
8
|
+
)
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -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,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
|
+
}
|