experimental-a2 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
  11. package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  12. package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
  13. package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +692 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
  24. package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
  25. package/dist/ai-harness-BuLh_EYi.js +273 -0
  26. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +950 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +113 -40
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  41. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  42. package/dist/ai.d.ts +1 -1
  43. package/dist/ai.js +1 -1
  44. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  45. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  46. package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
  47. package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
  48. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  49. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  50. package/dist/client.d.ts +3 -3
  51. package/dist/client.js +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  54. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  55. package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
  56. package/dist/internal-CscSY7K_.js.map +1 -0
  57. package/dist/otel.d.ts +1 -1
  58. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  59. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  60. package/dist/react.d.ts +1 -1
  61. package/dist/react.js +1 -1
  62. package/dist/reducer-BAXc8JyF.js.map +1 -1
  63. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  64. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-qstash.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +7 -7
  69. package/dist/scheduler-vercel.d.ts.map +1 -1
  70. package/dist/scheduler-vercel.js +9 -10
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
  73. package/dist/server-D-Y4pqwu.js.map +1 -0
  74. package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
  75. package/dist/server-DYAATFuT.d.ts.map +1 -0
  76. package/dist/server.d.ts +4 -4
  77. package/dist/server.js +1 -1
  78. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  79. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  80. package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
  81. package/dist/store-CyLUy86v.d.ts.map +1 -0
  82. package/dist/store-N8PXxDAS.js.map +1 -1
  83. package/dist/store-memory.d.ts +1 -1
  84. package/dist/store-memory.js +56 -11
  85. package/dist/store-memory.js.map +1 -1
  86. package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
  87. package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  88. package/dist/store-postgres.d.ts +1 -1
  89. package/dist/store-postgres.d.ts.map +1 -1
  90. package/dist/store-postgres.js +106 -25
  91. package/dist/store-postgres.js.map +1 -1
  92. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
  93. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  94. package/dist/store-redis-http.d.ts +1 -1
  95. package/dist/store-redis-http.js +3 -3
  96. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
  97. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  98. package/dist/store-redis.d.ts +1 -1
  99. package/dist/store-redis.js +1 -1
  100. package/dist/store-sqlite.d.ts +1 -1
  101. package/dist/store-sqlite.js +66 -21
  102. package/dist/store-sqlite.js.map +1 -1
  103. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  104. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  105. package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
  106. package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
  107. package/docs/01-quickstart.mdx +49 -83
  108. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  109. package/docs/actors/01-introduction.mdx +48 -86
  110. package/docs/actors/02-concurrency.mdx +43 -70
  111. package/docs/actors/03-timers.mdx +28 -42
  112. package/docs/actors/04-routes.mdx +110 -150
  113. package/docs/actors/meta.ts +1 -1
  114. package/docs/agents/01-introduction.mdx +257 -0
  115. package/docs/agents/02-tools.mdx +247 -0
  116. package/docs/agents/03-conversations.mdx +538 -0
  117. package/docs/agents/04-harnesses.mdx +136 -0
  118. package/docs/agents/meta.ts +1 -0
  119. package/docs/client/01-react.mdx +294 -0
  120. package/docs/client/02-transports.mdx +86 -0
  121. package/docs/client/03-presence.mdx +224 -0
  122. package/docs/client/meta.ts +1 -0
  123. package/docs/contract/01-events.mdx +57 -0
  124. package/docs/contract/02-state.mdx +142 -0
  125. package/docs/contract/03-sessions.mdx +175 -0
  126. package/docs/index.mdx +109 -145
  127. package/docs/reference/01-core.mdx +139 -0
  128. package/docs/reference/02-server.mdx +860 -0
  129. package/docs/reference/03-client.mdx +301 -0
  130. package/docs/reference/04-ai.mdx +731 -0
  131. package/docs/reference/05-adapters.mdx +351 -0
  132. package/docs/reference/06-errors.mdx +138 -0
  133. package/docs/reference/meta.ts +1 -1
  134. package/docs/server/01-handlers.mdx +200 -0
  135. package/docs/server/02-timers.mdx +130 -0
  136. package/docs/server/03-cancellation.mdx +142 -0
  137. package/docs/server/04-routes.mdx +243 -0
  138. package/docs/server/05-production.mdx +323 -0
  139. package/docs/server/06-guarantees.mdx +132 -0
  140. package/examples/playground/DEPLOY.md +3 -5
  141. package/examples/playground/app/a0/[appId]/page.tsx +12 -13
  142. package/examples/playground/app/a0/app-list.tsx +9 -2
  143. package/examples/playground/app/a0/app-view.tsx +9 -1
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  145. package/examples/playground/app/a0/apps.test.ts +12 -0
  146. package/examples/playground/app/a0/apps.ts +22 -7
  147. package/examples/playground/app/a0/chat.tsx +47 -1
  148. package/examples/playground/app/a0/devtools.tsx +2 -8
  149. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  150. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/model.test.ts +18 -1
  153. package/examples/playground/app/a0/model.ts +47 -10
  154. package/examples/playground/app/a0/no-photon.cjs +4 -0
  155. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  156. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  157. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  158. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  159. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  160. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  161. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  162. package/examples/playground/app/a0/server.ts +98 -54
  163. package/examples/playground/app/a0/session.ts +17 -5
  164. package/examples/playground/app/a0/shell.tsx +11 -3
  165. package/examples/playground/app/agent/ingress.test.ts +6 -3
  166. package/examples/playground/app/agent/server.ts +9 -6
  167. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  168. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  169. package/examples/playground/app/chat/server.test.ts +30 -26
  170. package/examples/playground/app/chat/server.ts +46 -46
  171. package/examples/playground/next.config.ts +17 -5
  172. package/examples/playground/package.json +4 -1
  173. package/examples/playground/vercel.json +18 -0
  174. package/package.json +27 -3
  175. package/src/ai-agent.ts +271 -90
  176. package/src/ai-fx.browser.ts +5 -0
  177. package/src/ai-fx.ts +912 -0
  178. package/src/ai-harness.ts +518 -0
  179. package/src/ai-model-metadata.ts +11 -6
  180. package/src/ai-pi.browser.ts +5 -0
  181. package/src/ai-pi.ts +1322 -0
  182. package/src/ai-sdk-step.ts +2 -2
  183. package/src/ai-server.ts +3 -1
  184. package/src/ai-tool-input.ts +45 -0
  185. package/src/internal.ts +13 -0
  186. package/src/libfx.d.ts +34 -0
  187. package/src/reducer.ts +1 -1
  188. package/src/scheduler-qstash.ts +5 -5
  189. package/src/scheduler-vercel.ts +14 -15
  190. package/src/server.ts +233 -29
  191. package/src/store-memory.ts +92 -16
  192. package/src/store-postgres.ts +156 -25
  193. package/src/store-redis-core.ts +322 -110
  194. package/src/store-redis-notify.ts +2 -0
  195. package/src/store-sqlite.ts +121 -28
  196. package/src/store.ts +55 -3
  197. package/src/telemetry.ts +1 -0
  198. package/dist/internal-CbVvEwad.js.map +0 -1
  199. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  200. package/dist/server-Rj64fNvX.js.map +0 -1
  201. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  202. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  203. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  204. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  205. package/docs/concepts/01-contracts.mdx +0 -237
  206. package/docs/concepts/02-handlers.mdx +0 -284
  207. package/docs/concepts/03-durability.mdx +0 -314
  208. package/docs/concepts/04-state.mdx +0 -209
  209. package/docs/guides/01-timers.mdx +0 -193
  210. package/docs/guides/02-cancellation.mdx +0 -161
  211. package/docs/guides/03-react.mdx +0 -324
  212. package/docs/guides/04-local-first.mdx +0 -47
  213. package/docs/guides/05-production.mdx +0 -534
  214. package/docs/guides/06-ai-agents.mdx +0 -1408
  215. package/docs/guides/08-application-data.mdx +0 -113
  216. package/docs/guides/09-presence.mdx +0 -262
  217. package/docs/guides/10-transports.mdx +0 -221
  218. package/docs/reference/01-api.mdx +0 -2358
  219. package/docs/reference/02-errors.mdx +0 -150
  220. /package/docs/{concepts → contract}/meta.ts +0 -0
  221. /package/docs/{guides → server}/meta.ts +0 -0
package/src/ai-pi.ts ADDED
@@ -0,0 +1,1322 @@
1
+ /**
2
+ * experimental-a2/ai/pi — `pi()`: the Pi harness for `agent({ harness })`.
3
+ * Server-only; Pi comes from the optional `@earendil-works/pi-coding-agent`
4
+ * and `@earendil-works/pi-ai` peer dependencies.
5
+ */
6
+
7
+ // oxlint-disable no-await-in-loop -- turns replay in order, each on the session the last one left.
8
+
9
+ import { createHash } from 'node:crypto'
10
+ import { readFile as readLocalFile, unlink } from 'node:fs/promises'
11
+ import { tmpdir } from 'node:os'
12
+ import { basename, dirname, join } from 'node:path'
13
+ import type {
14
+ AssistantMessage,
15
+ AssistantMessageEvent,
16
+ AssistantMessageEventStream,
17
+ JsonObject,
18
+ TextContent,
19
+ ThinkingContent,
20
+ ToolCall,
21
+ Usage,
22
+ } from '@earendil-works/pi-ai'
23
+ import type {
24
+ AgentSession,
25
+ AgentToolResult,
26
+ BashOperations,
27
+ ModelRuntime,
28
+ ResourceLoader,
29
+ SessionEntry,
30
+ SessionHeader,
31
+ ToolDefinition,
32
+ } from '@earendil-works/pi-coding-agent'
33
+ import {
34
+ asSchema,
35
+ jsonSchema,
36
+ type Experimental_SandboxSession,
37
+ type FinishReason,
38
+ type JSONSchema7,
39
+ type LanguageModelUsage,
40
+ type Tool,
41
+ type ToolExecutionOptions,
42
+ type ToolSet,
43
+ type UIMessageChunk,
44
+ } from 'ai'
45
+ import {
46
+ type Deferred,
47
+ defineHarness,
48
+ deferred,
49
+ Divergence,
50
+ gatewayCredential,
51
+ type Harness,
52
+ type HarnessGeneration,
53
+ type HarnessRead,
54
+ type HarnessSandbox,
55
+ type HarnessStep,
56
+ type HarnessTurn,
57
+ pending,
58
+ readHarness,
59
+ type RecordedGeneration,
60
+ saveHarness,
61
+ } from './ai-harness.ts'
62
+ import { type SchemaInput, schemaInput } from './ai-tool-input.ts'
63
+ import type { CompactionPartData, ToolResultPayload } from './ai.ts'
64
+ import { boundedCache } from './bounded-cache.ts'
65
+ import { ambientToolScopeStorage } from './internal.ts'
66
+
67
+ export type {
68
+ Harness,
69
+ HarnessSandbox,
70
+ HarnessSandboxContext,
71
+ } from './ai-harness.ts'
72
+
73
+ /** What `pi()` takes. */
74
+ export type PiOptions = {
75
+ /** The session's sandbox: Pi's tools, and the agent's, work in it. */
76
+ sandbox: HarnessSandbox
77
+ }
78
+
79
+ /**
80
+ * The Pi harness. Every model call Pi makes is one durable step; A2 rebuilds
81
+ * Pi's session for each step from its latest snapshot and the log since.
82
+ */
83
+ export function pi(options: PiOptions): Harness<'pi', Builtin> {
84
+ if (typeof options?.sandbox !== 'function')
85
+ throw new TypeError('pi() requires sandbox')
86
+ return defineHarness<'pi', Builtin>('pi', {
87
+ sandbox: options.sandbox,
88
+ tools: builtinTools,
89
+ step: (step) =>
90
+ runStep(step, async () =>
91
+ options.sandbox({
92
+ session: { id: step.sessionId },
93
+ signal: step.signal,
94
+ }),
95
+ ),
96
+ })
97
+ }
98
+
99
+ const SNAPSHOT_VERSION = 1
100
+ const GATEWAY = 'vercel-ai-gateway'
101
+ // The most a file read or a command's output moves from the sandbox to the
102
+ // server. Pi's tools show the model 50 KB at a time, after reading it all.
103
+ const MAX_TRANSFER_BYTES = 4 * 1024 * 1024
104
+ const MAX_TRANSFER = '4 MiB'
105
+ // Pi's default tools.
106
+ const BUILTINS = ['read', 'write', 'edit', 'bash'] as const
107
+ type Builtin = (typeof BUILTINS)[number]
108
+
109
+ type PiModules = {
110
+ agent: typeof import('@earendil-works/pi-coding-agent')
111
+ ai: typeof import('@earendil-works/pi-ai')
112
+ }
113
+
114
+ /**
115
+ * A content block without the text the transcript already holds. A tool call
116
+ * keeps its arguments only where the transcript's input is not the model's.
117
+ */
118
+ type StoredBlock =
119
+ | (Omit<TextContent, 'text'> & { ref: string })
120
+ | (Omit<ThinkingContent, 'thinking'> & { ref: string })
121
+ | (Omit<ToolCall, 'arguments'> & Partial<Pick<ToolCall, 'arguments'>>)
122
+
123
+ /** What a step's transcript parts do not hold of Pi's response. */
124
+ type StoredResponse = Omit<AssistantMessage, 'content'> & {
125
+ content: StoredBlock[]
126
+ }
127
+
128
+ /** The transient record of one model call, for replay. */
129
+ type PiRecord =
130
+ | { call: 'step'; response: StoredResponse }
131
+ | { call: 'compaction'; response: AssistantMessage }
132
+
133
+ type RecordedCall = {
134
+ kind: 'step' | 'compaction'
135
+ response: AssistantMessage
136
+ /** Steering to queue while serving this call. */
137
+ steers: string[]
138
+ }
139
+
140
+ type Saved = {
141
+ cwd: string
142
+ header: SessionHeader
143
+ entries: SessionEntry[]
144
+ }
145
+
146
+ type Completion = { finishReason?: FinishReason; usage?: LanguageModelUsage }
147
+
148
+ /** The turn being replayed or run live, as the model and tool hooks see it. */
149
+ type TurnState = {
150
+ plan: HarnessTurn
151
+ calls: RecordedCall[]
152
+ next: number
153
+ liveStep: boolean
154
+ /** The live step is over; Pi's run is being stopped. */
155
+ over: boolean
156
+ failure?: Error
157
+ /** Resolves when the live step is over: Pi asked for its tools or another step. */
158
+ stepOver: Deferred
159
+ /** Live compaction responses, in request order. */
160
+ compactions: AssistantMessage[]
161
+ }
162
+
163
+ function endStep(state: TurnState): void {
164
+ state.over = true
165
+ state.stepOver.resolve()
166
+ }
167
+
168
+ const diverged = (message: string): Divergence => new Divergence('Pi', message)
169
+
170
+ // ---------------------------------------------------------------- step
171
+
172
+ async function runStep(
173
+ step: HarnessStep,
174
+ sandbox: () => Promise<Experimental_SandboxSession>,
175
+ ): Promise<HarnessGeneration> {
176
+ const modules = await loadPi()
177
+ const credential = gatewayCredential('pi')
178
+ const name = `${step.agentName}.a2.harness.pi`
179
+ const { replayAll, ...first } = await readHarness({
180
+ step,
181
+ name,
182
+ parse: savedSession,
183
+ })
184
+ let read: HarnessRead<Saved> = first
185
+ let results = read.results
186
+ const cwd =
187
+ read.saved?.cwd ??
188
+ (await sessionCwd({
189
+ key: cwdKey(step.agentName, step.sessionId),
190
+ sandbox,
191
+ }))
192
+
193
+ let completion: Completion = {}
194
+ let session: AgentSession | undefined
195
+ let compacting = false
196
+ let turn: TurnState | undefined
197
+
198
+ const fail = (state: TurnState, error: Error): never => {
199
+ state.failure ??= error
200
+ endStep(state)
201
+ void session?.abort()
202
+ throw state.failure
203
+ }
204
+
205
+ let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
206
+ let closed = false
207
+ const emit = (chunk: UIMessageChunk): void => {
208
+ if (!closed) controller!.enqueue(chunk)
209
+ }
210
+ const record = (data: PiRecord): void =>
211
+ emit({ type: 'data-a2-pi', data, transient: true })
212
+
213
+ // ------------------------------------------------------ model calls
214
+
215
+ const answer = async (
216
+ out: AssistantMessageEventStream,
217
+ model: Parameters<ModelRuntime['streamSimple']>[0],
218
+ context: Parameters<ModelRuntime['streamSimple']>[1],
219
+ options: Parameters<ModelRuntime['streamSimple']>[2],
220
+ ): Promise<void> => {
221
+ const state = turn!
222
+ if (state.failure) throw state.failure
223
+ // Stopping Pi runs its turn-end hooks; nothing they ask the model counts.
224
+ if (state.over) {
225
+ replay(out, interrupted(model))
226
+ return
227
+ }
228
+ const kind = compacting ? 'compaction' : 'step'
229
+ const recorded = state.calls[state.next]
230
+ if (recorded !== undefined) {
231
+ if (recorded.kind !== kind)
232
+ fail(state, diverged(`call ${state.next} is not a ${kind}`))
233
+ state.next++
234
+ // Steering queued while a response streams enters after its tools.
235
+ for (const text of recorded.steers) await session!.steer(text)
236
+ replay(out, recorded.response)
237
+ return
238
+ }
239
+ if (state.plan.end === 'stop')
240
+ fail(state, diverged('Pi continued a turn the log ended'))
241
+ if (state.plan.end === 'interrupted') {
242
+ replay(out, interrupted(model))
243
+ return
244
+ }
245
+ if (kind === 'step' && state.liveStep) {
246
+ // One generation is one step; the next step is A2's next generation.
247
+ endStep(state)
248
+ replay(out, interrupted(model))
249
+ return
250
+ }
251
+ if (kind === 'step') state.liveStep = true
252
+ const upstream = realStream(model, context, {
253
+ ...options,
254
+ headers: { ...options?.headers, ...gatewayHeaders(credential) },
255
+ })
256
+ const live = kind === 'step' ? liveStep() : undefined
257
+ for await (const event of upstream) {
258
+ await live?.(event)
259
+ if (event.type === 'done' || event.type === 'error') {
260
+ const response = event.type === 'done' ? event.message : event.error
261
+ if (kind === 'compaction') state.compactions.push(response)
262
+ // A2 retries the step; Pi's own recovery would run outside it.
263
+ if (event.type === 'error' && event.reason === 'error') {
264
+ state.failure ??= new Error(
265
+ response.errorMessage ?? 'the Pi model call failed',
266
+ )
267
+ endStep(state)
268
+ }
269
+ }
270
+ out.push(event)
271
+ }
272
+ }
273
+
274
+ /**
275
+ * A live call's input as Pi's loop, then the AI SDK step, read it. Pi
276
+ * answers a call to no tool, or whose arguments it cannot prepare or
277
+ * validate, itself. The agent's tool reads the arguments Pi accepts through
278
+ * its own schema; A2 runs Pi's own tools from the model's arguments, as
279
+ * Pi's loop does.
280
+ */
281
+ const callInput = async (block: ToolCall): Promise<SchemaInput> => {
282
+ const definition = tools.find((tool) => tool.name === block.name)
283
+ if (definition === undefined)
284
+ return { success: false, errorText: `Tool ${block.name} not found` }
285
+ let args: unknown
286
+ try {
287
+ const prepared = definition.prepareArguments?.(block.arguments)
288
+ args = modules.ai.validateToolArguments(
289
+ definition,
290
+ prepared === undefined
291
+ ? block
292
+ : { ...block, arguments: prepared as JsonObject },
293
+ )
294
+ } catch (error) {
295
+ return { success: false, errorText: asError(error).message }
296
+ }
297
+ if (builtinOf(step.tools, block.name) !== undefined)
298
+ return { success: true, value: block.arguments }
299
+ return schemaInput({ tools: step.tools, toolName: block.name, value: args })
300
+ }
301
+
302
+ /** Streams one live step response as UI chunks and records it. */
303
+ const liveStep = () => {
304
+ const ref = (index: number) => `pi:${step.requestIndex}:${index}`
305
+ const started = new Set<string>()
306
+ return async (event: AssistantMessageEvent): Promise<void> => {
307
+ switch (event.type) {
308
+ case 'start':
309
+ emit({ type: 'start-step' })
310
+ break
311
+ case 'text_start':
312
+ emit({ type: 'text-start', id: ref(event.contentIndex) })
313
+ break
314
+ case 'text_delta':
315
+ emit({
316
+ type: 'text-delta',
317
+ id: ref(event.contentIndex),
318
+ delta: event.delta,
319
+ })
320
+ break
321
+ case 'text_end':
322
+ emit({ type: 'text-end', id: ref(event.contentIndex) })
323
+ break
324
+ case 'thinking_start':
325
+ emit({ type: 'reasoning-start', id: ref(event.contentIndex) })
326
+ break
327
+ case 'thinking_delta':
328
+ emit({
329
+ type: 'reasoning-delta',
330
+ id: ref(event.contentIndex),
331
+ delta: event.delta,
332
+ })
333
+ break
334
+ case 'thinking_end':
335
+ emit({ type: 'reasoning-end', id: ref(event.contentIndex) })
336
+ break
337
+ case 'toolcall_start':
338
+ case 'toolcall_delta': {
339
+ const call = event.partial.content[event.contentIndex] as ToolCall
340
+ if (!call.id) break
341
+ if (!started.has(call.id)) {
342
+ started.add(call.id)
343
+ emit({
344
+ type: 'tool-input-start',
345
+ toolCallId: call.id,
346
+ toolName: call.name,
347
+ })
348
+ }
349
+ if (event.type === 'toolcall_delta')
350
+ emit({
351
+ type: 'tool-input-delta',
352
+ toolCallId: call.id,
353
+ inputTextDelta: event.delta,
354
+ })
355
+ break
356
+ }
357
+ case 'done':
358
+ case 'error': {
359
+ const response = event.type === 'done' ? event.message : event.error
360
+ // Calls whose transcript input is not the model's arguments.
361
+ const changed = new Set<string>()
362
+ if (event.type === 'done') {
363
+ for (const block of response.content) {
364
+ if (block.type !== 'toolCall') continue
365
+ if (!started.has(block.id))
366
+ emit({
367
+ type: 'tool-input-start',
368
+ toolCallId: block.id,
369
+ toolName: block.name,
370
+ })
371
+ // Pi does not run the calls of a cut-off response.
372
+ const input: SchemaInput =
373
+ response.stopReason === 'length'
374
+ ? {
375
+ success: false,
376
+ errorText:
377
+ 'The response hit the output token limit before this call was complete.',
378
+ }
379
+ : await callInput(block)
380
+ if (
381
+ input.success &&
382
+ JSON.stringify(input.value) !== JSON.stringify(block.arguments)
383
+ )
384
+ changed.add(block.id)
385
+ emit(
386
+ input.success
387
+ ? {
388
+ type: 'tool-input-available',
389
+ toolCallId: block.id,
390
+ toolName: block.name,
391
+ input: input.value,
392
+ }
393
+ : {
394
+ type: 'tool-input-error',
395
+ toolCallId: block.id,
396
+ toolName: block.name,
397
+ input: block.arguments,
398
+ errorText: input.errorText,
399
+ },
400
+ )
401
+ }
402
+ }
403
+ record({
404
+ call: 'step',
405
+ response: storedResponse(response, ref, changed),
406
+ })
407
+ completion = {
408
+ finishReason: finishReason(response.stopReason),
409
+ usage: modelUsage(response.usage),
410
+ }
411
+ break
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ // ----------------------------------------------------- tool results
418
+
419
+ const toolResult = async (
420
+ toolCallId: string,
421
+ signal: AbortSignal | undefined,
422
+ shape: 'pi' | 'tool',
423
+ ): Promise<AgentToolResult<unknown>> => {
424
+ const state = turn!
425
+ if (state.failure) throw state.failure
426
+ const result = results.get(toolCallId)
427
+ if (result !== undefined) return answeredTool(result, shape)
428
+ if (state.plan.end === 'live' && state.liveStep) {
429
+ // Pi now waits on tools that A2's tool handlers run.
430
+ endStep(state)
431
+ return pending(signal)
432
+ }
433
+ if (state.plan.end === 'interrupted') {
434
+ void session!.abort()
435
+ return pending(signal)
436
+ }
437
+ return fail(state, diverged(`no recorded result for ${toolCallId}`))
438
+ }
439
+
440
+ // ------------------------------------------------------------ run
441
+
442
+ const runtime = await modules.agent.ModelRuntime.create({
443
+ credentials: new modules.ai.InMemoryCredentialStore(),
444
+ modelsPath: null,
445
+ allowModelNetwork: false,
446
+ refreshOnCreate: false,
447
+ })
448
+ await runtime.setRuntimeApiKey(GATEWAY, credential.token)
449
+ const model = runtime.getModel(GATEWAY, step.model)
450
+ if (model === undefined)
451
+ throw new Error(`Pi does not know the Gateway model ${step.model}`)
452
+ const realStream = runtime.streamSimple.bind(runtime)
453
+ runtime.streamSimple = (requested, context, options?) => {
454
+ const out = modules.ai.createAssistantMessageEventStream()
455
+ answer(out, requested, context, options).catch((error: unknown) => {
456
+ const state = turn
457
+ if (state) state.failure ??= asError(error)
458
+ replay(out, { ...interrupted(requested), stopReason: 'error' })
459
+ })
460
+ return out
461
+ }
462
+
463
+ const tools = await sessionTools(modules, step.tools, cwd, toolResult)
464
+
465
+ const createSession = async (from: Saved | undefined) => {
466
+ const sessionManager =
467
+ from === undefined
468
+ ? modules.agent.SessionManager.inMemory(cwd, {
469
+ id: sessionUuid(step.agentName, step.sessionId),
470
+ })
471
+ : modules.agent.SessionManager.inMemory(cwd, { id: from.header.id }, [
472
+ from.header,
473
+ ...from.entries,
474
+ ])
475
+ const created = await modules.agent.createAgentSession({
476
+ cwd,
477
+ // Pi reads nothing there: every resource and manager comes from A2.
478
+ agentDir: join(tmpdir(), 'a2-pi'),
479
+ model,
480
+ ...thinkingLevel(step),
481
+ modelRuntime: runtime,
482
+ resourceLoader: resources(modules, step.instructions),
483
+ sessionManager,
484
+ settingsManager: modules.agent.SettingsManager.inMemory({
485
+ // A2 retries failed steps.
486
+ retry: { enabled: false, maxRetries: 0, baseDelayMs: 0 },
487
+ cacheWarming: 'off',
488
+ }),
489
+ customTools: tools,
490
+ tools: tools.map((definition) => definition.name),
491
+ })
492
+ created.session.subscribe((event) => {
493
+ if (event.type === 'compaction_start') compacting = true
494
+ if (event.type !== 'compaction_end') return
495
+ compacting = false
496
+ const state = turn
497
+ if (state === undefined || state.over || state.compactions.length === 0)
498
+ return
499
+ for (const response of state.compactions.splice(0))
500
+ record({ call: 'compaction', response })
501
+ emit({
502
+ type: 'data-a2-compaction',
503
+ id: `pi-compaction:${step.requestIndex}:${compactions++}`,
504
+ data: compactionData(event),
505
+ })
506
+ })
507
+ return created.session
508
+ }
509
+ let compactions = 0
510
+
511
+ const saveSnapshot = async (): Promise<void> => {
512
+ const manager = session!.sessionManager
513
+ const header = manager.getHeader()
514
+ const entries = manager.getEntries()
515
+ const last = entries.findLast(
516
+ (entry) => entry.type === 'message' && entry.message.role === 'assistant',
517
+ )
518
+ const stop =
519
+ last?.type === 'message' && last.message.role === 'assistant'
520
+ ? last.message.stopReason
521
+ : undefined
522
+ // Only a turn this attempt finished describes the log.
523
+ if (
524
+ header === null ||
525
+ step.signal.aborted ||
526
+ stop === 'aborted' ||
527
+ stop === 'error'
528
+ )
529
+ return
530
+ await saveHarness({
531
+ step,
532
+ name,
533
+ state: { version: SNAPSHOT_VERSION, cwd, header, entries },
534
+ })
535
+ }
536
+
537
+ const run = async (): Promise<void> => {
538
+ emit({ type: 'start', messageId: step.responseMessageId })
539
+ try {
540
+ session = await createSession(read.saved)
541
+ } catch (error) {
542
+ if (read.saved === undefined) throw error
543
+ // An unusable snapshot: replay the whole log instead.
544
+ read = await replayAll()
545
+ results = read.results
546
+ session = await createSession(undefined)
547
+ }
548
+ for (const plan of read.turns) {
549
+ if (step.signal.aborted) return
550
+ const state: TurnState = {
551
+ plan,
552
+ calls: recordedTurn(plan),
553
+ next: 0,
554
+ liveStep: false,
555
+ over: false,
556
+ stepOver: deferred(),
557
+ compactions: [],
558
+ }
559
+ turn = state
560
+ const running = session.prompt(plan.prompt, {
561
+ expandPromptTemplates: false,
562
+ })
563
+ if (plan.end !== 'live') {
564
+ await running.catch((error: unknown) => {
565
+ throw state.failure ?? error
566
+ })
567
+ if (state.failure) throw state.failure
568
+ continue
569
+ }
570
+ const ended = await Promise.race([
571
+ running.then(() => true),
572
+ state.stepOver.promise.then(() => false),
573
+ ]).catch((error: unknown) => {
574
+ throw state.failure ?? error
575
+ })
576
+ if (!ended) {
577
+ await session.abort()
578
+ await running.catch(() => {})
579
+ }
580
+ if (state.failure) throw state.failure
581
+ if (ended) await saveSnapshot()
582
+ emit({ type: 'finish-step' })
583
+ emit({
584
+ type: 'finish',
585
+ ...(completion.finishReason === undefined
586
+ ? {}
587
+ : { finishReason: completion.finishReason }),
588
+ })
589
+ }
590
+ }
591
+
592
+ const stream = new ReadableStream<UIMessageChunk>({
593
+ start(streamController) {
594
+ controller = streamController
595
+ const abort = () => void session?.abort()
596
+ step.signal.addEventListener('abort', abort, { once: true })
597
+ void (async () => {
598
+ try {
599
+ await run()
600
+ closed = true
601
+ streamController.close()
602
+ } catch (error) {
603
+ closed = true
604
+ streamController.error(
605
+ step.signal.aborted ? step.signal.reason : error,
606
+ )
607
+ } finally {
608
+ step.signal.removeEventListener('abort', abort)
609
+ session?.dispose()
610
+ }
611
+ })()
612
+ },
613
+ cancel() {
614
+ closed = true
615
+ void session?.abort()
616
+ },
617
+ })
618
+
619
+ return { stream, completion: () => completion }
620
+ }
621
+
622
+ // ------------------------------------------------------------ the plan
623
+
624
+ /** A turn's recorded model calls, in the order Pi made them. */
625
+ function recordedTurn(turn: HarnessTurn): RecordedCall[] {
626
+ const calls: RecordedCall[] = []
627
+ turn.generations.forEach((generation, index) => {
628
+ calls.push(...recordedCalls(generation))
629
+ const steers = turn.steers.get(index)
630
+ const last = calls.findLast((call) => call.kind === 'step')
631
+ if (steers !== undefined && last !== undefined) last.steers.push(...steers)
632
+ })
633
+ return calls
634
+ }
635
+
636
+ function recordedCalls(generation: RecordedGeneration): RecordedCall[] {
637
+ const texts = new Map<string, string>()
638
+ const inputs = new Map<string, unknown>()
639
+ const calls: RecordedCall[] = []
640
+ for (const chunk of generation.chunks) {
641
+ switch (chunk.type) {
642
+ case 'text-delta':
643
+ case 'reasoning-delta':
644
+ texts.set(chunk.id, (texts.get(chunk.id) ?? '') + chunk.delta)
645
+ break
646
+ case 'tool-input-available':
647
+ case 'tool-input-error':
648
+ inputs.set(chunk.toolCallId, chunk.input)
649
+ break
650
+ case 'data-a2-pi': {
651
+ const record = chunk.data as PiRecord
652
+ calls.push({
653
+ kind: record.call,
654
+ response:
655
+ record.call === 'step'
656
+ ? restoredResponse(record.response, texts, inputs)
657
+ : record.response,
658
+ steers: [],
659
+ })
660
+ break
661
+ }
662
+ }
663
+ }
664
+ if (!calls.some((call) => call.kind === 'step'))
665
+ throw diverged('a completed generation has no recorded response')
666
+ return calls
667
+ }
668
+
669
+ function storedResponse(
670
+ response: AssistantMessage,
671
+ ref: (index: number) => string,
672
+ changed: ReadonlySet<string>,
673
+ ): StoredResponse {
674
+ return {
675
+ ...response,
676
+ content: response.content.map((block, index): StoredBlock => {
677
+ switch (block.type) {
678
+ case 'text': {
679
+ const { text: _, ...rest } = block
680
+ return { ...rest, ref: ref(index) }
681
+ }
682
+ case 'thinking': {
683
+ const { thinking: _, ...rest } = block
684
+ return { ...rest, ref: ref(index) }
685
+ }
686
+ case 'toolCall': {
687
+ if (changed.has(block.id)) return block
688
+ const { arguments: _, ...rest } = block
689
+ return rest
690
+ }
691
+ }
692
+ }),
693
+ }
694
+ }
695
+
696
+ function restoredResponse(
697
+ stored: StoredResponse,
698
+ texts: ReadonlyMap<string, string>,
699
+ inputs: ReadonlyMap<string, unknown>,
700
+ ): AssistantMessage {
701
+ return {
702
+ ...stored,
703
+ content: stored.content.map((block) => {
704
+ if (block.type === 'toolCall')
705
+ return {
706
+ ...block,
707
+ arguments:
708
+ block.arguments ?? ((inputs.get(block.id) ?? {}) as JsonObject),
709
+ }
710
+ const { ref, ...rest } = block
711
+ const text = texts.get(ref) ?? ''
712
+ return rest.type === 'text'
713
+ ? { ...rest, text }
714
+ : { ...rest, thinking: text }
715
+ }),
716
+ }
717
+ }
718
+
719
+ /** Hands a recorded response to Pi's loop. */
720
+ function replay(out: AssistantMessageEventStream, response: AssistantMessage) {
721
+ out.push({ type: 'start', partial: response })
722
+ const reason = response.stopReason
723
+ if (reason === 'error' || reason === 'aborted')
724
+ out.push({ type: 'error', reason, error: response })
725
+ else if (reason === 'pending')
726
+ out.push({ type: 'done', reason: 'stop', message: response })
727
+ else out.push({ type: 'done', reason, message: response })
728
+ }
729
+
730
+ /** The response of a model call that A2 interrupted. */
731
+ function interrupted(
732
+ model: Parameters<ModelRuntime['streamSimple']>[0],
733
+ ): AssistantMessage {
734
+ return {
735
+ role: 'assistant',
736
+ content: [],
737
+ api: model.api,
738
+ provider: model.provider,
739
+ model: model.id,
740
+ usage: {
741
+ input: 0,
742
+ output: 0,
743
+ cacheRead: 0,
744
+ cacheWrite: 0,
745
+ totalTokens: 0,
746
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
747
+ },
748
+ stopReason: 'aborted',
749
+ timestamp: Date.now(),
750
+ }
751
+ }
752
+
753
+ // ----------------------------------------------------------- the tools
754
+
755
+ const builtinTools: ToolSet = Object.fromEntries(
756
+ BUILTINS.map((name): [string, Tool] => [
757
+ name,
758
+ {
759
+ inputSchema: jsonSchema(
760
+ async () =>
761
+ builtinDefinition(await loadPi(), name, '/')
762
+ .parameters as JSONSchema7,
763
+ ),
764
+ execute: (input: unknown, options: ToolExecutionOptions<unknown>) =>
765
+ runBuiltin(name, input, options),
766
+ },
767
+ ]),
768
+ )
769
+
770
+ /** Runs one of Pi's tools in the session's sandbox, as Pi's loop would. */
771
+ async function runBuiltin(
772
+ name: Builtin,
773
+ input: unknown,
774
+ options: ToolExecutionOptions<unknown>,
775
+ ): Promise<AgentToolResult<unknown>> {
776
+ const sandbox = options.experimental_sandbox
777
+ if (sandbox === undefined)
778
+ throw new Error(`pi() runs ${name} in the agent's sandbox`)
779
+ const modules = await loadPi()
780
+ const scope = ambientToolScopeStorage()?.getStore()
781
+ const event = (scope?.context as { event?: { sessionId?: unknown } })?.event
782
+ const cwd = await sessionCwd({
783
+ key:
784
+ scope?.contract !== undefined && typeof event?.sessionId === 'string'
785
+ ? cwdKey(scope.contract.name, event.sessionId)
786
+ : undefined,
787
+ sandbox: async () => sandbox,
788
+ })
789
+ const definition = builtinDefinition(modules, name, cwd, {
790
+ sandbox,
791
+ signal: options.abortSignal,
792
+ })
793
+ const args: unknown = modules.ai.validateToolArguments(definition, {
794
+ type: 'toolCall',
795
+ id: options.toolCallId,
796
+ name,
797
+ arguments: (definition.prepareArguments?.(input) ?? input) as JsonObject,
798
+ })
799
+ // Pi's bash keeps a long output's full text in a local file, named in its
800
+ // updates and its result. It is removed however the call ends.
801
+ let outputFile: string | undefined
802
+ try {
803
+ const result = await definition.execute(
804
+ options.toolCallId,
805
+ args,
806
+ options.abortSignal,
807
+ (update) => {
808
+ outputFile ??= fullOutputPath(update)
809
+ },
810
+ // Pi's tools take their context from the session, which A2 runs elsewhere.
811
+ undefined as never,
812
+ )
813
+ outputFile ??= fullOutputPath(result)
814
+ return await recordedResult(result, sandbox)
815
+ } catch (error) {
816
+ // Output whose last bytes tip it over Pi's limit opens the file only as
817
+ // it ends, after the last update; a failed call then names it in its error.
818
+ outputFile ??= namedOutputFile(error)
819
+ throw error
820
+ } finally {
821
+ if (outputFile !== undefined) await unlink(outputFile).catch(() => {})
822
+ }
823
+ }
824
+
825
+ type BuiltinDetails =
826
+ { truncation?: { content?: unknown }; fullOutputPath?: unknown } | undefined
827
+
828
+ const fullOutputPath = (
829
+ result: AgentToolResult<unknown>,
830
+ ): string | undefined => {
831
+ const path = (result.details as BuiltinDetails)?.fullOutputPath
832
+ return typeof path === 'string' ? path : undefined
833
+ }
834
+
835
+ /** The full-output file an aborted or timed-out bash call's error names. */
836
+ function namedOutputFile(error: unknown): string | undefined {
837
+ if (!(error instanceof Error)) return undefined
838
+ const path = /Full output: ([^\n]+)\]\n\nCommand [^\n]+$/.exec(
839
+ error.message,
840
+ )?.[1]
841
+ // The command's own output precedes Pi's note, and could name any file.
842
+ return path !== undefined &&
843
+ dirname(path) === tmpdir() &&
844
+ /^pi-bash-[0-9a-f]{16}\.log$/.test(basename(path))
845
+ ? path
846
+ : undefined
847
+ }
848
+
849
+ /**
850
+ * What the log keeps of a result: what Pi's tool-result message holds. A
851
+ * long output's full text moves to the path its result names in the sandbox,
852
+ * where the model's `read` finds it.
853
+ */
854
+ async function recordedResult(
855
+ result: AgentToolResult<unknown>,
856
+ sandbox: Experimental_SandboxSession,
857
+ ): Promise<AgentToolResult<unknown>> {
858
+ // Pi's tool-result message does not carry structured content.
859
+ const { structuredContent: _, ...kept } =
860
+ result as AgentToolResult<unknown> & {
861
+ structuredContent?: unknown
862
+ }
863
+ const details = kept.details as BuiltinDetails
864
+ const path = fullOutputPath(kept)
865
+ if (path !== undefined) {
866
+ try {
867
+ await sandbox.writeBinaryFile({
868
+ path,
869
+ content: await readLocalFile(path),
870
+ })
871
+ } catch {
872
+ // The result stands without it; the model can rerun the command.
873
+ }
874
+ }
875
+ if (details?.truncation === undefined) return kept
876
+ // The truncated text is the result's content already.
877
+ const { content: __, ...truncation } = details.truncation
878
+ return { ...kept, details: { ...details, truncation } }
879
+ }
880
+
881
+ /** The Pi tool a name in the agent's tool set runs, unless the agent replaced it. */
882
+ const builtinOf = (tools: ToolSet, name: string): Builtin | undefined =>
883
+ BUILTINS.find(
884
+ (candidate) =>
885
+ candidate === name &&
886
+ Object.hasOwn(tools, name) &&
887
+ tools[name] === builtinTools[name],
888
+ )
889
+
890
+ // Each definition is typed by its parameters; a session takes any of them.
891
+ const untyped = (definition: object) => definition as ToolDefinition
892
+
893
+ function builtinDefinition(
894
+ modules: PiModules,
895
+ name: Builtin,
896
+ cwd: string,
897
+ call?: {
898
+ sandbox: Experimental_SandboxSession
899
+ signal: AbortSignal | undefined
900
+ },
901
+ ): ToolDefinition {
902
+ const sandbox = call?.sandbox
903
+ const files =
904
+ call === undefined ? undefined : { operations: sandboxFiles(call) }
905
+ switch (name) {
906
+ case 'read':
907
+ return untyped(modules.agent.createReadToolDefinition(cwd, files))
908
+ case 'write':
909
+ return untyped(modules.agent.createWriteToolDefinition(cwd, files))
910
+ case 'edit':
911
+ return untyped(modules.agent.createEditToolDefinition(cwd, files))
912
+ case 'bash':
913
+ return untyped(
914
+ modules.agent.createBashToolDefinition(cwd, {
915
+ // No PI_* variables reach the sandbox, so Pi's prompt names none.
916
+ exposeSessionEnvironment: false,
917
+ ...(sandbox === undefined
918
+ ? {}
919
+ : { operations: sandboxBash(sandbox) }),
920
+ }),
921
+ )
922
+ }
923
+ }
924
+
925
+ /**
926
+ * The tools Pi's session declares: Pi's own, and the agent's. Their results
927
+ * come from A2's log; A2's tool handlers ran them.
928
+ */
929
+ async function sessionTools(
930
+ modules: PiModules,
931
+ tools: ToolSet,
932
+ cwd: string,
933
+ result: (
934
+ toolCallId: string,
935
+ signal: AbortSignal | undefined,
936
+ shape: 'pi' | 'tool',
937
+ ) => Promise<AgentToolResult<unknown>>,
938
+ ): Promise<ToolDefinition[]> {
939
+ return Promise.all(
940
+ Object.entries(tools)
941
+ .filter(([, tool]) => tool.type !== 'provider')
942
+ .map(async ([name, tool]): Promise<ToolDefinition> => {
943
+ const builtin = builtinOf(tools, name)
944
+ if (builtin !== undefined)
945
+ // Pi's own definition, for its prompt; A2 already ran the call.
946
+ return Object.assign(builtinDefinition(modules, builtin, cwd), {
947
+ execute: (
948
+ toolCallId: string,
949
+ _params: unknown,
950
+ signal?: AbortSignal,
951
+ ) => result(toolCallId, signal, 'pi'),
952
+ })
953
+ return {
954
+ name,
955
+ label: name,
956
+ description:
957
+ typeof tool.description === 'string' ? tool.description : '',
958
+ parameters: (await asSchema(tool.inputSchema)
959
+ .jsonSchema) as ToolDefinition['parameters'],
960
+ execute: (toolCallId, _params, signal) =>
961
+ result(toolCallId, signal, 'tool'),
962
+ }
963
+ }),
964
+ )
965
+ }
966
+
967
+ function answeredTool(
968
+ result: ToolResultPayload,
969
+ shape: 'pi' | 'tool',
970
+ ): AgentToolResult<unknown> {
971
+ if (result.error !== undefined) throw new Error(result.error)
972
+ if (result.denied === true) throw new Error('The tool call was denied.')
973
+ if (shape === 'pi') return result.output as AgentToolResult<unknown>
974
+ const output = result.output
975
+ return {
976
+ content: [
977
+ {
978
+ type: 'text',
979
+ text:
980
+ typeof output === 'string' ? output : (JSON.stringify(output) ?? ''),
981
+ },
982
+ ],
983
+ details: undefined,
984
+ }
985
+ }
986
+
987
+ // ------------------------------------------------------------ sandbox
988
+
989
+ /** Pi's file operations, against the sandbox. */
990
+ function sandboxFiles({
991
+ sandbox,
992
+ signal,
993
+ }: {
994
+ sandbox: Experimental_SandboxSession
995
+ /** The tool call's: it stops a read or write in flight. */
996
+ signal: AbortSignal | undefined
997
+ }) {
998
+ const abortSignal = signal === undefined ? {} : { abortSignal: signal }
999
+ let checked: { path: string; bytes: Buffer } | undefined
1000
+ const readFile = async (path: string): Promise<Buffer> => {
1001
+ if (checked?.path === path) {
1002
+ const { bytes } = checked
1003
+ checked = undefined
1004
+ return bytes
1005
+ }
1006
+ const stream = await sandbox.readFile({ path, ...abortSignal })
1007
+ if (stream === null)
1008
+ throw Object.assign(
1009
+ new Error(`ENOENT: no such file or directory, open '${path}'`),
1010
+ { code: 'ENOENT' },
1011
+ )
1012
+ const chunks: Uint8Array[] = []
1013
+ let size = 0
1014
+ // Leaving the loop cancels the stream.
1015
+ for await (const chunk of stream) {
1016
+ size += chunk.byteLength
1017
+ if (size > MAX_TRANSFER_BYTES)
1018
+ throw new Error(
1019
+ `${path} is larger than ${MAX_TRANSFER}. Read or change parts of it with bash, using sed, head, or grep.`,
1020
+ )
1021
+ chunks.push(chunk)
1022
+ }
1023
+ return Buffer.concat(chunks)
1024
+ }
1025
+ return {
1026
+ readFile,
1027
+ // Pi checks a file just before reading it; the check's read serves both.
1028
+ access: async (path: string): Promise<void> => {
1029
+ checked = { path, bytes: await readFile(path) }
1030
+ },
1031
+ writeFile: async (path: string, content: string): Promise<void> => {
1032
+ checked = undefined
1033
+ await sandbox.writeTextFile({ path, content, ...abortSignal })
1034
+ },
1035
+ // The sandbox creates parent directories as it writes.
1036
+ mkdir: async (): Promise<void> => {},
1037
+ }
1038
+ }
1039
+
1040
+ /** Pi's shell operations, against the sandbox. */
1041
+ const sandboxBash = (sandbox: Experimental_SandboxSession): BashOperations => ({
1042
+ // Pi hands over the server's environment; it never reaches the sandbox.
1043
+ exec: (command, cwd, { onData, signal, timeout }) =>
1044
+ sandboxExec({
1045
+ sandbox,
1046
+ command,
1047
+ workingDirectory: cwd,
1048
+ onData,
1049
+ signal,
1050
+ timeout,
1051
+ }),
1052
+ })
1053
+
1054
+ /**
1055
+ * Runs a command in the sandbox with Pi's error contract. At most
1056
+ * `MAX_TRANSFER_BYTES` of its output reach the server; past that, the
1057
+ * command is stopped.
1058
+ */
1059
+ async function sandboxExec({
1060
+ sandbox,
1061
+ command,
1062
+ workingDirectory,
1063
+ onData,
1064
+ signal,
1065
+ timeout,
1066
+ }: {
1067
+ sandbox: Experimental_SandboxSession
1068
+ command: string
1069
+ /** The sandbox's own when absent. */
1070
+ workingDirectory?: string
1071
+ onData: (data: Buffer, stream: 'stdout' | 'stderr') => void
1072
+ signal?: AbortSignal | undefined
1073
+ /** In seconds. */
1074
+ timeout?: number | undefined
1075
+ }): Promise<{ exitCode: number }> {
1076
+ if (signal?.aborted) throw new Error('aborted')
1077
+ const stop = new AbortController()
1078
+ const abort = () => stop.abort()
1079
+ signal?.addEventListener('abort', abort, { once: true })
1080
+ let timedOut = false
1081
+ const timer =
1082
+ timeout === undefined || timeout <= 0
1083
+ ? undefined
1084
+ : setTimeout(() => {
1085
+ timedOut = true
1086
+ stop.abort()
1087
+ }, timeout * 1000)
1088
+ let size = 0
1089
+ let overflowed = false
1090
+ const overflow = (options?: ErrorOptions) =>
1091
+ new Error(
1092
+ `The command's output passed ${MAX_TRANSFER}, so it was stopped. Write long output to a file and read parts of it with head, tail, or grep.`,
1093
+ options,
1094
+ )
1095
+ let exitCode: number
1096
+ try {
1097
+ const process = await sandbox.spawn({
1098
+ command,
1099
+ ...(workingDirectory === undefined ? {} : { workingDirectory }),
1100
+ abortSignal: stop.signal,
1101
+ })
1102
+ const pipe = async (stream: 'stdout' | 'stderr') => {
1103
+ for await (const chunk of process[stream]) {
1104
+ size += chunk.byteLength
1105
+ if (size > MAX_TRANSFER_BYTES) {
1106
+ overflowed = true
1107
+ stop.abort()
1108
+ return
1109
+ }
1110
+ onData(Buffer.from(chunk), stream)
1111
+ }
1112
+ }
1113
+ const [exited] = await Promise.all([
1114
+ process.wait(),
1115
+ pipe('stdout'),
1116
+ pipe('stderr'),
1117
+ ])
1118
+ exitCode = exited.exitCode
1119
+ } catch (error) {
1120
+ if (signal?.aborted) throw new Error('aborted', { cause: error })
1121
+ if (timedOut) throw new Error(`timeout:${timeout}`, { cause: error })
1122
+ if (overflowed) throw overflow({ cause: error })
1123
+ throw error
1124
+ } finally {
1125
+ clearTimeout(timer)
1126
+ signal?.removeEventListener('abort', abort)
1127
+ }
1128
+ if (signal?.aborted) throw new Error('aborted')
1129
+ if (timedOut) throw new Error(`timeout:${timeout}`)
1130
+ if (overflowed) throw overflow()
1131
+ return { exitCode }
1132
+ }
1133
+
1134
+ // Sandbox resolvers may return a new object each time; sessions keep theirs.
1135
+ const cwds = boundedCache<string, Promise<string>>({
1136
+ maxEntries: 1000,
1137
+ maxBytes: 1000,
1138
+ })
1139
+
1140
+ const cwdKey = (agentName: string, sessionId: string): string =>
1141
+ `${agentName}\u001f${sessionId}`
1142
+
1143
+ /** A session's working directory in its sandbox: Pi's cwd. */
1144
+ function sessionCwd({
1145
+ key,
1146
+ sandbox,
1147
+ }: {
1148
+ /** The agent and session, when known; otherwise the sandbox is asked. */
1149
+ key: string | undefined
1150
+ sandbox: () => Promise<Experimental_SandboxSession>
1151
+ }): Promise<string> {
1152
+ const cached = key === undefined ? undefined : cwds.get(key)
1153
+ if (cached !== undefined) return cached
1154
+ const cwd = sandbox().then(async (resolved) => {
1155
+ // Bounded, unlike `run`: a login profile in the sandbox can print anything.
1156
+ const output = { stdout: '', stderr: '' }
1157
+ const decoder = { stdout: new TextDecoder(), stderr: new TextDecoder() }
1158
+ const { exitCode } = await sandboxExec({
1159
+ sandbox: resolved,
1160
+ command: 'pwd',
1161
+ onData: (data, stream) => {
1162
+ output[stream] += decoder[stream].decode(data, { stream: true })
1163
+ },
1164
+ }).catch((error: unknown) => {
1165
+ throw new Error("pi() could not read the sandbox's working directory", {
1166
+ cause: error,
1167
+ })
1168
+ })
1169
+ const path = output.stdout.trim()
1170
+ if (exitCode !== 0 || !path.startsWith('/'))
1171
+ throw new Error(
1172
+ `pi() could not read the sandbox's working directory: ${(output.stderr || output.stdout).slice(0, 1000)}`,
1173
+ )
1174
+ return path
1175
+ })
1176
+ if (key !== undefined) {
1177
+ cwds.set(key, cwd, 1)
1178
+ cwd.catch(() => cwds.delete(key))
1179
+ }
1180
+ return cwd
1181
+ }
1182
+
1183
+ // ------------------------------------------------------------- Pi
1184
+
1185
+ let loading: Promise<PiModules> | undefined
1186
+
1187
+ function loadPi(): Promise<PiModules> {
1188
+ loading ??= Promise.all([
1189
+ import('@earendil-works/pi-coding-agent'),
1190
+ import('@earendil-works/pi-ai'),
1191
+ ]).then(
1192
+ ([agent, ai]) => ({ agent, ai }),
1193
+ (error: unknown) => {
1194
+ loading = undefined
1195
+ throw new Error(
1196
+ 'pi() needs @earendil-works/pi-coding-agent and @earendil-works/pi-ai: install them',
1197
+ { cause: error },
1198
+ )
1199
+ },
1200
+ )
1201
+ return loading
1202
+ }
1203
+
1204
+ /** Pi's resources: nothing discovered from disk, the agent's instructions appended. */
1205
+ function resources(modules: PiModules, instructions: string[]): ResourceLoader {
1206
+ const runtime = modules.agent.createExtensionRuntime()
1207
+ return {
1208
+ getExtensions: () => ({ extensions: [], errors: [], runtime }),
1209
+ getSkills: () => ({ skills: [], diagnostics: [] }),
1210
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
1211
+ getThemes: () => ({ themes: [], diagnostics: [] }),
1212
+ getAgentsFiles: () => ({ agentsFiles: [] }),
1213
+ getSystemPrompt: () => undefined,
1214
+ getSystemPromptSource: () => undefined,
1215
+ getAppendSystemPrompt: () => instructions,
1216
+ getAppendSystemPromptSources: () => [],
1217
+ extendResources: () => {},
1218
+ reload: async () => {},
1219
+ }
1220
+ }
1221
+
1222
+ function thinkingLevel(step: HarnessStep): {
1223
+ thinkingLevel?: 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
1224
+ } {
1225
+ if (step.reasoning === undefined || step.reasoning === 'provider-default')
1226
+ return {}
1227
+ return { thinkingLevel: step.reasoning === 'none' ? 'off' : step.reasoning }
1228
+ }
1229
+
1230
+ function gatewayHeaders(credential: {
1231
+ token: string
1232
+ method: string
1233
+ }): Record<string, string> {
1234
+ return {
1235
+ authorization: `Bearer ${credential.token}`,
1236
+ 'ai-gateway-auth-method': credential.method,
1237
+ }
1238
+ }
1239
+
1240
+ /** A stable Pi session ID for an A2 session, so Pi's cache affinity holds. */
1241
+ function sessionUuid(agentName: string, sessionId: string): string {
1242
+ const hex = createHash('sha256')
1243
+ .update(`${agentName}\u001f${sessionId}`)
1244
+ .digest('hex')
1245
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
1246
+ }
1247
+
1248
+ function savedSession(state: unknown): Saved | undefined {
1249
+ if (typeof state !== 'object' || state === null) return undefined
1250
+ const { version, cwd, header, entries } = state as Record<string, unknown>
1251
+ if (
1252
+ version !== SNAPSHOT_VERSION ||
1253
+ typeof cwd !== 'string' ||
1254
+ typeof header !== 'object' ||
1255
+ header === null ||
1256
+ !Array.isArray(entries)
1257
+ )
1258
+ return undefined
1259
+ return {
1260
+ cwd,
1261
+ header: header as SessionHeader,
1262
+ entries: entries as SessionEntry[],
1263
+ }
1264
+ }
1265
+
1266
+ function compactionData(event: {
1267
+ result: { summary: string; tokensBefore: number; usage?: Usage } | undefined
1268
+ aborted: boolean
1269
+ errorMessage?: string
1270
+ }): CompactionPartData {
1271
+ if (event.result === undefined)
1272
+ return { status: event.aborted ? 'interrupted' : 'failed' }
1273
+ return {
1274
+ status: 'completed',
1275
+ inputTokens: event.result.tokensBefore,
1276
+ summary: event.result.summary,
1277
+ ...(event.result.usage === undefined
1278
+ ? {}
1279
+ : { usage: modelUsage(event.result.usage) }),
1280
+ }
1281
+ }
1282
+
1283
+ function finishReason(reason: AssistantMessage['stopReason']): FinishReason {
1284
+ switch (reason) {
1285
+ case 'stop':
1286
+ return 'stop'
1287
+ case 'length':
1288
+ return 'length'
1289
+ case 'toolUse':
1290
+ return 'tool-calls'
1291
+ case 'error':
1292
+ return 'error'
1293
+ default:
1294
+ return 'other'
1295
+ }
1296
+ }
1297
+
1298
+ function modelUsage(usage: Usage): LanguageModelUsage {
1299
+ return {
1300
+ inputTokens: usage.input + usage.cacheRead + usage.cacheWrite,
1301
+ inputTokenDetails: {
1302
+ noCacheTokens: usage.input,
1303
+ cacheReadTokens: usage.cacheRead,
1304
+ cacheWriteTokens: usage.cacheWrite,
1305
+ },
1306
+ outputTokens: usage.output,
1307
+ outputTokenDetails: {
1308
+ textTokens:
1309
+ usage.reasoning === undefined
1310
+ ? undefined
1311
+ : usage.output - usage.reasoning,
1312
+ reasoningTokens: usage.reasoning,
1313
+ },
1314
+ totalTokens: usage.totalTokens,
1315
+ }
1316
+ }
1317
+
1318
+ // ------------------------------------------------------------- helpers
1319
+
1320
+ function asError(error: unknown): Error {
1321
+ return error instanceof Error ? error : new Error(String(error))
1322
+ }