experimental-a2 0.16.0 → 0.17.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/CHANGELOG.md +360 -0
- package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
- package/dist/actor-DBbe5xeE.d.ts.map +1 -0
- package/dist/actor-client.d.ts +2 -5
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +13 -4
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -3
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +20 -10
- package/dist/actor-react.js.map +1 -1
- package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
- package/dist/actor-shared-t7w8xIKP.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +42 -23
- package/dist/actor.js.map +1 -1
- package/dist/{ai-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
- package/dist/ai-UrWfKeuv.js.map +1 -0
- package/dist/ai-client.d.ts +27 -0
- package/dist/ai-client.d.ts.map +1 -0
- package/dist/ai-client.js +29 -0
- package/dist/ai-client.js.map +1 -0
- package/dist/ai-react.d.ts +96 -0
- package/dist/ai-react.d.ts.map +1 -0
- package/dist/ai-react.js +142 -0
- package/dist/ai-react.js.map +1 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.browser.js.map +1 -1
- package/dist/ai-server.d.ts +80 -36
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +466 -265
- package/dist/ai-server.js.map +1 -1
- package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
- package/dist/ai-vpGtcaCB.d.ts.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
- package/dist/client-COaiaDub.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
- package/dist/client-qRnRUzBS.d.ts.map +1 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
- package/dist/contract-BYKASlCV.js.map +1 -0
- package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
- package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
- package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
- package/dist/internal-CbVvEwad.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BbJ-g6Uu.d.ts +86 -0
- package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
- package/dist/react.d.ts +2 -122
- package/dist/react.js +107 -100
- package/dist/react.js.map +1 -1
- package/dist/reducer-BAXc8JyF.js +87 -0
- package/dist/reducer-BAXc8JyF.js.map +1 -0
- package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
- package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
- package/dist/reducer-runtime-DQwz_J59.js +28 -0
- package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +33 -10
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -9
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +25 -44
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
- package/dist/server-CGM8WzRG.d.ts.map +1 -0
- package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
- package/dist/server-Rj64fNvX.js.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/session-clear-Dshpuhff.d.ts +12 -0
- package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
- package/dist/store-Gpkq7WBo.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +182 -69
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
- package/dist/store-polling-4_y8lETm.js.map +1 -0
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +399 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +293 -11
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
- package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +11 -11
- 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 +166 -32
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-Cx7AAPGq.js +23 -0
- package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
- package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
- package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
- package/dist/wire-DjfHOvdW.js.map +1 -0
- package/docs/01-quickstart.mdx +2 -2
- package/docs/actors/01-introduction.mdx +4 -0
- package/docs/actors/04-routes.mdx +14 -1
- package/docs/concepts/01-contracts.mdx +95 -0
- package/docs/concepts/04-state.mdx +3 -3
- package/docs/guides/01-timers.mdx +22 -14
- package/docs/guides/02-cancellation.mdx +1 -1
- package/docs/guides/03-react.mdx +268 -328
- package/docs/guides/04-local-first.mdx +32 -73
- package/docs/guides/05-production.mdx +40 -27
- package/docs/guides/06-ai-agents.mdx +680 -580
- package/docs/guides/09-presence.mdx +3 -7
- package/docs/guides/10-transports.mdx +5 -5
- package/docs/index.mdx +10 -18
- package/docs/reference/01-api.mdx +662 -195
- package/docs/reference/02-errors.mdx +2 -1
- package/examples/playground/DEPLOY.md +52 -6
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +48 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +211 -0
- package/examples/playground/app/a0/app-view.tsx +55 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
- package/examples/playground/app/a0/apps.test.ts +116 -0
- package/examples/playground/app/a0/apps.ts +87 -0
- package/examples/playground/app/a0/chat.tsx +364 -0
- package/examples/playground/app/a0/columns.tsx +109 -0
- package/examples/playground/app/a0/context-menu.tsx +116 -0
- package/examples/playground/app/a0/cookies.ts +46 -0
- package/examples/playground/app/a0/crash-on-request.ts +72 -0
- package/examples/playground/app/a0/crashes/route.ts +3 -0
- package/examples/playground/app/a0/crashes.test.ts +33 -0
- package/examples/playground/app/a0/crashes.ts +28 -0
- package/examples/playground/app/a0/devtools.tsx +164 -0
- package/examples/playground/app/a0/events/route.ts +6 -0
- package/examples/playground/app/a0/flags.ts +30 -0
- package/examples/playground/app/a0/layout.tsx +38 -0
- package/examples/playground/app/a0/list/route.ts +10 -0
- package/examples/playground/app/a0/model.test.ts +56 -0
- package/examples/playground/app/a0/model.ts +58 -0
- package/examples/playground/app/a0/page.tsx +14 -0
- package/examples/playground/app/a0/preview-pane.tsx +145 -0
- package/examples/playground/app/a0/preview.test.ts +53 -0
- package/examples/playground/app/a0/preview.ts +26 -0
- package/examples/playground/app/a0/sandbox-log.ts +69 -0
- package/examples/playground/app/a0/sandbox.ts +191 -0
- package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
- package/examples/playground/app/a0/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/server.ts +158 -0
- package/examples/playground/app/a0/session.ts +47 -0
- package/examples/playground/app/a0/shell.tsx +150 -0
- package/examples/playground/app/a0/short/events/route.ts +9 -0
- package/examples/playground/app/a0/splitter.tsx +120 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
- package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
- package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
- package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
- package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
- package/examples/playground/app/agent/events/route.ts +5 -2
- package/examples/playground/app/agent/ingress.test.ts +98 -0
- package/examples/playground/app/agent/model-input.test.tsx +58 -0
- package/examples/playground/app/agent/model-input.tsx +115 -0
- package/examples/playground/app/agent/model.test.ts +73 -0
- package/examples/playground/app/agent/model.ts +58 -26
- package/examples/playground/app/agent/new-agent-session.tsx +37 -19
- package/examples/playground/app/agent/page.tsx +4 -4
- package/examples/playground/app/agent/server.ts +15 -14
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +168 -0
- package/examples/playground/app/agent/telemetry.ts +30 -0
- package/examples/playground/app/agent/trace.test.ts +23 -0
- package/examples/playground/app/agent/trace.ts +18 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
- package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
- package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
- package/examples/playground/app/canvas/model.ts +53 -53
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
- package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
- package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
- package/examples/playground/app/chat/[chatId]/session.ts +2 -19
- package/examples/playground/app/chat/agent/route.ts +9 -0
- package/examples/playground/app/chat/model.test.ts +46 -119
- package/examples/playground/app/chat/model.ts +82 -128
- package/examples/playground/app/chat/scheduler/route.ts +2 -2
- package/examples/playground/app/chat/server.test.ts +108 -0
- package/examples/playground/app/chat/server.ts +69 -73
- package/examples/playground/app/components/activity-feed.tsx +30 -1
- package/examples/playground/app/components/activity-history.test.tsx +35 -0
- package/examples/playground/app/components/demo-icons.tsx +89 -0
- package/examples/playground/app/components/demos.tsx +33 -0
- package/examples/playground/app/components/fast-link.test.tsx +58 -0
- package/examples/playground/app/components/fast-link.tsx +55 -0
- package/examples/playground/app/components/site-header.tsx +96 -0
- package/examples/playground/app/counter/counter-client.tsx +3 -3
- package/examples/playground/app/counter/model.test.ts +3 -3
- package/examples/playground/app/counter/model.ts +11 -21
- package/examples/playground/app/counter/page.tsx +2 -15
- package/examples/playground/app/counter/session.ts +2 -2
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
- package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
- package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
- package/examples/playground/app/documents/[documentId]/session.ts +2 -2
- package/examples/playground/app/documents/model.ts +27 -27
- package/examples/playground/app/globals.css +420 -207
- package/examples/playground/app/initial-state.test.tsx +54 -0
- package/examples/playground/app/layout.tsx +7 -23
- package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
- package/examples/playground/app/orders/[orderId]/session.ts +2 -2
- package/examples/playground/app/orders/model.ts +49 -49
- package/examples/playground/app/page.tsx +13 -102
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
- package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
- package/examples/playground/app/recovery/model.ts +30 -30
- package/examples/playground/instrumentation.ts +5 -0
- package/examples/playground/package.json +10 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +18 -0
- package/package.json +3 -1
- package/src/actor-client.ts +16 -4
- package/src/actor-react.ts +25 -11
- package/src/actor-shared.ts +21 -6
- package/src/actor.ts +76 -31
- package/src/ai-agent-actions.ts +219 -0
- package/src/ai-agent.ts +2343 -0
- package/src/ai-app-state.ts +91 -0
- package/src/ai-client-state.ts +39 -2
- package/src/ai-client.ts +62 -0
- package/src/ai-context-schema.ts +25 -5
- package/src/ai-context.ts +90 -40
- package/src/ai-control-server.ts +94 -55
- package/src/ai-control.ts +2 -0
- package/src/ai-coordinator.ts +23 -22
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +382 -58
- package/src/ai-progress-batches.ts +26 -0
- package/src/ai-projector.ts +95 -14
- package/src/ai-react.ts +146 -0
- package/src/ai-server.browser.ts +2 -2
- package/src/ai-server.ts +20 -1951
- package/src/ai-stored-state.ts +48 -163
- package/src/ai.ts +343 -276
- package/src/bootstrap-stream.ts +72 -0
- package/src/bounded-cache.ts +55 -0
- package/src/client-render-session.ts +49 -0
- package/src/client.ts +688 -151
- package/src/contract.ts +92 -42
- package/src/deterministic-id.ts +37 -0
- package/src/equal-json.ts +32 -0
- package/src/errors.ts +13 -1
- package/src/index.ts +5 -1
- package/src/initial-snapshot.ts +39 -0
- package/src/internal.ts +45 -0
- package/src/postgres-notification-scope.ts +14 -3
- package/src/react.ts +171 -206
- package/src/redis-http-reply.ts +256 -0
- package/src/redis-http-stream.ts +73 -0
- package/src/redis-http-subscriptions.ts +13 -5
- package/src/reducer-runtime.ts +52 -0
- package/src/reducer.ts +92 -10
- package/src/scheduler-qstash.ts +49 -20
- package/src/scheduler-task.ts +34 -2
- package/src/scheduler-vercel.ts +32 -84
- package/src/server-fetch.ts +139 -35
- package/src/server.ts +957 -237
- package/src/session-clear.ts +23 -0
- package/src/session-socket.ts +154 -49
- package/src/sse.ts +71 -4
- package/src/store-memory.ts +220 -78
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +461 -114
- package/src/store-redis-core.ts +1260 -434
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +260 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +259 -45
- package/src/store.ts +46 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +13 -1
- package/src/wire.ts +241 -13
- package/dist/actor-ohPC-81x.d.ts.map +0 -1
- package/dist/actor-shared-B5tJfzt-.js.map +0 -1
- package/dist/ai-CrLYwNEx.js.map +0 -1
- package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
- package/dist/client-BYzHjkwU.js.map +0 -1
- package/dist/client-CzyacQpJ.d.ts.map +0 -1
- package/dist/contract-CKRg_E4q.js.map +0 -1
- package/dist/errors-DCk6ch5n.js.map +0 -1
- package/dist/errors-DvhSXnxk.d.ts.map +0 -1
- package/dist/internal-Dq2qYxou.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/reducer-DEMjEY_O.js +0 -29
- package/dist/reducer-DEMjEY_O.js.map +0 -1
- package/dist/reducer-otzHjuJj.d.ts.map +0 -1
- package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
- package/dist/server-BD5ckxZb.js.map +0 -1
- package/dist/server-DbD7IJVK.d.ts.map +0 -1
- package/dist/store-DyZM6fS5.d.ts.map +0 -1
- package/dist/store-polling-CmxUbV93.js.map +0 -1
- package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
- package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
- package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
- package/dist/validate-XKT4FSNn.js +0 -30
- package/dist/validate-XKT4FSNn.js.map +0 -1
- package/dist/wire-BO5wWCb1.js.map +0 -1
- package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
- package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
- package/examples/playground/app/agent/compaction-settings.ts +0 -49
- package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
- package/examples/playground/app/agent/compaction-timeline.ts +0 -208
|
@@ -20,32 +20,15 @@ AI_GATEWAY_API_KEY=your_api_key
|
|
|
20
20
|
|
|
21
21
|
## Define the agent
|
|
22
22
|
|
|
23
|
-
`agent()`
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
`agent()` is the whole server: the built-in AI protocol, the AI SDK runner, and
|
|
24
|
+
A2's ordinary store, scheduler, telemetry, and handler machinery, assembled as
|
|
25
|
+
one A2 server.
|
|
26
26
|
|
|
27
|
-
```ts assistant.ts
|
|
28
|
-
import { agent } from 'experimental-a2/ai'
|
|
27
|
+
```ts server/assistant.ts
|
|
28
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
29
29
|
|
|
30
30
|
export const assistant = agent({
|
|
31
31
|
name: 'support-agent',
|
|
32
|
-
})
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Keep this module isomorphic. Model providers, secrets, tools, and storage belong
|
|
36
|
-
in the server module.
|
|
37
|
-
|
|
38
|
-
## Connect the model
|
|
39
|
-
|
|
40
|
-
`createAgentServer()` adds the AI SDK runner and A2's ordinary store,
|
|
41
|
-
scheduler, telemetry, and handler machinery:
|
|
42
|
-
|
|
43
|
-
```ts server/assistant.ts
|
|
44
|
-
import { createAgentServer } from 'experimental-a2/ai/server'
|
|
45
|
-
import { assistant } from '../assistant'
|
|
46
|
-
|
|
47
|
-
export const assistantServer = createAgentServer({
|
|
48
|
-
agent: assistant,
|
|
49
32
|
model: 'openai/gpt-5.6-terra',
|
|
50
33
|
instructions: 'Help the customer. Be concise.',
|
|
51
34
|
generation: {
|
|
@@ -55,6 +38,11 @@ export const assistantServer = createAgentServer({
|
|
|
55
38
|
})
|
|
56
39
|
```
|
|
57
40
|
|
|
41
|
+
`name` is the agent's identity: it prefixes storage keys and queue messages.
|
|
42
|
+
`experimental-a2/ai/server` is server-only, and a browser bundle that imports it
|
|
43
|
+
fails loudly. Browser code reaches the agent through its route and a type-only
|
|
44
|
+
import, shown below.
|
|
45
|
+
|
|
58
46
|
A string model id uses the AI SDK's global provider, which is AI Gateway by
|
|
59
47
|
default. `model` also accepts any AI SDK `LanguageModel`, such as a provider
|
|
60
48
|
model returned by `openai('gpt-5.6-terra')`, or an async resolver that chooses a
|
|
@@ -72,6 +60,12 @@ settings, including `toolsContext`, when calling the AI SDK. A local tool
|
|
|
72
60
|
reads its durable execution context later, from the handler that runs it. Model-step
|
|
73
61
|
timeouts are supported; tool-execution timeouts are not.
|
|
74
62
|
|
|
63
|
+
`generation` also accepts a sync or async resolver with the same context as
|
|
64
|
+
`model`, called once per attempt before compaction and model dispatch. Read
|
|
65
|
+
application state with `session.state()`, which defaults to the captured
|
|
66
|
+
generation snapshot. See the
|
|
67
|
+
[API reference](/reference/api#agentoptions).
|
|
68
|
+
|
|
75
69
|
For AI Gateway models, the default generator sets
|
|
76
70
|
`generation.providerOptions.gateway.caching` to `'auto'`. A2 merges this
|
|
77
71
|
default with your provider options, with caller-supplied values taking
|
|
@@ -91,102 +85,35 @@ Gateway's provider or guarantee a cache hit.
|
|
|
91
85
|
See [Gateway automatic caching](https://vercel.com/docs/ai-gateway/models-and-providers/automatic-caching)
|
|
92
86
|
for provider support and cache-write costs.
|
|
93
87
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
Extend the agent's `UIMessage` type to keep generation status and usage on the
|
|
97
|
-
assistant message:
|
|
98
|
-
|
|
99
|
-
```ts metadata-assistant.ts
|
|
100
|
-
import type { LanguageModelUsage, UIMessage } from 'ai'
|
|
101
|
-
import { agent } from 'experimental-a2/ai'
|
|
102
|
-
|
|
103
|
-
export type MetadataAssistantMessage = UIMessage<{
|
|
104
|
-
status: 'writing' | 'complete'
|
|
105
|
-
usage?: LanguageModelUsage
|
|
106
|
-
}>
|
|
107
|
-
|
|
108
|
-
export const metadataAssistant = agent<MetadataAssistantMessage>({
|
|
109
|
-
name: 'metadata-assistant',
|
|
110
|
-
})
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Map the AI SDK's standard stream parts to that metadata type:
|
|
114
|
-
|
|
115
|
-
```ts server/metadata-assistant.ts
|
|
116
|
-
import { createAgentServer } from 'experimental-a2/ai/server'
|
|
117
|
-
import { metadataAssistant } from '../metadata-assistant'
|
|
118
|
-
|
|
119
|
-
export const metadataAssistantServer = createAgentServer({
|
|
120
|
-
agent: metadataAssistant,
|
|
121
|
-
model: 'openai/gpt-5.6-terra',
|
|
122
|
-
messageMetadata: ({ part }) => {
|
|
123
|
-
if (part.type === 'start') return { status: 'writing' as const }
|
|
124
|
-
if (part.type === 'finish') {
|
|
125
|
-
return { status: 'complete' as const, usage: part.totalUsage }
|
|
126
|
-
}
|
|
127
|
-
return undefined
|
|
128
|
-
},
|
|
129
|
-
})
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
The default generator passes `messageMetadata` to the AI SDK's
|
|
133
|
-
`toUIMessageStream()`. The callback receives the generation context and its
|
|
134
|
-
current `TextStreamPart`. Its return type follows the metadata type in the
|
|
135
|
-
agent's `UIMessage`. Metadata on standard `start` and `finish` chunks enters
|
|
136
|
-
the event log with the rest of the stream. A2 shallow-merges those values onto
|
|
137
|
-
the projected assistant message, so the example exposes `writing` immediately
|
|
138
|
-
and keeps final token usage after completion. The callback is synchronous.
|
|
139
|
-
|
|
140
|
-
`experimental-a2/ai/server` is server-only. The isomorphic
|
|
141
|
-
`experimental-a2/ai` entry point contains the contract, reducer, schemas,
|
|
142
|
-
`handlerContext()`, and pure inputs; it never imports a model provider or
|
|
143
|
-
backend.
|
|
144
|
-
|
|
145
|
-
## Expose the event stream
|
|
88
|
+
## Expose the route
|
|
146
89
|
|
|
147
90
|
One HTTP route gives the browser a read and write path. `GET` streams events;
|
|
148
91
|
`POST` accepts optimistic pushes:
|
|
149
92
|
|
|
150
|
-
```ts app/api/agent
|
|
151
|
-
import {
|
|
93
|
+
```ts app/api/agent/route.ts
|
|
94
|
+
import { assistant } from '@/server/assistant'
|
|
152
95
|
|
|
153
|
-
export const GET =
|
|
154
|
-
export const POST =
|
|
96
|
+
export const GET = assistant.fetch
|
|
97
|
+
export const POST = assistant.fetch
|
|
155
98
|
```
|
|
156
99
|
|
|
157
|
-
The route never calls the model directly. The browser
|
|
158
|
-
`ai.control.requested` commands
|
|
100
|
+
The route never calls the model directly. The browser pushes user messages and
|
|
101
|
+
controls as `ai.control.requested` commands, and `fetch` rejects every
|
|
102
|
+
server-authored event with `400`. The controller admits inputs and schedules
|
|
159
103
|
`ai.generation.requested`; workers run the AI SDK and append progress to the
|
|
160
104
|
same log.
|
|
161
105
|
|
|
162
|
-
|
|
106
|
+
Authentication stays in your route. Here's where you'd do auth, or any other
|
|
107
|
+
checks, then hand the request to `assistant.fetch(request, { authorize })` for
|
|
108
|
+
per-operation policy.
|
|
163
109
|
|
|
164
|
-
|
|
110
|
+
## Render the conversation
|
|
165
111
|
|
|
166
|
-
|
|
167
|
-
'use client'
|
|
168
|
-
import { createClient } from 'experimental-a2/client'
|
|
169
|
-
import { createReact } from 'experimental-a2/react'
|
|
170
|
-
import { assistant } from '@/assistant'
|
|
171
|
-
|
|
172
|
-
export const assistantClient = createClient({
|
|
173
|
-
reducer: assistant.reducer,
|
|
174
|
-
api: '/api/agent-events',
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
export const { SessionProvider, useSession } = createReact({
|
|
178
|
-
client: assistantClient,
|
|
179
|
-
})
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
Fold the initial state on the server, then let the provider resume the event
|
|
183
|
-
stream from that exact index:
|
|
112
|
+
Fold the initial state on the server and hand it to a client component:
|
|
184
113
|
|
|
185
114
|
```tsx app/agent/[sessionId]/page.tsx
|
|
186
|
-
import { assistant } from '@/assistant'
|
|
187
|
-
import {
|
|
188
|
-
import { AgentClient } from './agent-client'
|
|
189
|
-
import { SessionProvider } from '../session'
|
|
115
|
+
import { assistant } from '@/server/assistant'
|
|
116
|
+
import { Chat } from './chat'
|
|
190
117
|
|
|
191
118
|
export default async function AgentPage({
|
|
192
119
|
params,
|
|
@@ -194,96 +121,53 @@ export default async function AgentPage({
|
|
|
194
121
|
params: Promise<{ sessionId: string }>
|
|
195
122
|
}) {
|
|
196
123
|
const { sessionId } = await params
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return (
|
|
201
|
-
<SessionProvider
|
|
202
|
-
sessionId={sessionId}
|
|
203
|
-
initialState={state}
|
|
204
|
-
initialIndex={index}
|
|
205
|
-
>
|
|
206
|
-
<AgentClient />
|
|
207
|
-
</SessionProvider>
|
|
208
|
-
)
|
|
124
|
+
const initial = await assistant.session(sessionId).state()
|
|
125
|
+
return <Chat sessionId={sessionId} initial={initial} />
|
|
209
126
|
}
|
|
210
127
|
```
|
|
211
128
|
|
|
212
129
|
An empty session folds to the initial `AIState`. Its log is created lazily when
|
|
213
130
|
the first message is pushed.
|
|
214
131
|
|
|
215
|
-
|
|
216
|
-
before navigating. The destination server render then sees the message in its
|
|
217
|
-
folded state instead of racing the `POST`:
|
|
218
|
-
|
|
219
|
-
```tsx app/agent/new-agent-session.tsx
|
|
220
|
-
'use client'
|
|
221
|
-
import type { UIMessage } from 'ai'
|
|
222
|
-
import { inputs } from 'experimental-a2/ai'
|
|
223
|
-
import { assistantClient } from './session'
|
|
224
|
-
|
|
225
|
-
export async function openAgent(
|
|
226
|
-
sessionId: string,
|
|
227
|
-
message: UIMessage,
|
|
228
|
-
router: { push(href: string): void },
|
|
229
|
-
): Promise<void> {
|
|
230
|
-
await assistantClient.session(sessionId).push(...inputs.message(message))
|
|
231
|
-
router.push(`/agent/${sessionId}`)
|
|
232
|
-
}
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
The draft clears immediately while the append is in flight and can be restored
|
|
236
|
-
if it fails. Once navigation begins, SSR already has folded state at a durable
|
|
237
|
-
frontier. When the destination provider mounts, it resolves the same live
|
|
238
|
-
session and reconciles that server render without replacing the object. Idle
|
|
239
|
-
session objects expire after five minutes by default; configure `gcTime` on
|
|
240
|
-
`createClient` when a different lifetime fits the app.
|
|
241
|
-
|
|
242
|
-
## Send messages from the client
|
|
243
|
-
|
|
244
|
-
Build the message inline and pass the resulting events straight to `push()`:
|
|
132
|
+
`useAgent()` keeps that state live and returns the actions a chat needs:
|
|
245
133
|
|
|
246
|
-
```tsx app/agent/[sessionId]/
|
|
134
|
+
```tsx app/agent/[sessionId]/chat.tsx
|
|
247
135
|
'use client'
|
|
248
136
|
import type { FormEvent } from 'react'
|
|
249
137
|
import { useState } from 'react'
|
|
250
|
-
import {
|
|
251
|
-
import {
|
|
138
|
+
import type { StateSnapshot } from 'experimental-a2'
|
|
139
|
+
import type { AIState } from 'experimental-a2/ai'
|
|
140
|
+
import { useAgent } from 'experimental-a2/ai/react'
|
|
141
|
+
import type { assistant } from '@/server/assistant'
|
|
142
|
+
|
|
143
|
+
const indicatorLabel = {
|
|
144
|
+
starting: 'Starting…',
|
|
145
|
+
thinking: 'Thinking…',
|
|
146
|
+
compacting: 'Compacting…',
|
|
147
|
+
}
|
|
252
148
|
|
|
253
|
-
export function
|
|
254
|
-
|
|
149
|
+
export function Chat({
|
|
150
|
+
sessionId,
|
|
151
|
+
initial,
|
|
152
|
+
}: {
|
|
153
|
+
sessionId: string
|
|
154
|
+
initial: StateSnapshot<AIState>
|
|
155
|
+
}) {
|
|
156
|
+
const { messages, status, indicator, sendMessage } =
|
|
157
|
+
useAgent<typeof assistant>({ api: '/api/agent', id: sessionId, initial })
|
|
255
158
|
const [draft, setDraft] = useState('')
|
|
256
159
|
const [error, setError] = useState<string | null>(null)
|
|
257
160
|
|
|
258
|
-
const canSend = state.status !== 'closed'
|
|
259
|
-
const lastMessage = state.messages.at(-1)
|
|
260
|
-
const lastText =
|
|
261
|
-
lastMessage?.parts
|
|
262
|
-
.flatMap((part) => (part.type === 'text' ? [part.text] : []))
|
|
263
|
-
.join('\n') ?? ''
|
|
264
|
-
const showThinking =
|
|
265
|
-
state.status === 'generating' &&
|
|
266
|
-
(lastMessage?.role !== 'assistant' || lastText.length === 0)
|
|
267
|
-
|
|
268
161
|
async function send(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
269
162
|
event.preventDefault()
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
if (!text || !canSend) return
|
|
273
|
-
|
|
163
|
+
const text = draft.trim()
|
|
164
|
+
if (!text) return
|
|
274
165
|
setDraft('')
|
|
275
166
|
setError(null)
|
|
276
|
-
|
|
277
167
|
try {
|
|
278
|
-
await
|
|
279
|
-
...inputs.message({
|
|
280
|
-
id: crypto.randomUUID(),
|
|
281
|
-
role: 'user',
|
|
282
|
-
parts: [{ type: 'text', text }],
|
|
283
|
-
}),
|
|
284
|
-
)
|
|
168
|
+
await sendMessage(text)
|
|
285
169
|
} catch (cause) {
|
|
286
|
-
setDraft(
|
|
170
|
+
setDraft(text)
|
|
287
171
|
setError(cause instanceof Error ? cause.message : String(cause))
|
|
288
172
|
}
|
|
289
173
|
}
|
|
@@ -291,25 +175,16 @@ export function AgentClient() {
|
|
|
291
175
|
return (
|
|
292
176
|
<main>
|
|
293
177
|
<section aria-label="Conversation">
|
|
294
|
-
{
|
|
295
|
-
|
|
296
|
-
.
|
|
297
|
-
.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return (
|
|
301
|
-
<article key={message.id}>
|
|
302
|
-
<strong>{message.role === 'user' ? 'You' : 'Agent'}</strong>
|
|
303
|
-
<p>{text}</p>
|
|
304
|
-
</article>
|
|
305
|
-
)
|
|
306
|
-
})}
|
|
307
|
-
|
|
308
|
-
{showThinking ? (
|
|
309
|
-
<article aria-live="polite">
|
|
310
|
-
<strong>Agent</strong>
|
|
311
|
-
<p>Thinking…</p>
|
|
178
|
+
{messages.map((message) => (
|
|
179
|
+
<article key={message.id}>
|
|
180
|
+
<strong>{message.role === 'user' ? 'You' : 'Agent'}</strong>
|
|
181
|
+
{message.parts.map((part, index) =>
|
|
182
|
+
part.type === 'text' ? <p key={index}>{part.text}</p> : null,
|
|
183
|
+
)}
|
|
312
184
|
</article>
|
|
185
|
+
))}
|
|
186
|
+
{indicator ? (
|
|
187
|
+
<p role="status">{indicatorLabel[indicator]}</p>
|
|
313
188
|
) : null}
|
|
314
189
|
</section>
|
|
315
190
|
|
|
@@ -319,7 +194,7 @@ export function AgentClient() {
|
|
|
319
194
|
value={draft}
|
|
320
195
|
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
321
196
|
/>
|
|
322
|
-
<button disabled={!draft.trim() ||
|
|
197
|
+
<button disabled={!draft.trim() || status === 'closed'}>Send</button>
|
|
323
198
|
</form>
|
|
324
199
|
|
|
325
200
|
{error ? <p role="alert">{error}</p> : null}
|
|
@@ -328,14 +203,29 @@ export function AgentClient() {
|
|
|
328
203
|
}
|
|
329
204
|
```
|
|
330
205
|
|
|
331
|
-
`
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
206
|
+
The `import type` of the server module is erased at build time, so neither the
|
|
207
|
+
server nor a contract enters the client bundle. The AI protocol and its reducer
|
|
208
|
+
are library code; the type argument only carries the agent's message types. The hook keeps one client per `api` for the page's lifetime,
|
|
209
|
+
and every component that calls `useAgent()` with the same `api` and `id` shares
|
|
210
|
+
one live session. That client reads `globalThis.fetch` on each request; pass
|
|
211
|
+
`client` instead of `api` for a custom `fetch`, headers, or `gcTime`. Actions are
|
|
212
|
+
rebuilt on every render, so do not rely on their identity.
|
|
213
|
+
|
|
214
|
+
The result is the ordinary session result (`state`, `index`, `connection`,
|
|
215
|
+
`push`, `events`, and history) plus actions, `messages` (the same array as
|
|
216
|
+
`state.messages`), `status`, and `indicator`. `indicator` names what the UI
|
|
217
|
+
shows while nothing new is visible yet: `starting` for an optimistic turn
|
|
218
|
+
awaiting acceptance, `compacting` while a summary runs, `thinking` while the
|
|
219
|
+
current model step has produced no visible part, and `null` otherwise. Reasoning
|
|
220
|
+
and step boundaries do not count as visible output, so the indicator returns
|
|
221
|
+
between tool steps.
|
|
222
|
+
|
|
223
|
+
`sendMessage()` turns a string into one text part with a fresh message ID, or
|
|
224
|
+
takes a complete `UIMessage`. Every action returns the same result as `push()`:
|
|
225
|
+
it resolves to the persisted command events once they are stored, and its
|
|
226
|
+
`.confirmed` promise resolves when the stream delivers them back. The draft
|
|
227
|
+
clears immediately and returns if the push fails. `state.inbox.items`
|
|
228
|
+
contains pending inputs; `state.messages` contains the admitted conversation.
|
|
339
229
|
|
|
340
230
|
Users can send another message while a response is active. The message enters
|
|
341
231
|
the durable log immediately, but its turn waits until the active assistant
|
|
@@ -343,22 +233,8 @@ response, including every tool step and approval, reaches a terminal event.
|
|
|
343
233
|
Inbox order defaults to arrival order and can be edited explicitly.
|
|
344
234
|
|
|
345
235
|
Pass `{ generate: false }` when a user message should update the conversation
|
|
346
|
-
without starting a model turn
|
|
347
|
-
|
|
348
|
-
```ts app/agent/passive-message.ts
|
|
349
|
-
import type { UIMessage } from 'ai'
|
|
350
|
-
import { inputs } from 'experimental-a2/ai'
|
|
351
|
-
import { assistantClient } from './session'
|
|
352
|
-
|
|
353
|
-
export async function recordPassiveMessage(
|
|
354
|
-
sessionId: string,
|
|
355
|
-
message: UIMessage,
|
|
356
|
-
): Promise<void> {
|
|
357
|
-
await assistantClient
|
|
358
|
-
.session(sessionId)
|
|
359
|
-
.push(...inputs.message(message, { generate: false }))
|
|
360
|
-
}
|
|
361
|
-
```
|
|
236
|
+
without starting a model turn, as in
|
|
237
|
+
`sendMessage('Order #4411 shipped this morning.', { generate: false })`.
|
|
362
238
|
|
|
363
239
|
The passive user message appears in `AIState.messages` when admitted. A later user
|
|
364
240
|
message with the default generation behavior includes passive user messages
|
|
@@ -367,7 +243,92 @@ outside its prompt and wait for the next generating message. This ordering
|
|
|
367
243
|
supports ambient agents without letting new background context leak into an
|
|
368
244
|
already queued turn.
|
|
369
245
|
|
|
370
|
-
##
|
|
246
|
+
## Start a session from another page
|
|
247
|
+
|
|
248
|
+
Wait for the durable append before navigating, so the destination's server
|
|
249
|
+
render sees the message in its folded state instead of racing the `POST`.
|
|
250
|
+
`createAgentClient()` is the same contract-free client outside React:
|
|
251
|
+
|
|
252
|
+
```ts app/agent/client.ts
|
|
253
|
+
'use client'
|
|
254
|
+
import { createAgentClient } from 'experimental-a2/ai/client'
|
|
255
|
+
import type { assistant } from '@/server/assistant'
|
|
256
|
+
|
|
257
|
+
export const assistantClient = createAgentClient<typeof assistant>({
|
|
258
|
+
api: '/api/agent',
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
```ts app/agent/open-agent.ts
|
|
263
|
+
'use client'
|
|
264
|
+
import { inputs } from 'experimental-a2/ai'
|
|
265
|
+
import { assistantClient } from './client'
|
|
266
|
+
|
|
267
|
+
export async function openAgent({
|
|
268
|
+
sessionId,
|
|
269
|
+
text,
|
|
270
|
+
router,
|
|
271
|
+
}: {
|
|
272
|
+
sessionId: string
|
|
273
|
+
text: string
|
|
274
|
+
router: { push(href: string): void }
|
|
275
|
+
}): Promise<void> {
|
|
276
|
+
await assistantClient.session(sessionId).push(
|
|
277
|
+
...inputs.message({
|
|
278
|
+
id: crypto.randomUUID(),
|
|
279
|
+
role: 'user',
|
|
280
|
+
parts: [{ type: 'text', text }],
|
|
281
|
+
}),
|
|
282
|
+
)
|
|
283
|
+
router.push(`/agent/${sessionId}`)
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Pass the same client to the destination's hook,
|
|
288
|
+
`useAgent<typeof assistant>({ client: assistantClient, id: sessionId, initial })`,
|
|
289
|
+
so it resolves the live session the push created and reconciles the server
|
|
290
|
+
render without replacing it. Idle session objects expire after five minutes by
|
|
291
|
+
default; configure `gcTime` on `createAgentClient` when a different lifetime
|
|
292
|
+
fits the app.
|
|
293
|
+
|
|
294
|
+
## Choose the model
|
|
295
|
+
|
|
296
|
+
`model` takes a model, or `ModelSettings` to choose its reasoning level and
|
|
297
|
+
speed with it:
|
|
298
|
+
|
|
299
|
+
```ts server/fast-assistant.ts
|
|
300
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
301
|
+
|
|
302
|
+
export const fastAssistant = agent({
|
|
303
|
+
name: 'fast-support',
|
|
304
|
+
model: { model: 'openai/gpt-5.6-terra', reasoning: 'low', speed: 'fast' },
|
|
305
|
+
})
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
A resolver returns the same shape for each generation, so the choice can come
|
|
309
|
+
from anything it reads, such as the conversation so far:
|
|
310
|
+
|
|
311
|
+
```ts server/routed-assistant.ts
|
|
312
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
313
|
+
|
|
314
|
+
export const routedAssistant = agent({
|
|
315
|
+
name: 'routed-support',
|
|
316
|
+
model: ({ messages }) =>
|
|
317
|
+
messages.length > 20
|
|
318
|
+
? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
|
|
319
|
+
: { model: 'openai/gpt-5.6-luna', speed: 'fast' },
|
|
320
|
+
})
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`reasoning` becomes the AI SDK's `reasoning` call option. `speed: 'fast'` sets
|
|
324
|
+
`providerOptions.gateway.speed` to prefer AI Gateway's faster tier when the
|
|
325
|
+
model offers one; `'standard'` removes a configured speed. Both override the
|
|
326
|
+
same settings in `generation`. `reasoningLevels` from `experimental-a2/ai`
|
|
327
|
+
lists the levels, for building a picker. To choose from your app's state, such
|
|
328
|
+
as the user's plan or a picker's selection, see
|
|
329
|
+
[Add your own events and state](#add-your-own-events-and-state).
|
|
330
|
+
|
|
331
|
+
## What happens after a send
|
|
371
332
|
|
|
372
333
|
One generating user interaction becomes a durable sequence:
|
|
373
334
|
|
|
@@ -394,15 +355,20 @@ automatic tool can finish while the model stream is still open. The join waits
|
|
|
394
355
|
for both step closure and every required terminal result.
|
|
395
356
|
|
|
396
357
|
The SSE connection delivers the server events to the same reducer. Progress
|
|
397
|
-
stores
|
|
358
|
+
stores streamed output once. Within a batch, adjacent text, reasoning, and
|
|
359
|
+
tool-input deltas of one part are joined into a single chunk. The reducer and the exported
|
|
398
360
|
`deriveUIMessages()` apply those chunks synchronously to produce the current
|
|
399
361
|
`UIMessage`; cumulative message snapshots are not duplicated in the log.
|
|
400
362
|
|
|
401
|
-
`AIState` exposes `messages`, `status`, `
|
|
402
|
-
`
|
|
403
|
-
`
|
|
404
|
-
|
|
405
|
-
|
|
363
|
+
`AIState` exposes `messages`, `status`, `inbox`, `active`,
|
|
364
|
+
`pendingApprovals`, `pendingInputs`, `tools`, `compaction`, per-generation
|
|
365
|
+
`usage`, the latest command `receipt`, and the last generation `error`. It also
|
|
366
|
+
carries the protocol's ownership bookkeeping: `activeGeneration`,
|
|
367
|
+
`activeRequestId`, `activeResponseMessageId`, `activeProjection`,
|
|
368
|
+
`responseOwners`, `terminalRequestIds`, and `terminalGenerations`. The hook's
|
|
369
|
+
actions read these fields for you; lower-level `inputs` builders need them
|
|
370
|
+
explicitly.
|
|
371
|
+
|
|
406
372
|
`activeProjection` is the temporary indexed generation frontier used for exact
|
|
407
373
|
interruption. It remains available after a generation step completes while the
|
|
408
374
|
response waits on tool, approval, or input barriers, and after a generation
|
|
@@ -412,10 +378,9 @@ replaces it.
|
|
|
412
378
|
|
|
413
379
|
`activeRequestId` identifies the server-authorized generation request. It
|
|
414
380
|
prevents a delayed request or recovered attempt from taking ownership from the
|
|
415
|
-
current response before its generation starts.
|
|
416
|
-
|
|
417
|
-
`
|
|
418
|
-
`activeGeneration` exists. It is `null` after the generation starts.
|
|
381
|
+
current response before its generation starts. `activeResponseMessageId`
|
|
382
|
+
identifies that request's response before `activeGeneration` exists. It is
|
|
383
|
+
`null` after the generation starts.
|
|
419
384
|
|
|
420
385
|
`responseOwners` keeps the latest generation ID, request ID, and attempt for each response
|
|
421
386
|
message. Late lifecycle events from a superseded generation stay in raw
|
|
@@ -429,61 +394,12 @@ IDs as opaque. An assistant response uses its first model-request event's ID. Ge
|
|
|
429
394
|
Ownership comes from these fields and the generation's `requestId` and `attempt`,
|
|
430
395
|
so ID strings never embed the preceding tool loop.
|
|
431
396
|
|
|
432
|
-
Approval and application-input events carry both `requestId` and `generationId`.
|
|
433
|
-
Copy both from the pending approval, pending input, or active generation when
|
|
434
|
-
sending a response. `activeRequestReason` and `activeRequestSourceGenerationId`
|
|
435
|
-
describe the current request independently from the message projection.
|
|
436
|
-
|
|
437
397
|
Generation requests share one durable lane, so model calls do not overlap.
|
|
438
398
|
The queued-turn policy is the stronger ordering rule: a later user message is
|
|
439
399
|
not scheduled until the current response's complete tool loop finishes. Lane
|
|
440
400
|
FIFO alone would not prevent a newly appended user turn from overtaking a tool
|
|
441
401
|
continuation that has not been appended yet.
|
|
442
402
|
|
|
443
|
-
## The built-in inputs
|
|
444
|
-
|
|
445
|
-
`inputs` contains pure, isomorphic builders for protocol interactions:
|
|
446
|
-
|
|
447
|
-
| Input | Events |
|
|
448
|
-
| --- | --- |
|
|
449
|
-
| `inputs.message(message, { generate? })` | queues input; `false` queues passive context |
|
|
450
|
-
| `inputs.seed(message)` | records `generate: false`; non-user roles require a trusted append |
|
|
451
|
-
| `inputs.approval(response)` | requests a decision on a pending approval |
|
|
452
|
-
| `inputs.input(response)` | records an application input response fact |
|
|
453
|
-
| `inputs.requestInput(request)` | records a trusted server request for application input |
|
|
454
|
-
| `inputs.retry(options)` | requests a fresh attempt for a failed response |
|
|
455
|
-
| `inputs.stop({ turnId })` | ends the observed turn and advances |
|
|
456
|
-
| `inputs.steer({ turnId, message })` | prioritizes input after the observed turn's current step finishes |
|
|
457
|
-
| `inputs.queue.edit({ message })` | edits a pending input |
|
|
458
|
-
| `inputs.queue.move({ inputId, beforeId })` | reorders pending input; `null` means the end |
|
|
459
|
-
| `inputs.queue.remove({ inputId })` | removes pending input from the inbox |
|
|
460
|
-
| `inputs.queue.sendNow({ inputId, turnId })` | atomically prioritizes queued input and interrupts the observed turn |
|
|
461
|
-
| `inputs.pause({ when })` | pauses now or after the active turn |
|
|
462
|
-
| `inputs.resume()` | resumes retained work and inbox admission |
|
|
463
|
-
| `inputs.toolResult(result)` | records a trusted deferred provider-tool result |
|
|
464
|
-
| `inputs.interrupt(options)` | addresses a specific generation or request |
|
|
465
|
-
|
|
466
|
-
Builders return `ai.control.requested` commands. Message, approval, input, and
|
|
467
|
-
retry builders identify a stable interaction. Other controls use the event ID
|
|
468
|
-
assigned by `push()` or `append()`; transport retries preserve that ID.
|
|
469
|
-
Generation scheduling considers only user-role `ai.message.created` facts.
|
|
470
|
-
Assistant and system message facts remain context regardless of the `generate`
|
|
471
|
-
field, and model output is recorded as generation progress rather than a new
|
|
472
|
-
message fact, so an assistant reply cannot recursively schedule another turn.
|
|
473
|
-
Browser `push()` accepts user messages, approval and input responses,
|
|
474
|
-
interruptions, and explicit retries. `inputs.requestInput()` and non-user
|
|
475
|
-
messages built with `inputs.seed()` are for trusted server appends. Browser
|
|
476
|
-
inputs never append server scheduling, seeded non-user messages, or lifecycle
|
|
477
|
-
events.
|
|
478
|
-
Approval and input request/response payloads carry the active `generationId`;
|
|
479
|
-
clients copy it from the pending request so a stale interaction cannot satisfy
|
|
480
|
-
a later model step.
|
|
481
|
-
|
|
482
|
-
`inputs` deliberately has no session lifecycle methods. The A2 log begins with
|
|
483
|
-
the first append. Apps that use explicit `ai.session.created` or
|
|
484
|
-
`ai.session.closed` lifecycle events append those trusted server facts
|
|
485
|
-
directly.
|
|
486
|
-
|
|
487
403
|
## Add tools and approval
|
|
488
404
|
|
|
489
405
|
Tools are ordinary AI SDK `tool()` definitions. A2 passes them through to
|
|
@@ -493,8 +409,7 @@ belongs in `generation`, beside the other per-step settings:
|
|
|
493
409
|
```ts server/with-tools.ts
|
|
494
410
|
import { tool } from 'ai'
|
|
495
411
|
import { z } from 'zod'
|
|
496
|
-
import {
|
|
497
|
-
import { assistant } from '../assistant'
|
|
412
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
498
413
|
|
|
499
414
|
const tools = {
|
|
500
415
|
closeTicket: tool({
|
|
@@ -508,8 +423,8 @@ const tools = {
|
|
|
508
423
|
}),
|
|
509
424
|
}
|
|
510
425
|
|
|
511
|
-
export const
|
|
512
|
-
|
|
426
|
+
export const toolsAssistant = agent({
|
|
427
|
+
name: 'support-tools',
|
|
513
428
|
model: 'openai/gpt-5.6-terra',
|
|
514
429
|
tools,
|
|
515
430
|
generation: {
|
|
@@ -523,15 +438,15 @@ export const assistantServerWithTools = createAgentServer({
|
|
|
523
438
|
The AI SDK infers the tool input and output from its schemas and
|
|
524
439
|
`execute`. A2 additionally runs each local `execute` inside an ambient
|
|
525
440
|
scope carrying the current durable handler attempt. Read it with
|
|
526
|
-
`handlerContext(
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
441
|
+
`handlerContext()` from `experimental-a2/ai`: it returns the same context an
|
|
442
|
+
event handler receives, with `event`, `attempt`, `session`, and `signal`. Tools
|
|
443
|
+
execute an `ai.tool.execution.requested` event after authorization. Its payload
|
|
444
|
+
contains the admitted `call`, its `generation`, and its turn and execution
|
|
445
|
+
version. `handlerContext()` throws when called outside a tool execution. The
|
|
446
|
+
scope survives awaited helpers and async iteration, so any code below `execute`
|
|
447
|
+
can read it. Because the call takes no argument, a tool can live inside the
|
|
448
|
+
`agent()` call that serves it. Pass a shared `agentContract()` to type
|
|
449
|
+
application events on `ctx.session`; A2 checks it against the executing agent.
|
|
535
450
|
|
|
536
451
|
`generation.toolApproval` is evaluated during the model step, before A2 has a
|
|
537
452
|
durable tool handler. Static policies such as the example above are simplest.
|
|
@@ -545,16 +460,14 @@ untouched. Any model-phase hook that receives tool definitions sees
|
|
|
545
460
|
them without local `execute` functions. The durable A2 context is
|
|
546
461
|
ambient only inside `execute`.
|
|
547
462
|
|
|
548
|
-
The agent
|
|
549
|
-
`
|
|
550
|
-
|
|
551
|
-
reminder as a new user message:
|
|
463
|
+
The context's `session` belongs to the agent executing the tool, so its
|
|
464
|
+
`schedule()` method uses that agent's scheduler. For example, this tool
|
|
465
|
+
schedules a reminder as a new user message:
|
|
552
466
|
|
|
553
467
|
```ts server/set-reminder.ts
|
|
554
468
|
import { tool } from 'ai'
|
|
555
469
|
import { handlerContext, inputs } from 'experimental-a2/ai'
|
|
556
470
|
import { z } from 'zod'
|
|
557
|
-
import { assistant } from '../assistant'
|
|
558
471
|
|
|
559
472
|
export const setReminder = tool({
|
|
560
473
|
description: 'Set a reminder for yourself',
|
|
@@ -563,7 +476,7 @@ export const setReminder = tool({
|
|
|
563
476
|
reminder: z.string(),
|
|
564
477
|
}),
|
|
565
478
|
execute: async ({ delaySeconds, reminder }, { toolCallId }) => {
|
|
566
|
-
const ctx = handlerContext(
|
|
479
|
+
const ctx = handlerContext()
|
|
567
480
|
await ctx.session.schedule(
|
|
568
481
|
`reminder:${toolCallId}`,
|
|
569
482
|
{ delay: `${delaySeconds}s` },
|
|
@@ -579,7 +492,7 @@ export const setReminder = tool({
|
|
|
579
492
|
})
|
|
580
493
|
```
|
|
581
494
|
|
|
582
|
-
Add `setReminder` to the
|
|
495
|
+
Add `setReminder` to the agent's `tools` and configure a
|
|
583
496
|
[scheduler](/guides/production).
|
|
584
497
|
The numeric schema lets the model choose any whole-second delay in the
|
|
585
498
|
provider's supported range instead of selecting from a fixed application enum.
|
|
@@ -601,51 +514,29 @@ A tool that never reads `handlerContext()` is just an AI SDK tool;
|
|
|
601
514
|
nothing else changes. To test a context-reading tool directly, wrap the
|
|
602
515
|
call with `runWithHandlerContext(ctx, fn)` from
|
|
603
516
|
`experimental-a2/ai/server`: inside `fn`, `handlerContext()` returns
|
|
604
|
-
`ctx
|
|
517
|
+
`ctx`.
|
|
605
518
|
|
|
606
519
|
The AI SDK emits an approval request instead of executing the tool. A2 records
|
|
607
520
|
it as `ai.approval.requested`, and the reducer adds it to
|
|
608
|
-
`state.pendingApprovals`.
|
|
521
|
+
`state.pendingApprovals`. Answer it from the hook:
|
|
609
522
|
|
|
610
|
-
```tsx app/
|
|
523
|
+
```tsx app/tools/[sessionId]/approval-controls.tsx
|
|
611
524
|
'use client'
|
|
612
|
-
import {
|
|
613
|
-
import {
|
|
525
|
+
import { useAgent } from 'experimental-a2/ai/react'
|
|
526
|
+
import type { toolsAssistant } from '@/server/with-tools'
|
|
614
527
|
|
|
615
|
-
export function ApprovalControls() {
|
|
616
|
-
const { state,
|
|
528
|
+
export function ApprovalControls({ sessionId }: { sessionId: string }) {
|
|
529
|
+
const { state, approve, deny } = useAgent<typeof toolsAssistant>({
|
|
530
|
+
api: '/api/tools',
|
|
531
|
+
id: sessionId,
|
|
532
|
+
})
|
|
617
533
|
|
|
618
534
|
return state.pendingApprovals.map((approval) => (
|
|
619
535
|
<div key={approval.approvalId}>
|
|
620
536
|
<span>Allow tool call {approval.toolCallId}?</span>
|
|
537
|
+
<button onClick={() => void approve(approval)}>Allow</button>
|
|
621
538
|
<button
|
|
622
|
-
onClick={() =>
|
|
623
|
-
void push(
|
|
624
|
-
...inputs.approval({
|
|
625
|
-
requestId: approval.requestId,
|
|
626
|
-
messageId: approval.messageId,
|
|
627
|
-
generationId: approval.generationId,
|
|
628
|
-
approvalId: approval.approvalId,
|
|
629
|
-
approved: true,
|
|
630
|
-
}),
|
|
631
|
-
)
|
|
632
|
-
}
|
|
633
|
-
>
|
|
634
|
-
Allow
|
|
635
|
-
</button>
|
|
636
|
-
<button
|
|
637
|
-
onClick={() =>
|
|
638
|
-
void push(
|
|
639
|
-
...inputs.approval({
|
|
640
|
-
requestId: approval.requestId,
|
|
641
|
-
messageId: approval.messageId,
|
|
642
|
-
generationId: approval.generationId,
|
|
643
|
-
approvalId: approval.approvalId,
|
|
644
|
-
approved: false,
|
|
645
|
-
reason: 'Denied by the user',
|
|
646
|
-
}),
|
|
647
|
-
)
|
|
648
|
-
}
|
|
539
|
+
onClick={() => void deny(approval, { reason: 'Denied by the user' })}
|
|
649
540
|
>
|
|
650
541
|
Deny
|
|
651
542
|
</button>
|
|
@@ -654,10 +545,11 @@ export function ApprovalControls() {
|
|
|
654
545
|
}
|
|
655
546
|
```
|
|
656
547
|
|
|
657
|
-
`
|
|
658
|
-
the
|
|
659
|
-
|
|
660
|
-
|
|
548
|
+
`approve()` and `deny()` copy the request, message, generation, and approval
|
|
549
|
+
IDs from the pending approval, so a delayed click cannot authorize a later
|
|
550
|
+
model step. The decision updates the approval part in the projected
|
|
551
|
+
`UIMessage`. A built-in handler executes the approved tool or records a denied
|
|
552
|
+
result. It never bypasses the same sibling join used by automatic tools.
|
|
661
553
|
|
|
662
554
|
Tool activity follows the full AI SDK chunk lifecycle. Input validation errors,
|
|
663
555
|
execution errors, denials, and preliminary and final outputs become durable
|
|
@@ -667,7 +559,10 @@ cannot deduplicate the final one. Dynamic-tool, provider-executed, provider
|
|
|
667
559
|
metadata, and tool metadata fields are preserved in the projection.
|
|
668
560
|
|
|
669
561
|
Independent tool handlers run concurrently as soon as their durable inputs and
|
|
670
|
-
authorization are ready. There is no fixed tool concurrency limit.
|
|
562
|
+
authorization are ready. There is no fixed tool concurrency limit. Set
|
|
563
|
+
`toolLane: 'tools'` on the agent when local tools must run one at a time, for
|
|
564
|
+
example because they share a sandbox; every local tool execution then queues on
|
|
565
|
+
that lane. A private
|
|
671
566
|
coordinator reducer tracks generation closure, cancellation, calls, approvals,
|
|
672
567
|
and terminal results for the active response. Its retained state is bounded;
|
|
673
568
|
completed responses do not accumulate in the coordinator. Each join reads a
|
|
@@ -685,51 +580,45 @@ streaming tool outputs use immediate appends. An approved provider-executed
|
|
|
685
580
|
call can satisfy the join through its response. A provider tool that declares
|
|
686
581
|
deferred-result support can wait for its provider result instead of a local
|
|
687
582
|
executor. The authenticated provider callback appends that `ai.tool.result`
|
|
688
|
-
through the trusted server session API. Browser
|
|
583
|
+
through the trusted server session API. Browser pushes reject lifecycle
|
|
689
584
|
events, including provider results.
|
|
690
585
|
|
|
691
|
-
`
|
|
692
|
-
application-defined input.
|
|
693
|
-
|
|
694
|
-
when the value should become
|
|
695
|
-
|
|
696
|
-
Approval and application-input request/response facts carry the active
|
|
697
|
-
`generationId`. Echo that value from the pending request so a delayed UI action
|
|
698
|
-
cannot authorize or resume a later model step.
|
|
586
|
+
`state.pendingInputs` provides the same durable request/response shape for
|
|
587
|
+
application-defined input. Answer one with `respond(input, value)` from the
|
|
588
|
+
hook. Its value stays in raw history and `state.pendingInputs`; resolve
|
|
589
|
+
instructions dynamically or replace `generate` when the value should become
|
|
590
|
+
model context.
|
|
699
591
|
|
|
700
592
|
## Interrupt and retry
|
|
701
593
|
|
|
702
|
-
|
|
594
|
+
Stop the active turn, or retry a failed response, from the hook:
|
|
703
595
|
|
|
704
|
-
```tsx app/agent/[sessionId]/
|
|
596
|
+
```tsx app/agent/[sessionId]/turn-controls.tsx
|
|
705
597
|
'use client'
|
|
706
|
-
import {
|
|
707
|
-
import {
|
|
708
|
-
|
|
709
|
-
export function
|
|
710
|
-
const { state,
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
lastSeenIndex: index,
|
|
721
|
-
}),
|
|
722
|
-
)
|
|
723
|
-
}
|
|
724
|
-
>
|
|
725
|
-
Stop
|
|
726
|
-
</button>
|
|
727
|
-
)
|
|
598
|
+
import { useAgent } from 'experimental-a2/ai/react'
|
|
599
|
+
import type { assistant } from '@/server/assistant'
|
|
600
|
+
|
|
601
|
+
export function TurnControls({ sessionId }: { sessionId: string }) {
|
|
602
|
+
const { state, status, stop, retry } = useAgent<typeof assistant>({
|
|
603
|
+
api: '/api/agent',
|
|
604
|
+
id: sessionId,
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
if (status === 'failed')
|
|
608
|
+
return <button onClick={() => void retry()}>Retry</button>
|
|
609
|
+
return state.active ? (
|
|
610
|
+
<button onClick={() => void stop()}>Stop</button>
|
|
611
|
+
) : null
|
|
728
612
|
}
|
|
729
613
|
```
|
|
730
614
|
|
|
731
|
-
|
|
732
|
-
|
|
615
|
+
`stop()` addresses the observed turn and sends the exact confirmed log frontier
|
|
616
|
+
the rendered state showed, not whatever arrived before the click was handled.
|
|
617
|
+
The client immediately previews Stop for that turn. While that generation's
|
|
618
|
+
indexed projection is retained, the client freezes the response at that
|
|
619
|
+
frontier through the request echo and controller decision. A rejected push or
|
|
620
|
+
decision removes the preview.
|
|
621
|
+
The reducer rewinds the indexed generation projection to that
|
|
733
622
|
frontier and keeps the partial text the user actually saw. Tool parts whose
|
|
734
623
|
arguments are still streaming are drafts: interruption removes them from the
|
|
735
624
|
conversation and subsequent model prompts, even if their current JSON parses.
|
|
@@ -740,7 +629,25 @@ input, and compaction events remain in raw history but cannot reactivate it or
|
|
|
740
629
|
alter the projection.
|
|
741
630
|
Completed tool results at or before the visible frontier remain completed.
|
|
742
631
|
|
|
743
|
-
|
|
632
|
+
Once Stop is persisted, A2's existing cancellation stream aborts running model
|
|
633
|
+
and tool work for that turn without waiting for the control handler. Startup
|
|
634
|
+
checks also skip matching jobs created while Stop is pending. Cancellation is
|
|
635
|
+
cooperative: tools must honor their abort signal. The control handler remains
|
|
636
|
+
responsible for recording the interruption and advancing the inbox; a Stop for
|
|
637
|
+
an older turn cannot cancel its successor.
|
|
638
|
+
|
|
639
|
+
`retry()` records `inputs.retry()` for the failed response, with a fresh
|
|
640
|
+
`retryId` for each click. The server reruns that failed model call.
|
|
641
|
+
Earlier completed model steps and their tool results remain, including steps
|
|
642
|
+
completed after the latest compaction. The committed summary also remains. For
|
|
643
|
+
example, if a tool succeeds after compaction and the next model call fails, retry
|
|
644
|
+
uses the summary and that tool's result.
|
|
645
|
+
Resending one retry is safe while a later retry remains distinct. The failed
|
|
646
|
+
model call's uncommitted step remains in
|
|
647
|
+
raw history but is excluded from the new prompt and response. Later user messages remain queued while
|
|
648
|
+
the failed response awaits an explicit retry or interruption.
|
|
649
|
+
|
|
650
|
+
The lower-level `inputs.interrupt()` builder addresses a specific owner. Between
|
|
744
651
|
`ai.generation.requested` and `ai.generation.started`, copy
|
|
745
652
|
`activeRequestId` as `requestId` and `activeResponseMessageId` as `messageId`.
|
|
746
653
|
Once `activeGeneration` exists, send its `generationId` and
|
|
@@ -751,31 +658,20 @@ later work that reuses the response message. After a generation completes,
|
|
|
751
658
|
copy the owner from a pending approval, pending input, or running tool while
|
|
752
659
|
the response is still waiting.
|
|
753
660
|
|
|
754
|
-
After a failed model call, `inputs.retry({ messageId, responseMessageId,
|
|
755
|
-
retryId })` records a retry request. The server reruns that failed model call.
|
|
756
|
-
Earlier completed model steps and their tool results remain, including steps
|
|
757
|
-
completed after the latest compaction. The committed summary also remains. For
|
|
758
|
-
example, if a tool succeeds after compaction and the next model call fails, retry
|
|
759
|
-
uses the summary and that tool's result.
|
|
760
|
-
`retryId` identifies the user's action, so resending one retry is safe while a
|
|
761
|
-
later retry remains distinct. The failed model call's uncommitted step remains in
|
|
762
|
-
raw history but is excluded from the new prompt and response. Later user messages remain queued while
|
|
763
|
-
the failed response awaits an explicit retry or interruption.
|
|
764
|
-
|
|
765
661
|
Applications own conversation forks and edit-and-resend flows for previously
|
|
766
662
|
admitted messages. A2's retry operation continues the current response.
|
|
767
663
|
|
|
768
664
|
## Append from the server
|
|
769
665
|
|
|
770
|
-
Browser interactions
|
|
666
|
+
Browser interactions go through the hook for instant state. Server-originated
|
|
771
667
|
turns use the same inputs with `append()`:
|
|
772
668
|
|
|
773
669
|
```ts server/append-message.ts
|
|
774
670
|
import { inputs } from 'experimental-a2/ai'
|
|
775
|
-
import {
|
|
671
|
+
import { assistant } from './assistant'
|
|
776
672
|
|
|
777
673
|
export async function appendServerMessage(sessionId: string, text: string) {
|
|
778
|
-
await
|
|
674
|
+
await assistant.session(sessionId).append(
|
|
779
675
|
...inputs.message({
|
|
780
676
|
id: crypto.randomUUID(),
|
|
781
677
|
role: 'user',
|
|
@@ -789,6 +685,51 @@ This path fits webhooks, jobs, and other backend sources. A connected browser
|
|
|
789
685
|
still receives the events over SSE, but it has no optimistic overlay for an
|
|
790
686
|
interaction that began elsewhere.
|
|
791
687
|
|
|
688
|
+
## The built-in inputs
|
|
689
|
+
|
|
690
|
+
The hook's actions build these commands. `inputs` exposes the same pure,
|
|
691
|
+
isomorphic builders for raw `push()` and server `append()`:
|
|
692
|
+
|
|
693
|
+
| Input | Events |
|
|
694
|
+
| --- | --- |
|
|
695
|
+
| `inputs.message(message, { generate? })` | queues input; `generate: false` queues passive context |
|
|
696
|
+
| `inputs.seed(message)` | records `generate: false`; non-user roles require a trusted append |
|
|
697
|
+
| `inputs.approval(response)` | requests a decision on a pending approval |
|
|
698
|
+
| `inputs.input(response)` | records an application input response fact |
|
|
699
|
+
| `inputs.requestInput(request)` | records a trusted server request for application input |
|
|
700
|
+
| `inputs.retry(options)` | requests a fresh attempt for a failed response |
|
|
701
|
+
| `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances |
|
|
702
|
+
| `inputs.steer({ turnId, message })` | prioritizes input after the observed turn's current step finishes |
|
|
703
|
+
| `inputs.queue.edit({ message })` | edits a pending input |
|
|
704
|
+
| `inputs.queue.move({ inputId, beforeId })` | reorders pending input; `null` means the end |
|
|
705
|
+
| `inputs.queue.remove({ inputId })` | removes pending input from the inbox |
|
|
706
|
+
| `inputs.queue.sendNow({ inputId, turnId })` | atomically prioritizes queued input and interrupts the observed turn |
|
|
707
|
+
| `inputs.pause({ when })` | pauses now or after the active turn |
|
|
708
|
+
| `inputs.resume()` | resumes retained work and inbox admission |
|
|
709
|
+
| `inputs.toolResult(result)` | records a trusted deferred provider-tool result |
|
|
710
|
+
| `inputs.interrupt(options)` | addresses a specific generation or request |
|
|
711
|
+
|
|
712
|
+
Builders return `ai.control.requested` commands. Message, approval, input, and
|
|
713
|
+
retry builders identify a stable interaction. Other controls use the event ID
|
|
714
|
+
assigned by `push()` or `append()`; transport retries preserve that ID.
|
|
715
|
+
Generation scheduling considers only user-role `ai.message.created` facts.
|
|
716
|
+
Assistant and system message facts remain context regardless of the `generate`
|
|
717
|
+
field, and model output is recorded as generation progress rather than a new
|
|
718
|
+
message fact, so an assistant reply cannot recursively schedule another turn.
|
|
719
|
+
Browser pushes accept user messages, approval and input responses,
|
|
720
|
+
interruptions, and explicit retries. `inputs.requestInput()` and non-user
|
|
721
|
+
messages built with `inputs.seed()` are for trusted server appends.
|
|
722
|
+
Browser inputs never append server scheduling, seeded non-user messages, or
|
|
723
|
+
lifecycle events.
|
|
724
|
+
Approval and input request/response payloads carry the active `generationId`;
|
|
725
|
+
clients copy it from the pending request so a stale interaction cannot satisfy
|
|
726
|
+
a later model step.
|
|
727
|
+
|
|
728
|
+
`inputs` deliberately has no session lifecycle methods. The A2 log begins with
|
|
729
|
+
the first append. Apps that use explicit `ai.session.created` or
|
|
730
|
+
`ai.session.closed` lifecycle events append those trusted server facts
|
|
731
|
+
directly.
|
|
732
|
+
|
|
792
733
|
## Compact long conversations
|
|
793
734
|
|
|
794
735
|
Compaction is automatic for supported Gateway models. A2 uses the active model
|
|
@@ -797,11 +738,10 @@ an internal user message asking for a summary. This preserves the request prefix
|
|
|
797
738
|
for prompt caching when the provider has a matching cache entry.
|
|
798
739
|
|
|
799
740
|
```ts server/compacted.ts
|
|
800
|
-
import {
|
|
801
|
-
import { assistant } from '../assistant'
|
|
741
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
802
742
|
|
|
803
|
-
export const
|
|
804
|
-
|
|
743
|
+
export const compactedAssistant = agent({
|
|
744
|
+
name: 'compacted-agent',
|
|
805
745
|
model: 'openai/gpt-5.6-terra',
|
|
806
746
|
})
|
|
807
747
|
```
|
|
@@ -812,7 +752,7 @@ handler fetches the public Gateway model catalog and records the selected limits
|
|
|
812
752
|
in `ai.model.metadata.resolved`. It runs outside the AI turn lane. The first model
|
|
813
753
|
request proceeds immediately; metadata that arrives afterward is available to
|
|
814
754
|
later steps. Returning to a previously used model reuses its durable metadata.
|
|
815
|
-
The catalog is also cached in memory for one hour across
|
|
755
|
+
The catalog is also cached in memory for one hour across agents, with one
|
|
816
756
|
shared in-flight request and a five-second timeout. A failed lookup follows A2's
|
|
817
757
|
handler retry policy without failing the model generation. A model absent from
|
|
818
758
|
the catalog is recorded as unavailable. A pending entry means no result has been
|
|
@@ -823,17 +763,18 @@ failure or use an explicit threshold in that case.
|
|
|
823
763
|
The default threshold is 75% of the context window. A larger configured
|
|
824
764
|
`generation.maxOutputTokens` lowers the threshold further to reserve that output
|
|
825
765
|
allowance. This is an input-context policy, not a new output limit. Limits and
|
|
826
|
-
metadata status are available
|
|
766
|
+
metadata status are available in `state.modelMetadata`.
|
|
827
767
|
|
|
828
768
|
`compaction: { thresholdTokens: 80_000 }` overrides discovery with an explicit
|
|
829
769
|
positive safe integer. `compaction: { instructions: 'Preserve exact IDs.' }`
|
|
830
770
|
adds guidance to the built-in summary prompt while keeping the derived threshold.
|
|
831
771
|
`compaction: false` disables both compaction and metadata lookup. The `compaction`
|
|
832
772
|
option can also be a resolver receiving the same
|
|
833
|
-
`{ event, messages, session, signal }` context as `model` and
|
|
834
|
-
returns `false`, automatic options, or a custom policy once
|
|
835
|
-
can await `session.state(settingsReducer)` to read
|
|
836
|
-
Use this to select a threshold from application settings
|
|
773
|
+
`{ event, messages, session, signal }` context as `model` and
|
|
774
|
+
`instructions`. It returns `false`, automatic options, or a custom policy once
|
|
775
|
+
per generation and can await `session.state(settingsReducer)` to read
|
|
776
|
+
application settings. Use this to select a threshold from application settings
|
|
777
|
+
at the prompt boundary.
|
|
837
778
|
Changes after resolution apply to a later model step. Disabling compaction
|
|
838
779
|
preserves summaries that were already recorded. Custom provider
|
|
839
780
|
models and custom global SDK providers need an explicit threshold. A custom
|
|
@@ -870,36 +811,84 @@ the summary, its usage, and the exact event frontier it covers. Later model
|
|
|
870
811
|
requests receive the summary plus everything produced after that frontier,
|
|
871
812
|
including later tool steps in the same assistant message. Queued user messages
|
|
872
813
|
remain queued. Compaction waits until earlier tool calls have terminal results.
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
messages and request information, not reducer state or execution internals.
|
|
877
|
-
Application reducers remain explicit reads through `session.state(reducer)`.
|
|
878
|
-
To read the full transcript, load `session.state(agent.reducer)` and pass its
|
|
879
|
-
`state` to `agent.reducer.view()`. Client hooks continue to expose `AIState`.
|
|
814
|
+
Server execution uses a separate model-context reducer and checkpoint.
|
|
815
|
+
Successful compaction permanently replaces covered context in model requests;
|
|
816
|
+
the raw log and the transcript in `AIState.messages` stay complete.
|
|
880
817
|
|
|
881
|
-
|
|
882
|
-
available for deterministic replacement messages or application-specific
|
|
883
|
-
retention. Both receive the typed UI messages and `modelMessages`, the model-ready
|
|
884
|
-
context including any existing summary. Custom generation callbacks also receive
|
|
885
|
-
`modelMessages`; use it when forwarding context to a provider. A2 stores built-in
|
|
886
|
-
summaries separately from application-typed UI messages, so a summary does not
|
|
887
|
-
need your message metadata or schema.
|
|
818
|
+
### Show compaction in the conversation
|
|
888
819
|
|
|
889
|
-
|
|
820
|
+
The transcript marks where each compaction happened. When compaction is
|
|
821
|
+
requested, the reducer adds a `data-a2-compaction` part to the assistant
|
|
822
|
+
response, between the steps before it and the model step it compacted for. A
|
|
823
|
+
compaction before the first step opens the response with that part. Render it
|
|
824
|
+
in the same loop as the other parts:
|
|
890
825
|
|
|
891
|
-
```tsx app/agent/
|
|
826
|
+
```tsx app/agent/message-view.tsx
|
|
892
827
|
'use client'
|
|
893
|
-
import {
|
|
828
|
+
import type { UIMessage } from 'ai'
|
|
829
|
+
import { isCompactionPart, type TranscriptMessage } from 'experimental-a2/ai'
|
|
894
830
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
831
|
+
const compactionLabel = {
|
|
832
|
+
running: 'Compacting…',
|
|
833
|
+
completed: 'Context compacted',
|
|
834
|
+
failed: 'Compaction did not complete',
|
|
835
|
+
interrupted: 'Compaction stopped',
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function MessageView({
|
|
839
|
+
message,
|
|
840
|
+
}: {
|
|
841
|
+
message: TranscriptMessage<UIMessage>
|
|
842
|
+
}) {
|
|
843
|
+
return (
|
|
844
|
+
<article>
|
|
845
|
+
{message.parts.map((part, index) => {
|
|
846
|
+
if (isCompactionPart(part))
|
|
847
|
+
return (
|
|
848
|
+
<details key={index}>
|
|
849
|
+
<summary>{compactionLabel[part.data.status]}</summary>
|
|
850
|
+
{part.data.summary ? <p>{part.data.summary}</p> : null}
|
|
851
|
+
</details>
|
|
852
|
+
)
|
|
853
|
+
return part.type === 'text' ? <p key={index}>{part.text}</p> : null
|
|
854
|
+
})}
|
|
855
|
+
</article>
|
|
856
|
+
)
|
|
900
857
|
}
|
|
901
858
|
```
|
|
902
859
|
|
|
860
|
+
The part's `data` holds `status` plus, when known, `inputTokens`,
|
|
861
|
+
`thresholdTokens`, the `summary`, and its `usage`. It starts `running` and
|
|
862
|
+
settles in place: `completed` when the summary commits, `failed` when its
|
|
863
|
+
generation fails first, and `interrupted` when Stop, Pause now, or session
|
|
864
|
+
close ends it first. A pending Stop previews the part as `interrupted`
|
|
865
|
+
immediately. A worker attempt that recovery supersedes removes its part; the
|
|
866
|
+
replacement attempt compacts again if it still needs to. A completed part stays
|
|
867
|
+
through Stop cutoffs, later failures, and retries of the step that compacted,
|
|
868
|
+
because successful compaction is permanent. The part lives in the state, not the event
|
|
869
|
+
feed, so it survives server rendering, reloads, and other tabs. AI SDK
|
|
870
|
+
`convertToModelMessages()` skips data parts by default, and A2's model context
|
|
871
|
+
never includes it. `state.messages` is typed as `TranscriptMessage<M>[]`: your
|
|
872
|
+
message type with `CompactionPart` added to its parts, so `isCompactionPart()`
|
|
873
|
+
narrows even when your message type declares its own data parts.
|
|
874
|
+
|
|
875
|
+
`state.compaction` holds the current or latest compaction, and the hook's
|
|
876
|
+
`indicator` reports `compacting` while one runs.
|
|
877
|
+
|
|
878
|
+
Compaction runs only when retained tool exchanges have final results and all
|
|
879
|
+
A2-owned tool work has settled, including work from stopped or superseded turns.
|
|
880
|
+
A2 checks a fresh coordinator checkpoint before starting a candidate compaction.
|
|
881
|
+
This adds one state read on that path. Unresolved dead-lettered tool work keeps
|
|
882
|
+
compaction deferred until repair and settlement or session closure.
|
|
883
|
+
|
|
884
|
+
Custom `shouldCompact(context)` and `compact(context)` callbacks remain
|
|
885
|
+
available for deterministic replacement messages or application-specific
|
|
886
|
+
retention. Both receive the typed UI messages and `modelMessages`, the model-ready
|
|
887
|
+
context including any existing summary. Custom generation callbacks also receive
|
|
888
|
+
`modelMessages`; use it when forwarding context to a provider. A2 stores built-in
|
|
889
|
+
summaries separately from application-typed messages, so a summary does not
|
|
890
|
+
need your message metadata or schema.
|
|
891
|
+
|
|
903
892
|
Successful compaction is a permanent boundary. Failure, interruption, retry, and
|
|
904
893
|
worker recovery never restore the messages it replaced. Retry reruns the failed
|
|
905
894
|
model step from the latest committed context and preserves earlier completed
|
|
@@ -907,65 +896,190 @@ steps. A failed summary attempt leaves the previous committed context intact.
|
|
|
907
896
|
The synthetic summary request is not a user turn or an assistant reply in
|
|
908
897
|
`state.messages`.
|
|
909
898
|
|
|
910
|
-
|
|
911
|
-
A2-owned tool work has settled, including work from stopped or superseded turns.
|
|
912
|
-
A2 checks a fresh coordinator checkpoint before starting a candidate compaction.
|
|
913
|
-
This adds one state read on that path. Unresolved dead-lettered tool work keeps
|
|
914
|
-
compaction deferred until repair and settlement or session closure.
|
|
899
|
+
## Store assistant metadata
|
|
915
900
|
|
|
916
|
-
|
|
901
|
+
Give the agent a narrower `UIMessage` type to keep generation status and usage
|
|
902
|
+
on the assistant message. `agentContract()` carries the type:
|
|
917
903
|
|
|
918
|
-
|
|
904
|
+
```ts server/metadata-assistant.ts
|
|
905
|
+
import type { LanguageModelUsage, UIMessage } from 'ai'
|
|
906
|
+
import { agentContract } from 'experimental-a2/ai'
|
|
907
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
908
|
+
|
|
909
|
+
export type MetadataAssistantMessage = UIMessage<{
|
|
910
|
+
status: 'writing' | 'complete'
|
|
911
|
+
usage?: LanguageModelUsage
|
|
912
|
+
}>
|
|
913
|
+
|
|
914
|
+
export const metadataAssistant = agent({
|
|
915
|
+
contract: agentContract<MetadataAssistantMessage>({
|
|
916
|
+
name: 'metadata-assistant',
|
|
917
|
+
}),
|
|
918
|
+
model: 'openai/gpt-5.6-terra',
|
|
919
|
+
messageMetadata: ({ part }) => {
|
|
920
|
+
if (part.type === 'start') return { status: 'writing' as const }
|
|
921
|
+
if (part.type === 'finish') {
|
|
922
|
+
return { status: 'complete' as const, usage: part.totalUsage }
|
|
923
|
+
}
|
|
924
|
+
return undefined
|
|
925
|
+
},
|
|
926
|
+
})
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
The default generator passes `messageMetadata` to the AI SDK's
|
|
930
|
+
`toUIMessageStream()`. The callback receives the same context as `generate`,
|
|
931
|
+
plus its current `TextStreamPart` as `part`. Its return type follows the metadata type in the
|
|
932
|
+
agent's `UIMessage`. Metadata on standard `start` and `finish` chunks enters
|
|
933
|
+
the event log with the rest of the stream. A2 shallow-merges those values onto
|
|
934
|
+
the projected assistant message, so the example exposes `writing` immediately
|
|
935
|
+
and keeps final token usage after completion. The callback is synchronous.
|
|
936
|
+
`useAgent<typeof metadataAssistant>()` types `messages` with the same metadata.
|
|
919
937
|
|
|
920
|
-
|
|
921
|
-
- `createReducer({ contract })` builds the normalized state and `AIState` view for a
|
|
922
|
-
compatible contract.
|
|
923
|
-
- `agent()` combines the built-in protocol, application events, and reducer.
|
|
924
|
-
- `tool()` binds an AI SDK function-tool definition to an agent's typed A2
|
|
925
|
-
handler context.
|
|
926
|
-
- `createHandlers({ agent, ... })` returns the AI handler table.
|
|
927
|
-
- `createAgentServer()` combines those handlers with `createServer()`.
|
|
938
|
+
## Add your own events and state
|
|
928
939
|
|
|
929
|
-
|
|
930
|
-
|
|
940
|
+
Apps always carry more than the conversation: a plan, a selected ticket, a
|
|
941
|
+
chosen model. Put those events and the state they fold into in a shared
|
|
942
|
+
`agentContract()`. It takes a core `contract()`'s `events`, and its `reducer`
|
|
943
|
+
takes the same `name`, `initialState`, and `fold`:
|
|
931
944
|
|
|
932
|
-
```ts
|
|
945
|
+
```ts support-desk.ts
|
|
933
946
|
import { z } from 'zod'
|
|
934
|
-
import {
|
|
935
|
-
import { createHandlers } from 'experimental-a2/ai/server'
|
|
936
|
-
import { createServer } from 'experimental-a2/server'
|
|
947
|
+
import { agentContract } from 'experimental-a2/ai'
|
|
937
948
|
|
|
938
|
-
const
|
|
939
|
-
|
|
949
|
+
const noFlags: string[] = []
|
|
950
|
+
|
|
951
|
+
export const supportDesk = agentContract({
|
|
952
|
+
name: 'support-desk',
|
|
940
953
|
events: {
|
|
941
|
-
'
|
|
954
|
+
'plan.changed': z.object({ plan: z.enum(['free', 'pro']) }),
|
|
955
|
+
'ticket.flagged': z.object({ ticketId: z.string() }),
|
|
956
|
+
},
|
|
957
|
+
reducer: {
|
|
958
|
+
name: 'support-desk.v1',
|
|
959
|
+
initialState: { plan: 'free' as 'free' | 'pro', flagged: noFlags },
|
|
960
|
+
fold: (state, event) =>
|
|
961
|
+
event.type === 'plan.changed'
|
|
962
|
+
? { ...state, plan: event.payload.plan }
|
|
963
|
+
: event.type === 'ticket.flagged'
|
|
964
|
+
? { ...state, flagged: [...state.flagged, event.payload.ticketId] }
|
|
965
|
+
: state,
|
|
942
966
|
},
|
|
943
967
|
})
|
|
968
|
+
```
|
|
944
969
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
970
|
+
Keep this module isomorphic. `fold` sees your state and every event, but not
|
|
971
|
+
the transcript. The session's state is `AIState` merged with yours, so a key
|
|
972
|
+
that shadows one of `AIState`'s is a type error. The reducer's `name` is its
|
|
973
|
+
snapshot identity: rename it when `fold` changes.
|
|
974
|
+
|
|
975
|
+
Serve it with `agent({ contract })`. Every resolver receives your state as
|
|
976
|
+
`state`, so model, reasoning level, and speed follow the app:
|
|
949
977
|
|
|
950
|
-
|
|
951
|
-
|
|
978
|
+
```ts server/support-desk.ts
|
|
979
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
980
|
+
import { supportDesk } from '../support-desk'
|
|
981
|
+
|
|
982
|
+
export const supportDeskAgent = agent({
|
|
983
|
+
contract: supportDesk,
|
|
984
|
+
model: ({ state }) =>
|
|
985
|
+
state.plan === 'pro'
|
|
986
|
+
? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
|
|
987
|
+
: { model: 'openai/gpt-5.6-luna', speed: 'fast' },
|
|
952
988
|
handlers: {
|
|
953
|
-
|
|
954
|
-
'ticket.linked': async ({ event }) => {
|
|
989
|
+
'ticket.flagged': async ({ event }) => {
|
|
955
990
|
// your side effect; event.id makes a stable idempotency key:
|
|
956
|
-
// await
|
|
991
|
+
// await notifyOnCall(event.payload.ticketId, { idempotencyKey: event.id })
|
|
957
992
|
},
|
|
958
993
|
},
|
|
959
994
|
})
|
|
960
995
|
```
|
|
961
996
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
997
|
+
`state` is your state as of the snapshot the generation already captured. It
|
|
998
|
+
rides in the checkpoint every model step reads, so it costs no extra read. Each
|
|
999
|
+
model step resolves again, so a change during a turn applies from the next
|
|
1000
|
+
step. A resolver that throws fails the turn with its message, like a provider
|
|
1001
|
+
error; `retry()` runs it again.
|
|
1002
|
+
|
|
1003
|
+
In the browser, pass the contract to `useAgent()`. This import is real, not
|
|
1004
|
+
type-only, because the browser folds your state too:
|
|
1005
|
+
|
|
1006
|
+
```tsx app/support/desk.tsx
|
|
1007
|
+
'use client'
|
|
1008
|
+
import { useAgent } from 'experimental-a2/ai/react'
|
|
1009
|
+
import { supportDesk } from '@/support-desk'
|
|
1010
|
+
|
|
1011
|
+
export function Desk({ id }: { id: string }) {
|
|
1012
|
+
const { state, push, sendMessage } = useAgent({
|
|
1013
|
+
api: '/api/support',
|
|
1014
|
+
id,
|
|
1015
|
+
contract: supportDesk,
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
return (
|
|
1019
|
+
<section>
|
|
1020
|
+
<p>
|
|
1021
|
+
{state.plan} plan, {state.flagged.length} flagged tickets
|
|
1022
|
+
</p>
|
|
1023
|
+
<button
|
|
1024
|
+
onClick={() =>
|
|
1025
|
+
void push({ type: 'ticket.flagged', payload: { ticketId: 'T-1' } })
|
|
1026
|
+
}
|
|
1027
|
+
>
|
|
1028
|
+
Flag T-1
|
|
1029
|
+
</button>
|
|
1030
|
+
<button onClick={() => void sendMessage('Summarize the flagged tickets.')}>
|
|
1031
|
+
Ask
|
|
1032
|
+
</button>
|
|
1033
|
+
</section>
|
|
1034
|
+
)
|
|
1035
|
+
}
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
Browsers push a shared contract's application events like any A2 client, and
|
|
1039
|
+
the route's `authorize` decides who may push what. The agent refuses only its
|
|
1040
|
+
own server-authored `ai.*` events. Here the plan comes from your billing code,
|
|
1041
|
+
not the browser:
|
|
1042
|
+
|
|
1043
|
+
```ts app/api/support/route.ts
|
|
1044
|
+
import { supportDeskAgent } from '@/server/support-desk'
|
|
1045
|
+
|
|
1046
|
+
function handle(request: Request): Promise<Response> {
|
|
1047
|
+
// here's where you'd do auth, or any other checks:
|
|
1048
|
+
return supportDeskAgent.fetch(request, {
|
|
1049
|
+
authorize: (operation) =>
|
|
1050
|
+
operation.type !== 'push' ||
|
|
1051
|
+
operation.events.every((event) => event.type !== 'plan.changed'),
|
|
1052
|
+
})
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
export const GET = handle
|
|
1056
|
+
export const POST = handle
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
Server appends, such as
|
|
1060
|
+
`supportDeskAgent.session(id).append({ type: 'plan.changed', payload })`, are
|
|
1061
|
+
trusted and skip `authorize`. `push()` also accepts any `inputs` builder, and
|
|
1062
|
+
`handlerContext(supportDesk)` types application events inside tools.
|
|
1063
|
+
|
|
1064
|
+
`agent({ contract })` takes only an `agentContract()`, so every resolver gets
|
|
1065
|
+
your `state`. When the agent belongs inside something larger, such as a chat
|
|
1066
|
+
room several people share, keep them as two sessions. The room is an ordinary
|
|
1067
|
+
core A2 session. Its handler appends each message the agent should see to the
|
|
1068
|
+
agent's session with `inputs.message()`. Event ids are unique across a store's
|
|
1069
|
+
sessions, so derive the message id from the room event's id with
|
|
1070
|
+
`idempotentId()`; a retried handler then converges on the same message. A tool
|
|
1071
|
+
the agent calls to speak appends to the room's session with an `idempotentId()`
|
|
1072
|
+
of its `toolCallId`, so the room's log orders the agent's messages among
|
|
1073
|
+
everyone else's. Neither fold ever reads the other's state.
|
|
1074
|
+
|
|
1075
|
+
The contract-free client, `createAgentClient()` and `useAgent({ api | client })`
|
|
1076
|
+
with a type-only import, folds the built-in reducer, so it accepts only agents
|
|
1077
|
+
whose reducer carries the built-in name: `agent({ name })`, or
|
|
1078
|
+
`agent({ contract })` with an `agentContract()` that has no `reducer`. The types
|
|
1079
|
+
enforce it through `AI_STATE_REDUCER`.
|
|
1080
|
+
|
|
1081
|
+
`reduceAIState()` remains a pure transition over confirmed `AIState` for
|
|
1082
|
+
applications that project raw event histories.
|
|
969
1083
|
|
|
970
1084
|
### Replace generation, not durability
|
|
971
1085
|
|
|
@@ -975,19 +1089,12 @@ SDK assembly:
|
|
|
975
1089
|
|
|
976
1090
|
```ts server/custom-generation.ts
|
|
977
1091
|
import { convertToModelMessages, streamText } from 'ai'
|
|
978
|
-
import {
|
|
979
|
-
import { assistant } from '../assistant'
|
|
1092
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
980
1093
|
|
|
981
|
-
export const
|
|
982
|
-
|
|
1094
|
+
export const customGeneration = agent({
|
|
1095
|
+
name: 'custom-generation',
|
|
983
1096
|
model: 'openai/gpt-5.6-terra',
|
|
984
|
-
generate: async ({
|
|
985
|
-
messages,
|
|
986
|
-
model,
|
|
987
|
-
instructions,
|
|
988
|
-
responseMessageId,
|
|
989
|
-
signal,
|
|
990
|
-
}) => {
|
|
1097
|
+
generate: async ({ event, messages, model, instructions, signal }) => {
|
|
991
1098
|
const result = streamText({
|
|
992
1099
|
model,
|
|
993
1100
|
messages: await convertToModelMessages(messages),
|
|
@@ -997,15 +1104,18 @@ export const customGenerationServer = createAgentServer({
|
|
|
997
1104
|
|
|
998
1105
|
return result.toUIMessageStream({
|
|
999
1106
|
originalMessages: messages,
|
|
1000
|
-
generateMessageId: () => responseMessageId,
|
|
1107
|
+
generateMessageId: () => event.payload.responseMessageId,
|
|
1001
1108
|
})
|
|
1002
1109
|
},
|
|
1003
1110
|
})
|
|
1004
1111
|
```
|
|
1005
1112
|
|
|
1006
|
-
`generate` receives
|
|
1007
|
-
|
|
1008
|
-
|
|
1113
|
+
`generate` receives what every resolver does, `{ event, messages, state,
|
|
1114
|
+
session, signal }`, with `messages` already compacted. It also gets the
|
|
1115
|
+
resolved `model`, `instructions`, `tools`, and `generation` settings, the
|
|
1116
|
+
model-ready `modelMessages` with any compaction summary, and the step's
|
|
1117
|
+
`generationId`. Its stream writes the assistant message
|
|
1118
|
+
`event.payload.responseMessageId`. It returns exactly one model step as a
|
|
1009
1119
|
`ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
|
|
1010
1120
|
the model without running local `execute` functions. A2 still owns durable
|
|
1011
1121
|
progress, tool execution, approval, continuation, interruption, and failure.
|
|
@@ -1015,36 +1125,36 @@ emit typed metadata chunks itself.
|
|
|
1015
1125
|
|
|
1016
1126
|
## Checkpoints and recovery reads
|
|
1017
1127
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
immediately, including streamed message parts and tool activity.
|
|
1022
|
-
|
|
1023
|
-
For server-side inspection, use the reducer's view:
|
|
1128
|
+
`assistant.session(id).state()` returns `AIState` and its log index, ready for
|
|
1129
|
+
server code or client hydration. No conversion step is needed. The reducer's
|
|
1130
|
+
initial state and fold use that same type.
|
|
1024
1131
|
|
|
1025
1132
|
```ts server/read-agent.ts
|
|
1026
1133
|
import type { UIMessage } from 'ai'
|
|
1027
|
-
import {
|
|
1028
|
-
import { assistant } from '
|
|
1134
|
+
import type { TranscriptMessage } from 'experimental-a2/ai'
|
|
1135
|
+
import { assistant } from './assistant'
|
|
1029
1136
|
|
|
1030
|
-
export async function readMessages(
|
|
1031
|
-
|
|
1032
|
-
|
|
1137
|
+
export async function readMessages(
|
|
1138
|
+
sessionId: string,
|
|
1139
|
+
): Promise<TranscriptMessage<UIMessage>[]> {
|
|
1140
|
+
const snapshot = await assistant.session(sessionId).state()
|
|
1141
|
+
return snapshot.state.messages
|
|
1033
1142
|
}
|
|
1034
1143
|
```
|
|
1035
1144
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1145
|
+
Tool payloads live in message parts. `state.tools` keeps only identities and
|
|
1146
|
+
execution status; it does not repeat inputs, outputs, or provider metadata.
|
|
1147
|
+
Checkpoint and subscription encoding remain internal. The active step retains
|
|
1148
|
+
its events and the information needed to restore overwritten message fields
|
|
1149
|
+
for interruption cutoffs and retry. It does not copy the whole prior message.
|
|
1150
|
+
Recovery is released when the response completes; completed model
|
|
1151
|
+
context is kept by the separate context reducer.
|
|
1041
1152
|
|
|
1042
1153
|
Model steps read the model-context reducer and coordinator at a fixed log
|
|
1043
1154
|
boundary. Model, instruction, compaction, and custom-generation callbacks
|
|
1044
|
-
receive prompt messages
|
|
1045
|
-
|
|
1046
|
-
`session.state(yourReducer)
|
|
1047
|
-
They do not receive a raw history array. Their `session.state(reducer)` reads
|
|
1155
|
+
receive prompt messages, request information, and the contract's application
|
|
1156
|
+
`state`, which rides in the model-context checkpoint. Read any other reducer
|
|
1157
|
+
with `session.state(yourReducer)`. They do not receive a raw history array. Their `session.state(reducer)` reads
|
|
1048
1158
|
application reducers at the captured prompt boundary by default; explicit
|
|
1049
1159
|
`through` options follow the ordinary state-read contract. Compaction selection
|
|
1050
1160
|
can be asynchronous when it needs a settings-state read.
|
|
@@ -1108,10 +1218,10 @@ This input protocol requires matching client and server versions. Start a new
|
|
|
1108
1218
|
agent session when adopting it. Existing event logs remain readable; finish
|
|
1109
1219
|
pending work with the runtime that created it before upgrading.
|
|
1110
1220
|
|
|
1111
|
-
`
|
|
1112
|
-
admits one input at a time, freezes its content, and adds it to
|
|
1113
|
-
Pending inputs appear in `state.inbox.items`; they do not enter
|
|
1114
|
-
Each item has an `id`, `message`, and `generate` flag.
|
|
1221
|
+
`sendMessage()` places a message in the durable inbox. The controller
|
|
1222
|
+
admits one input at a time, freezes its content, and adds it to
|
|
1223
|
+
`state.messages`. Pending inputs appear in `state.inbox.items`; they do not enter
|
|
1224
|
+
the model prompt. Each item has an `id`, `message`, and `generate` flag.
|
|
1115
1225
|
|
|
1116
1226
|
New-input commands from `inputs.message()`, `inputs.seed()`, and `inputs.steer()`
|
|
1117
1227
|
use the message ID as their event ID. Those IDs must match on hand-written
|
|
@@ -1126,60 +1236,71 @@ different command action, or another session rejects at append with
|
|
|
1126
1236
|
message ID to create another input. Edit, move, remove, and other commands have
|
|
1127
1237
|
their own event IDs. The controller stores no historical input-ID registry.
|
|
1128
1238
|
|
|
1129
|
-
|
|
1130
1239
|
`state.active` identifies the logical turn, its frozen `input`, and its `phase`.
|
|
1131
1240
|
The turn ID stays the same across model steps, tools, and a pause. Stop ends that
|
|
1132
1241
|
turn and admits the next queued input when admission is running.
|
|
1133
1242
|
|
|
1134
|
-
```
|
|
1135
|
-
|
|
1136
|
-
import
|
|
1137
|
-
|
|
1138
|
-
export function editPending(message: UIMessage) {
|
|
1139
|
-
return inputs.queue.edit({ message })
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
export function movePending(inputId: string, beforeId: string | null) {
|
|
1143
|
-
return inputs.queue.move({ inputId, beforeId })
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
export function removePending(inputId: string) {
|
|
1147
|
-
return inputs.queue.remove({ inputId })
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
export function sendPendingNow(state: AIState, inputId: string) {
|
|
1151
|
-
return inputs.queue.sendNow({ inputId, turnId: state.active?.turnId ?? null })
|
|
1152
|
-
}
|
|
1243
|
+
```tsx app/agent/[sessionId]/queue.tsx
|
|
1244
|
+
'use client'
|
|
1245
|
+
import { useAgent } from 'experimental-a2/ai/react'
|
|
1246
|
+
import type { assistant } from '@/server/assistant'
|
|
1153
1247
|
|
|
1154
|
-
export function
|
|
1155
|
-
|
|
1156
|
-
|
|
1248
|
+
export function Queue({ sessionId }: { sessionId: string }) {
|
|
1249
|
+
const { state, queue, steer, pause, resume } = useAgent<typeof assistant>({
|
|
1250
|
+
api: '/api/agent',
|
|
1251
|
+
id: sessionId,
|
|
1252
|
+
})
|
|
1157
1253
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1254
|
+
return (
|
|
1255
|
+
<section>
|
|
1256
|
+
<ol aria-label="Queued messages">
|
|
1257
|
+
{state.inbox.items.map((item) => (
|
|
1258
|
+
<li key={item.id}>
|
|
1259
|
+
{item.message.parts.map((part, index) =>
|
|
1260
|
+
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
|
|
1261
|
+
)}
|
|
1262
|
+
<button onClick={() => void queue.sendNow(item.id)}>Send now</button>
|
|
1263
|
+
<button
|
|
1264
|
+
onClick={() =>
|
|
1265
|
+
void queue.move({ inputId: item.id, beforeId: null })
|
|
1266
|
+
}
|
|
1267
|
+
>
|
|
1268
|
+
Move to end
|
|
1269
|
+
</button>
|
|
1270
|
+
<button onClick={() => void queue.remove(item.id)}>Remove</button>
|
|
1271
|
+
</li>
|
|
1272
|
+
))}
|
|
1273
|
+
</ol>
|
|
1274
|
+
<button onClick={() => void steer('Check the billing service first.')}>
|
|
1275
|
+
Steer
|
|
1276
|
+
</button>
|
|
1277
|
+
{state.inbox.paused ? (
|
|
1278
|
+
<button onClick={() => void resume()}>Resume</button>
|
|
1279
|
+
) : (
|
|
1280
|
+
<button onClick={() => void pause({ when: 'after-turn' })}>
|
|
1281
|
+
Pause after this turn
|
|
1282
|
+
</button>
|
|
1283
|
+
)}
|
|
1284
|
+
</section>
|
|
1285
|
+
)
|
|
1162
1286
|
}
|
|
1163
|
-
|
|
1164
|
-
export const pauseAfterTurn = () => inputs.pause({ when: 'after-turn' })
|
|
1165
|
-
export const pauseNow = () => inputs.pause({ when: 'now' })
|
|
1166
|
-
export const resume = () => inputs.resume()
|
|
1167
1287
|
```
|
|
1168
1288
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1289
|
+
`queue.edit(message)` replaces a pending input's content. `queue.move()` takes
|
|
1290
|
+
`beforeId: null` to move an item to the end. Removal only applies to pending
|
|
1291
|
+
input. It preserves the immutable log and keeps
|
|
1172
1292
|
its original submission event, so replay cannot recreate a removed prompt.
|
|
1173
1293
|
Removing an input that is no longer pending returns `not-pending`, whether it was
|
|
1174
1294
|
admitted, removed, or never queued.
|
|
1175
1295
|
|
|
1176
1296
|
Edits to pending input use last-write-wins in session log order. Clients can send
|
|
1177
1297
|
another edit before an earlier one is confirmed. Editing preserves the message's
|
|
1178
|
-
identity and role; changing its role returns `role-mismatch`. An edit
|
|
1179
|
-
to admission returns `not-pending`. A Stop aimed at a completed turn
|
|
1180
|
-
`stale-turn`.
|
|
1298
|
+
identity and role; changing its role returns `role-mismatch`. An edit
|
|
1299
|
+
that loses to admission returns `not-pending`. A Stop aimed at a completed turn
|
|
1300
|
+
returns `stale-turn`.
|
|
1181
1301
|
|
|
1182
|
-
|
|
1302
|
+
`steer()` validates the new input and places it ahead of the inbox in one
|
|
1303
|
+
decision. With no active turn, it sends like `sendMessage()`.
|
|
1183
1304
|
If the observed turn is still active, it finishes its current model response and
|
|
1184
1305
|
the tools produced by that response before handing off. Parallel tools all
|
|
1185
1306
|
settle, and steering runs before the old turn starts another model step.
|
|
@@ -1196,11 +1317,10 @@ first behind a different active turn. Stop remains an immediate interruption.
|
|
|
1196
1317
|
Steering preserves the admission gate: steering while paused does not resume
|
|
1197
1318
|
execution. Duplicate input identities and closed sessions still reject the command.
|
|
1198
1319
|
|
|
1199
|
-
|
|
1200
|
-
accepted content. It validates the selected input and the observed
|
|
1201
|
-
then moves the input first for the same handoff after the current step.
|
|
1202
|
-
|
|
1203
|
-
returns `stale-turn` without changing the queue.
|
|
1320
|
+
`queue.sendNow()` uses the selected queued message's existing identity and
|
|
1321
|
+
latest accepted content. It validates the selected input and the observed turn,
|
|
1322
|
+
then moves the input first for the same handoff after the current step. If that
|
|
1323
|
+
expectation is stale, the command returns `stale-turn` without changing the queue.
|
|
1204
1324
|
|
|
1205
1325
|
Send now preserves pause just like steering. A paused queue keeps the selected
|
|
1206
1326
|
input first until Resume. It accepts user-role inputs, including passive user
|
|
@@ -1215,12 +1335,14 @@ queue controls which input is admitted at that boundary.
|
|
|
1215
1335
|
|
|
1216
1336
|
### Optimistic conversation display
|
|
1217
1337
|
|
|
1218
|
-
|
|
1219
|
-
An idle, unpaused send appears in `state.messages`
|
|
1220
|
-
`state.starting: true`. Another send waits in
|
|
1221
|
-
optimistic turn or a confirmed turn is active.
|
|
1222
|
-
the inbox. Render the conversation and queue
|
|
1223
|
-
|
|
1338
|
+
The AI reducer is optimistic in every client, including one built from an
|
|
1339
|
+
extended `agentContract()`. An idle, unpaused send appears in `state.messages`
|
|
1340
|
+
immediately, with `state.starting: true`. Another send waits in
|
|
1341
|
+
`state.inbox.items` while that optimistic turn or a confirmed turn is active.
|
|
1342
|
+
Paused sessions keep new sends in the inbox. Render the conversation and queue
|
|
1343
|
+
from `state`. A pending Stop clears the displayed `state.active` turn
|
|
1344
|
+
immediately; rejection restores it. New active turn identities still come from
|
|
1345
|
+
accepted control decisions.
|
|
1224
1346
|
Steering appears immediately in `state.messages`, after the current response,
|
|
1225
1347
|
and stays there while that step finishes. Send now has the same display when
|
|
1226
1348
|
an active turn is targeted. These inputs are omitted from the client inbox,
|
|
@@ -1229,45 +1351,19 @@ Accepted steering survives receipt and hydration in this display. Multiple
|
|
|
1229
1351
|
steering inputs appear in admission order, with the most recent first.
|
|
1230
1352
|
No additional request, subscription, or component-owned queue is needed.
|
|
1231
1353
|
|
|
1232
|
-
```tsx app/agent/[sessionId]/conversation.tsx
|
|
1233
|
-
'use client'
|
|
1234
|
-
import { useSession } from '../session'
|
|
1235
|
-
|
|
1236
|
-
export function Conversation() {
|
|
1237
|
-
const { state } = useSession()
|
|
1238
|
-
const { messages, inbox, starting } = state
|
|
1239
|
-
const text = (message: (typeof messages)[number]) =>
|
|
1240
|
-
message.parts.filter((part) => part.type === 'text').map((part) => part.text).join(' ')
|
|
1241
|
-
return (
|
|
1242
|
-
<section>
|
|
1243
|
-
<div aria-label="Conversation">
|
|
1244
|
-
{messages.map((message) => <p key={message.id}>{text(message)}</p>)}
|
|
1245
|
-
{starting ? <p role="status">Starting…</p> : null}
|
|
1246
|
-
</div>
|
|
1247
|
-
<ol aria-label="Queued messages">
|
|
1248
|
-
{inbox.items.map((item) => <li key={item.id}>{text(item.message)}</li>)}
|
|
1249
|
-
</ol>
|
|
1250
|
-
</section>
|
|
1251
|
-
)
|
|
1252
|
-
}
|
|
1253
|
-
```
|
|
1254
|
-
|
|
1255
1354
|
The request echo keeps the optimistic display until `ai.control.decided` arrives. Accepted
|
|
1256
1355
|
commits replace the confirmed base; rejected receipts remove the pending intent.
|
|
1257
1356
|
A failed POST rolls back through the ordinary client optimistic overlay.
|
|
1258
1357
|
Another client's accepted turn can move a speculative ordinary send back into
|
|
1259
|
-
the queue. Steering stays visible as a message while waiting behind that turn.
|
|
1358
|
+
the queue. Steering stays visible as a message while waiting behind that turn.
|
|
1359
|
+
`state.starting` describes an optimistic turn awaiting acceptance; it does
|
|
1260
1360
|
not indicate a running model.
|
|
1261
1361
|
|
|
1262
|
-
Server `session.state(
|
|
1362
|
+
Server `session.state()`, reducer folds, checkpoints, and model prompts
|
|
1263
1363
|
contain confirmed messages and inbox entries, with `starting: false`. The client
|
|
1264
1364
|
applies pending intent and displays accepted steering only when returning its
|
|
1265
1365
|
state, after folding events.
|
|
1266
|
-
|
|
1267
|
-
and derive its view with `agent.reducer.view()`. It does not inherit the built-in
|
|
1268
|
-
optimistic client display. `reduceAIState()` remains a pure transition over the
|
|
1269
|
-
confirmed `AIState` view for applications that project raw event histories.
|
|
1270
|
-
`state.pendingQueueCommands` tracks unresolved queue commands incrementally, so
|
|
1366
|
+
`state.pendingQueueCommands` tracks unresolved queue commands and Stops incrementally, so
|
|
1271
1367
|
the client does not scan raw history. Pending edits apply in order, so the latest
|
|
1272
1368
|
local edit stays visible while earlier commands are confirmed. Editing and Send
|
|
1273
1369
|
now can remain available while an edit awaits its receipt. Close an editor as
|
|
@@ -1275,12 +1371,16 @@ soon as its save is pushed; retain the draft if the push fails.
|
|
|
1275
1371
|
|
|
1276
1372
|
### Persistence and application are separate
|
|
1277
1373
|
|
|
1278
|
-
|
|
1374
|
+
A push confirms that the command is persisted. The `ai.control.decided` event
|
|
1279
1375
|
confirms its application, using the submitted event's ID as `commandId`. Its
|
|
1280
|
-
`outcome` is `applied` or `rejected`; a rejection includes a `reason
|
|
1376
|
+
`outcome` is `applied` or `rejected`; a rejection includes a `reason`, such as
|
|
1377
|
+
`not-pending` or `stale-turn`.
|
|
1281
1378
|
`state.receipt` exposes the most recent user command receipt. Applications that
|
|
1282
1379
|
have several commands in flight correlate receipts through the existing event
|
|
1283
|
-
stream
|
|
1380
|
+
stream: a hook action resolves to its persisted command events, and each
|
|
1381
|
+
event's `id` is the `commandId` of its receipt. An action with nothing to
|
|
1382
|
+
address, such as Stop with no active turn, resolves to an empty array. A
|
|
1383
|
+
rejection is a normal result and does not spend a handler retry.
|
|
1284
1384
|
|
|
1285
1385
|
### Pause and resume
|
|
1286
1386
|
|
|
@@ -1305,4 +1405,4 @@ can replay older events when a snapshot is missing.
|
|
|
1305
1405
|
|
|
1306
1406
|
For a deferred provider-executed tool, trusted server code submits
|
|
1307
1407
|
`inputs.toolResult(result)`. The controller checks its admitted call and records
|
|
1308
|
-
the result before continuing. Browser
|
|
1408
|
+
the result before continuing. Browser pushes reject this server-only input.
|