experimental-a2 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
  3. package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor.d.ts +1 -1
  8. package/dist/actor.js +2 -2
  9. package/dist/{ai-DrUaMyRt.d.ts → ai-BMLlI2Ff.d.ts} +2 -2
  10. package/dist/{ai-DrUaMyRt.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  11. package/dist/{ai-BpOGm-zB.js → ai-D6mGlO8j.js} +2 -2
  12. package/dist/{ai-BpOGm-zB.js.map → ai-D6mGlO8j.js.map} +1 -1
  13. package/dist/ai-client.d.ts +1 -1
  14. package/dist/ai-client.js +2 -2
  15. package/dist/ai-fx.d.ts +1 -1
  16. package/dist/ai-fx.d.ts.map +1 -1
  17. package/dist/ai-fx.js +92 -34
  18. package/dist/ai-fx.js.map +1 -1
  19. package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-B2suP2Qh.d.ts} +2 -2
  20. package/dist/{ai-harness-DvsPeJeB.d.ts.map → ai-harness-B2suP2Qh.d.ts.map} +1 -1
  21. package/dist/ai-pi.d.ts +1 -1
  22. package/dist/ai-pi.d.ts.map +1 -1
  23. package/dist/ai-pi.js +59 -13
  24. package/dist/ai-pi.js.map +1 -1
  25. package/dist/ai-react.d.ts +1 -1
  26. package/dist/ai-react.js +2 -2
  27. package/dist/ai-server.d.ts +3 -3
  28. package/dist/ai-server.d.ts.map +1 -1
  29. package/dist/ai-server.js +10 -6
  30. package/dist/ai-server.js.map +1 -1
  31. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  32. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  33. package/dist/ai.d.ts +1 -1
  34. package/dist/ai.js +1 -1
  35. package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
  36. package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
  37. package/dist/client.js +1 -1
  38. package/dist/index.d.ts +2 -2
  39. package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
  40. package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
  41. package/dist/react.js +1 -1
  42. package/dist/scheduler-qstash.d.ts +2 -2
  43. package/dist/scheduler-qstash.js +3 -3
  44. package/dist/scheduler-qstash.js.map +1 -1
  45. package/dist/scheduler-vercel.d.ts +7 -7
  46. package/dist/scheduler-vercel.d.ts.map +1 -1
  47. package/dist/scheduler-vercel.js +9 -10
  48. package/dist/scheduler-vercel.js.map +1 -1
  49. package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
  50. package/dist/server-D-Y4pqwu.js.map +1 -0
  51. package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
  52. package/dist/server-DYAATFuT.d.ts.map +1 -0
  53. package/dist/server.d.ts +2 -2
  54. package/dist/server.js +1 -1
  55. package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  56. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  57. package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
  58. package/dist/store-CyLUy86v.d.ts.map +1 -0
  59. package/dist/store-N8PXxDAS.js.map +1 -1
  60. package/dist/store-memory.d.ts +1 -1
  61. package/dist/store-memory.js +25 -6
  62. package/dist/store-memory.js.map +1 -1
  63. package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
  64. package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  65. package/dist/store-postgres.d.ts +1 -1
  66. package/dist/store-postgres.d.ts.map +1 -1
  67. package/dist/store-postgres.js +55 -25
  68. package/dist/store-postgres.js.map +1 -1
  69. package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
  70. package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  71. package/dist/store-redis-http.d.ts +1 -1
  72. package/dist/store-redis-http.js +3 -3
  73. package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
  74. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  75. package/dist/store-redis.d.ts +1 -1
  76. package/dist/store-redis.js +1 -1
  77. package/dist/store-sqlite.d.ts +1 -1
  78. package/dist/store-sqlite.js +35 -20
  79. package/dist/store-sqlite.js.map +1 -1
  80. package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
  81. package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
  82. package/docs/agents/03-conversations.mdx +2 -2
  83. package/docs/index.mdx +3 -3
  84. package/docs/reference/02-server.mdx +12 -7
  85. package/docs/reference/04-ai.mdx +16 -7
  86. package/docs/reference/05-adapters.mdx +4 -4
  87. package/docs/server/01-handlers.mdx +1 -1
  88. package/docs/server/03-cancellation.mdx +3 -2
  89. package/docs/server/05-production.mdx +2 -1
  90. package/docs/server/06-guarantees.mdx +20 -11
  91. package/examples/playground/DEPLOY.md +2 -4
  92. package/examples/playground/app/a0/[appId]/page.tsx +2 -8
  93. package/examples/playground/app/a0/devtools.tsx +2 -8
  94. package/examples/playground/app/a0/server.ts +8 -19
  95. package/examples/playground/package.json +1 -1
  96. package/package.json +1 -1
  97. package/src/ai-agent.ts +10 -3
  98. package/src/ai-fx.ts +126 -47
  99. package/src/ai-pi.ts +74 -15
  100. package/src/ai-tool-input.ts +45 -0
  101. package/src/internal.ts +6 -0
  102. package/src/scheduler-qstash.ts +5 -5
  103. package/src/scheduler-vercel.ts +14 -15
  104. package/src/server.ts +58 -12
  105. package/src/store-memory.ts +50 -9
  106. package/src/store-postgres.ts +79 -28
  107. package/src/store-redis-core.ts +112 -31
  108. package/src/store-sqlite.ts +61 -28
  109. package/src/store.ts +22 -3
  110. package/dist/server-Dw_k_7kO.js.map +0 -1
  111. package/dist/server-En7cbi3v.d.ts.map +0 -1
  112. package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
  113. package/dist/store-Djdv0bnZ.d.ts.map +0 -1
  114. package/dist/store-redis-notify-BRITti_o.js.map +0 -1
@@ -577,7 +577,10 @@ fx(): Harness<'fx'>
577
577
  The [FX](https://fx.sh) harness for `agent({ harness })`, running FX's kernel
578
578
  from `libfx` (an optional peer dependency) in the server process. FX adds no
579
579
  system prompt and brings no tools: `instructions` is the whole system prompt
580
- and the agent's `tools` are all FX can call. `reasoning` becomes FX's effort and
580
+ and the agent's `tools` are all FX can call. A call's input goes through its
581
+ tool's schema, as under `{ type: 'ai-sdk' }`: `execute` receives the schema's
582
+ output, defaults included, and input the schema rejects goes back to the model
583
+ as an error, without running the tool. `reasoning` becomes FX's effort and
581
584
  `speed: 'fast'` its fast tier. FX compacts its own context; a compaction
582
585
  appears in the transcript as a `data-a2-compaction` part with its summary.
583
586
 
@@ -607,12 +610,18 @@ agent session from `@earendil-works/pi-coding-agent` (with
607
610
  `@earendil-works/pi-ai`, both optional peer dependencies). Pi brings its system
608
611
  prompt and its `read`, `write`, `edit`, and `bash` tools, which run in the
609
612
  session's sandbox. `instructions` is added to Pi's prompt. The agent's `tools`
610
- are added to Pi's; a tool with one of their names replaces Pi's. `reasoning`
611
- becomes Pi's thinking level, and `model` is one of the Gateway models Pi's
612
- catalog lists. Pi works in the sandbox's working directory; its `bash` runs
613
- with the sandbox's environment, never the server's. Pi compacts its own
614
- context; a compaction appears in the transcript as a `data-a2-compaction` part
615
- with its summary.
613
+ are added to Pi's; a tool with one of their names replaces Pi's. Every call
614
+ first passes Pi's own checks: Pi coerces its arguments to the tool's JSON Schema
615
+ (the string `"5"` becomes `5` for a number) and rejects what that schema
616
+ forbids, such as a property it does not list. A call to one of the agent's tools
617
+ then goes through the tool's schema, as under `{ type: 'ai-sdk' }`: `execute`
618
+ receives the schema's output, defaults included. Input either check rejects goes
619
+ back to the model as an error, without running the tool. `reasoning` becomes
620
+ Pi's thinking level, and `model` is one of the Gateway models Pi's catalog
621
+ lists. Pi works in the sandbox's working directory; its `bash` runs with the
622
+ sandbox's environment, never the server's. Pi compacts its own context; a
623
+ compaction appears in the transcript as a `data-a2-compaction` part with its
624
+ summary.
616
625
 
617
626
  - `sandbox` (`HarnessSandbox`, given a `HarnessSandboxContext`) resolves the
618
627
  session's AI SDK sandbox session (`Experimental_SandboxSession` from `ai`).
@@ -104,8 +104,8 @@ mid-span.
104
104
  | `a2.ai.ack.report_id` | `a2.ai.ack` | start | work report event id, matching the control span's id |
105
105
 
106
106
  - Drain outcomes: `settled` means nothing actionable is left; `busy` means live
107
- per-event claims remain; `stalled` means a caught failure needs a later
108
- retry.
107
+ per-event claims or a failed event's retry hold remain; `stalled` means a
108
+ caught failure in this drain needs a later retry.
109
109
  - A failing handler marks its `a2.event` span with the exception and error
110
110
  status. Alert on `a2.event.outcome = dead_lettered`. A dead-lettered event
111
111
  blocks only later events in its lane, until `session.redrive()` resolves it;
@@ -294,8 +294,8 @@ export const POST = scheduler.handler(ordersServer, billingServer)
294
294
  signatures return 401.
295
295
  - A signed malformed envelope, or a declared or streamed body over
296
296
  `maxBodyBytes`, returns 489 with `Upstash-NonRetryable-Error: true`.
297
- - Returns 200 when settled; when busy, after a durable successor watchdog
298
- exists; when stalled, after publishing a fresh successor, which resets the
297
+ - Returns 200 when settled; when busy or stalled, after a durable successor
298
+ watchdog exists, so each caught failure gets a fresh message and a fresh
299
299
  per-message retry budget. Append tasks return 200 after their fixed-id append
300
300
  succeeds and any recovery watchdog for pending handler work is durable; clear
301
301
  tasks after their conditional clear and its deletion finish.
@@ -192,7 +192,7 @@ records completion runs the handler again.
192
192
  ## When handlers throw
193
193
 
194
194
  Throwing means "retry me." A2 records the failure and retries with backoff.
195
- The failed event blocks only its lane. After ten caught failures it
195
+ The failed event blocks only its lane. After ten failed attempts it
196
196
  dead-letters and keeps blocking its lane until
197
197
  [you retry or skip it](/server/production#3-when-an-event-dead-letters).
198
198
 
@@ -70,8 +70,9 @@ what stops it racing its successor outside A2.
70
70
  Pass `ctx.signal` to your I/O and let the abort propagate. That handles every
71
71
  case:
72
72
 
73
- - **A propagated lease abort** surrenders the attempt: no completion, no
74
- failure budget spent, normal recovery.
73
+ - **A propagated lease abort** surrenders the attempt: no completion, and
74
+ normal recovery. Like a crash, it costs the event one attempt of its
75
+ failure budget.
75
76
  - **A user cancellation** (`abortOn`) is the one to catch and return normally
76
77
  from. The stop is the outcome, so the completion counts.
77
78
  - **Return normally after a lease abort** only when the work is done. A lapsed
@@ -241,7 +241,8 @@ import { ordersServer } from '@/server'
241
241
  const order = ordersServer.session(orderId)
242
242
  const [dead] = await order.deadLetters()
243
243
  if (dead) {
244
- // dead.lastError says why; deploy the handler fix first, then:
244
+ // dead.lastError says why (null if every attempt timed out or crashed);
245
+ // deploy the handler fix first, then:
245
246
  await order.redrive(dead.index, { action: 'retry' })
246
247
  }
247
248
  ```
@@ -22,8 +22,9 @@ and the log decides what remains. Reads never dispatch handlers.
22
22
  Each claim expires. If its holder dies, the claim lapses and the next wakeup
23
23
  claims the event again with a larger `ctx.attempt`. The retry gets the same
24
24
  event id, index, type, and payload. A kill after the claim can use up an
25
- attempt before your code runs, but it doesn't count as a caught failure. An
26
- older attempt can never overwrite a newer one's completion or failure.
25
+ attempt before your code runs, and that attempt counts toward the event's
26
+ [failure budget](#when-a-handler-keeps-failing). An older attempt can never
27
+ overwrite a newer one's completion or failure.
27
28
 
28
29
  :::note[Recovery is not an event]
29
30
  A2 does not append `recovered` or `continued`. Recovery retries the same event.
@@ -86,20 +87,28 @@ keeps its original cause.
86
87
  ## A chain that crosses the function timeout
87
88
 
88
89
  A function killed mid-handler restarts that handler from its entry point in a
89
- fresh invocation: same event, new attempt, no caught failure. Events already
90
- processed stay processed, so a chain of handlers can outlast one invocation,
91
- but each handler must fit in a fresh one. Split longer work into smaller
92
- events or increase the function duration. On Vercel, A2 caps the claim window
90
+ fresh invocation: same event, new attempt. Events already processed stay
91
+ processed, so a chain of handlers can outlast one invocation, but each handler
92
+ must fit in a fresh one: an attempt that runs out of time is a failed attempt,
93
+ and a handler that never fits dead-letters after ten. Split longer work into
94
+ smaller events or increase the function duration. On Vercel, A2 caps the claim window
93
95
  at the function deadline when the platform exposes it, so a retry starts
94
- promptly after a timeout.
96
+ promptly after a timeout. Once the deadline passes, A2 claims nothing more in
97
+ that invocation, so a timeout costs the event one attempt.
95
98
 
96
99
  ## When a handler keeps failing
97
100
 
98
101
  A caught failure counts against that event, releases its claim, and leaves
99
- other eligible events running. A2 retries it with backoff. Ten caught failures
100
- dead-letter the event: later events in its lane stay blocked, while unlaned
101
- events and other lanes continue. Hard kills don't consume this budget.
102
- The event waits until you
102
+ other eligible events running. A2 holds it back for a delay that starts at
103
+ one second and doubles with each failed attempt up to 30 seconds, then retries
104
+ it on the session's next wake. With a scheduler, that's a few seconds at first,
105
+ then the delay itself. An attempt that ends before it finishes, because its
106
+ process timed out, crashed, or lost its claim, counts too, once the next
107
+ attempt takes its claim over. Ten failed attempts dead-letter the event: later
108
+ events in its lane stay blocked, while unlaned events and other lanes continue.
109
+ A crash costs every event it interrupts one attempt, so a session whose work
110
+ keeps outrunning its function dead-letters: bound its fan-out, or put work that
111
+ waits on the same resource in one lane. The event waits until you
103
112
  [retry or skip it](/server/production#3-when-an-event-dead-letters) with
104
113
  `session.redrive()`.
105
114
 
@@ -118,10 +118,8 @@ cookie.
118
118
 
119
119
  A0's dev tools have a Crash button, which ends the function instance running
120
120
  an app's agent, mid-turn, so the scheduler resumes the turn in a fresh one.
121
- Crashing ends everything else that instance is serving, so only preview
122
- deployments allow it, behind Deployment Protection. Set `A0_CRASHES=on` to
123
- allow it anywhere else. Under `next dev` that takes the dev server down with
124
- it, so it's for trying recovery, not for everyday local runs.
121
+ Crashing ends everything else that instance is serving, and under `next dev`
122
+ it takes the dev server down with it.
125
123
 
126
124
  ## 6. WebSockets for the canvas
127
125
 
@@ -4,7 +4,7 @@ import { AppView } from '../app-view'
4
4
  import { isAppId } from '../apps'
5
5
  import { DevTools } from '../devtools'
6
6
  import { harnessFrom, logPage } from '../model'
7
- import { agentServers, crashesAllowed } from '../server'
7
+ import { agentServers } from '../server'
8
8
  import { SessionProvider } from '../session'
9
9
 
10
10
  /** An app, from its current state; the provider follows its log from there. */
@@ -40,13 +40,7 @@ export default async function A0App({
40
40
  <AppView
41
41
  appId={appId}
42
42
  harness={harness}
43
- devtools={
44
- <DevTools
45
- appId={appId}
46
- deployed={!!process.env.VERCEL}
47
- crashesAllowed={crashesAllowed}
48
- />
49
- }
43
+ devtools={<DevTools appId={appId} deployed={!!process.env.VERCEL} />}
50
44
  />
51
45
  </SessionProvider>
52
46
  )
@@ -20,12 +20,10 @@ const gap = (ms: number): string =>
20
20
  export function DevTools({
21
21
  appId,
22
22
  deployed,
23
- crashesAllowed,
24
23
  }: {
25
24
  appId: string
26
25
  /** Function limits and crashes only mean something on Vercel. */
27
26
  deployed: boolean
28
- crashesAllowed: boolean
29
27
  }): ReactNode {
30
28
  const { state, events, index, history, hydrated, loadHistory } = useSession()
31
29
  const short = useSearchParams().get('limit') === '5s'
@@ -83,12 +81,8 @@ export function DevTools({
83
81
  <button
84
82
  type="button"
85
83
  className="a0-ghost a0-devtools-crash"
86
- disabled={!crashesAllowed || crashes.state}
87
- title={
88
- crashesAllowed
89
- ? 'Ends the process running the agent, right now'
90
- : 'Crashes need a preview deployment, or A0_CRASHES=on'
91
- }
84
+ disabled={crashes.state}
85
+ title="Ends the process running the agent, right now"
92
86
  onClick={crash}
93
87
  >
94
88
  <Zap size={13} aria-hidden />
@@ -106,16 +106,14 @@ function chosenModel({
106
106
  }) {
107
107
  // Every step starts here, in the process that runs it, which is what
108
108
  // the dev tools' crash has to end.
109
- if (crashesAllowed) {
110
- const appId = event.sessionId
111
- waitUntil(
112
- crashOnRequest({
113
- appId,
114
- app: agentServers[harness].session(appId),
115
- log: crashServer.session(crashLogId(appId)),
116
- }).catch(() => {}),
117
- )
118
- }
109
+ const appId = event.sessionId
110
+ waitUntil(
111
+ crashOnRequest({
112
+ appId,
113
+ app: agentServers[harness].session(appId),
114
+ log: crashServer.session(crashLogId(appId)),
115
+ }).catch(() => {}),
116
+ )
119
117
  const { model, reasoning, fast } = state.modelChoice
120
118
  return { model, reasoning, speed: fast ? 'fast' : 'standard' } as const
121
119
  }
@@ -193,20 +191,11 @@ export const crashServer = createServer({
193
191
  contract: crashLog,
194
192
  })
195
193
 
196
- /**
197
- * A crash ends the function instance, and anything else it's serving, so
198
- * only previews allow it, behind Deployment Protection, unless A0_CRASHES
199
- * is on. Under next dev it would take the dev server down.
200
- */
201
- export const crashesAllowed =
202
- process.env.VERCEL_ENV === 'preview' || process.env.A0_CRASHES === 'on'
203
-
204
194
  /** The browser can only ask for a crash; the process it ends records it. */
205
195
  export function crashEvents(request: Request): Promise<Response> {
206
196
  return crashServer.fetch(request, {
207
197
  authorize: (operation) =>
208
198
  isCrashLogId(operation.sessionId) &&
209
- (crashesAllowed || operation.type !== 'push') &&
210
199
  (operation.type !== 'push' ||
211
200
  operation.events.every((event) => event.type === 'crash.requested')),
212
201
  })
@@ -28,7 +28,7 @@
28
28
  "@vercel/sandbox": "^3.0.0",
29
29
  "ai": "^7.0.58",
30
30
  "codemirror": "^6.0.2",
31
- "experimental-a2": "0.18.0",
31
+ "experimental-a2": "0.18.1",
32
32
  "geist": "^1.7.2",
33
33
  "ioredis": "^5.9.0",
34
34
  "libfx": "0.0.12",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "Syncs your immutable event log, from server to browser: one contract, handlers, derived state, and a live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai-agent.ts CHANGED
@@ -680,7 +680,15 @@ async function* consumeGeneration(options: {
680
680
  chunks: mergeDeltas(pending.splice(0)),
681
681
  })
682
682
  const reader = options.source.stream.getReader()
683
- let pendingChunk = reader.read()
683
+ // The next read starts before a batch is yielded, and the consumer may
684
+ // take a while to ask again: a stream that fails meanwhile must not leave
685
+ // that read rejected with no handler, which ends the process.
686
+ const readAhead = (): Promise<StreamRead<UIMessageChunk>> => {
687
+ const read = reader.read()
688
+ read.catch(() => {})
689
+ return read
690
+ }
691
+ let pendingChunk = readAhead()
684
692
  try {
685
693
  for (;;) {
686
694
  let result: StreamRead<UIMessageChunk>
@@ -701,7 +709,7 @@ async function* consumeGeneration(options: {
701
709
  pending.push(chunk)
702
710
  if (chunk.type === 'finish') streamedFinishReason = chunk.finishReason
703
711
  if (chunk.type === 'error') streamedError = chunk.errorText
704
- pendingChunk = reader.read()
712
+ pendingChunk = readAhead()
705
713
 
706
714
  if (pending.length >= maxChunks || isBoundaryChunk(pending.at(-1))) {
707
715
  yield batch()
@@ -712,7 +720,6 @@ async function* consumeGeneration(options: {
712
720
  if (pending.length > 0) yield batch()
713
721
  throw error
714
722
  } finally {
715
- void pendingChunk.catch(() => {})
716
723
  try {
717
724
  await reader.cancel()
718
725
  } catch {
package/src/ai-fx.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  saveHarness,
31
31
  } from './ai-harness.ts'
32
32
  import type { CompactionPartData, GenerationCompletedPayload } from './ai.ts'
33
+ import { schemaInput } from './ai-tool-input.ts'
33
34
  import { boundedCache } from './bounded-cache.ts'
34
35
 
35
36
  export type { Harness } from './ai-harness.ts'
@@ -49,6 +50,7 @@ const SNAPSHOT_VERSION = 1
49
50
  const CATALOG_TTL_MS = 60 * 60 * 1000
50
51
 
51
52
  type StreamPart = { type: string } & Record<string, unknown>
53
+ /** A recorded tool call, with the input the kernel reads from its text. */
52
54
  type ToolCall = { toolCallId: string; toolName: string; input: unknown }
53
55
 
54
56
  /** A model response A2 recorded, in the Gateway's stream format. */
@@ -108,6 +110,10 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
108
110
 
109
111
  let completion: Completion = {}
110
112
  let stopped = false
113
+ // Live responses belong to the step: the kernel aborts its fetch once it
114
+ // has read a response's finish, while A2 may still be reading its copy.
115
+ const released = new AbortController()
116
+ const network = AbortSignal.any([step.signal, released.signal])
111
117
  let agent: FxAgent | undefined
112
118
  let turn: TurnState | undefined
113
119
 
@@ -245,9 +251,18 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
245
251
  const response = await fetch(url, {
246
252
  ...init,
247
253
  headers: withAuth(init.headers, credential),
254
+ signal: network,
248
255
  })
256
+ // Abandoned before its headers came: the kernel has moved on without it.
257
+ if (init.signal?.aborted) {
258
+ void response.body?.cancel()
259
+ throw init.signal.reason
260
+ }
249
261
  if (!response.ok || response.body === null) return response
250
- const [forKernel, forLog] = response.body.tee()
262
+ const [fromNetwork, forLog] = response.body.tee()
263
+ const forKernel = init.signal
264
+ ? fromNetwork.pipeThrough(new TransformStream(), { signal: init.signal })
265
+ : fromNetwork
251
266
  // Live responses reach the log in request order: a compaction's part
252
267
  // precedes the step that follows it.
253
268
  const previous = state.parsed
@@ -268,8 +283,13 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
268
283
  state.parsed = (async () => {
269
284
  await previous
270
285
  emit({ type: 'start-step' })
271
- await streamStep(forLog, emit, (finished) => {
272
- completion = finished
286
+ await streamStep({
287
+ body: forLog,
288
+ tools: step.tools,
289
+ emit,
290
+ finished: (finished) => {
291
+ completion = finished
292
+ },
273
293
  })
274
294
  })()
275
295
  }
@@ -357,6 +377,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
357
377
  step.signal.aborted ? step.signal.reason : error,
358
378
  )
359
379
  } finally {
380
+ released.abort()
360
381
  step.signal.removeEventListener('abort', abort)
361
382
  await agent?.close().catch(() => {})
362
383
  }
@@ -365,6 +386,7 @@ async function runStep(step: HarnessStep): Promise<HarnessGeneration> {
365
386
  cancel() {
366
387
  closed = true
367
388
  stopped = true
389
+ released.abort()
368
390
  turn?.fxTurn.cancel()
369
391
  },
370
392
  })
@@ -448,36 +470,35 @@ function recordedCalls(
448
470
  })
449
471
  break
450
472
  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':
473
+ case 'tool-input-available': {
474
+ // A call recorded without its text: input that failed to parse is
475
+ // that text.
476
+ const text =
477
+ inputText.get(chunk.toolCallId) ||
478
+ (chunk.type === 'tool-input-error' && typeof chunk.input === 'string'
479
+ ? chunk.input
480
+ : JSON.stringify(chunk.input))
466
481
  if (inputText.has(chunk.toolCallId))
467
482
  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
- })
483
+ // The kernel calls back with this text's JSON, not the input the
484
+ // tool's schema made of it. A call it answers itself, such as one
485
+ // whose text is not JSON, is never asked for.
486
+ const input = parseJson(text)
487
+ if (input !== undefined)
488
+ toolCalls.push({
489
+ toolCallId: chunk.toolCallId,
490
+ toolName: chunk.toolName,
491
+ input,
492
+ })
473
493
  parts.push({
474
494
  type: 'tool-call',
475
495
  toolCallId: chunk.toolCallId,
476
496
  toolName: chunk.toolName,
477
- input: inputText.get(chunk.toolCallId) || JSON.stringify(chunk.input),
497
+ input: text,
478
498
  ...metadata,
479
499
  })
480
500
  break
501
+ }
481
502
  }
482
503
  }
483
504
  parts.push({
@@ -505,11 +526,19 @@ function compactionSse(data: CompactionPartData): string {
505
526
  // ------------------------------------------------------- live responses
506
527
 
507
528
  /** 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> {
529
+ async function streamStep({
530
+ body,
531
+ tools,
532
+ emit,
533
+ finished,
534
+ }: {
535
+ body: ReadableStream<Uint8Array>
536
+ tools: ToolSet
537
+ emit: (chunk: UIMessageChunk) => void
538
+ finished: (completion: Completion) => void
539
+ }): Promise<void> {
540
+ // The tool calls whose input started streaming, and whether text arrived.
541
+ const streamed = new Map<string, boolean>()
513
542
  for await (const part of sseParts(body)) {
514
543
  const metadata =
515
544
  part['providerMetadata'] === undefined
@@ -532,6 +561,7 @@ async function streamStep(
532
561
  })
533
562
  break
534
563
  case 'tool-input-start':
564
+ streamed.set(part['id'] as string, false)
535
565
  emit({
536
566
  type: 'tool-input-start',
537
567
  toolCallId: part['id'] as string,
@@ -540,6 +570,7 @@ async function streamStep(
540
570
  })
541
571
  break
542
572
  case 'tool-input-delta':
573
+ if (part['delta'] !== '') streamed.set(part['id'] as string, true)
543
574
  emit({
544
575
  type: 'tool-input-delta',
545
576
  toolCallId: part['id'] as string,
@@ -547,27 +578,39 @@ async function streamStep(
547
578
  })
548
579
  break
549
580
  case 'tool-call': {
581
+ const toolCallId = part['toolCallId'] as string
582
+ const toolName = part['toolName'] as string
550
583
  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
584
+ // Replay hands the kernel the text the log holds, so the log keeps
585
+ // the provider's text when it streamed none.
586
+ if (streamed.get(toolCallId) !== true && text !== '') {
587
+ if (!streamed.has(toolCallId))
588
+ emit({ type: 'tool-input-start', toolCallId, toolName })
589
+ emit({ type: 'tool-input-delta', toolCallId, inputTextDelta: text })
563
590
  }
564
- emit({
565
- type: 'tool-input-available',
566
- toolCallId: part['toolCallId'] as string,
567
- toolName: part['toolName'] as string,
568
- input,
569
- ...metadata,
591
+ const input = await parseToolInput({
592
+ tools,
593
+ toolName,
594
+ text,
570
595
  })
596
+ emit(
597
+ input.success
598
+ ? {
599
+ type: 'tool-input-available',
600
+ toolCallId,
601
+ toolName,
602
+ input: input.value,
603
+ ...metadata,
604
+ }
605
+ : {
606
+ type: 'tool-input-error',
607
+ toolCallId,
608
+ toolName,
609
+ input: input.input,
610
+ errorText: input.errorText,
611
+ ...metadata,
612
+ },
613
+ )
571
614
  break
572
615
  }
573
616
  case 'finish': {
@@ -587,6 +630,34 @@ async function streamStep(
587
630
  }
588
631
  }
589
632
 
633
+ type ToolInput =
634
+ | { success: true; value: unknown }
635
+ | { success: false; input: unknown; errorText: string }
636
+
637
+ /**
638
+ * A live call's input as the AI SDK step reads it: JSON, then the tool's
639
+ * schema with its defaults. Input that fails either is an input error, which
640
+ * A2 answers without running the tool.
641
+ */
642
+ async function parseToolInput({
643
+ tools,
644
+ toolName,
645
+ text,
646
+ }: {
647
+ tools: ToolSet
648
+ toolName: string
649
+ text: string
650
+ }): Promise<ToolInput> {
651
+ let value: unknown
652
+ try {
653
+ value = JSON.parse(text)
654
+ } catch (error) {
655
+ return { success: false, input: text, errorText: errorText(error) }
656
+ }
657
+ const input = await schemaInput({ tools, toolName, value })
658
+ return input.success ? input : { ...input, input: value }
659
+ }
660
+
590
661
  async function readParts(
591
662
  body: ReadableStream<Uint8Array>,
592
663
  ): Promise<StreamPart[]> {
@@ -760,6 +831,14 @@ function decodeBody(body: BodyInit | null | undefined): string {
760
831
  throw new TypeError('FX sent an unexpected request body')
761
832
  }
762
833
 
834
+ function parseJson(text: string): unknown {
835
+ try {
836
+ return JSON.parse(text)
837
+ } catch {
838
+ return undefined
839
+ }
840
+ }
841
+
763
842
  function sameJson(a: unknown, b: unknown): boolean {
764
843
  return JSON.stringify(a) === JSON.stringify(b)
765
844
  }