experimental-a2 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/dist/{actor-DBbe5xeE.d.ts → actor-Bjce9_5U.d.ts} +5 -5
- package/dist/{actor-DBbe5xeE.d.ts.map → actor-Bjce9_5U.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-UrWfKeuv.js → ai-BpOGm-zB.js} +2 -2
- package/dist/{ai-UrWfKeuv.js.map → ai-BpOGm-zB.js.map} +1 -1
- package/dist/{ai-vpGtcaCB.d.ts → ai-DrUaMyRt.d.ts} +3 -3
- package/dist/{ai-vpGtcaCB.d.ts.map → ai-DrUaMyRt.d.ts.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +634 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
- package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +904 -0
- package/dist/ai-pi.js.map +1 -0
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +47 -25
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +106 -37
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-COaiaDub.js → client-BReKqwZC.js} +3 -3
- package/dist/{client-COaiaDub.js.map → client-BReKqwZC.js.map} +1 -1
- package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
- package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
- package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
- package/dist/{internal-CbVvEwad.js → internal-B2T3U4Rq.js} +8 -2
- package/dist/internal-B2T3U4Rq.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
- package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/reducer-BAXc8JyF.js.map +1 -1
- package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
- package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-Rj64fNvX.js → server-Dw_k_7kO.js} +93 -60
- package/dist/server-Dw_k_7kO.js.map +1 -0
- package/dist/{server-CGM8WzRG.d.ts → server-En7cbi3v.d.ts} +36 -7
- package/dist/server-En7cbi3v.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +1 -1
- package/dist/{session-clear-Dshpuhff.d.ts → session-clear-Bo-XTFEy.d.ts} +2 -2
- package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
- package/dist/{store-Gpkq7WBo.d.ts → store-Djdv0bnZ.d.ts} +31 -2
- package/dist/store-Djdv0bnZ.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +32 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-4_y8lETm.js → store-polling-B5FcwJfo.js} +2 -2
- package/dist/{store-polling-4_y8lETm.js.map → store-polling-B5FcwJfo.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +54 -3
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-BAwS8RWn.js} +3 -3
- package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-BRITti_o.js} +187 -68
- package/dist/store-redis-notify-BRITti_o.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +32 -2
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-DjfHOvdW.js → wire-rSUglYo8.js} +2 -2
- package/dist/{wire-DjfHOvdW.js.map → wire-rSUglYo8.js.map} +1 -1
- package/docs/01-quickstart.mdx +49 -83
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -86
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -150
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +108 -144
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +855 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +722 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +141 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +322 -0
- package/docs/server/06-guarantees.mdx +123 -0
- package/examples/playground/DEPLOY.md +1 -1
- package/examples/playground/app/a0/[appId]/page.tsx +11 -6
- package/examples/playground/app/a0/app-list.tsx +9 -2
- package/examples/playground/app/a0/app-view.tsx +9 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
- package/examples/playground/app/a0/apps.test.ts +12 -0
- package/examples/playground/app/a0/apps.ts +22 -7
- package/examples/playground/app/a0/chat.tsx +47 -1
- package/examples/playground/app/a0/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
- package/examples/playground/app/a0/model.test.ts +18 -1
- package/examples/playground/app/a0/model.ts +47 -10
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
- package/examples/playground/app/a0/sandbox-log.ts +10 -4
- package/examples/playground/app/a0/sandbox-session.ts +152 -0
- package/examples/playground/app/a0/scheduler/route.ts +2 -2
- package/examples/playground/app/a0/server.ts +100 -45
- package/examples/playground/app/a0/session.ts +17 -5
- package/examples/playground/app/a0/shell.tsx +11 -3
- package/examples/playground/app/agent/ingress.test.ts +6 -3
- package/examples/playground/app/agent/server.ts +9 -6
- package/examples/playground/app/agent/telemetry.test.ts +71 -50
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +30 -26
- package/examples/playground/app/chat/server.ts +46 -46
- package/examples/playground/next.config.ts +17 -5
- package/examples/playground/package.json +4 -1
- package/examples/playground/vercel.json +18 -0
- package/package.json +27 -3
- package/src/ai-agent.ts +261 -87
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +833 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1263 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.ts +3 -1
- package/src/internal.ts +7 -0
- package/src/libfx.d.ts +34 -0
- package/src/reducer.ts +1 -1
- package/src/server.ts +177 -19
- package/src/store-memory.ts +42 -7
- package/src/store-postgres.ts +80 -0
- package/src/store-redis-core.ts +210 -79
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +60 -0
- package/src/store.ts +33 -0
- package/src/telemetry.ts +1 -0
- package/dist/internal-CbVvEwad.js.map +0 -1
- package/dist/server-CGM8WzRG.d.ts.map +0 -1
- package/dist/server-Rj64fNvX.js.map +0 -1
- package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
- package/dist/store-Gpkq7WBo.d.ts.map +0 -1
- package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
- package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
- package/docs/concepts/01-contracts.mdx +0 -237
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -193
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -324
- package/docs/guides/04-local-first.mdx +0 -47
- package/docs/guides/05-production.mdx +0 -534
- package/docs/guides/06-ai-agents.mdx +0 -1408
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -262
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -2358
- package/docs/reference/02-errors.mdx +0 -150
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
package/src/ai-fx.ts
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* experimental-a2/ai/fx — `fx()`: the FX harness for `agent({ harness })`.
|
|
3
|
+
* Server-only; FX's kernel comes from the optional `libfx` peer dependency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// oxlint-disable no-await-in-loop -- turns replay in order, each on the kernel the last one left.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
asSchema,
|
|
10
|
+
type FinishReason,
|
|
11
|
+
type LanguageModelUsage,
|
|
12
|
+
type ToolSet,
|
|
13
|
+
type UIMessageChunk,
|
|
14
|
+
} from 'ai'
|
|
15
|
+
import type { FxAgent, FxHostTool, FxTurn } from 'libfx'
|
|
16
|
+
import {
|
|
17
|
+
type Deferred,
|
|
18
|
+
defineHarness,
|
|
19
|
+
deferred,
|
|
20
|
+
Divergence,
|
|
21
|
+
gatewayCredential,
|
|
22
|
+
type GatewayCredential,
|
|
23
|
+
type Harness,
|
|
24
|
+
type HarnessGeneration,
|
|
25
|
+
type HarnessRead,
|
|
26
|
+
type HarnessStep,
|
|
27
|
+
type HarnessTurn,
|
|
28
|
+
pending,
|
|
29
|
+
readHarness,
|
|
30
|
+
saveHarness,
|
|
31
|
+
} from './ai-harness.ts'
|
|
32
|
+
import type { CompactionPartData, GenerationCompletedPayload } from './ai.ts'
|
|
33
|
+
import { boundedCache } from './bounded-cache.ts'
|
|
34
|
+
|
|
35
|
+
export type { Harness } from './ai-harness.ts'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The FX harness. Every model call FX makes is one durable step; A2 rebuilds
|
|
39
|
+
* the kernel for each step from its latest checkpoint and the log since.
|
|
40
|
+
*/
|
|
41
|
+
export function fx(): Harness<'fx', never> {
|
|
42
|
+
return defineHarness('fx', {
|
|
43
|
+
tools: {},
|
|
44
|
+
step: runStep,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SNAPSHOT_VERSION = 1
|
|
49
|
+
const CATALOG_TTL_MS = 60 * 60 * 1000
|
|
50
|
+
|
|
51
|
+
type StreamPart = { type: string } & Record<string, unknown>
|
|
52
|
+
type ToolCall = { toolCallId: string; toolName: string; input: unknown }
|
|
53
|
+
|
|
54
|
+
/** A model response A2 recorded, in the Gateway's stream format. */
|
|
55
|
+
type RecordedCall = {
|
|
56
|
+
kind: 'step' | 'compaction'
|
|
57
|
+
sse: string
|
|
58
|
+
toolCalls: ToolCall[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One FX turn to replay: its prompt, recorded calls, and how it ended. */
|
|
62
|
+
type TurnPlan = {
|
|
63
|
+
prompt: string
|
|
64
|
+
calls: RecordedCall[]
|
|
65
|
+
/** Steering to queue while serving the call at that index. */
|
|
66
|
+
steers: Map<number, string[]>
|
|
67
|
+
end: 'stop' | 'interrupted' | 'live'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Completion = { finishReason?: FinishReason; usage?: LanguageModelUsage }
|
|
71
|
+
|
|
72
|
+
/** The turn being replayed or run live, as the fetch and tool hooks see it. */
|
|
73
|
+
type TurnState = {
|
|
74
|
+
plan: TurnPlan
|
|
75
|
+
fxTurn: FxTurn
|
|
76
|
+
next: number
|
|
77
|
+
/** Tool calls of the last served step and the ones already answered. */
|
|
78
|
+
served: ToolCall[]
|
|
79
|
+
answered: Set<string>
|
|
80
|
+
live: boolean
|
|
81
|
+
liveStep: boolean
|
|
82
|
+
failure?: Error
|
|
83
|
+
/** Resolves when the live step is over: FX asked for its tools. */
|
|
84
|
+
toolsRequested: Deferred
|
|
85
|
+
parsed?: Promise<void>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fail(state: TurnState, error: Error): never {
|
|
89
|
+
state.failure ??= error
|
|
90
|
+
state.fxTurn.cancel()
|
|
91
|
+
throw state.failure
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const diverged = (message: string): Divergence => new Divergence('FX', message)
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------- step
|
|
97
|
+
|
|
98
|
+
async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
|
|
99
|
+
const { createFxAgent } = await loadLibfx()
|
|
100
|
+
const credential = gatewayCredential('fx')
|
|
101
|
+
const name = `${step.agentName}.a2.harness.fx`
|
|
102
|
+
const { replayAll, ...first } = await readHarness({
|
|
103
|
+
step,
|
|
104
|
+
name,
|
|
105
|
+
parse: savedCheckpoint,
|
|
106
|
+
})
|
|
107
|
+
let read: HarnessRead<Uint8Array> = first
|
|
108
|
+
|
|
109
|
+
let completion: Completion = {}
|
|
110
|
+
let stopped = false
|
|
111
|
+
let agent: FxAgent | undefined
|
|
112
|
+
let turn: TurnState | undefined
|
|
113
|
+
|
|
114
|
+
const run = async (
|
|
115
|
+
controller: ReadableStreamDefaultController<UIMessageChunk>,
|
|
116
|
+
turns: TurnPlan[],
|
|
117
|
+
): Promise<void> => {
|
|
118
|
+
controller.enqueue({ type: 'start', messageId: step.responseMessageId })
|
|
119
|
+
for (const plan of turns) {
|
|
120
|
+
if (stopped) return
|
|
121
|
+
const fxTurn = agent!.prompt(plan.prompt, { signal: step.signal })
|
|
122
|
+
const state: TurnState = {
|
|
123
|
+
plan,
|
|
124
|
+
fxTurn,
|
|
125
|
+
next: 0,
|
|
126
|
+
served: [],
|
|
127
|
+
answered: new Set(),
|
|
128
|
+
live: false,
|
|
129
|
+
liveStep: false,
|
|
130
|
+
toolsRequested: deferred(),
|
|
131
|
+
}
|
|
132
|
+
turn = state
|
|
133
|
+
const drained = (async () => {
|
|
134
|
+
for await (const _ of fxTurn) {
|
|
135
|
+
// Live output reaches A2 from the Gateway response itself.
|
|
136
|
+
}
|
|
137
|
+
})()
|
|
138
|
+
// A failed turn surfaces through its result.
|
|
139
|
+
drained.catch(() => {})
|
|
140
|
+
if (plan.end !== 'live') {
|
|
141
|
+
const result = await fxTurn.result.catch((error: unknown) => {
|
|
142
|
+
throw state.failure ?? error
|
|
143
|
+
})
|
|
144
|
+
await drained
|
|
145
|
+
if (state.failure) throw state.failure
|
|
146
|
+
const expected = plan.end === 'stop' ? 'end_turn' : 'cancelled'
|
|
147
|
+
if (result.stopReason !== expected)
|
|
148
|
+
throw diverged(
|
|
149
|
+
`a recorded turn ended with ${result.stopReason}, not ${expected}`,
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
const outcome = await Promise.race([
|
|
154
|
+
fxTurn.result.then((result) => ({ ended: true, result })),
|
|
155
|
+
state.toolsRequested.promise.then(() => ({ ended: false as const })),
|
|
156
|
+
]).catch((error: unknown) => {
|
|
157
|
+
throw state.failure ?? error
|
|
158
|
+
})
|
|
159
|
+
if (state.failure) throw state.failure
|
|
160
|
+
await state.parsed
|
|
161
|
+
if (outcome.ended) {
|
|
162
|
+
await drained
|
|
163
|
+
// Only a turn this attempt finished describes the log.
|
|
164
|
+
if (outcome.result.stopReason === 'end_turn' && !step.signal.aborted)
|
|
165
|
+
await saveCheckpoint()
|
|
166
|
+
} else {
|
|
167
|
+
// FX now waits on tools that A2's tool handlers run.
|
|
168
|
+
fxTurn.cancel()
|
|
169
|
+
}
|
|
170
|
+
controller.enqueue({ type: 'finish-step' })
|
|
171
|
+
controller.enqueue({
|
|
172
|
+
type: 'finish',
|
|
173
|
+
...(completion.finishReason === undefined
|
|
174
|
+
? {}
|
|
175
|
+
: { finishReason: completion.finishReason }),
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const saveCheckpoint = async (): Promise<void> => {
|
|
181
|
+
let checkpoint: Uint8Array
|
|
182
|
+
try {
|
|
183
|
+
checkpoint = await agent!.checkpoint()
|
|
184
|
+
} catch {
|
|
185
|
+
// Over libfx's bound: later steps replay from the previous snapshot.
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
await saveHarness({
|
|
189
|
+
step,
|
|
190
|
+
name,
|
|
191
|
+
state: {
|
|
192
|
+
version: SNAPSHOT_VERSION,
|
|
193
|
+
checkpoint: Buffer.from(checkpoint).toString('base64'),
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const hostFetch = async (
|
|
199
|
+
input: string | URL | Request,
|
|
200
|
+
init: RequestInit = {},
|
|
201
|
+
): Promise<Response> => {
|
|
202
|
+
const url = input instanceof Request ? input.url : String(input)
|
|
203
|
+
if ((init.method ?? 'GET').toUpperCase() === 'GET')
|
|
204
|
+
return catalog(url, credential, withAuth(init.headers, credential))
|
|
205
|
+
const state = turn!
|
|
206
|
+
if (state.failure) throw state.failure
|
|
207
|
+
const body = decodeBody(init.body)
|
|
208
|
+
const compaction = isCompaction(body)
|
|
209
|
+
const recorded = state.plan.calls[state.next]
|
|
210
|
+
if (recorded !== undefined) {
|
|
211
|
+
if ((recorded.kind === 'compaction') !== compaction)
|
|
212
|
+
fail(state, diverged(`request ${state.next} is not a ${recorded.kind}`))
|
|
213
|
+
const index = state.next++
|
|
214
|
+
if (recorded.kind === 'step') {
|
|
215
|
+
state.served = recorded.toolCalls
|
|
216
|
+
state.answered = new Set()
|
|
217
|
+
}
|
|
218
|
+
const steers = state.plan.steers.get(index) ?? []
|
|
219
|
+
return relay(recorded.sse, async () => {
|
|
220
|
+
for (const text of steers) await state.fxTurn.steer(text)
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
if (state.plan.end === 'stop')
|
|
224
|
+
fail(state, diverged('FX continued a turn the log ended'))
|
|
225
|
+
if (state.plan.end === 'interrupted') {
|
|
226
|
+
state.fxTurn.cancel()
|
|
227
|
+
throw new DOMException('The recorded turn was interrupted', 'AbortError')
|
|
228
|
+
}
|
|
229
|
+
if (state.liveStep && !compaction) {
|
|
230
|
+
// One generation is one step; the next step is A2's next generation.
|
|
231
|
+
state.toolsRequested.resolve()
|
|
232
|
+
return pending(init.signal)
|
|
233
|
+
}
|
|
234
|
+
state.live = true
|
|
235
|
+
return live(state, url, init, compaction)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let compactions = 0
|
|
239
|
+
const live = async (
|
|
240
|
+
state: TurnState,
|
|
241
|
+
url: string,
|
|
242
|
+
init: RequestInit,
|
|
243
|
+
compaction: boolean,
|
|
244
|
+
): Promise<Response> => {
|
|
245
|
+
const response = await fetch(url, {
|
|
246
|
+
...init,
|
|
247
|
+
headers: withAuth(init.headers, credential),
|
|
248
|
+
})
|
|
249
|
+
if (!response.ok || response.body === null) return response
|
|
250
|
+
const [forKernel, forLog] = response.body.tee()
|
|
251
|
+
// Live responses reach the log in request order: a compaction's part
|
|
252
|
+
// precedes the step that follows it.
|
|
253
|
+
const previous = state.parsed
|
|
254
|
+
if (compaction) {
|
|
255
|
+
// Unique across the response's generations: the transcript keys data parts by ID.
|
|
256
|
+
const id = `fx-compaction:${step.requestIndex}:${compactions++}`
|
|
257
|
+
state.parsed = (async () => {
|
|
258
|
+
const all = await readParts(forLog)
|
|
259
|
+
await previous
|
|
260
|
+
emit({
|
|
261
|
+
type: 'data-a2-compaction',
|
|
262
|
+
id,
|
|
263
|
+
data: compactionData(all),
|
|
264
|
+
})
|
|
265
|
+
})()
|
|
266
|
+
} else {
|
|
267
|
+
state.liveStep = true
|
|
268
|
+
state.parsed = (async () => {
|
|
269
|
+
await previous
|
|
270
|
+
emit({ type: 'start-step' })
|
|
271
|
+
await streamStep(forLog, emit, (finished) => {
|
|
272
|
+
completion = finished
|
|
273
|
+
})
|
|
274
|
+
})()
|
|
275
|
+
}
|
|
276
|
+
// A stopped step abandons its parse; its failure surfaces through the turn.
|
|
277
|
+
state.parsed.catch(() => {})
|
|
278
|
+
return new Response(forKernel, {
|
|
279
|
+
status: response.status,
|
|
280
|
+
headers: response.headers,
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const hostTools = await kernelTools(step.tools, (toolName, toolInput) => {
|
|
285
|
+
const state = turn!
|
|
286
|
+
if (state.failure) throw state.failure
|
|
287
|
+
const call = state.served.find(
|
|
288
|
+
(candidate) =>
|
|
289
|
+
candidate.toolName === toolName &&
|
|
290
|
+
!state.answered.has(candidate.toolCallId) &&
|
|
291
|
+
sameJson(candidate.input, toolInput),
|
|
292
|
+
)
|
|
293
|
+
const result = call === undefined ? undefined : results.get(call.toolCallId)
|
|
294
|
+
if (call !== undefined && result !== undefined) {
|
|
295
|
+
state.answered.add(call.toolCallId)
|
|
296
|
+
if (result.error !== undefined) throw new Error(result.error)
|
|
297
|
+
if (result.denied === true) throw new Error('The tool call was denied.')
|
|
298
|
+
return result.output ?? null
|
|
299
|
+
}
|
|
300
|
+
if (state.live) {
|
|
301
|
+
state.toolsRequested.resolve()
|
|
302
|
+
return pending(undefined)
|
|
303
|
+
}
|
|
304
|
+
if (state.plan.end === 'interrupted') {
|
|
305
|
+
state.fxTurn.cancel()
|
|
306
|
+
return pending(undefined)
|
|
307
|
+
}
|
|
308
|
+
return fail(state, diverged(`no recorded result for ${toolName}`))
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
let turns = read.turns.map(turnPlan)
|
|
312
|
+
let results = read.results
|
|
313
|
+
let controller: ReadableStreamDefaultController<UIMessageChunk> | undefined
|
|
314
|
+
let closed = false
|
|
315
|
+
const emit = (chunk: UIMessageChunk): void => {
|
|
316
|
+
if (!closed) controller!.enqueue(chunk)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const create = (checkpoint: Uint8Array | undefined) =>
|
|
320
|
+
createFxAgent({
|
|
321
|
+
apiKey: credential.token,
|
|
322
|
+
model: kernelModel(step),
|
|
323
|
+
...(step.instructions.length === 0
|
|
324
|
+
? {}
|
|
325
|
+
: { instructions: step.instructions }),
|
|
326
|
+
tools: hostTools,
|
|
327
|
+
...(checkpoint === undefined ? {} : { checkpoint }),
|
|
328
|
+
fetch: hostFetch,
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const stream = new ReadableStream<UIMessageChunk>({
|
|
332
|
+
start(streamController) {
|
|
333
|
+
controller = streamController
|
|
334
|
+
const abort = () => {
|
|
335
|
+
stopped = true
|
|
336
|
+
turn?.fxTurn.cancel()
|
|
337
|
+
}
|
|
338
|
+
step.signal.addEventListener('abort', abort, { once: true })
|
|
339
|
+
void (async () => {
|
|
340
|
+
try {
|
|
341
|
+
try {
|
|
342
|
+
agent = await create(read.saved)
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (read.saved === undefined) throw error
|
|
345
|
+
// An unusable checkpoint: replay the whole log instead.
|
|
346
|
+
read = await replayAll()
|
|
347
|
+
turns = read.turns.map(turnPlan)
|
|
348
|
+
results = read.results
|
|
349
|
+
agent = await create(undefined)
|
|
350
|
+
}
|
|
351
|
+
await run(streamController, turns)
|
|
352
|
+
closed = true
|
|
353
|
+
streamController.close()
|
|
354
|
+
} catch (error) {
|
|
355
|
+
closed = true
|
|
356
|
+
streamController.error(
|
|
357
|
+
step.signal.aborted ? step.signal.reason : error,
|
|
358
|
+
)
|
|
359
|
+
} finally {
|
|
360
|
+
step.signal.removeEventListener('abort', abort)
|
|
361
|
+
await agent?.close().catch(() => {})
|
|
362
|
+
}
|
|
363
|
+
})()
|
|
364
|
+
},
|
|
365
|
+
cancel() {
|
|
366
|
+
closed = true
|
|
367
|
+
stopped = true
|
|
368
|
+
turn?.fxTurn.cancel()
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
return { stream, completion: () => completion }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ------------------------------------------------------------ the plan
|
|
376
|
+
|
|
377
|
+
function turnPlan(turn: HarnessTurn): TurnPlan {
|
|
378
|
+
const calls: RecordedCall[] = []
|
|
379
|
+
const steers = new Map<number, string[]>()
|
|
380
|
+
const steer = (at: number, texts: string[] | undefined) => {
|
|
381
|
+
if (texts !== undefined) steers.set(Math.max(0, at), texts)
|
|
382
|
+
}
|
|
383
|
+
steer(0, turn.generations.length === 0 ? turn.steers.get(0) : undefined)
|
|
384
|
+
turn.generations.forEach((generation, index) => {
|
|
385
|
+
calls.push(...recordedCalls(generation.chunks, generation.completed))
|
|
386
|
+
// A generation's step is its last call.
|
|
387
|
+
steer(calls.length - 1, turn.steers.get(index))
|
|
388
|
+
})
|
|
389
|
+
return { prompt: turn.prompt, calls, steers, end: turn.end }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A completed generation's model calls: its compactions, then its step. */
|
|
393
|
+
function recordedCalls(
|
|
394
|
+
chunks: readonly UIMessageChunk[],
|
|
395
|
+
completed: GenerationCompletedPayload,
|
|
396
|
+
): RecordedCall[] {
|
|
397
|
+
const calls: RecordedCall[] = []
|
|
398
|
+
const parts: StreamPart[] = []
|
|
399
|
+
const toolCalls: ToolCall[] = []
|
|
400
|
+
// A streamed call's input is the provider's exact text; the kernel copies it.
|
|
401
|
+
const inputText = new Map<string, string>()
|
|
402
|
+
for (const chunk of chunks) {
|
|
403
|
+
const metadata =
|
|
404
|
+
'providerMetadata' in chunk && chunk.providerMetadata !== undefined
|
|
405
|
+
? { providerMetadata: chunk.providerMetadata }
|
|
406
|
+
: {}
|
|
407
|
+
switch (chunk.type) {
|
|
408
|
+
case 'data-a2-compaction':
|
|
409
|
+
calls.push({
|
|
410
|
+
kind: 'compaction',
|
|
411
|
+
sse: compactionSse(chunk.data as CompactionPartData),
|
|
412
|
+
toolCalls: [],
|
|
413
|
+
})
|
|
414
|
+
break
|
|
415
|
+
case 'text-start':
|
|
416
|
+
case 'text-end':
|
|
417
|
+
case 'reasoning-start':
|
|
418
|
+
case 'reasoning-end':
|
|
419
|
+
parts.push({ type: chunk.type, id: chunk.id, ...metadata })
|
|
420
|
+
break
|
|
421
|
+
case 'text-delta':
|
|
422
|
+
case 'reasoning-delta':
|
|
423
|
+
parts.push({
|
|
424
|
+
type: chunk.type,
|
|
425
|
+
id: chunk.id,
|
|
426
|
+
delta: chunk.delta,
|
|
427
|
+
...metadata,
|
|
428
|
+
})
|
|
429
|
+
break
|
|
430
|
+
case 'tool-input-start':
|
|
431
|
+
inputText.set(chunk.toolCallId, '')
|
|
432
|
+
parts.push({
|
|
433
|
+
type: 'tool-input-start',
|
|
434
|
+
id: chunk.toolCallId,
|
|
435
|
+
toolName: chunk.toolName,
|
|
436
|
+
...metadata,
|
|
437
|
+
})
|
|
438
|
+
break
|
|
439
|
+
case 'tool-input-delta':
|
|
440
|
+
inputText.set(
|
|
441
|
+
chunk.toolCallId,
|
|
442
|
+
(inputText.get(chunk.toolCallId) ?? '') + chunk.inputTextDelta,
|
|
443
|
+
)
|
|
444
|
+
parts.push({
|
|
445
|
+
type: 'tool-input-delta',
|
|
446
|
+
id: chunk.toolCallId,
|
|
447
|
+
delta: chunk.inputTextDelta,
|
|
448
|
+
})
|
|
449
|
+
break
|
|
450
|
+
case 'tool-input-error':
|
|
451
|
+
// A call whose input did not parse: FX answered it itself.
|
|
452
|
+
if (inputText.has(chunk.toolCallId))
|
|
453
|
+
parts.push({ type: 'tool-input-end', id: chunk.toolCallId })
|
|
454
|
+
parts.push({
|
|
455
|
+
type: 'tool-call',
|
|
456
|
+
toolCallId: chunk.toolCallId,
|
|
457
|
+
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),
|
|
478
|
+
...metadata,
|
|
479
|
+
})
|
|
480
|
+
break
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
parts.push({
|
|
484
|
+
type: 'finish',
|
|
485
|
+
finishReason: { unified: completed.finishReason ?? 'other' },
|
|
486
|
+
usage: streamUsage(completed.usage),
|
|
487
|
+
})
|
|
488
|
+
calls.push({ kind: 'step', sse: sse(parts), toolCalls })
|
|
489
|
+
return calls
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function compactionSse(data: CompactionPartData): string {
|
|
493
|
+
return sse([
|
|
494
|
+
{ type: 'text-start', id: '0' },
|
|
495
|
+
{ type: 'text-delta', id: '0', delta: data.summary ?? '' },
|
|
496
|
+
{ type: 'text-end', id: '0' },
|
|
497
|
+
{
|
|
498
|
+
type: 'finish',
|
|
499
|
+
finishReason: { unified: 'stop' },
|
|
500
|
+
usage: streamUsage(data.usage),
|
|
501
|
+
},
|
|
502
|
+
])
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ------------------------------------------------------- live responses
|
|
506
|
+
|
|
507
|
+
/** Streams one live step response as UI chunks and reports its finish. */
|
|
508
|
+
async function streamStep(
|
|
509
|
+
body: ReadableStream<Uint8Array>,
|
|
510
|
+
emit: (chunk: UIMessageChunk) => void,
|
|
511
|
+
finished: (completion: Completion) => void,
|
|
512
|
+
): Promise<void> {
|
|
513
|
+
for await (const part of sseParts(body)) {
|
|
514
|
+
const metadata =
|
|
515
|
+
part['providerMetadata'] === undefined
|
|
516
|
+
? {}
|
|
517
|
+
: { providerMetadata: part['providerMetadata'] as never }
|
|
518
|
+
switch (part.type) {
|
|
519
|
+
case 'text-start':
|
|
520
|
+
case 'text-end':
|
|
521
|
+
case 'reasoning-start':
|
|
522
|
+
case 'reasoning-end':
|
|
523
|
+
emit({ type: part.type, id: part['id'] as string, ...metadata })
|
|
524
|
+
break
|
|
525
|
+
case 'text-delta':
|
|
526
|
+
case 'reasoning-delta':
|
|
527
|
+
emit({
|
|
528
|
+
type: part.type,
|
|
529
|
+
id: part['id'] as string,
|
|
530
|
+
delta: part['delta'] as string,
|
|
531
|
+
...metadata,
|
|
532
|
+
})
|
|
533
|
+
break
|
|
534
|
+
case 'tool-input-start':
|
|
535
|
+
emit({
|
|
536
|
+
type: 'tool-input-start',
|
|
537
|
+
toolCallId: part['id'] as string,
|
|
538
|
+
toolName: part['toolName'] as string,
|
|
539
|
+
...metadata,
|
|
540
|
+
})
|
|
541
|
+
break
|
|
542
|
+
case 'tool-input-delta':
|
|
543
|
+
emit({
|
|
544
|
+
type: 'tool-input-delta',
|
|
545
|
+
toolCallId: part['id'] as string,
|
|
546
|
+
inputTextDelta: part['delta'] as string,
|
|
547
|
+
})
|
|
548
|
+
break
|
|
549
|
+
case 'tool-call': {
|
|
550
|
+
const text = part['input'] as string
|
|
551
|
+
let input: unknown
|
|
552
|
+
try {
|
|
553
|
+
input = JSON.parse(text)
|
|
554
|
+
} catch (error) {
|
|
555
|
+
emit({
|
|
556
|
+
type: 'tool-input-error',
|
|
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
|
|
563
|
+
}
|
|
564
|
+
emit({
|
|
565
|
+
type: 'tool-input-available',
|
|
566
|
+
toolCallId: part['toolCallId'] as string,
|
|
567
|
+
toolName: part['toolName'] as string,
|
|
568
|
+
input,
|
|
569
|
+
...metadata,
|
|
570
|
+
})
|
|
571
|
+
break
|
|
572
|
+
}
|
|
573
|
+
case 'finish': {
|
|
574
|
+
const reason = part['finishReason'] as { unified?: FinishReason }
|
|
575
|
+
finished({
|
|
576
|
+
...(reason?.unified === undefined
|
|
577
|
+
? {}
|
|
578
|
+
: { finishReason: reason.unified }),
|
|
579
|
+
usage: modelUsage(part['usage']),
|
|
580
|
+
})
|
|
581
|
+
break
|
|
582
|
+
}
|
|
583
|
+
case 'error':
|
|
584
|
+
emit({ type: 'error', errorText: errorText(part['error']) })
|
|
585
|
+
break
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function readParts(
|
|
591
|
+
body: ReadableStream<Uint8Array>,
|
|
592
|
+
): Promise<StreamPart[]> {
|
|
593
|
+
const all: StreamPart[] = []
|
|
594
|
+
for await (const part of sseParts(body)) all.push(part)
|
|
595
|
+
return all
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function compactionData(all: readonly StreamPart[]): CompactionPartData {
|
|
599
|
+
const finish = all.find((part) => part.type === 'finish')
|
|
600
|
+
return {
|
|
601
|
+
status: 'completed',
|
|
602
|
+
summary: all
|
|
603
|
+
.filter((part) => part.type === 'text-delta')
|
|
604
|
+
.map((part) => part['delta'] as string)
|
|
605
|
+
.join(''),
|
|
606
|
+
...(finish === undefined ? {} : { usage: modelUsage(finish['usage']) }),
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function* sseParts(
|
|
611
|
+
body: ReadableStream<Uint8Array>,
|
|
612
|
+
): AsyncGenerator<StreamPart> {
|
|
613
|
+
const decoder = new TextDecoder()
|
|
614
|
+
let pendingText = ''
|
|
615
|
+
for await (const chunk of body) {
|
|
616
|
+
pendingText += decoder.decode(chunk, { stream: true })
|
|
617
|
+
let end: number
|
|
618
|
+
while ((end = pendingText.indexOf('\n\n')) !== -1) {
|
|
619
|
+
const frame = pendingText.slice(0, end)
|
|
620
|
+
pendingText = pendingText.slice(end + 2)
|
|
621
|
+
if (frame.startsWith('data: {')) yield JSON.parse(frame.slice(6))
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Hands a recorded response to the kernel frame by frame. Steering due after
|
|
628
|
+
* it is queued before its finish part, while the kernel still waits on it.
|
|
629
|
+
*/
|
|
630
|
+
function relay(text: string, beforeFinish: () => Promise<void>): Response {
|
|
631
|
+
const encoder = new TextEncoder()
|
|
632
|
+
const frames = text.split(/(?<=\n\n)/)
|
|
633
|
+
let index = 0
|
|
634
|
+
let steered = false
|
|
635
|
+
return new Response(
|
|
636
|
+
new ReadableStream({
|
|
637
|
+
async pull(controller) {
|
|
638
|
+
const frame = frames[index++]
|
|
639
|
+
if (frame === undefined) {
|
|
640
|
+
controller.close()
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
if (!steered && frame.includes('"type":"finish"')) {
|
|
644
|
+
steered = true
|
|
645
|
+
await beforeFinish()
|
|
646
|
+
}
|
|
647
|
+
controller.enqueue(encoder.encode(frame))
|
|
648
|
+
},
|
|
649
|
+
}),
|
|
650
|
+
{ headers: { 'content-type': 'text/event-stream' } },
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ----------------------------------------------------------- the kernel
|
|
655
|
+
|
|
656
|
+
async function loadLibfx(): Promise<typeof import('libfx')> {
|
|
657
|
+
try {
|
|
658
|
+
return await import('libfx')
|
|
659
|
+
} catch (error) {
|
|
660
|
+
throw new Error('fx() needs the libfx package: install libfx', {
|
|
661
|
+
cause: error,
|
|
662
|
+
})
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function withAuth(
|
|
667
|
+
headers: HeadersInit | undefined,
|
|
668
|
+
credential: GatewayCredential,
|
|
669
|
+
): Headers {
|
|
670
|
+
const result = new Headers(headers)
|
|
671
|
+
result.set('ai-gateway-auth-method', credential.method)
|
|
672
|
+
return result
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function kernelModel(
|
|
676
|
+
step: HarnessStep,
|
|
677
|
+
): string | { id: string; effort?: string; fast?: boolean } {
|
|
678
|
+
const effort =
|
|
679
|
+
step.reasoning === undefined || step.reasoning === 'provider-default'
|
|
680
|
+
? undefined
|
|
681
|
+
: step.reasoning
|
|
682
|
+
const fast = step.speed === 'fast'
|
|
683
|
+
if (effort === undefined && !fast) return step.model
|
|
684
|
+
return {
|
|
685
|
+
id: step.model,
|
|
686
|
+
...(effort === undefined ? {} : { effort }),
|
|
687
|
+
...(fast ? { fast } : {}),
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function kernelTools(
|
|
692
|
+
tools: ToolSet,
|
|
693
|
+
execute: (toolName: string, input: unknown) => unknown,
|
|
694
|
+
): Promise<FxHostTool[]> {
|
|
695
|
+
return Promise.all(
|
|
696
|
+
Object.entries(tools)
|
|
697
|
+
.filter(([, tool]) => tool.type !== 'provider')
|
|
698
|
+
.map(async ([toolName, tool]) => ({
|
|
699
|
+
name: toolName,
|
|
700
|
+
description:
|
|
701
|
+
typeof tool.description === 'string' ? tool.description : toolName,
|
|
702
|
+
inputSchema: (await asSchema(tool.inputSchema).jsonSchema) as object,
|
|
703
|
+
execute: (input: unknown) => execute(toolName, input),
|
|
704
|
+
})),
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Keyed by credential, and Vercel's OIDC tokens rotate, so it is bounded.
|
|
709
|
+
const catalogCache = boundedCache<
|
|
710
|
+
string,
|
|
711
|
+
{ expires: number; body: Promise<string> }
|
|
712
|
+
>({ maxEntries: 16, maxBytes: 16 })
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Model catalog reads, shared for an hour by every step in the process that
|
|
716
|
+
* uses the same credential; credentials can see different catalogs.
|
|
717
|
+
*/
|
|
718
|
+
async function catalog(
|
|
719
|
+
url: string,
|
|
720
|
+
credential: GatewayCredential,
|
|
721
|
+
headers: Headers,
|
|
722
|
+
): Promise<Response> {
|
|
723
|
+
const key = `${url}\n${credential.token}`
|
|
724
|
+
const now = Date.now()
|
|
725
|
+
let entry = catalogCache.get(key)
|
|
726
|
+
if (entry === undefined || entry.expires <= now) {
|
|
727
|
+
const body = fetch(url, { headers }).then(async (response) => {
|
|
728
|
+
if (!response.ok)
|
|
729
|
+
throw new Error(`catalog request failed: ${response.status}`)
|
|
730
|
+
return response.text()
|
|
731
|
+
})
|
|
732
|
+
entry = { expires: now + CATALOG_TTL_MS, body }
|
|
733
|
+
catalogCache.set(key, entry, 1)
|
|
734
|
+
body.catch(() => catalogCache.delete(key))
|
|
735
|
+
}
|
|
736
|
+
return new Response(await entry.body, {
|
|
737
|
+
headers: { 'content-type': 'application/json' },
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ------------------------------------------------------------- helpers
|
|
742
|
+
|
|
743
|
+
function savedCheckpoint(state: unknown): Uint8Array | undefined {
|
|
744
|
+
if (typeof state !== 'object' || state === null) return undefined
|
|
745
|
+
const { version, checkpoint } = state as Record<string, unknown>
|
|
746
|
+
if (version !== SNAPSHOT_VERSION || typeof checkpoint !== 'string')
|
|
747
|
+
return undefined
|
|
748
|
+
return new Uint8Array(Buffer.from(checkpoint, 'base64'))
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function isCompaction(body: string): boolean {
|
|
752
|
+
const request = JSON.parse(body) as { toolChoice?: { type?: string } }
|
|
753
|
+
return request.toolChoice?.type === 'none'
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function decodeBody(body: BodyInit | null | undefined): string {
|
|
757
|
+
if (typeof body === 'string') return body
|
|
758
|
+
if (body instanceof Uint8Array || body instanceof ArrayBuffer)
|
|
759
|
+
return new TextDecoder().decode(body)
|
|
760
|
+
throw new TypeError('FX sent an unexpected request body')
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
764
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function sse(all: readonly object[]): string {
|
|
768
|
+
return (
|
|
769
|
+
all.map((part) => `data: ${JSON.stringify(part)}\n\n`).join('') +
|
|
770
|
+
'data: [DONE]\n\n'
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function errorText(error: unknown): string {
|
|
775
|
+
if (typeof error === 'string') return error
|
|
776
|
+
if (error instanceof Error) return error.message
|
|
777
|
+
return JSON.stringify(error)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
type StreamUsage = {
|
|
781
|
+
inputTokens?: {
|
|
782
|
+
total?: number | undefined
|
|
783
|
+
noCache?: number | undefined
|
|
784
|
+
cacheRead?: number | undefined
|
|
785
|
+
cacheWrite?: number | undefined
|
|
786
|
+
}
|
|
787
|
+
outputTokens?: {
|
|
788
|
+
total?: number | undefined
|
|
789
|
+
text?: number | undefined
|
|
790
|
+
reasoning?: number | undefined
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** A Gateway stream usage, as AI SDK normalizes it. */
|
|
795
|
+
function modelUsage(value: unknown): LanguageModelUsage {
|
|
796
|
+
const usage = (value ?? {}) as StreamUsage
|
|
797
|
+
const input = usage.inputTokens ?? {}
|
|
798
|
+
const output = usage.outputTokens ?? {}
|
|
799
|
+
return {
|
|
800
|
+
inputTokens: input.total,
|
|
801
|
+
inputTokenDetails: {
|
|
802
|
+
noCacheTokens: input.noCache,
|
|
803
|
+
cacheReadTokens: input.cacheRead,
|
|
804
|
+
cacheWriteTokens: input.cacheWrite,
|
|
805
|
+
},
|
|
806
|
+
outputTokens: output.total,
|
|
807
|
+
outputTokenDetails: {
|
|
808
|
+
textTokens: output.text,
|
|
809
|
+
reasoningTokens: output.reasoning,
|
|
810
|
+
},
|
|
811
|
+
totalTokens:
|
|
812
|
+
input.total === undefined && output.total === undefined
|
|
813
|
+
? undefined
|
|
814
|
+
: (input.total ?? 0) + (output.total ?? 0),
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** The inverse of `modelUsage`, for replay. */
|
|
819
|
+
function streamUsage(usage: LanguageModelUsage | undefined): StreamUsage {
|
|
820
|
+
return {
|
|
821
|
+
inputTokens: {
|
|
822
|
+
total: usage?.inputTokens,
|
|
823
|
+
noCache: usage?.inputTokenDetails?.noCacheTokens,
|
|
824
|
+
cacheRead: usage?.inputTokenDetails?.cacheReadTokens,
|
|
825
|
+
cacheWrite: usage?.inputTokenDetails?.cacheWriteTokens,
|
|
826
|
+
},
|
|
827
|
+
outputTokens: {
|
|
828
|
+
total: usage?.outputTokens,
|
|
829
|
+
text: usage?.outputTokenDetails?.textTokens,
|
|
830
|
+
reasoning: usage?.outputTokenDetails?.reasoningTokens,
|
|
831
|
+
},
|
|
832
|
+
}
|
|
833
|
+
}
|