experimental-a2 0.17.0 → 0.18.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 +21 -0
- package/README.md +1 -1
- package/dist/{actor-DBbe5xeE.d.ts → actor-Bjce9_5U.d.ts} +5 -5
- package/dist/{actor-DBbe5xeE.d.ts.map → actor-Bjce9_5U.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-UrWfKeuv.js → ai-BpOGm-zB.js} +2 -2
- package/dist/{ai-UrWfKeuv.js.map → ai-BpOGm-zB.js.map} +1 -1
- package/dist/{ai-vpGtcaCB.d.ts → ai-DrUaMyRt.d.ts} +3 -3
- package/dist/{ai-vpGtcaCB.d.ts.map → ai-DrUaMyRt.d.ts.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +634 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
- package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +904 -0
- package/dist/ai-pi.js.map +1 -0
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +47 -25
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +106 -37
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-COaiaDub.js → client-BReKqwZC.js} +3 -3
- package/dist/{client-COaiaDub.js.map → client-BReKqwZC.js.map} +1 -1
- package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
- package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
- package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
- package/dist/{internal-CbVvEwad.js → internal-B2T3U4Rq.js} +8 -2
- package/dist/internal-B2T3U4Rq.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
- package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/reducer-BAXc8JyF.js.map +1 -1
- package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
- package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-Rj64fNvX.js → server-Dw_k_7kO.js} +93 -60
- package/dist/server-Dw_k_7kO.js.map +1 -0
- package/dist/{server-CGM8WzRG.d.ts → server-En7cbi3v.d.ts} +36 -7
- package/dist/server-En7cbi3v.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +1 -1
- package/dist/{session-clear-Dshpuhff.d.ts → session-clear-Bo-XTFEy.d.ts} +2 -2
- package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
- package/dist/{store-Gpkq7WBo.d.ts → store-Djdv0bnZ.d.ts} +31 -2
- package/dist/store-Djdv0bnZ.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.js +32 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-4_y8lETm.js → store-polling-B5FcwJfo.js} +2 -2
- package/dist/{store-polling-4_y8lETm.js.map → store-polling-B5FcwJfo.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +54 -3
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-BAwS8RWn.js} +3 -3
- package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-BRITti_o.js} +187 -68
- package/dist/store-redis-notify-BRITti_o.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +32 -2
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-DjfHOvdW.js → wire-rSUglYo8.js} +2 -2
- package/dist/{wire-DjfHOvdW.js.map → wire-rSUglYo8.js.map} +1 -1
- package/docs/01-quickstart.mdx +49 -83
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -86
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -150
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +108 -144
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +855 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +722 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +141 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +322 -0
- package/docs/server/06-guarantees.mdx +123 -0
- package/examples/playground/DEPLOY.md +1 -1
- package/examples/playground/app/a0/[appId]/page.tsx +11 -6
- package/examples/playground/app/a0/app-list.tsx +9 -2
- package/examples/playground/app/a0/app-view.tsx +9 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
- package/examples/playground/app/a0/apps.test.ts +12 -0
- package/examples/playground/app/a0/apps.ts +22 -7
- package/examples/playground/app/a0/chat.tsx +47 -1
- package/examples/playground/app/a0/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
- package/examples/playground/app/a0/model.test.ts +18 -1
- package/examples/playground/app/a0/model.ts +47 -10
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
- package/examples/playground/app/a0/sandbox-log.ts +10 -4
- package/examples/playground/app/a0/sandbox-session.ts +152 -0
- package/examples/playground/app/a0/scheduler/route.ts +2 -2
- package/examples/playground/app/a0/server.ts +100 -45
- package/examples/playground/app/a0/session.ts +17 -5
- package/examples/playground/app/a0/shell.tsx +11 -3
- package/examples/playground/app/agent/ingress.test.ts +6 -3
- package/examples/playground/app/agent/server.ts +9 -6
- package/examples/playground/app/agent/telemetry.test.ts +71 -50
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +30 -26
- package/examples/playground/app/chat/server.ts +46 -46
- package/examples/playground/next.config.ts +17 -5
- package/examples/playground/package.json +4 -1
- package/examples/playground/vercel.json +18 -0
- package/package.json +27 -3
- package/src/ai-agent.ts +261 -87
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +833 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1263 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.ts +3 -1
- package/src/internal.ts +7 -0
- package/src/libfx.d.ts +34 -0
- package/src/reducer.ts +1 -1
- package/src/server.ts +177 -19
- package/src/store-memory.ts +42 -7
- package/src/store-postgres.ts +80 -0
- package/src/store-redis-core.ts +210 -79
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +60 -0
- package/src/store.ts +33 -0
- package/src/telemetry.ts +1 -0
- package/dist/internal-CbVvEwad.js.map +0 -1
- package/dist/server-CGM8WzRG.d.ts.map +0 -1
- package/dist/server-Rj64fNvX.js.map +0 -1
- package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
- package/dist/store-Gpkq7WBo.d.ts.map +0 -1
- package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
- package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
- package/docs/concepts/01-contracts.mdx +0 -237
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -193
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -324
- package/docs/guides/04-local-first.mdx +0 -47
- package/docs/guides/05-production.mdx +0 -534
- package/docs/guides/06-ai-agents.mdx +0 -1408
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -262
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -2358
- package/docs/reference/02-errors.mdx +0 -150
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Conversations
|
|
3
|
+
description: "Compact long conversations, add your own events and state, and control delivery, the inbox, and turns."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Compact long conversations
|
|
7
|
+
|
|
8
|
+
```ts server/compacted.ts
|
|
9
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
10
|
+
|
|
11
|
+
export const compactedAssistant = agent({
|
|
12
|
+
name: 'compacted-agent',
|
|
13
|
+
model: 'openai/gpt-5.6-terra',
|
|
14
|
+
harness: {
|
|
15
|
+
type: 'ai-sdk',
|
|
16
|
+
// optional: compaction is automatic for supported Gateway models
|
|
17
|
+
compaction: {
|
|
18
|
+
thresholdTokens: 80_000,
|
|
19
|
+
instructions: 'Preserve exact IDs.',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Compaction belongs to the AI SDK harness, and is automatic for supported
|
|
26
|
+
Gateway models. When the estimated prompt crosses the threshold, A2 sends one
|
|
27
|
+
summary request to the active model with the same instructions, tools, and
|
|
28
|
+
provider settings, keeping the prefix cacheable.
|
|
29
|
+
Later requests get the summary plus everything after it; the raw log and
|
|
30
|
+
`state.messages` stay complete, and the summary request never appears there as
|
|
31
|
+
a turn. Successful compaction is permanent across failure, interruption, retry,
|
|
32
|
+
and recovery, and a failed summary leaves the previous context intact.
|
|
33
|
+
|
|
34
|
+
The default threshold is 75% of the context window from the Gateway model
|
|
35
|
+
catalog, lowered for a larger `generation.maxOutputTokens`; it limits input
|
|
36
|
+
context, not output. `thresholdTokens` overrides it, `instructions` adds
|
|
37
|
+
guidance to the summary prompt, and `false` turns off compaction and the catalog
|
|
38
|
+
lookup, keeping recorded summaries. A resolver can choose per generation, for
|
|
39
|
+
example from `session.state(settingsReducer)`. Custom `shouldCompact(context)`
|
|
40
|
+
and `compact(context)` callbacks receive the typed messages and the model-ready
|
|
41
|
+
`modelMessages`, summary included.
|
|
42
|
+
|
|
43
|
+
An AI SDK global provider that resolves model IDs to models outside AI
|
|
44
|
+
Gateway, and Gateway fallback model lists, need an explicit `thresholdTokens`
|
|
45
|
+
(for a fallback list, one that fits every model it may use). A custom `generate` keeps compaction off unless you
|
|
46
|
+
configure it. Set a threshold too when importing a large conversation: a
|
|
47
|
+
session does not compact before the catalog lookup lands, so an oversized first
|
|
48
|
+
prompt is sent as-is, never truncated, and fails with the provider's error.
|
|
49
|
+
Provider-executed tools, structured output, and forced tool choices skip
|
|
50
|
+
automatic compaction, and configuring it for them throws; use custom callbacks.
|
|
51
|
+
|
|
52
|
+
The first generation with each Gateway model in a session records
|
|
53
|
+
`ai.model.metadata.requested`; a separate handler fetches the catalog and
|
|
54
|
+
records `ai.model.metadata.resolved`, without delaying or failing the
|
|
55
|
+
generation. `state.modelMetadata` holds each model's limits and status:
|
|
56
|
+
pending, resolved, or unavailable (absent from the catalog). A lookup that
|
|
57
|
+
exhausted its retries stays pending without a new lookup each turn, so inspect
|
|
58
|
+
that handler's failure or set a threshold. The estimate is serialized UTF-8
|
|
59
|
+
bytes divided by four, calibrated by the previous same-model call's usage. It is
|
|
60
|
+
not a tokenizer, makes no remote calls, and does not precisely measure new image
|
|
61
|
+
or audio tokens.
|
|
62
|
+
|
|
63
|
+
Compaction waits until earlier tool calls have final results and all A2-owned
|
|
64
|
+
tool work has settled, including work from stopped or superseded turns, with one
|
|
65
|
+
extra state read before each candidate compaction. Dead-lettered tool work
|
|
66
|
+
defers it until repair or session close. Queued messages stay queued. The
|
|
67
|
+
summary request runs none of your generation callbacks, stream transforms, or
|
|
68
|
+
tool hooks and never executes local tools; an empty summary or a summary tool
|
|
69
|
+
call fails the generation, as do provider context-limit errors. See
|
|
70
|
+
[`agent()`](/reference/ai#agentoptions) for every option.
|
|
71
|
+
|
|
72
|
+
### Show compaction in the conversation
|
|
73
|
+
|
|
74
|
+
```tsx app/agent/message-view.tsx
|
|
75
|
+
'use client'
|
|
76
|
+
import type { UIMessage } from 'ai'
|
|
77
|
+
import { isCompactionPart, type TranscriptMessage } from 'experimental-a2/ai'
|
|
78
|
+
|
|
79
|
+
const compactionLabel = {
|
|
80
|
+
running: 'Compacting…',
|
|
81
|
+
completed: 'Context compacted',
|
|
82
|
+
failed: 'Compaction did not complete',
|
|
83
|
+
interrupted: 'Compaction stopped',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function MessageView({
|
|
87
|
+
message,
|
|
88
|
+
}: {
|
|
89
|
+
message: TranscriptMessage<UIMessage>
|
|
90
|
+
}) {
|
|
91
|
+
return (
|
|
92
|
+
<article>
|
|
93
|
+
{message.parts.map((part, index) => {
|
|
94
|
+
if (isCompactionPart(part))
|
|
95
|
+
return (
|
|
96
|
+
<details key={index}>
|
|
97
|
+
<summary>{compactionLabel[part.data.status]}</summary>
|
|
98
|
+
{part.data.summary ? <p>{part.data.summary}</p> : null}
|
|
99
|
+
</details>
|
|
100
|
+
)
|
|
101
|
+
return part.type === 'text' ? <p key={index}>{part.text}</p> : null
|
|
102
|
+
})}
|
|
103
|
+
</article>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The reducer marks each compaction with a `data-a2-compaction` part in the
|
|
109
|
+
assistant response. Its `data` holds a `status` (`running`, then `completed`,
|
|
110
|
+
`failed`, or `interrupted`) and, when known, `inputTokens`, `thresholdTokens`,
|
|
111
|
+
`summary`, and `usage`. The part lives in state, so it survives server
|
|
112
|
+
rendering, reloads, and other tabs, and never enters model context. Because
|
|
113
|
+
`state.messages` is `TranscriptMessage<M>[]`, `isCompactionPart()` narrows even
|
|
114
|
+
when your message type declares its own data parts. `state.compaction` holds the
|
|
115
|
+
current or latest compaction, and `indicator` is `compacting` while one runs.
|
|
116
|
+
See [`isCompactionPart()`](/reference/ai#iscompactionpartpart) for how the part
|
|
117
|
+
settles.
|
|
118
|
+
|
|
119
|
+
## Store assistant metadata
|
|
120
|
+
|
|
121
|
+
```ts server/metadata-assistant.ts
|
|
122
|
+
import type { LanguageModelUsage, UIMessage } from 'ai'
|
|
123
|
+
import { agentContract } from 'experimental-a2/ai'
|
|
124
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
125
|
+
|
|
126
|
+
export type MetadataAssistantMessage = UIMessage<{
|
|
127
|
+
status: 'writing' | 'complete'
|
|
128
|
+
usage?: LanguageModelUsage
|
|
129
|
+
}>
|
|
130
|
+
|
|
131
|
+
export const metadataAssistant = agent({
|
|
132
|
+
contract: agentContract<MetadataAssistantMessage>({
|
|
133
|
+
name: 'metadata-assistant',
|
|
134
|
+
}),
|
|
135
|
+
model: 'openai/gpt-5.6-terra',
|
|
136
|
+
harness: {
|
|
137
|
+
type: 'ai-sdk',
|
|
138
|
+
messageMetadata: ({ part }) => {
|
|
139
|
+
if (part.type === 'start') return { status: 'writing' as const }
|
|
140
|
+
if (part.type === 'finish') {
|
|
141
|
+
return { status: 'complete' as const, usage: part.totalUsage }
|
|
142
|
+
}
|
|
143
|
+
return undefined
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A narrower `UIMessage` type, carried by `agentContract()`, keeps generation
|
|
150
|
+
status and usage on the assistant message. The synchronous
|
|
151
|
+
`harness.messageMetadata` callback gets the `generate` context plus the current `TextStreamPart` as
|
|
152
|
+
`part`. What it returns for `start` and `finish` is logged with the stream and
|
|
153
|
+
shallow-merged onto the assistant message, so the example shows `writing` at
|
|
154
|
+
once and keeps final usage. `useAgent<typeof metadataAssistant>()` types
|
|
155
|
+
`messages` with it.
|
|
156
|
+
|
|
157
|
+
## Add your own events and state
|
|
158
|
+
|
|
159
|
+
Apps carry more than the conversation: a plan, a selected ticket, a chosen
|
|
160
|
+
model. Put those events and their state in a shared `agentContract()`. It takes
|
|
161
|
+
a core `contract()`'s `events`, and its `reducer` takes the same `name`,
|
|
162
|
+
`initialState`, and `fold`:
|
|
163
|
+
|
|
164
|
+
```ts support-desk.ts
|
|
165
|
+
import { z } from 'zod'
|
|
166
|
+
import { agentContract } from 'experimental-a2/ai'
|
|
167
|
+
|
|
168
|
+
const noFlags: string[] = []
|
|
169
|
+
|
|
170
|
+
export const supportDesk = agentContract({
|
|
171
|
+
name: 'support-desk',
|
|
172
|
+
events: {
|
|
173
|
+
'plan.changed': z.object({ plan: z.enum(['free', 'pro']) }),
|
|
174
|
+
'ticket.flagged': z.object({ ticketId: z.string() }),
|
|
175
|
+
},
|
|
176
|
+
reducer: {
|
|
177
|
+
name: 'support-desk.v1',
|
|
178
|
+
initialState: { plan: 'free' as 'free' | 'pro', flagged: noFlags },
|
|
179
|
+
fold: (state, event) =>
|
|
180
|
+
event.type === 'plan.changed'
|
|
181
|
+
? { ...state, plan: event.payload.plan }
|
|
182
|
+
: event.type === 'ticket.flagged'
|
|
183
|
+
? { ...state, flagged: [...state.flagged, event.payload.ticketId] }
|
|
184
|
+
: state,
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Keep this module isomorphic. `fold` sees your state and every event, never the
|
|
190
|
+
transcript. The session's state is `AIState` merged with yours, so a key that
|
|
191
|
+
shadows one of `AIState`'s is a type error. The reducer's `name` is its snapshot
|
|
192
|
+
identity: rename it when `fold` changes.
|
|
193
|
+
|
|
194
|
+
Serve it with `agent({ contract })`. Every resolver receives your state as
|
|
195
|
+
`state`:
|
|
196
|
+
|
|
197
|
+
```ts server/support-desk.ts
|
|
198
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
199
|
+
import { supportDesk } from '../support-desk'
|
|
200
|
+
|
|
201
|
+
export const supportDeskAgent = agent({
|
|
202
|
+
contract: supportDesk,
|
|
203
|
+
model: ({ state }) =>
|
|
204
|
+
state.plan === 'pro'
|
|
205
|
+
? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
|
|
206
|
+
: { model: 'openai/gpt-5.6-luna', speed: 'fast' },
|
|
207
|
+
handlers: {
|
|
208
|
+
'ticket.flagged': async ({ event }) => {
|
|
209
|
+
// your side effect; event.id makes a stable idempotency key:
|
|
210
|
+
// await notifyOnCall(event.payload.ticketId, { idempotencyKey: event.id })
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`state` comes from the snapshot the generation already captured, so it costs no
|
|
217
|
+
extra read. Each model step resolves again, so a change during a turn applies
|
|
218
|
+
from the next step. A resolver that throws fails the turn with its message, like
|
|
219
|
+
a provider error; `retry()` runs it again.
|
|
220
|
+
|
|
221
|
+
In the browser, pass the contract to `useAgent()`. This import is real, not
|
|
222
|
+
type-only, because the browser folds your state too:
|
|
223
|
+
|
|
224
|
+
```tsx app/support/desk.tsx
|
|
225
|
+
'use client'
|
|
226
|
+
import { useAgent } from 'experimental-a2/ai/react'
|
|
227
|
+
import { supportDesk } from '@/support-desk'
|
|
228
|
+
|
|
229
|
+
export function Desk({ id }: { id: string }) {
|
|
230
|
+
const { state, push, sendMessage } = useAgent({
|
|
231
|
+
api: '/api/support',
|
|
232
|
+
id,
|
|
233
|
+
contract: supportDesk,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<section>
|
|
238
|
+
<p>
|
|
239
|
+
{state.plan} plan, {state.flagged.length} flagged tickets
|
|
240
|
+
</p>
|
|
241
|
+
<button
|
|
242
|
+
onClick={() =>
|
|
243
|
+
void push({ type: 'ticket.flagged', payload: { ticketId: 'T-1' } })
|
|
244
|
+
}
|
|
245
|
+
>
|
|
246
|
+
Flag T-1
|
|
247
|
+
</button>
|
|
248
|
+
<button onClick={() => void sendMessage('Summarize the flagged tickets.')}>
|
|
249
|
+
Ask
|
|
250
|
+
</button>
|
|
251
|
+
</section>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Browsers push a shared contract's application events like any A2 client. The
|
|
257
|
+
agent refuses only its own server-authored `ai.*` events; the route's
|
|
258
|
+
`authorize` decides the rest. Here the plan comes from your billing code, not
|
|
259
|
+
the browser:
|
|
260
|
+
|
|
261
|
+
```ts app/api/support/route.ts
|
|
262
|
+
import { supportDeskAgent } from '@/server/support-desk'
|
|
263
|
+
|
|
264
|
+
function handle(request: Request): Promise<Response> {
|
|
265
|
+
// here's where you'd do auth, or any other checks:
|
|
266
|
+
return supportDeskAgent.fetch(request, {
|
|
267
|
+
authorize: (operation) =>
|
|
268
|
+
operation.type !== 'push' ||
|
|
269
|
+
operation.events.every((event) => event.type !== 'plan.changed'),
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const GET = handle
|
|
274
|
+
export const POST = handle
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Server appends, such as
|
|
278
|
+
`supportDeskAgent.session(id).append({ type: 'plan.changed', payload })`, are
|
|
279
|
+
trusted and skip `authorize`. `push()` also accepts any `inputs` builder, and
|
|
280
|
+
`handlerContext(supportDesk)` types application events inside tools.
|
|
281
|
+
|
|
282
|
+
The contract-free client, `createAgentClient()` or `useAgent({ api | client })`
|
|
283
|
+
with a type-only import, folds only the built-in reducer. It accepts
|
|
284
|
+
`agent({ name })`, or `agent({ contract })` with an `agentContract()` that has
|
|
285
|
+
no `reducer`; the types enforce this through `AI_STATE_REDUCER`.
|
|
286
|
+
|
|
287
|
+
When the agent belongs inside something larger, such as a shared chat room,
|
|
288
|
+
keep two sessions. The room's handler appends each message the agent should see
|
|
289
|
+
to the agent's session with `inputs.message()`. Event IDs are unique across a
|
|
290
|
+
store's sessions, so derive the message ID from the room event's ID with
|
|
291
|
+
`idempotentId()`; a retried handler then converges on the same message. A tool
|
|
292
|
+
the agent calls to speak appends to the room with an `idempotentId()` of its
|
|
293
|
+
`toolCallId`. Neither fold reads the other's state.
|
|
294
|
+
|
|
295
|
+
### Replace generation, not durability
|
|
296
|
+
|
|
297
|
+
```ts server/custom-generation.ts
|
|
298
|
+
import { convertToModelMessages, streamText } from 'ai'
|
|
299
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
300
|
+
|
|
301
|
+
export const customGeneration = agent({
|
|
302
|
+
name: 'custom-generation',
|
|
303
|
+
model: 'openai/gpt-5.6-terra',
|
|
304
|
+
harness: {
|
|
305
|
+
type: 'ai-sdk',
|
|
306
|
+
generate: async ({ event, messages, model, instructions, signal }) => {
|
|
307
|
+
const result = streamText({
|
|
308
|
+
model,
|
|
309
|
+
messages: await convertToModelMessages(messages),
|
|
310
|
+
abortSignal: signal,
|
|
311
|
+
...(instructions === undefined ? {} : { instructions }),
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
return result.toUIMessageStream({
|
|
315
|
+
originalMessages: messages,
|
|
316
|
+
generateMessageId: () => event.payload.responseMessageId,
|
|
317
|
+
})
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
})
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`harness.generate` replaces only the model-facing step, for fixtures, provider routing,
|
|
324
|
+
or a different AI SDK assembly. It receives the resolver context (with
|
|
325
|
+
`messages` already compacted), the resolved settings, `modelMessages`, and the
|
|
326
|
+
step's `generationId`, and returns exactly one model step as a
|
|
327
|
+
`ReadableStream<UIMessageChunk>` that writes the assistant message
|
|
328
|
+
`event.payload.responseMessageId`. A tool-aware replacement sends tool
|
|
329
|
+
definitions without running local `execute` functions. A2 still owns durable
|
|
330
|
+
progress, tool execution, approval, continuation, interruption, and failure.
|
|
331
|
+
`messageMetadata` applies only to the default generator, so a custom `generate`
|
|
332
|
+
emits its own metadata.
|
|
333
|
+
|
|
334
|
+
## Checkpoints and recovery reads
|
|
335
|
+
|
|
336
|
+
Samples from here on import `assistant`, the agent from
|
|
337
|
+
[Agents](/agents/introduction#define-the-agent):
|
|
338
|
+
|
|
339
|
+
```ts server/read-agent.ts
|
|
340
|
+
import type { UIMessage } from 'ai'
|
|
341
|
+
import type { TranscriptMessage } from 'experimental-a2/ai'
|
|
342
|
+
import { assistant } from './assistant'
|
|
343
|
+
|
|
344
|
+
export async function readMessages(
|
|
345
|
+
sessionId: string,
|
|
346
|
+
): Promise<TranscriptMessage<UIMessage>[]> {
|
|
347
|
+
const snapshot = await assistant.session(sessionId).state()
|
|
348
|
+
return snapshot.state.messages
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`session(id).state()` returns `AIState` and its log index, ready for server code
|
|
353
|
+
or client hydration. Tool inputs, outputs, and provider metadata live in message
|
|
354
|
+
parts; `state.tools` keeps only identities and execution status.
|
|
355
|
+
`reduceAIState()` is the reducer's pure transition over confirmed `AIState`, for
|
|
356
|
+
projecting raw event histories.
|
|
357
|
+
|
|
358
|
+
Model, instruction, compaction, and custom-generation callbacks receive prompt
|
|
359
|
+
messages, the triggering request, and your contract's `state`, never a raw
|
|
360
|
+
history. Read any other reducer with `session.state(yourReducer)`, at the
|
|
361
|
+
captured prompt boundary unless you pass `through`. Recovery reads at fixed
|
|
362
|
+
boundaries too: a tool recovered in a fresh process sees its original prompt,
|
|
363
|
+
and A2 rebuilds a missing or invalid reducer snapshot from the log.
|
|
364
|
+
|
|
365
|
+
While tool arguments stream, `input-streaming` parts carry a best-effort partial
|
|
366
|
+
JSON `input`, so a command string can grow before its closing quote. It is a
|
|
367
|
+
display draft, not validated input; `tool-input-available` supplies the
|
|
368
|
+
completed input.
|
|
369
|
+
|
|
370
|
+
## Delivery semantics
|
|
371
|
+
|
|
372
|
+
Model steps and tools run inside at-least-once A2 handlers. A successful
|
|
373
|
+
completion commits atomically with its terminal events, and deterministic IDs
|
|
374
|
+
make scheduling and lifecycle appends effectively once. An incomplete model
|
|
375
|
+
attempt is marked `superseded` before its replacement starts. An expired claim
|
|
376
|
+
or superseded attempt keeps its abort reason through model, resolver,
|
|
377
|
+
compaction-policy, and tool calls, and retries without spending its failure
|
|
378
|
+
budget; an explicit interruption stays terminal. A retried tool can stream
|
|
379
|
+
different preliminary output under the same tool-call ID.
|
|
380
|
+
|
|
381
|
+
Provider calls and external side effects sit outside that transaction, so
|
|
382
|
+
recovery may repeat a call whose effect landed before its handler committed.
|
|
383
|
+
Give side-effecting tools their own idempotency, normally keyed by the AI SDK
|
|
384
|
+
`toolCallId`. A fast tool can also start before its model step fails; the
|
|
385
|
+
failure blocks the continuation but cannot undo the effect.
|
|
386
|
+
|
|
387
|
+
For a tool whose effect must not repeat and has no idempotency key, such as a
|
|
388
|
+
shell command, set `toolRecovery: { yourTool: 'report' }`. When a crash, a
|
|
389
|
+
timeout, Stop, or Pause now may have cut a call short, A2 does not run it again,
|
|
390
|
+
even on resume; the model gets an error result saying the call was interrupted
|
|
391
|
+
and may have partly run. A call that failed before reaching the tool still
|
|
392
|
+
retries, and so does one where A2 itself fails while or just after the tool
|
|
393
|
+
runs. Every other tool uses the default, `'rerun'`. See
|
|
394
|
+
[`agent()`](/reference/ai#agentoptions) for the exact cases.
|
|
395
|
+
|
|
396
|
+
Configure a [production scheduler](/server/production) as for any A2 server. It
|
|
397
|
+
recovers an incomplete generation after the original serverless invocation
|
|
398
|
+
disappears.
|
|
399
|
+
|
|
400
|
+
## Inbox and turn controls
|
|
401
|
+
|
|
402
|
+
```tsx app/agent/[sessionId]/queue.tsx
|
|
403
|
+
'use client'
|
|
404
|
+
import { useAgent } from 'experimental-a2/ai/react'
|
|
405
|
+
import type { assistant } from '@/server/assistant'
|
|
406
|
+
|
|
407
|
+
export function Queue({ sessionId }: { sessionId: string }) {
|
|
408
|
+
const { state, queue, steer, pause, resume } = useAgent<typeof assistant>({
|
|
409
|
+
api: '/api/agent',
|
|
410
|
+
id: sessionId,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<section>
|
|
415
|
+
<ol aria-label="Queued messages">
|
|
416
|
+
{state.inbox.items.map((item) => (
|
|
417
|
+
<li key={item.id}>
|
|
418
|
+
{item.message.parts.map((part, index) =>
|
|
419
|
+
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
|
|
420
|
+
)}
|
|
421
|
+
<button onClick={() => void queue.sendNow(item.id)}>Send now</button>
|
|
422
|
+
<button
|
|
423
|
+
onClick={() =>
|
|
424
|
+
void queue.move({ inputId: item.id, beforeId: null })
|
|
425
|
+
}
|
|
426
|
+
>
|
|
427
|
+
Move to end
|
|
428
|
+
</button>
|
|
429
|
+
<button onClick={() => void queue.remove(item.id)}>Remove</button>
|
|
430
|
+
</li>
|
|
431
|
+
))}
|
|
432
|
+
</ol>
|
|
433
|
+
<button onClick={() => void steer('Check the billing service first.')}>
|
|
434
|
+
Steer
|
|
435
|
+
</button>
|
|
436
|
+
{state.inbox.paused ? (
|
|
437
|
+
<button onClick={() => void resume()}>Resume</button>
|
|
438
|
+
) : (
|
|
439
|
+
<button onClick={() => void pause({ when: 'after-turn' })}>
|
|
440
|
+
Pause after this turn
|
|
441
|
+
</button>
|
|
442
|
+
)}
|
|
443
|
+
</section>
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
`sendMessage()` puts a message in the durable inbox, `state.inbox.items`. The
|
|
449
|
+
controller admits one input at a time, freezes its content, and adds it to
|
|
450
|
+
`state.messages`; pending inputs never enter the model prompt. `state.active`
|
|
451
|
+
is the logical turn: its ID, frozen `input`, and `phase`. The turn ID holds
|
|
452
|
+
across model steps, tools, and a pause. Stop ends the turn and admits the next
|
|
453
|
+
input when admission is running.
|
|
454
|
+
|
|
455
|
+
:::note
|
|
456
|
+
This input protocol requires matching client and server versions. Start a new
|
|
457
|
+
agent session when adopting it; existing logs stay readable, but finish pending
|
|
458
|
+
work with the runtime that created it before upgrading.
|
|
459
|
+
:::
|
|
460
|
+
|
|
461
|
+
`queue.edit(message)` replaces a pending input's content; edits are
|
|
462
|
+
last-write-wins in log order. An edit keeps the message's identity and role;
|
|
463
|
+
changing the role returns `role-mismatch`. `queue.move()` takes `beforeId: null`
|
|
464
|
+
for the end. `queue.remove()` keeps the original submission in the log, so
|
|
465
|
+
replay cannot recreate the prompt. Removing an input that is no longer pending
|
|
466
|
+
(admitted, removed, or never queued) returns `not-pending`, as does an edit that
|
|
467
|
+
loses to admission; a Stop aimed at a finished turn returns `stale-turn`.
|
|
468
|
+
|
|
469
|
+
`steer()` puts new input first. If the observed turn is still active, it
|
|
470
|
+
finishes its current model response and that response's tools, including
|
|
471
|
+
authorized deferred provider results, then hands off before another model
|
|
472
|
+
step. Output produced after the click stays, and unanswered approval and input
|
|
473
|
+
requests close, so the unapproved tool never runs; a denied approval never
|
|
474
|
+
blocks the handoff. With no active turn, `steer()` sends like `sendMessage()`.
|
|
475
|
+
|
|
476
|
+
`queue.sendNow()` makes the same handoff with a queued message, keeping its ID
|
|
477
|
+
and latest accepted content and promoting passive user input to generation.
|
|
478
|
+
Non-user context returns `not-user-input`, and a stale observed turn returns
|
|
479
|
+
`stale-turn` without changing the queue. Send now uses the accepted content, so
|
|
480
|
+
save editor text first. `steer()` and `queue.sendNow()` both preserve pause: a
|
|
481
|
+
paused queue keeps the selected input first until Resume. Stop remains the
|
|
482
|
+
immediate interruption.
|
|
483
|
+
|
|
484
|
+
A queued item's optional `afterStepOf` records the observed turn for steering.
|
|
485
|
+
Editing keeps it, removing the item cancels it, and queue order decides which
|
|
486
|
+
input is admitted at the handoff. See [`inputs`](/reference/ai#inputs) for the
|
|
487
|
+
full handoff rules.
|
|
488
|
+
|
|
489
|
+
### Input identity
|
|
490
|
+
|
|
491
|
+
New-input commands from `inputs.message()`, `inputs.seed()`, and
|
|
492
|
+
`inputs.steer()` use the message ID as their event ID, and hand-written commands
|
|
493
|
+
must too. Caller IDs cannot start with the reserved `a2.ai:` prefix and must be
|
|
494
|
+
unique across the store; include the contract and session when deriving them
|
|
495
|
+
with `idempotentId()`. An identical submission replays without new work, even
|
|
496
|
+
after removal or compaction. Reusing an ID with different content, action, or
|
|
497
|
+
session rejects with `PARTIAL_DUPLICATE_BATCH`: use `queue.edit()` to change a
|
|
498
|
+
pending input and a fresh ID for a new one.
|
|
499
|
+
|
|
500
|
+
### Optimistic conversation display
|
|
501
|
+
|
|
502
|
+
Every client folding the AI reducer, an extended `agentContract()` included,
|
|
503
|
+
applies pending commands to `state` with no extra I/O, so render the
|
|
504
|
+
conversation and queue from `state`. An idle, unpaused send appears in
|
|
505
|
+
`state.messages` at once with `state.starting: true`: a turn awaiting
|
|
506
|
+
acceptance, not a running model. Later sends, and every send while paused, wait
|
|
507
|
+
in `state.inbox.items`; steering, and Send now aimed at an active turn, appear
|
|
508
|
+
in `state.messages` after the current response instead. Another client's
|
|
509
|
+
accepted turn can move a speculative send back into the queue, while steering
|
|
510
|
+
stays visible as a message. A pending Stop clears `state.active` at once.
|
|
511
|
+
Close an editor as soon as its save is pushed, and keep the draft if the push
|
|
512
|
+
fails. See
|
|
513
|
+
[Optimistic AI client state](/reference/ai#optimistic-ai-client-state).
|
|
514
|
+
|
|
515
|
+
### Persistence and application are separate
|
|
516
|
+
|
|
517
|
+
A push confirms that the command is persisted; `ai.control.decided` confirms its
|
|
518
|
+
application, with `commandId` (the submitted event's ID) and an `outcome` of
|
|
519
|
+
`applied` or `rejected`. A rejection carries a `reason` such as `not-pending` or
|
|
520
|
+
`stale-turn`.
|
|
521
|
+
`state.receipt` is the most recent user command receipt. To correlate several
|
|
522
|
+
in flight, each action resolves to its persisted command events, whose `id` is
|
|
523
|
+
the receipt's `commandId`, or to an empty array when there is nothing to
|
|
524
|
+
address, such as Stop with no active turn. A rejection is a normal result and
|
|
525
|
+
spends no handler retry.
|
|
526
|
+
|
|
527
|
+
### Pause and resume
|
|
528
|
+
|
|
529
|
+
`pause({ when: 'after-turn' })` closes inbox admission and lets the active turn
|
|
530
|
+
finish. `pause({ when: 'now' })` also cancels current model and tool work;
|
|
531
|
+
`state.active.phase` is `pausing` while old work settles, then `paused`.
|
|
532
|
+
`resume()` resumes retained work before admitting another input.
|
|
533
|
+
|
|
534
|
+
Resume starts a fresh model request from durable state; it does not resume the
|
|
535
|
+
provider's old stream or undo completed tool effects. While paused, accepted
|
|
536
|
+
tool calls are retained with their outcomes recorded, and the turn holds no
|
|
537
|
+
model or tool claim. A cancelled tool without a terminal result can run again
|
|
538
|
+
after resume, so use the stable `toolCallId` for external idempotency.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Harnesses
|
|
3
|
+
description: "Run FX's or Pi's own loop as the agent's, durably, behind the same conversation."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Choose the loop
|
|
7
|
+
|
|
8
|
+
An agent's harness is its loop: its system prompt, its tools, and how it
|
|
9
|
+
decides what to do next. The default, `{ type: 'ai-sdk' }`, runs one AI SDK
|
|
10
|
+
`streamText()` step per generation with the prompt and tools you give it.
|
|
11
|
+
`fx()` runs [FX](https://fx.sh) and `pi()` runs [Pi](https://pi.dev) instead,
|
|
12
|
+
and A2 keeps them durable. Each model call the harness makes is one step of the response,
|
|
13
|
+
with streamed progress, tool calls, steering, interruption, and recovery like
|
|
14
|
+
any other step. The conversation is the same `AIState`, so `useAgent()` renders
|
|
15
|
+
every harness alike.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add libfx # fx()
|
|
21
|
+
pnpm add @earendil-works/pi-coding-agent @earendil-works/pi-ai # pi()
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Run FX
|
|
25
|
+
|
|
26
|
+
`fx()` runs FX's kernel in your server process. FX adds no system prompt of its
|
|
27
|
+
own and brings no tools, so `instructions` is the whole prompt and `tools` is
|
|
28
|
+
everything it can call:
|
|
29
|
+
|
|
30
|
+
```ts server/coder.ts
|
|
31
|
+
import { tool } from 'ai'
|
|
32
|
+
import { Sandbox } from '@vercel/sandbox'
|
|
33
|
+
import { z } from 'zod'
|
|
34
|
+
import { handlerContext } from 'experimental-a2/ai'
|
|
35
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
36
|
+
import { fx } from 'experimental-a2/ai/fx'
|
|
37
|
+
|
|
38
|
+
export const coder = agent({
|
|
39
|
+
name: 'coder',
|
|
40
|
+
harness: fx(),
|
|
41
|
+
model: 'anthropic/claude-sonnet-5.5',
|
|
42
|
+
instructions: 'Fix the bug the user describes. Keep replies short.',
|
|
43
|
+
tools: {
|
|
44
|
+
bash: tool({
|
|
45
|
+
description: 'Run a shell command in the repository.',
|
|
46
|
+
inputSchema: z.object({ command: z.string() }),
|
|
47
|
+
execute: async ({ command }) => {
|
|
48
|
+
const { session, signal } = handlerContext()
|
|
49
|
+
// One Vercel Sandbox per session, created on first use.
|
|
50
|
+
const sandbox = await Sandbox.getOrCreate({
|
|
51
|
+
name: session.id,
|
|
52
|
+
persistent: true,
|
|
53
|
+
signal,
|
|
54
|
+
})
|
|
55
|
+
const result = await sandbox.runCommand({
|
|
56
|
+
cmd: 'bash',
|
|
57
|
+
args: ['-c', command],
|
|
58
|
+
signal,
|
|
59
|
+
})
|
|
60
|
+
return { exitCode: result.exitCode, output: await result.stdout() }
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
toolRecovery: { bash: 'report' },
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Run Pi
|
|
69
|
+
|
|
70
|
+
`pi()` runs Pi's own agent session: Pi's system prompt, and Pi's `read`,
|
|
71
|
+
`write`, `edit`, and `bash` tools. `instructions` adds to Pi's prompt and
|
|
72
|
+
`tools` adds to Pi's tools; a tool with the name of one of Pi's replaces it.
|
|
73
|
+
Pi's tools work in the session's sandbox, so `pi()` takes one:
|
|
74
|
+
|
|
75
|
+
```ts server/pi-coder.ts
|
|
76
|
+
import type { Experimental_SandboxSession } from 'ai'
|
|
77
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
78
|
+
import { pi } from 'experimental-a2/ai/pi'
|
|
79
|
+
|
|
80
|
+
// your sandbox: the same AI SDK sandbox session for a session every time,
|
|
81
|
+
// for example from @ai-sdk/sandbox-vercel
|
|
82
|
+
declare function openSandbox(
|
|
83
|
+
sessionId: string,
|
|
84
|
+
): Promise<Experimental_SandboxSession>
|
|
85
|
+
|
|
86
|
+
export const piCoder = agent({
|
|
87
|
+
name: 'pi-coder',
|
|
88
|
+
harness: pi({ sandbox: ({ session }) => openSandbox(session.id) }),
|
|
89
|
+
model: { model: 'anthropic/claude-sonnet-5.5', reasoning: 'low' },
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`sandbox` resolves the session's sandbox, any AI SDK sandbox session, for each
|
|
94
|
+
tool execution, and for a step until Pi's session records the sandbox's
|
|
95
|
+
working directory, so return the same one for a session every time and create
|
|
96
|
+
it only once. Your own tools under `pi()`
|
|
97
|
+
receive it as `experimental_sandbox` too. A2 never stops it; stopping it is
|
|
98
|
+
yours. Pi works in the sandbox's working directory, and its `bash` runs with
|
|
99
|
+
the sandbox's own environment; none of your server's environment reaches it.
|
|
100
|
+
|
|
101
|
+
Pi's tools move at most 4 MiB from the sandbox to your server per file read
|
|
102
|
+
or command. A larger file fails `read` or `edit`, and a command whose output
|
|
103
|
+
passes 4 MiB is stopped and fails `bash`; both errors tell the model to read in
|
|
104
|
+
parts. Files arrive through the sandbox's `readFile`, so implement it as a
|
|
105
|
+
stream for the limit to bound the download too.
|
|
106
|
+
|
|
107
|
+
## Models and credentials
|
|
108
|
+
|
|
109
|
+
Both harnesses call models through Vercel AI Gateway. `model` is a Gateway
|
|
110
|
+
model ID, or [`ModelSettings`](/reference/ai#modelsettings) with one, resolved
|
|
111
|
+
for each step like any agent's model; `pi()` takes the Gateway models Pi's own
|
|
112
|
+
catalog lists. `reasoning` becomes FX's effort or Pi's thinking level, and
|
|
113
|
+
`speed: 'fast'` asks FX for the faster tier. Credentials come from
|
|
114
|
+
`AI_GATEWAY_API_KEY`, or from Vercel's OIDC token on Vercel. The harness reads
|
|
115
|
+
the text of each user message; files and seeded assistant messages do not
|
|
116
|
+
reach it.
|
|
117
|
+
|
|
118
|
+
## What the harness owns
|
|
119
|
+
|
|
120
|
+
The harness owns its loop, its prompt, and its compaction, so `generation`,
|
|
121
|
+
`generate`, `compaction`, and `messageMetadata` exist only in
|
|
122
|
+
`{ type: 'ai-sdk' }`. A harness runs its tools without approval, so a tool that
|
|
123
|
+
sets `needsApproval` is rejected. `maxSteps` limits the model calls in one
|
|
124
|
+
response under every harness. Steering enters at the next step, as with any
|
|
125
|
+
agent: the harness sees the steered message before its next model call.
|
|
126
|
+
|
|
127
|
+
## Recovery
|
|
128
|
+
|
|
129
|
+
A2 records each model call and each tool result as the harness works. When a
|
|
130
|
+
process dies in the middle of a response, the next attempt resumes at the
|
|
131
|
+
interrupted step: finished model calls are not made again and finished tools
|
|
132
|
+
do not run again. A2 stores nothing outside the log: a process that ran the
|
|
133
|
+
last response resumes the harness from its state at the end of that response,
|
|
134
|
+
FX's checkpoint or Pi's session, kept in memory, and any other process replays
|
|
135
|
+
the session from the log. A tool call that a crash cut short reruns unless its
|
|
136
|
+
`toolRecovery` is `'report'`; see [Delivery semantics](/agents/conversations#delivery-semantics).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { order: 5 }
|