experimental-a2 0.18.0 → 0.19.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 +39 -0
- package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
- package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
- 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.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-DrUaMyRt.d.ts → ai-Cz3hqVhk.d.ts} +34 -17
- package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +2 -2
- package/dist/ai-control-CvLbW10B.js +46 -0
- package/dist/ai-control-CvLbW10B.js.map +1 -0
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +122 -44
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
- package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
- package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
- package/dist/{ai-BpOGm-zB.js → ai-mMiWul1T.js} +117 -78
- package/dist/ai-mMiWul1T.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +114 -23
- package/dist/ai-pi.js.map +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 +127 -55
- package/dist/ai-server.js.map +1 -1
- package/dist/ai-tool-input-DmSqMUQi.js +40 -0
- package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1
- package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
- package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
- package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
- package/dist/react.js +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +7 -7
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +10 -14
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
- package/dist/server-D-Y4pqwu.js.map +1 -0
- package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
- package/dist/server-DYAATFuT.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
- package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
- package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
- package/dist/store-CyLUy86v.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 +25 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
- package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.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 +55 -25
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
- package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.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-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
- package/dist/store-redis-notify-CM0AfLY0.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 +35 -20
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
- package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
- package/docs/agents/03-conversations.mdx +27 -15
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/contract/03-sessions.mdx +1 -1
- package/docs/index.mdx +3 -3
- package/docs/reference/02-server.mdx +12 -7
- package/docs/reference/04-ai.mdx +41 -23
- package/docs/reference/05-adapters.mdx +6 -6
- package/docs/server/01-handlers.mdx +1 -1
- package/docs/server/02-timers.mdx +1 -1
- package/docs/server/03-cancellation.mdx +3 -2
- package/docs/server/05-production.mdx +4 -3
- package/docs/server/06-guarantees.mdx +20 -11
- package/examples/playground/DEPLOY.md +11 -4
- package/examples/playground/app/a0/[appId]/page.tsx +2 -8
- package/examples/playground/app/a0/a0.css +244 -5
- package/examples/playground/app/a0/app-view.tsx +2 -1
- package/examples/playground/app/a0/attachments.ts +133 -0
- package/examples/playground/app/a0/chat.test.tsx +504 -0
- package/examples/playground/app/a0/chat.tsx +623 -63
- package/examples/playground/app/a0/context-menu.tsx +38 -11
- package/examples/playground/app/a0/devtools.tsx +2 -8
- package/examples/playground/app/a0/model.test.ts +28 -0
- package/examples/playground/app/a0/model.ts +30 -0
- package/examples/playground/app/a0/server.ts +13 -19
- package/examples/playground/app/a0/uploads/route.ts +29 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
- package/examples/playground/app/agent/model.test.ts +12 -3
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
- package/examples/playground/next.config.ts +7 -4
- package/examples/playground/package.json +3 -1
- package/package.json +1 -1
- package/src/ai-agent.ts +14 -3
- package/src/ai-client-state.ts +21 -9
- package/src/ai-context-schema.ts +28 -4
- package/src/ai-context.ts +83 -3
- package/src/ai-control-server.ts +70 -43
- package/src/ai-control-state.ts +3 -4
- package/src/ai-control.ts +59 -10
- package/src/ai-coordinator.ts +7 -6
- package/src/ai-fx.ts +184 -67
- package/src/ai-harness.ts +153 -29
- package/src/ai-message-shape.ts +41 -0
- package/src/ai-pi.ts +161 -46
- package/src/ai-stored-state.ts +1 -3
- package/src/ai-tool-input.ts +45 -0
- package/src/ai.ts +116 -77
- package/src/internal.ts +6 -0
- package/src/libfx.d.ts +8 -1
- package/src/scheduler-qstash.ts +5 -5
- package/src/scheduler-vercel.ts +15 -27
- package/src/server.ts +58 -12
- package/src/store-memory.ts +50 -9
- package/src/store-postgres.ts +79 -28
- package/src/store-redis-core.ts +112 -31
- package/src/store-sqlite.ts +61 -28
- package/src/store.ts +22 -3
- package/dist/ai-BpOGm-zB.js.map +0 -1
- package/dist/ai-DrUaMyRt.d.ts.map +0 -1
- package/dist/ai-harness-BuLh_EYi.js.map +0 -1
- package/dist/ai-harness-DvsPeJeB.d.ts.map +0 -1
- package/dist/server-Dw_k_7kO.js.map +0 -1
- package/dist/server-En7cbi3v.d.ts.map +0 -1
- package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
- package/dist/store-Djdv0bnZ.d.ts.map +0 -1
- package/dist/store-redis-notify-BRITti_o.js.map +0 -1
- package/examples/playground/app/a0/no-photon.cjs +0 -4
package/src/ai-fx.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type ToolSet,
|
|
13
13
|
type UIMessageChunk,
|
|
14
14
|
} from 'ai'
|
|
15
|
-
import type { FxAgent, FxHostTool, FxTurn } from 'libfx'
|
|
15
|
+
import type { FxAgent, FxHostTool, FxPromptBlock, FxTurn } from 'libfx'
|
|
16
16
|
import {
|
|
17
17
|
type Deferred,
|
|
18
18
|
defineHarness,
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
type GatewayCredential,
|
|
23
23
|
type Harness,
|
|
24
24
|
type HarnessGeneration,
|
|
25
|
+
harnessImages,
|
|
26
|
+
type HarnessInput,
|
|
25
27
|
type HarnessRead,
|
|
26
28
|
type HarnessStep,
|
|
27
29
|
type HarnessTurn,
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
saveHarness,
|
|
31
33
|
} from './ai-harness.ts'
|
|
32
34
|
import type { CompactionPartData, GenerationCompletedPayload } from './ai.ts'
|
|
35
|
+
import { schemaInput } from './ai-tool-input.ts'
|
|
33
36
|
import { boundedCache } from './bounded-cache.ts'
|
|
34
37
|
|
|
35
38
|
export type { Harness } from './ai-harness.ts'
|
|
@@ -47,8 +50,11 @@ export function fx(): Harness<'fx', never> {
|
|
|
47
50
|
|
|
48
51
|
const SNAPSHOT_VERSION = 1
|
|
49
52
|
const CATALOG_TTL_MS = 60 * 60 * 1000
|
|
53
|
+
/** The Gateway model catalog libfx reads for each new kernel. */
|
|
54
|
+
const CATALOG_PATH = '/coding-agent/v1/models'
|
|
50
55
|
|
|
51
56
|
type StreamPart = { type: string } & Record<string, unknown>
|
|
57
|
+
/** A recorded tool call, with the input the kernel reads from its text. */
|
|
52
58
|
type ToolCall = { toolCallId: string; toolName: string; input: unknown }
|
|
53
59
|
|
|
54
60
|
/** A model response A2 recorded, in the Gateway's stream format. */
|
|
@@ -60,7 +66,7 @@ type RecordedCall = {
|
|
|
60
66
|
|
|
61
67
|
/** One FX turn to replay: its prompt, recorded calls, and how it ended. */
|
|
62
68
|
type TurnPlan = {
|
|
63
|
-
prompt: string
|
|
69
|
+
prompt: string | FxPromptBlock[]
|
|
64
70
|
calls: RecordedCall[]
|
|
65
71
|
/** Steering to queue while serving the call at that index. */
|
|
66
72
|
steers: Map<number, string[]>
|
|
@@ -108,6 +114,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
108
114
|
|
|
109
115
|
let completion: Completion = {}
|
|
110
116
|
let stopped = false
|
|
117
|
+
// Live responses belong to the step: the kernel aborts its fetch once it
|
|
118
|
+
// has read a response's finish, while A2 may still be reading its copy.
|
|
119
|
+
const released = new AbortController()
|
|
120
|
+
const network = AbortSignal.any([step.signal, released.signal])
|
|
111
121
|
let agent: FxAgent | undefined
|
|
112
122
|
let turn: TurnState | undefined
|
|
113
123
|
|
|
@@ -182,7 +192,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
182
192
|
try {
|
|
183
193
|
checkpoint = await agent!.checkpoint()
|
|
184
194
|
} catch {
|
|
185
|
-
//
|
|
195
|
+
// Later steps replay from the previous snapshot.
|
|
186
196
|
return
|
|
187
197
|
}
|
|
188
198
|
await saveHarness({
|
|
@@ -200,8 +210,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
200
210
|
init: RequestInit = {},
|
|
201
211
|
): Promise<Response> => {
|
|
202
212
|
const url = input instanceof Request ? input.url : String(input)
|
|
203
|
-
|
|
213
|
+
const method = (init.method ?? 'GET').toUpperCase()
|
|
214
|
+
if (method === 'GET' && new URL(url).pathname === CATALOG_PATH)
|
|
204
215
|
return catalog(url, credential, withAuth(init.headers, credential))
|
|
216
|
+
if (method === 'GET') return fetch(input, init)
|
|
205
217
|
const state = turn!
|
|
206
218
|
if (state.failure) throw state.failure
|
|
207
219
|
const body = decodeBody(init.body)
|
|
@@ -245,9 +257,18 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
245
257
|
const response = await fetch(url, {
|
|
246
258
|
...init,
|
|
247
259
|
headers: withAuth(init.headers, credential),
|
|
260
|
+
signal: network,
|
|
248
261
|
})
|
|
262
|
+
// Abandoned before its headers came: the kernel has moved on without it.
|
|
263
|
+
if (init.signal?.aborted) {
|
|
264
|
+
void response.body?.cancel()
|
|
265
|
+
throw init.signal.reason
|
|
266
|
+
}
|
|
249
267
|
if (!response.ok || response.body === null) return response
|
|
250
|
-
const [
|
|
268
|
+
const [fromNetwork, forLog] = response.body.tee()
|
|
269
|
+
const forKernel = init.signal
|
|
270
|
+
? fromNetwork.pipeThrough(new TransformStream(), { signal: init.signal })
|
|
271
|
+
: fromNetwork
|
|
251
272
|
// Live responses reach the log in request order: a compaction's part
|
|
252
273
|
// precedes the step that follows it.
|
|
253
274
|
const previous = state.parsed
|
|
@@ -268,8 +289,13 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
268
289
|
state.parsed = (async () => {
|
|
269
290
|
await previous
|
|
270
291
|
emit({ type: 'start-step' })
|
|
271
|
-
await streamStep(
|
|
272
|
-
|
|
292
|
+
await streamStep({
|
|
293
|
+
body: forLog,
|
|
294
|
+
tools: step.tools,
|
|
295
|
+
emit,
|
|
296
|
+
finished: (finished) => {
|
|
297
|
+
completion = finished
|
|
298
|
+
},
|
|
273
299
|
})
|
|
274
300
|
})()
|
|
275
301
|
}
|
|
@@ -308,7 +334,8 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
308
334
|
return fail(state, diverged(`no recorded result for ${toolName}`))
|
|
309
335
|
})
|
|
310
336
|
|
|
311
|
-
|
|
337
|
+
const plans = (turns: HarnessTurn[]): Promise<TurnPlan[]> =>
|
|
338
|
+
Promise.all(turns.map((recorded) => turnPlan(recorded, step.signal)))
|
|
312
339
|
let results = read.results
|
|
313
340
|
let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
|
|
314
341
|
let closed = false
|
|
@@ -344,11 +371,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
344
371
|
if (read.saved === undefined) throw error
|
|
345
372
|
// An unusable checkpoint: replay the whole log instead.
|
|
346
373
|
read = await replayAll()
|
|
347
|
-
turns = read.turns.map(turnPlan)
|
|
348
374
|
results = read.results
|
|
349
375
|
agent = await create(undefined)
|
|
350
376
|
}
|
|
351
|
-
await run(streamController, turns)
|
|
377
|
+
await run(streamController, await plans(read.turns))
|
|
352
378
|
closed = true
|
|
353
379
|
streamController.close()
|
|
354
380
|
} catch (error) {
|
|
@@ -357,6 +383,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
357
383
|
step.signal.aborted ? step.signal.reason : error,
|
|
358
384
|
)
|
|
359
385
|
} finally {
|
|
386
|
+
released.abort()
|
|
360
387
|
step.signal.removeEventListener('abort', abort)
|
|
361
388
|
await agent?.close().catch(() => {})
|
|
362
389
|
}
|
|
@@ -365,6 +392,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
365
392
|
cancel() {
|
|
366
393
|
closed = true
|
|
367
394
|
stopped = true
|
|
395
|
+
released.abort()
|
|
368
396
|
turn?.fxTurn.cancel()
|
|
369
397
|
},
|
|
370
398
|
})
|
|
@@ -374,11 +402,14 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
|
374
402
|
|
|
375
403
|
// ------------------------------------------------------------ the plan
|
|
376
404
|
|
|
377
|
-
function turnPlan(
|
|
405
|
+
async function turnPlan(
|
|
406
|
+
turn: HarnessTurn,
|
|
407
|
+
signal: AbortSignal,
|
|
408
|
+
): Promise<TurnPlan> {
|
|
378
409
|
const calls: RecordedCall[] = []
|
|
379
410
|
const steers = new Map<number, string[]>()
|
|
380
|
-
const steer = (at: number,
|
|
381
|
-
if (
|
|
411
|
+
const steer = (at: number, inputs: HarnessInput[] | undefined) => {
|
|
412
|
+
if (inputs !== undefined) steers.set(Math.max(0, at), inputs.map(steerText))
|
|
382
413
|
}
|
|
383
414
|
steer(0, turn.generations.length === 0 ? turn.steers.get(0) : undefined)
|
|
384
415
|
turn.generations.forEach((generation, index) => {
|
|
@@ -386,7 +417,38 @@ function turnPlan(turn: HarnessTurn): TurnPlan {
|
|
|
386
417
|
// A generation's step is its last call.
|
|
387
418
|
steer(calls.length - 1, turn.steers.get(index))
|
|
388
419
|
})
|
|
389
|
-
|
|
420
|
+
const images = await harnessImages({
|
|
421
|
+
harness: 'fx()',
|
|
422
|
+
input: turn.prompt,
|
|
423
|
+
signal,
|
|
424
|
+
})
|
|
425
|
+
return {
|
|
426
|
+
prompt:
|
|
427
|
+
images.length === 0
|
|
428
|
+
? turn.prompt.text
|
|
429
|
+
: [
|
|
430
|
+
...images.map((image): FxPromptBlock => ({
|
|
431
|
+
type: 'image',
|
|
432
|
+
data: image.data,
|
|
433
|
+
mimeType: image.mediaType,
|
|
434
|
+
})),
|
|
435
|
+
...(turn.prompt.text === ''
|
|
436
|
+
? []
|
|
437
|
+
: [{ type: 'text' as const, text: turn.prompt.text }]),
|
|
438
|
+
],
|
|
439
|
+
calls,
|
|
440
|
+
steers,
|
|
441
|
+
end: turn.end,
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** libfx steers with text alone, so a steer's files fail the step. */
|
|
446
|
+
function steerText(input: HarnessInput): string {
|
|
447
|
+
if (input.files.length > 0)
|
|
448
|
+
throw new Error(
|
|
449
|
+
'fx() steers with text only: send files in a message of their own',
|
|
450
|
+
)
|
|
451
|
+
return input.text
|
|
390
452
|
}
|
|
391
453
|
|
|
392
454
|
/** A completed generation's model calls: its compactions, then its step. */
|
|
@@ -448,36 +510,35 @@ function recordedCalls(
|
|
|
448
510
|
})
|
|
449
511
|
break
|
|
450
512
|
case 'tool-input-error':
|
|
451
|
-
|
|
513
|
+
case 'tool-input-available': {
|
|
514
|
+
// A call recorded without its text: input that failed to parse is
|
|
515
|
+
// that text.
|
|
516
|
+
const text =
|
|
517
|
+
inputText.get(chunk.toolCallId) ||
|
|
518
|
+
(chunk.type === 'tool-input-error' && typeof chunk.input === 'string'
|
|
519
|
+
? chunk.input
|
|
520
|
+
: JSON.stringify(chunk.input))
|
|
452
521
|
if (inputText.has(chunk.toolCallId))
|
|
453
522
|
parts.push({ type: 'tool-input-end', id: chunk.toolCallId })
|
|
523
|
+
// The kernel calls back with this text's JSON, not the input the
|
|
524
|
+
// tool's schema made of it. A call it answers itself, such as one
|
|
525
|
+
// whose text is not JSON, is never asked for.
|
|
526
|
+
const input = parseJson(text)
|
|
527
|
+
if (input !== undefined)
|
|
528
|
+
toolCalls.push({
|
|
529
|
+
toolCallId: chunk.toolCallId,
|
|
530
|
+
toolName: chunk.toolName,
|
|
531
|
+
input,
|
|
532
|
+
})
|
|
454
533
|
parts.push({
|
|
455
534
|
type: 'tool-call',
|
|
456
535
|
toolCallId: chunk.toolCallId,
|
|
457
536
|
toolName: chunk.toolName,
|
|
458
|
-
input:
|
|
459
|
-
inputText.get(chunk.toolCallId) ||
|
|
460
|
-
(typeof chunk.input === 'string'
|
|
461
|
-
? chunk.input
|
|
462
|
-
: JSON.stringify(chunk.input)),
|
|
463
|
-
})
|
|
464
|
-
break
|
|
465
|
-
case 'tool-input-available':
|
|
466
|
-
if (inputText.has(chunk.toolCallId))
|
|
467
|
-
parts.push({ type: 'tool-input-end', id: chunk.toolCallId })
|
|
468
|
-
toolCalls.push({
|
|
469
|
-
toolCallId: chunk.toolCallId,
|
|
470
|
-
toolName: chunk.toolName,
|
|
471
|
-
input: chunk.input,
|
|
472
|
-
})
|
|
473
|
-
parts.push({
|
|
474
|
-
type: 'tool-call',
|
|
475
|
-
toolCallId: chunk.toolCallId,
|
|
476
|
-
toolName: chunk.toolName,
|
|
477
|
-
input: inputText.get(chunk.toolCallId) || JSON.stringify(chunk.input),
|
|
537
|
+
input: text,
|
|
478
538
|
...metadata,
|
|
479
539
|
})
|
|
480
540
|
break
|
|
541
|
+
}
|
|
481
542
|
}
|
|
482
543
|
}
|
|
483
544
|
parts.push({
|
|
@@ -505,11 +566,19 @@ function compactionSse(data: CompactionPartData): string {
|
|
|
505
566
|
// ------------------------------------------------------- live responses
|
|
506
567
|
|
|
507
568
|
/** Streams one live step response as UI chunks and reports its finish. */
|
|
508
|
-
async function streamStep(
|
|
509
|
-
body
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
569
|
+
async function streamStep({
|
|
570
|
+
body,
|
|
571
|
+
tools,
|
|
572
|
+
emit,
|
|
573
|
+
finished,
|
|
574
|
+
}: {
|
|
575
|
+
body: ReadableStream<Uint8Array>
|
|
576
|
+
tools: ToolSet
|
|
577
|
+
emit: (chunk: UIMessageChunk) => void
|
|
578
|
+
finished: (completion: Completion) => void
|
|
579
|
+
}): Promise<void> {
|
|
580
|
+
// The tool calls whose input started streaming, and whether text arrived.
|
|
581
|
+
const streamed = new Map<string, boolean>()
|
|
513
582
|
for await (const part of sseParts(body)) {
|
|
514
583
|
const metadata =
|
|
515
584
|
part['providerMetadata'] === undefined
|
|
@@ -532,6 +601,7 @@ async function streamStep(
|
|
|
532
601
|
})
|
|
533
602
|
break
|
|
534
603
|
case 'tool-input-start':
|
|
604
|
+
streamed.set(part['id'] as string, false)
|
|
535
605
|
emit({
|
|
536
606
|
type: 'tool-input-start',
|
|
537
607
|
toolCallId: part['id'] as string,
|
|
@@ -540,6 +610,7 @@ async function streamStep(
|
|
|
540
610
|
})
|
|
541
611
|
break
|
|
542
612
|
case 'tool-input-delta':
|
|
613
|
+
if (part['delta'] !== '') streamed.set(part['id'] as string, true)
|
|
543
614
|
emit({
|
|
544
615
|
type: 'tool-input-delta',
|
|
545
616
|
toolCallId: part['id'] as string,
|
|
@@ -547,27 +618,39 @@ async function streamStep(
|
|
|
547
618
|
})
|
|
548
619
|
break
|
|
549
620
|
case 'tool-call': {
|
|
621
|
+
const toolCallId = part['toolCallId'] as string
|
|
622
|
+
const toolName = part['toolName'] as string
|
|
550
623
|
const text = part['input'] as string
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
toolCallId: part['toolCallId'] as string,
|
|
558
|
-
toolName: part['toolName'] as string,
|
|
559
|
-
input: text,
|
|
560
|
-
errorText: error instanceof Error ? error.message : String(error),
|
|
561
|
-
})
|
|
562
|
-
break
|
|
624
|
+
// Replay hands the kernel the text the log holds, so the log keeps
|
|
625
|
+
// the provider's text when it streamed none.
|
|
626
|
+
if (streamed.get(toolCallId) !== true && text !== '') {
|
|
627
|
+
if (!streamed.has(toolCallId))
|
|
628
|
+
emit({ type: 'tool-input-start', toolCallId, toolName })
|
|
629
|
+
emit({ type: 'tool-input-delta', toolCallId, inputTextDelta: text })
|
|
563
630
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
input,
|
|
569
|
-
...metadata,
|
|
631
|
+
const input = await parseToolInput({
|
|
632
|
+
tools,
|
|
633
|
+
toolName,
|
|
634
|
+
text,
|
|
570
635
|
})
|
|
636
|
+
emit(
|
|
637
|
+
input.success
|
|
638
|
+
? {
|
|
639
|
+
type: 'tool-input-available',
|
|
640
|
+
toolCallId,
|
|
641
|
+
toolName,
|
|
642
|
+
input: input.value,
|
|
643
|
+
...metadata,
|
|
644
|
+
}
|
|
645
|
+
: {
|
|
646
|
+
type: 'tool-input-error',
|
|
647
|
+
toolCallId,
|
|
648
|
+
toolName,
|
|
649
|
+
input: input.input,
|
|
650
|
+
errorText: input.errorText,
|
|
651
|
+
...metadata,
|
|
652
|
+
},
|
|
653
|
+
)
|
|
571
654
|
break
|
|
572
655
|
}
|
|
573
656
|
case 'finish': {
|
|
@@ -587,6 +670,34 @@ async function streamStep(
|
|
|
587
670
|
}
|
|
588
671
|
}
|
|
589
672
|
|
|
673
|
+
type ToolInput =
|
|
674
|
+
| { success: true; value: unknown }
|
|
675
|
+
| { success: false; input: unknown; errorText: string }
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* A live call's input as the AI SDK step reads it: JSON, then the tool's
|
|
679
|
+
* schema with its defaults. Input that fails either is an input error, which
|
|
680
|
+
* A2 answers without running the tool.
|
|
681
|
+
*/
|
|
682
|
+
async function parseToolInput({
|
|
683
|
+
tools,
|
|
684
|
+
toolName,
|
|
685
|
+
text,
|
|
686
|
+
}: {
|
|
687
|
+
tools: ToolSet
|
|
688
|
+
toolName: string
|
|
689
|
+
text: string
|
|
690
|
+
}): Promise<ToolInput> {
|
|
691
|
+
let value: unknown
|
|
692
|
+
try {
|
|
693
|
+
value = JSON.parse(text)
|
|
694
|
+
} catch (error) {
|
|
695
|
+
return { success: false, input: text, errorText: errorText(error) }
|
|
696
|
+
}
|
|
697
|
+
const input = await schemaInput({ tools, toolName, value })
|
|
698
|
+
return input.success ? input : { ...input, input: value }
|
|
699
|
+
}
|
|
700
|
+
|
|
590
701
|
async function readParts(
|
|
591
702
|
body: ReadableStream<Uint8Array>,
|
|
592
703
|
): Promise<StreamPart[]> {
|
|
@@ -693,15 +804,13 @@ async function kernelTools(
|
|
|
693
804
|
execute: (toolName: string, input: unknown) => unknown,
|
|
694
805
|
): Promise<FxHostTool[]> {
|
|
695
806
|
return Promise.all(
|
|
696
|
-
Object.entries(tools)
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
execute: (input: unknown) => execute(toolName, input),
|
|
704
|
-
})),
|
|
807
|
+
Object.entries(tools).map(async ([toolName, tool]) => ({
|
|
808
|
+
name: toolName,
|
|
809
|
+
description:
|
|
810
|
+
typeof tool.description === 'string' ? tool.description : toolName,
|
|
811
|
+
inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
|
|
812
|
+
execute: (input: unknown) => execute(toolName, input),
|
|
813
|
+
})),
|
|
705
814
|
)
|
|
706
815
|
}
|
|
707
816
|
|
|
@@ -760,6 +869,14 @@ function decodeBody(body: BodyInit | null | undefined): string {
|
|
|
760
869
|
throw new TypeError('FX sent an unexpected request body')
|
|
761
870
|
}
|
|
762
871
|
|
|
872
|
+
function parseJson(text: string): unknown {
|
|
873
|
+
try {
|
|
874
|
+
return JSON.parse(text)
|
|
875
|
+
} catch {
|
|
876
|
+
return undefined
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
763
880
|
function sameJson(a: unknown, b: unknown): boolean {
|
|
764
881
|
return JSON.stringify(a) === JSON.stringify(b)
|
|
765
882
|
}
|
package/src/ai-harness.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import {
|
|
2
|
+
createDownload,
|
|
3
|
+
type Experimental_SandboxSession,
|
|
4
|
+
type FileUIPart,
|
|
5
|
+
type FinishReason,
|
|
6
|
+
type LanguageModelUsage,
|
|
7
|
+
type ToolSet,
|
|
8
|
+
type UIMessage,
|
|
9
|
+
type UIMessageChunk,
|
|
8
10
|
} from 'ai'
|
|
9
11
|
import type {
|
|
10
12
|
GenerationCompletedPayload,
|
|
13
|
+
MessageCreatedPayload,
|
|
11
14
|
ReasoningLevel,
|
|
12
15
|
ToolResultPayload,
|
|
13
16
|
} from './ai.ts'
|
|
17
|
+
import { STEERED_REASON } from './ai-control.ts'
|
|
14
18
|
import { boundedCache, type BoundedCache } from './bounded-cache.ts'
|
|
15
19
|
import type { ContractEvent, EventDefs } from './contract.ts'
|
|
16
20
|
|
|
@@ -307,21 +311,21 @@ function savedEnvelope(value: unknown): SavedEnvelope | undefined {
|
|
|
307
311
|
|
|
308
312
|
// ------------------------------------------------- the log as harness turns
|
|
309
313
|
|
|
310
|
-
// A2's reason on `ai.message.interrupted` when a steer ends a response.
|
|
311
|
-
const STEERED = 'steered after step'
|
|
312
|
-
|
|
313
314
|
/** A completed generation as the log recorded it. */
|
|
314
315
|
export type RecordedGeneration = {
|
|
315
316
|
chunks: UIMessageChunk[]
|
|
316
317
|
completed: GenerationCompletedPayload
|
|
317
318
|
}
|
|
318
319
|
|
|
320
|
+
/** What user messages hand a harness: their text, and the files they attach. */
|
|
321
|
+
export type HarnessInput = { text: string; files: FileUIPart[] }
|
|
322
|
+
|
|
319
323
|
/** One harness turn to rebuild: its prompt, completed generations, and end. */
|
|
320
324
|
export type HarnessTurn = {
|
|
321
|
-
prompt:
|
|
325
|
+
prompt: HarnessInput
|
|
322
326
|
generations: RecordedGeneration[]
|
|
323
327
|
/** Steering that entered after the generation at that index. */
|
|
324
|
-
steers: Map<number,
|
|
328
|
+
steers: Map<number, HarnessInput[]>
|
|
325
329
|
end: 'stop' | 'interrupted' | 'live'
|
|
326
330
|
}
|
|
327
331
|
|
|
@@ -342,15 +346,18 @@ export function harnessTurns(
|
|
|
342
346
|
}
|
|
343
347
|
type Response = {
|
|
344
348
|
id: string
|
|
345
|
-
prompt:
|
|
349
|
+
prompt: HarnessInput[]
|
|
346
350
|
generations: Generation[]
|
|
347
351
|
end?: 'stop' | 'steered' | 'interrupted'
|
|
348
352
|
}
|
|
349
|
-
//
|
|
350
|
-
|
|
353
|
+
// Prompts take their content from the transcript: a harness agent never
|
|
354
|
+
// runs A2's compaction, so the step's messages hold every input admitted by
|
|
355
|
+
// its read, before the snapshot or after it. A session another loop
|
|
356
|
+
// compacted can't move to a harness.
|
|
357
|
+
const userInput = new Map<string, HarnessInput>(
|
|
351
358
|
step.messages
|
|
352
359
|
.filter((message) => message.role === 'user')
|
|
353
|
-
.map((message) => [message.id,
|
|
360
|
+
.map((message) => [message.id, inputOf(message)]),
|
|
354
361
|
)
|
|
355
362
|
let passive: string[] = []
|
|
356
363
|
const responses = new Map<string, Response>()
|
|
@@ -362,10 +369,9 @@ export function harnessTurns(
|
|
|
362
369
|
const payload = event.payload as Record<string, unknown>
|
|
363
370
|
switch (event.type) {
|
|
364
371
|
case 'ai.message.created': {
|
|
365
|
-
const
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
if (payload['generate'] === false) passive.push(message.id)
|
|
372
|
+
const admitted = event.payload as MessageCreatedPayload
|
|
373
|
+
if (admitted.role === 'user' && admitted.generate === false)
|
|
374
|
+
passive.push(admitted.messageId)
|
|
369
375
|
break
|
|
370
376
|
}
|
|
371
377
|
case 'ai.generation.requested': {
|
|
@@ -376,7 +382,7 @@ export function harnessTurns(
|
|
|
376
382
|
passive = []
|
|
377
383
|
responses.set(id, {
|
|
378
384
|
id,
|
|
379
|
-
prompt: ids.flatMap((p) =>
|
|
385
|
+
prompt: ids.flatMap((p) => userInput.get(p) ?? []),
|
|
380
386
|
generations: [],
|
|
381
387
|
})
|
|
382
388
|
break
|
|
@@ -420,7 +426,7 @@ export function harnessTurns(
|
|
|
420
426
|
const response = responses.get(payload['messageId'] as string)
|
|
421
427
|
if (response)
|
|
422
428
|
response.end =
|
|
423
|
-
payload['reason'] ===
|
|
429
|
+
payload['reason'] === STEERED_REASON ? 'steered' : 'interrupted'
|
|
424
430
|
break
|
|
425
431
|
}
|
|
426
432
|
}
|
|
@@ -429,13 +435,16 @@ export function harnessTurns(
|
|
|
429
435
|
const turns: HarnessTurn[] = []
|
|
430
436
|
let steered = false
|
|
431
437
|
for (const response of responses.values()) {
|
|
432
|
-
const
|
|
438
|
+
const input: HarnessInput = {
|
|
439
|
+
text: response.prompt.map((prompt) => prompt.text).join('\n\n'),
|
|
440
|
+
files: response.prompt.flatMap((prompt) => prompt.files),
|
|
441
|
+
}
|
|
433
442
|
let turn = turns.at(-1)
|
|
434
443
|
if (steered && turn !== undefined) {
|
|
435
444
|
const at = Math.max(0, turn.generations.length - 1)
|
|
436
|
-
turn.steers.set(at, [...(turn.steers.get(at) ?? []),
|
|
445
|
+
turn.steers.set(at, [...(turn.steers.get(at) ?? []), input])
|
|
437
446
|
} else {
|
|
438
|
-
turn = { prompt:
|
|
447
|
+
turn = { prompt: input, generations: [], steers: new Map(), end: 'live' }
|
|
439
448
|
turns.push(turn)
|
|
440
449
|
}
|
|
441
450
|
for (const generation of response.generations)
|
|
@@ -455,10 +464,125 @@ export function harnessTurns(
|
|
|
455
464
|
return { turns, results }
|
|
456
465
|
}
|
|
457
466
|
|
|
458
|
-
function
|
|
459
|
-
return
|
|
460
|
-
|
|
461
|
-
|
|
467
|
+
function inputOf(message: UIMessage): HarnessInput {
|
|
468
|
+
return {
|
|
469
|
+
text: message.parts
|
|
470
|
+
.flatMap((part) => (part.type === 'text' ? [part.text] : []))
|
|
471
|
+
.join('\n'),
|
|
472
|
+
files: message.parts.filter(
|
|
473
|
+
(part): part is FileUIPart => part.type === 'file',
|
|
474
|
+
),
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ------------------------------------------------------------- attachments
|
|
479
|
+
|
|
480
|
+
/** An attached image as an engine takes it: its bytes in base64. */
|
|
481
|
+
export type HarnessImage = { mediaType: string; data: string }
|
|
482
|
+
|
|
483
|
+
// What one message's files hold in a step's memory, at most.
|
|
484
|
+
const MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024
|
|
485
|
+
|
|
486
|
+
type Attachment = { data: string; bytes: number }
|
|
487
|
+
|
|
488
|
+
// Every step of a response replays its turn, and a URL's file doesn't change,
|
|
489
|
+
// so the process keeps what it fetched.
|
|
490
|
+
const attachments = boundedCache<string, Attachment>({
|
|
491
|
+
maxEntries: 256,
|
|
492
|
+
maxBytes: 64 * 1024 * 1024,
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* The images an input attaches, as its engine takes them: engines take
|
|
497
|
+
* bytes, not URLs. A file it can't attach fails the step, naming the file,
|
|
498
|
+
* and is never left out: the failure may pass, and the step is retried like
|
|
499
|
+
* any other.
|
|
500
|
+
*/
|
|
501
|
+
export async function harnessImages({
|
|
502
|
+
harness,
|
|
503
|
+
input,
|
|
504
|
+
signal,
|
|
505
|
+
}: {
|
|
506
|
+
/** As the error names it: `fx()` or `pi()`. */
|
|
507
|
+
harness: string
|
|
508
|
+
input: HarnessInput
|
|
509
|
+
signal: AbortSignal
|
|
510
|
+
}): Promise<HarnessImage[]> {
|
|
511
|
+
const images: HarnessImage[] = []
|
|
512
|
+
let budget = MAX_ATTACHMENT_BYTES
|
|
513
|
+
// One at a time, so the budget bounds what the step holds.
|
|
514
|
+
for (const file of input.files) {
|
|
515
|
+
try {
|
|
516
|
+
if (!file.mediaType.startsWith('image/'))
|
|
517
|
+
throw new Error(`it takes images only, not ${file.mediaType}`)
|
|
518
|
+
const attached = await attachment({ url: file.url, budget, signal })
|
|
519
|
+
budget -= attached.bytes
|
|
520
|
+
images.push({
|
|
521
|
+
mediaType: file.mediaType,
|
|
522
|
+
data: attached.data,
|
|
523
|
+
})
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (signal.aborted) throw error
|
|
526
|
+
throw new Error(
|
|
527
|
+
`${harness} couldn't attach ${file.filename ?? 'a file'}: ${
|
|
528
|
+
error instanceof Error ? error.message : String(error)
|
|
529
|
+
}`,
|
|
530
|
+
{ cause: error },
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return images
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function attachment({
|
|
538
|
+
url,
|
|
539
|
+
budget,
|
|
540
|
+
signal,
|
|
541
|
+
}: {
|
|
542
|
+
url: string
|
|
543
|
+
budget: number
|
|
544
|
+
signal: AbortSignal
|
|
545
|
+
}): Promise<Attachment> {
|
|
546
|
+
const tooLarge = () =>
|
|
547
|
+
new Error(
|
|
548
|
+
`a message's files may hold ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MiB in a step`,
|
|
549
|
+
)
|
|
550
|
+
const cached = attachments.get(url)
|
|
551
|
+
if (cached !== undefined) {
|
|
552
|
+
if (cached.bytes > budget) throw tooLarge()
|
|
553
|
+
return cached
|
|
554
|
+
}
|
|
555
|
+
const { protocol } = new URL(url)
|
|
556
|
+
if (protocol !== 'https:' && protocol !== 'data:')
|
|
557
|
+
throw new Error(`attachments must be https: or data: URLs, not ${protocol}`)
|
|
558
|
+
// AI SDK's download checks an https: URL's address and its redirects,
|
|
559
|
+
// which a message's sender chooses; a data: URL holds its bytes.
|
|
560
|
+
const bytes =
|
|
561
|
+
protocol === 'data:'
|
|
562
|
+
? dataBytes(url)
|
|
563
|
+
: (
|
|
564
|
+
await createDownload({ maxBytes: budget })({
|
|
565
|
+
url: new URL(url),
|
|
566
|
+
abortSignal: signal,
|
|
567
|
+
})
|
|
568
|
+
).data
|
|
569
|
+
if (bytes.byteLength > budget) throw tooLarge()
|
|
570
|
+
const attached: Attachment = {
|
|
571
|
+
data: Buffer.from(bytes).toString('base64'),
|
|
572
|
+
bytes: bytes.byteLength,
|
|
573
|
+
}
|
|
574
|
+
if (protocol === 'https:')
|
|
575
|
+
attachments.set(url, attached, attached.data.length)
|
|
576
|
+
return attached
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function dataBytes(url: string): Uint8Array {
|
|
580
|
+
const comma = url.indexOf(',')
|
|
581
|
+
if (comma === -1) throw new Error('its data: URL has no data')
|
|
582
|
+
const body = url.slice(comma + 1)
|
|
583
|
+
return url.slice(0, comma).endsWith(';base64')
|
|
584
|
+
? Buffer.from(body, 'base64')
|
|
585
|
+
: Buffer.from(decodeURIComponent(body))
|
|
462
586
|
}
|
|
463
587
|
|
|
464
588
|
// ------------------------------------------------------------- Gateway
|