experimental-a2 0.21.1 → 0.23.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 +32 -0
- package/dist/{actor-HnGmrOZZ.d.ts → actor-CqibLgjQ.d.ts} +13 -5
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +1 -1
- package/dist/actor-react.js +1 -1
- package/dist/actor-shared-DFET5q16.js.map +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +3 -3
- package/dist/{ai-CvEnqVfo.d.ts → ai-DAu77CDr.d.ts} +2 -2
- package/dist/{ai-CvEnqVfo.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-CzE672Q3.js → ai-EEfKyc-z.js} +2 -2
- package/dist/{ai-CzE672Q3.js.map → ai-EEfKyc-z.js.map} +1 -1
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +234 -62
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-Bfk-Lycn.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-Bfk-Lycn.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/ai-harness-dQeJaR1t.js.map +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +10 -4
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-9IeTsiAs.js → client-BQS4i_V9.js} +27 -4
- package/dist/client-BQS4i_V9.js.map +1 -0
- package/dist/client-CUMbS_38.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{internal-Bc4PvfHk.js → internal-CvsrprnU.js} +32 -15
- package/dist/{internal-Bc4PvfHk.js.map → internal-CvsrprnU.js.map} +1 -1
- package/dist/react.js +1 -1
- package/dist/{runtime-consumer-BWmht60z.js → runtime-consumer-CIeL33wj.js} +2 -2
- package/dist/{runtime-consumer-BWmht60z.js.map → runtime-consumer-CIeL33wj.js.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-DvIB8kj5.js → server-DtFrWxi3.js} +30 -8
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-BshFZ0eL.d.ts → server-qgez0Xny.d.ts} +3 -3
- package/dist/{server-BshFZ0eL.d.ts.map → server-qgez0Xny.d.ts.map} +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-Bk_29eEw.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
- package/dist/{store-Z2qjw50n.d.ts → store-D0MhBvg6.d.ts} +15 -6
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +15 -17
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-DYNyxCev.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-DYNyxCev.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +31 -40
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-IiVdlYHc.js → store-redis-notify-CBDVJxA_.js} +545 -399
- package/dist/store-redis-notify-CBDVJxA_.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +3 -4
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +16 -18
- package/dist/store-sqlite.js.map +1 -1
- package/dist/store-valkey.d.ts +33 -0
- package/dist/store-valkey.d.ts.map +1 -0
- package/dist/store-valkey.js +218 -0
- package/dist/store-valkey.js.map +1 -0
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- package/dist/{wire-CZGqNZRx.js → wire-CV-Z7wz-.js} +3 -3
- package/dist/{wire-CZGqNZRx.js.map → wire-CV-Z7wz-.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +5 -2
- package/docs/agents/03-conversations.mdx +9 -8
- package/docs/agents/04-harnesses.mdx +7 -4
- package/docs/client/03-presence.mdx +1 -0
- package/docs/reference/01-core.mdx +4 -2
- package/docs/reference/02-server.mdx +5 -1
- package/docs/reference/04-ai.mdx +13 -4
- package/docs/reference/06-errors.mdx +1 -1
- package/docs/server/01-consumers.mdx +4 -1
- package/docs/server/06-guarantees.mdx +7 -4
- package/docs/stores/01-overview.mdx +1 -0
- package/docs/stores/02-redis.mdx +18 -10
- package/docs/stores/03-redis-http.mdx +6 -4
- package/docs/stores/04-postgres.mdx +2 -2
- package/docs/stores/07-valkey.mdx +97 -0
- package/docs/upgrades/01-overview.mdx +1 -0
- package/docs/upgrades/78-0-22.mdx +115 -0
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a2slack/db/a2.ts +7 -4
- package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
- package/examples/playground/app/chat/server.ts +2 -2
- package/examples/playground/lib/store.ts +1 -1
- package/examples/playground/package.json +1 -1
- package/package.json +8 -2
- package/sql/postgres/drizzle-schema.ts +7 -4
- package/sql/postgres/schema.sql +24 -3
- package/src/actor-shared.ts +10 -2
- package/src/ai-agent.ts +13 -0
- package/src/ai-fx.ts +365 -78
- package/src/ai-harness.ts +7 -0
- package/src/client.ts +19 -2
- package/src/deterministic-id.ts +1 -1
- package/src/internal.ts +42 -11
- package/src/libfx.d.ts +2 -0
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +2 -2
- package/src/server.ts +49 -15
- package/src/store-memory.ts +24 -32
- package/src/store-postgres.ts +43 -53
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +595 -439
- package/src/store-redis-notify.ts +82 -28
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +34 -40
- package/src/store-valkey.ts +376 -0
- package/src/store.ts +14 -5
- package/dist/actor-HnGmrOZZ.d.ts.map +0 -1
- package/dist/client-9IeTsiAs.js.map +0 -1
- package/dist/server-DvIB8kj5.js.map +0 -1
- package/dist/session-clear-Bk_29eEw.d.ts.map +0 -1
- package/dist/store-Z2qjw50n.d.ts.map +0 -1
- package/dist/store-presence-polling-CzQ0wdtM.js +0 -94
- package/dist/store-presence-polling-CzQ0wdtM.js.map +0 -1
- package/dist/store-redis-notify-IiVdlYHc.js.map +0 -1
- package/dist/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
package/src/ai-fx.ts
CHANGED
|
@@ -34,18 +34,35 @@ import {
|
|
|
34
34
|
} from './ai-harness.ts'
|
|
35
35
|
import type { CompactionPartData, GenerationCompletedPayload } from './ai.ts'
|
|
36
36
|
import { schemaInput } from './ai-tool-input.ts'
|
|
37
|
+
import { HARNESS_PARKING } from './internal.ts'
|
|
37
38
|
import { boundedCache } from './bounded-cache.ts'
|
|
38
39
|
|
|
39
40
|
export type { Harness } from './ai-harness.ts'
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* The FX harness. Every model call FX makes is one durable step; A2 rebuilds
|
|
43
|
-
* the kernel for each step from its latest checkpoint and the log since
|
|
44
|
+
* the kernel for each step from its latest checkpoint and the log since,
|
|
45
|
+
* unless this process holds the kernel the step before left waiting on its
|
|
46
|
+
* tool calls.
|
|
44
47
|
*/
|
|
45
48
|
export function fx(): Harness<'fx', never> {
|
|
49
|
+
// Per agent, as its saved states are: sessions of two agents never share one.
|
|
50
|
+
const lots = new WeakMap<object, ParkingLot>()
|
|
51
|
+
const lotOf = (scope: object): ParkingLot => {
|
|
52
|
+
let lot = lots.get(scope)
|
|
53
|
+
if (lot === undefined) {
|
|
54
|
+
lot = parkingLot()
|
|
55
|
+
lots.set(scope, lot)
|
|
56
|
+
}
|
|
57
|
+
return lot
|
|
58
|
+
}
|
|
46
59
|
return defineHarness('fx', {
|
|
47
60
|
tools: {},
|
|
48
|
-
step: runStep,
|
|
61
|
+
step: (step) => runStep(step, lotOf(step.scope)),
|
|
62
|
+
release: ({ scope, sessionId }) => {
|
|
63
|
+
const entry = lots.get(scope)?.take(sessionId)
|
|
64
|
+
if (entry !== undefined) closeParked(entry)
|
|
65
|
+
},
|
|
49
66
|
})
|
|
50
67
|
}
|
|
51
68
|
|
|
@@ -53,6 +70,8 @@ const SNAPSHOT_VERSION = 1
|
|
|
53
70
|
const CATALOG_TTL_MS = 60 * 60 * 1000
|
|
54
71
|
/** The Gateway model catalog libfx reads for each new kernel. */
|
|
55
72
|
const CATALOG_PATH = '/coding-agent/v1/models'
|
|
73
|
+
/** Times a step rebuilds its kernel for a stopped turn FX failed on replay. */
|
|
74
|
+
const REPLAY_REBUILDS = 3
|
|
56
75
|
|
|
57
76
|
type StreamPart = { type: string } & Record<string, unknown>
|
|
58
77
|
/** A recorded tool call, with the input the kernel reads from its text. */
|
|
@@ -72,6 +91,8 @@ type TurnPlan = {
|
|
|
72
91
|
/** Steering to queue while serving the call at that index. */
|
|
73
92
|
steers: Map<number, string[]>
|
|
74
93
|
end: 'stop' | 'interrupted' | 'live'
|
|
94
|
+
/** The attempt that made its last committed model call. */
|
|
95
|
+
generationId?: string
|
|
75
96
|
}
|
|
76
97
|
|
|
77
98
|
type Completion = { finishReason?: FinishReason; usage?: LanguageModelUsage }
|
|
@@ -92,6 +113,12 @@ type TurnState = {
|
|
|
92
113
|
parsed?: Promise<void>
|
|
93
114
|
}
|
|
94
115
|
|
|
116
|
+
/** A model request as the kernel sends it to the Gateway. */
|
|
117
|
+
type GatewayRequest = {
|
|
118
|
+
toolChoice?: { type?: string }
|
|
119
|
+
providerOptions?: Record<string, Record<string, unknown> | undefined>
|
|
120
|
+
}
|
|
121
|
+
|
|
95
122
|
function fail(state: TurnState, error: Error): never {
|
|
96
123
|
state.failure ??= error
|
|
97
124
|
state.fxTurn.cancel()
|
|
@@ -100,12 +127,108 @@ function fail(state: TurnState, error: Error): never {
|
|
|
100
127
|
|
|
101
128
|
const diverged = (message: string): Divergence => new Divergence('FX', message)
|
|
102
129
|
|
|
130
|
+
// ------------------------------------------------------------- kernels
|
|
131
|
+
|
|
132
|
+
/** A tool call the kernel waits on while A2's tool consumer runs it. */
|
|
133
|
+
type Waiting = {
|
|
134
|
+
toolName: string
|
|
135
|
+
input: unknown
|
|
136
|
+
resolve: (output: unknown) => void
|
|
137
|
+
reject: (error: unknown) => void
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a kernel's fetch and tools call: the step driving it now. */
|
|
141
|
+
type KernelHooks = {
|
|
142
|
+
fetch: (
|
|
143
|
+
input: string | URL | Request,
|
|
144
|
+
init?: RequestInit,
|
|
145
|
+
) => Promise<Response>
|
|
146
|
+
tool: (toolName: string, input: unknown) => unknown
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type Kernel = {
|
|
150
|
+
agent: FxAgent
|
|
151
|
+
hooks: KernelHooks
|
|
152
|
+
waiting: Waiting[]
|
|
153
|
+
/** Whether FX answered the kernel's latest prompt with an error. */
|
|
154
|
+
failed: boolean
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A kernel a step left waiting on its tool calls. The session's next step in
|
|
159
|
+
* this process, in the same response or one that steered it, continues it
|
|
160
|
+
* with their results instead of rebuilding one from the log.
|
|
161
|
+
*/
|
|
162
|
+
type Parked = {
|
|
163
|
+
kernel: Kernel
|
|
164
|
+
fxTurn: FxTurn
|
|
165
|
+
drained: Promise<void>
|
|
166
|
+
/** The attempt whose model call the kernel made. */
|
|
167
|
+
generationId: string
|
|
168
|
+
settings: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type ParkingLot = {
|
|
172
|
+
/** Removes the session's parked kernel; the caller then owns it. */
|
|
173
|
+
take(sessionId: string): Parked | undefined
|
|
174
|
+
park(sessionId: string, entry: Parked): void
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function closeParked(entry: Parked): void {
|
|
178
|
+
entry.fxTurn.cancel()
|
|
179
|
+
void entry.kernel.agent.close().catch(() => {})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* One agent's parked kernels: one per session, at most `maxKernels`. Each is
|
|
184
|
+
* closed when its session has nothing left to run in this process (the
|
|
185
|
+
* agent's release), when its turn ends, or when another takes its place.
|
|
186
|
+
*/
|
|
187
|
+
function parkingLot(): ParkingLot {
|
|
188
|
+
const held = new Map<string, Parked>()
|
|
189
|
+
const take = (sessionId: string): Parked | undefined => {
|
|
190
|
+
const entry = held.get(sessionId)
|
|
191
|
+
held.delete(sessionId)
|
|
192
|
+
return entry
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
take,
|
|
196
|
+
park(sessionId, entry) {
|
|
197
|
+
const replaced = take(sessionId)
|
|
198
|
+
if (replaced !== undefined) closeParked(replaced)
|
|
199
|
+
held.set(sessionId, entry)
|
|
200
|
+
// A turn that ends while it waits, as a cancelled one does, is done.
|
|
201
|
+
const ended = () => {
|
|
202
|
+
if (held.get(sessionId) !== entry) return
|
|
203
|
+
held.delete(sessionId)
|
|
204
|
+
closeParked(entry)
|
|
205
|
+
}
|
|
206
|
+
entry.fxTurn.result.then(ended, ended)
|
|
207
|
+
const oldest = held.keys().next().value
|
|
208
|
+
if (held.size > HARNESS_PARKING.maxKernels && oldest !== undefined) {
|
|
209
|
+
const evicted = take(oldest)
|
|
210
|
+
if (evicted !== undefined) closeParked(evicted)
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
103
216
|
// ---------------------------------------------------------------- step
|
|
104
217
|
|
|
105
|
-
async function runStep(
|
|
218
|
+
async function runStep(
|
|
219
|
+
step: HarnessStep,
|
|
220
|
+
lot: ParkingLot,
|
|
221
|
+
): Promise<HarnessGeneration> {
|
|
106
222
|
const { createFxAgent } = await loadLibfx()
|
|
107
223
|
const credential = gatewayCredential('fx')
|
|
108
224
|
const name = `${step.agentName}.a2.harness.fx`
|
|
225
|
+
const definitions = await toolDefinitions(step.tools)
|
|
226
|
+
// What the kernel is made with: one made otherwise can't continue the step.
|
|
227
|
+
const settings = JSON.stringify([
|
|
228
|
+
kernelModel(step),
|
|
229
|
+
step.instructions,
|
|
230
|
+
definitions,
|
|
231
|
+
])
|
|
109
232
|
const { replayAll, ...first } = await readHarness({
|
|
110
233
|
step,
|
|
111
234
|
name,
|
|
@@ -119,17 +242,29 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
119
242
|
// has read a response's finish, while A2 may still be reading its copy.
|
|
120
243
|
const released = new AbortController()
|
|
121
244
|
const network = AbortSignal.any([step.signal, released.signal])
|
|
122
|
-
let
|
|
245
|
+
let kernel: Kernel | undefined
|
|
246
|
+
/** Whether this step left its kernel for the next one. */
|
|
247
|
+
let parked = false
|
|
123
248
|
let turn: TurnState | undefined
|
|
124
249
|
|
|
125
250
|
const run = async (
|
|
126
251
|
controller: ReadableStreamDefaultController<UIMessageChunk>,
|
|
127
252
|
turns: TurnPlan[],
|
|
253
|
+
adopted: Parked | undefined,
|
|
128
254
|
): Promise<void> => {
|
|
129
255
|
controller.enqueue({ type: 'start', messageId: step.responseMessageId })
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
256
|
+
if (adopted !== undefined) {
|
|
257
|
+
await resume(controller, turns.at(-1)!, adopted)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
let rebuilds = 0
|
|
261
|
+
let next = 0
|
|
262
|
+
while (next < turns.length) {
|
|
263
|
+
if (stopped || step.signal.aborted) return
|
|
264
|
+
const plan = turns[next]!
|
|
265
|
+
next += 1
|
|
266
|
+
// The step's abort cancels the turn; the turn outlives it when parked.
|
|
267
|
+
const fxTurn = kernel!.agent.prompt(plan.prompt)
|
|
133
268
|
const state: TurnState = {
|
|
134
269
|
plan,
|
|
135
270
|
fxTurn,
|
|
@@ -159,39 +294,137 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
159
294
|
throw diverged(
|
|
160
295
|
`a recorded turn ended with ${result.stopReason}, not ${expected}`,
|
|
161
296
|
)
|
|
297
|
+
if (kernel!.failed) {
|
|
298
|
+
// libfx's cancel aborts the request FX waits on as it sends the
|
|
299
|
+
// stop, and FX fails the turn when it reads the abort first. That
|
|
300
|
+
// kernel holds no interruption where the log has one, so the turns
|
|
301
|
+
// replay on a new kernel.
|
|
302
|
+
if (rebuilds === REPLAY_REBUILDS)
|
|
303
|
+
throw diverged('FX failed a stopped turn instead of cancelling it')
|
|
304
|
+
rebuilds += 1
|
|
305
|
+
await kernel!.agent.close().catch(() => {})
|
|
306
|
+
kernel = await create(read.saved)
|
|
307
|
+
next = 0
|
|
308
|
+
}
|
|
162
309
|
continue
|
|
163
310
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
311
|
+
await finishLive(controller, state, drained)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Continues a parked kernel: the results it waits on are in the log. */
|
|
316
|
+
const resume = async (
|
|
317
|
+
controller: ReadableStreamDefaultController<UIMessageChunk>,
|
|
318
|
+
plan: TurnPlan,
|
|
319
|
+
adopted: Parked,
|
|
320
|
+
): Promise<void> => {
|
|
321
|
+
const state: TurnState = {
|
|
322
|
+
plan,
|
|
323
|
+
fxTurn: adopted.fxTurn,
|
|
324
|
+
next: plan.calls.length,
|
|
325
|
+
served: plan.calls.at(-1)!.toolCalls,
|
|
326
|
+
answered: new Set(),
|
|
327
|
+
live: false,
|
|
328
|
+
liveStep: false,
|
|
329
|
+
toolsRequested: deferred(),
|
|
330
|
+
}
|
|
331
|
+
turn = state
|
|
332
|
+
// Steering goes in before any tool result, so FX's next model call can't
|
|
333
|
+
// start without it, as replay queues it before the recorded call ends.
|
|
334
|
+
for (const text of plan.steers.get(plan.calls.length - 1) ?? [])
|
|
335
|
+
await adopted.fxTurn.steer(text)
|
|
336
|
+
for (const waiting of kernel!.waiting.splice(0)) {
|
|
337
|
+
try {
|
|
338
|
+
const answered = answer(state, waiting.toolName, waiting.input)
|
|
339
|
+
if (answered === undefined)
|
|
340
|
+
fail(state, diverged(`no recorded result for ${waiting.toolName}`))
|
|
341
|
+
waiting.resolve(answered.output)
|
|
342
|
+
} catch (error) {
|
|
343
|
+
waiting.reject(error)
|
|
180
344
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
345
|
+
}
|
|
346
|
+
await finishLive(controller, state, adopted.drained)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const finishLive = async (
|
|
350
|
+
controller: ReadableStreamDefaultController<UIMessageChunk>,
|
|
351
|
+
state: TurnState,
|
|
352
|
+
drained: Promise<void>,
|
|
353
|
+
): Promise<void> => {
|
|
354
|
+
const outcome = await Promise.race([
|
|
355
|
+
state.fxTurn.result.then((result) => ({ ended: true, result })),
|
|
356
|
+
state.toolsRequested.promise.then(() => ({ ended: false as const })),
|
|
357
|
+
]).catch((error: unknown) => {
|
|
358
|
+
throw state.failure ?? error
|
|
359
|
+
})
|
|
360
|
+
if (state.failure) throw state.failure
|
|
361
|
+
await state.parsed
|
|
362
|
+
if (outcome.ended) {
|
|
363
|
+
if (
|
|
364
|
+
outcome.result.stopReason === 'cancelled' &&
|
|
365
|
+
!stopped &&
|
|
366
|
+
!step.signal.aborted
|
|
367
|
+
)
|
|
368
|
+
throw diverged('the turn was cancelled while this step ran it')
|
|
369
|
+
await drained
|
|
370
|
+
// Only a turn this attempt finished describes the log.
|
|
371
|
+
if (outcome.result.stopReason === 'end_turn' && !step.signal.aborted)
|
|
372
|
+
await saveCheckpoint()
|
|
373
|
+
} else if (kernel!.waiting.length > 0 && !stopped && !closed) {
|
|
374
|
+
// FX now waits on tools that A2's tool consumer runs; the next step in
|
|
375
|
+
// this process can answer it with their results.
|
|
376
|
+
lot.park(step.sessionId, {
|
|
377
|
+
kernel: kernel!,
|
|
378
|
+
fxTurn: state.fxTurn,
|
|
379
|
+
drained,
|
|
380
|
+
generationId: step.generationId,
|
|
381
|
+
settings,
|
|
187
382
|
})
|
|
383
|
+
parked = true
|
|
384
|
+
} else {
|
|
385
|
+
state.fxTurn.cancel()
|
|
188
386
|
}
|
|
387
|
+
controller.enqueue({ type: 'finish-step' })
|
|
388
|
+
controller.enqueue({
|
|
389
|
+
type: 'finish',
|
|
390
|
+
...(completion.finishReason === undefined
|
|
391
|
+
? {}
|
|
392
|
+
: { finishReason: completion.finishReason }),
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Whether a parked kernel stands where this step's log does: made the same
|
|
398
|
+
* way, its model call the last one the log's live turn committed, and a
|
|
399
|
+
* recorded result for each tool call it waits on. A steered response
|
|
400
|
+
* continues the same turn, so its steps qualify too.
|
|
401
|
+
*/
|
|
402
|
+
const adoptable = (entry: Parked, turns: TurnPlan[]): boolean => {
|
|
403
|
+
const plan = turns.at(-1)
|
|
404
|
+
if (
|
|
405
|
+
entry.settings !== settings ||
|
|
406
|
+
plan?.end !== 'live' ||
|
|
407
|
+
plan.generationId !== entry.generationId
|
|
408
|
+
)
|
|
409
|
+
return false
|
|
410
|
+
const unanswered = [...(plan.calls.at(-1)?.toolCalls ?? [])]
|
|
411
|
+
return entry.kernel.waiting.every((waiting) => {
|
|
412
|
+
const at = unanswered.findIndex(
|
|
413
|
+
(call) =>
|
|
414
|
+
call.toolName === waiting.toolName &&
|
|
415
|
+
sameJson(call.input, waiting.input) &&
|
|
416
|
+
results.has(call.toolCallId),
|
|
417
|
+
)
|
|
418
|
+
if (at === -1) return false
|
|
419
|
+
unanswered.splice(at, 1)
|
|
420
|
+
return true
|
|
421
|
+
})
|
|
189
422
|
}
|
|
190
423
|
|
|
191
424
|
const saveCheckpoint = async (): Promise<void> => {
|
|
192
425
|
let checkpoint: Uint8Array
|
|
193
426
|
try {
|
|
194
|
-
checkpoint = await agent
|
|
427
|
+
checkpoint = await kernel!.agent.checkpoint()
|
|
195
428
|
} catch {
|
|
196
429
|
// Later steps replay from the previous snapshot.
|
|
197
430
|
return
|
|
@@ -218,7 +451,8 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
218
451
|
const state = turn!
|
|
219
452
|
if (state.failure) throw state.failure
|
|
220
453
|
const body = decodeBody(init.body)
|
|
221
|
-
const
|
|
454
|
+
const request = JSON.parse(body) as GatewayRequest
|
|
455
|
+
const compaction = request.toolChoice?.type === 'none'
|
|
222
456
|
const recorded = state.plan.calls[state.next]
|
|
223
457
|
if (recorded !== undefined) {
|
|
224
458
|
if ((recorded.kind === 'compaction') !== compaction)
|
|
@@ -245,7 +479,12 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
245
479
|
return pending(init.signal)
|
|
246
480
|
}
|
|
247
481
|
state.live = true
|
|
248
|
-
return live(
|
|
482
|
+
return live(
|
|
483
|
+
state,
|
|
484
|
+
url,
|
|
485
|
+
{ ...init, body: withGatewayCaching(body, request) },
|
|
486
|
+
compaction,
|
|
487
|
+
)
|
|
249
488
|
}
|
|
250
489
|
|
|
251
490
|
const affinity = gatewaySessionAffinity({
|
|
@@ -256,18 +495,13 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
256
495
|
const live = async (
|
|
257
496
|
state: TurnState,
|
|
258
497
|
url: string,
|
|
259
|
-
init: RequestInit
|
|
498
|
+
init: RequestInit,
|
|
260
499
|
compaction: boolean,
|
|
261
500
|
): Promise<Response> => {
|
|
262
501
|
const headers = withAuth(init.headers, credential)
|
|
263
502
|
// The kernel's own affinity is new with every rebuild; A2's lasts the session.
|
|
264
503
|
headers.set('x-session-affinity', affinity)
|
|
265
|
-
const response = await fetch(url, {
|
|
266
|
-
...init,
|
|
267
|
-
body: withGatewayCaching(init.body),
|
|
268
|
-
headers,
|
|
269
|
-
signal: network,
|
|
270
|
-
})
|
|
504
|
+
const response = await fetch(url, { ...init, headers, signal: network })
|
|
271
505
|
// Abandoned before its headers came: the kernel has moved on without it.
|
|
272
506
|
if (init.signal?.aborted) {
|
|
273
507
|
void response.body?.cancel()
|
|
@@ -316,9 +550,12 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
316
550
|
})
|
|
317
551
|
}
|
|
318
552
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
553
|
+
/** The recorded result of a call from the step the kernel was served. */
|
|
554
|
+
const answer = (
|
|
555
|
+
state: TurnState,
|
|
556
|
+
toolName: string,
|
|
557
|
+
toolInput: unknown,
|
|
558
|
+
): { output: unknown } | undefined => {
|
|
322
559
|
const call = state.served.find(
|
|
323
560
|
(candidate) =>
|
|
324
561
|
candidate.toolName === toolName &&
|
|
@@ -326,24 +563,33 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
326
563
|
sameJson(candidate.input, toolInput),
|
|
327
564
|
)
|
|
328
565
|
const result = call === undefined ? undefined : results.get(call.toolCallId)
|
|
329
|
-
if (call
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
566
|
+
if (call === undefined || result === undefined) return undefined
|
|
567
|
+
state.answered.add(call.toolCallId)
|
|
568
|
+
if (result.error !== undefined) throw new Error(result.error)
|
|
569
|
+
if (result.denied === true) throw new Error('The tool call was denied.')
|
|
570
|
+
return { output: result.output ?? null }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const hostTool = (toolName: string, toolInput: unknown): unknown => {
|
|
574
|
+
const state = turn!
|
|
575
|
+
if (state.failure) throw state.failure
|
|
576
|
+
const answered = answer(state, toolName, toolInput)
|
|
577
|
+
if (answered !== undefined) return answered.output
|
|
335
578
|
if (state.live) {
|
|
336
579
|
state.toolsRequested.resolve()
|
|
337
|
-
return
|
|
580
|
+
return new Promise((resolve, reject) => {
|
|
581
|
+
kernel!.waiting.push({ toolName, input: toolInput, resolve, reject })
|
|
582
|
+
})
|
|
338
583
|
}
|
|
339
584
|
if (state.plan.end === 'interrupted') {
|
|
340
585
|
state.fxTurn.cancel()
|
|
341
586
|
return pending(undefined)
|
|
342
587
|
}
|
|
343
588
|
return fail(state, diverged(`no recorded result for ${toolName}`))
|
|
344
|
-
}
|
|
589
|
+
}
|
|
345
590
|
|
|
346
|
-
const
|
|
591
|
+
const hooks: KernelHooks = { fetch: hostFetch, tool: hostTool }
|
|
592
|
+
const planTurns = (turns: HarnessTurn[]): Promise<TurnPlan[]> =>
|
|
347
593
|
Promise.all(turns.map((recorded) => turnPlan(recorded, step.signal)))
|
|
348
594
|
let results = read.results
|
|
349
595
|
let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
|
|
@@ -352,17 +598,45 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
352
598
|
if (!closed) controller!.enqueue(chunk)
|
|
353
599
|
}
|
|
354
600
|
|
|
355
|
-
const create = (
|
|
356
|
-
|
|
601
|
+
const create = async (
|
|
602
|
+
checkpoint: Uint8Array | undefined,
|
|
603
|
+
): Promise<Kernel> => {
|
|
604
|
+
const created = { hooks, waiting: [] as Waiting[], failed: false }
|
|
605
|
+
let prompt: unknown
|
|
606
|
+
const agent = await createFxAgent({
|
|
357
607
|
apiKey: credential.token,
|
|
358
608
|
model: kernelModel(step),
|
|
359
609
|
...(step.instructions.length === 0
|
|
360
610
|
? {}
|
|
361
611
|
: { instructions: step.instructions }),
|
|
362
|
-
tools:
|
|
612
|
+
tools: definitions.map((definition): FxHostTool => ({
|
|
613
|
+
...definition,
|
|
614
|
+
execute: (toolInput: unknown) =>
|
|
615
|
+
created.hooks.tool(definition.name, toolInput),
|
|
616
|
+
})),
|
|
363
617
|
...(checkpoint === undefined ? {} : { checkpoint }),
|
|
364
|
-
fetch:
|
|
618
|
+
fetch: (input, init) => created.hooks.fetch(input, init),
|
|
619
|
+
// libfx reports a turn FX failed after its cancel as cancelled; only
|
|
620
|
+
// the ACP response to the turn's prompt tells the two apart.
|
|
621
|
+
onEvent: ({ type, message }) => {
|
|
622
|
+
const rpc = (message ?? {}) as {
|
|
623
|
+
id?: unknown
|
|
624
|
+
method?: unknown
|
|
625
|
+
error?: unknown
|
|
626
|
+
}
|
|
627
|
+
if (type === 'acp.send' && rpc.method === 'session/prompt') {
|
|
628
|
+
prompt = rpc.id
|
|
629
|
+
created.failed = false
|
|
630
|
+
} else if (
|
|
631
|
+
type === 'acp.receive' &&
|
|
632
|
+
rpc.method === undefined &&
|
|
633
|
+
rpc.id === prompt
|
|
634
|
+
)
|
|
635
|
+
created.failed = rpc.error !== undefined
|
|
636
|
+
},
|
|
365
637
|
})
|
|
638
|
+
return Object.assign(created, { agent })
|
|
639
|
+
}
|
|
366
640
|
|
|
367
641
|
const stream = new ReadableStream<UIMessageChunk>({
|
|
368
642
|
start(streamController) {
|
|
@@ -374,16 +648,33 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
374
648
|
step.signal.addEventListener('abort', abort, { once: true })
|
|
375
649
|
void (async () => {
|
|
376
650
|
try {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
651
|
+
let turns = await planTurns(read.turns)
|
|
652
|
+
// An aborted step leaves a parked kernel for a later attempt.
|
|
653
|
+
const candidate = step.signal.aborted
|
|
654
|
+
? undefined
|
|
655
|
+
: lot.take(step.sessionId)
|
|
656
|
+
const adopted =
|
|
657
|
+
candidate !== undefined && adoptable(candidate, turns)
|
|
658
|
+
? candidate
|
|
659
|
+
: undefined
|
|
660
|
+
if (candidate !== undefined && adopted === undefined)
|
|
661
|
+
closeParked(candidate)
|
|
662
|
+
if (adopted !== undefined) {
|
|
663
|
+
kernel = adopted.kernel
|
|
664
|
+
kernel.hooks = hooks
|
|
665
|
+
} else {
|
|
666
|
+
try {
|
|
667
|
+
kernel = await create(read.saved)
|
|
668
|
+
} catch (error) {
|
|
669
|
+
if (read.saved === undefined) throw error
|
|
670
|
+
// An unusable checkpoint: replay the whole log instead.
|
|
671
|
+
read = await replayAll()
|
|
672
|
+
results = read.results
|
|
673
|
+
kernel = await create(undefined)
|
|
674
|
+
turns = await planTurns(read.turns)
|
|
675
|
+
}
|
|
385
676
|
}
|
|
386
|
-
await run(streamController,
|
|
677
|
+
await run(streamController, turns, adopted)
|
|
387
678
|
closed = true
|
|
388
679
|
streamController.close()
|
|
389
680
|
} catch (error) {
|
|
@@ -394,7 +685,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
394
685
|
} finally {
|
|
395
686
|
released.abort()
|
|
396
687
|
step.signal.removeEventListener('abort', abort)
|
|
397
|
-
await agent
|
|
688
|
+
if (!parked) await kernel?.agent.close().catch(() => {})
|
|
398
689
|
}
|
|
399
690
|
})()
|
|
400
691
|
},
|
|
@@ -429,7 +720,9 @@ async function turnPlan(
|
|
|
429
720
|
input: turn.prompt,
|
|
430
721
|
signal,
|
|
431
722
|
})
|
|
723
|
+
const generationId = turn.generations.at(-1)?.completed.generationId
|
|
432
724
|
return {
|
|
725
|
+
...(generationId === undefined ? {} : { generationId }),
|
|
433
726
|
prompt:
|
|
434
727
|
images.length === 0
|
|
435
728
|
? turn.prompt.text
|
|
@@ -786,10 +1079,7 @@ async function loadLibfx(): Promise<typeof import('libfx')> {
|
|
|
786
1079
|
* asks for it; options the request already has take precedence. Every other
|
|
787
1080
|
* byte stays as the kernel wrote it, a tool input's spacing included.
|
|
788
1081
|
*/
|
|
789
|
-
function withGatewayCaching(body: string): string {
|
|
790
|
-
const request = JSON.parse(body) as {
|
|
791
|
-
providerOptions?: Record<string, Record<string, unknown> | undefined>
|
|
792
|
-
}
|
|
1082
|
+
function withGatewayCaching(body: string, request: GatewayRequest): string {
|
|
793
1083
|
const options = request.providerOptions
|
|
794
1084
|
if (options === undefined)
|
|
795
1085
|
return body.replace(
|
|
@@ -812,6 +1102,9 @@ function withAuth(
|
|
|
812
1102
|
): Headers {
|
|
813
1103
|
const result = new Headers(headers)
|
|
814
1104
|
result.set('ai-gateway-auth-method', credential.method)
|
|
1105
|
+
// A parked kernel keeps the credential it was made with; the step's is current.
|
|
1106
|
+
if (result.has('authorization'))
|
|
1107
|
+
result.set('authorization', `Bearer ${credential.token}`)
|
|
815
1108
|
return result
|
|
816
1109
|
}
|
|
817
1110
|
|
|
@@ -831,17 +1124,16 @@ function kernelModel(
|
|
|
831
1124
|
}
|
|
832
1125
|
}
|
|
833
1126
|
|
|
834
|
-
|
|
1127
|
+
/** The agent's tools as the kernel takes them, without their execution. */
|
|
1128
|
+
async function toolDefinitions(
|
|
835
1129
|
tools: ToolSet,
|
|
836
|
-
|
|
837
|
-
): Promise<FxHostTool[]> {
|
|
1130
|
+
): Promise<{ name: string; description: string; inputSchema: object }[]> {
|
|
838
1131
|
return Promise.all(
|
|
839
1132
|
Object.entries(tools).map(async ([toolName, tool]) => ({
|
|
840
1133
|
name: toolName,
|
|
841
1134
|
description:
|
|
842
1135
|
typeof tool.description === 'string' ? tool.description : toolName,
|
|
843
1136
|
inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
|
|
844
|
-
execute: (input: unknown) => execute(toolName, input),
|
|
845
1137
|
})),
|
|
846
1138
|
)
|
|
847
1139
|
}
|
|
@@ -889,11 +1181,6 @@ function savedCheckpoint(state: unknown): Uint8Array | undefined {
|
|
|
889
1181
|
return new Uint8Array(Buffer.from(checkpoint, 'base64'))
|
|
890
1182
|
}
|
|
891
1183
|
|
|
892
|
-
function isCompaction(body: string): boolean {
|
|
893
|
-
const request = JSON.parse(body) as { toolChoice?: { type?: string } }
|
|
894
|
-
return request.toolChoice?.type === 'none'
|
|
895
|
-
}
|
|
896
|
-
|
|
897
1184
|
function decodeBody(body: BodyInit | null | undefined): string {
|
|
898
1185
|
if (typeof body === 'string') return body
|
|
899
1186
|
if (body instanceof Uint8Array || body instanceof ArrayBuffer)
|
package/src/ai-harness.ts
CHANGED
|
@@ -69,6 +69,8 @@ export type HarnessLog = {
|
|
|
69
69
|
|
|
70
70
|
/** Everything one durable generation hands its harness. */
|
|
71
71
|
export type HarnessStep = {
|
|
72
|
+
/** The agent's own identity: what a harness keeps in memory is per scope. */
|
|
73
|
+
scope: object
|
|
72
74
|
agentName: string
|
|
73
75
|
sessionId: string
|
|
74
76
|
requestIndex: number
|
|
@@ -95,6 +97,11 @@ export type HarnessDriver = {
|
|
|
95
97
|
/** Tools the harness brings; A2 executes them like the agent's own. */
|
|
96
98
|
readonly tools: ToolSet
|
|
97
99
|
step(step: HarnessStep): Promise<HarnessGeneration>
|
|
100
|
+
/**
|
|
101
|
+
* The session has nothing left to run in this process. Lets go of anything
|
|
102
|
+
* kept in memory for its next step; it holds nothing the log needs.
|
|
103
|
+
*/
|
|
104
|
+
release?(session: { scope: object; sessionId: string }): void
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
// A global symbol, so harness values work across separately bundled copies.
|