@tanstack/ai 0.41.0 → 0.43.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.
Files changed (272) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/activities/chat/adapter.js +23 -16
  3. package/dist/esm/activities/chat/adapter.js.map +1 -1
  4. package/dist/esm/activities/chat/agent-loop-strategies.d.ts +10 -4
  5. package/dist/esm/activities/chat/agent-loop-strategies.js +75 -17
  6. package/dist/esm/activities/chat/agent-loop-strategies.js.map +1 -1
  7. package/dist/esm/activities/chat/cancel.d.ts +40 -0
  8. package/dist/esm/activities/chat/cancel.js +54 -0
  9. package/dist/esm/activities/chat/cancel.js.map +1 -0
  10. package/dist/esm/activities/chat/index.d.ts +28 -16
  11. package/dist/esm/activities/chat/index.js +2100 -1744
  12. package/dist/esm/activities/chat/index.js.map +1 -1
  13. package/dist/esm/activities/chat/mcp/manager.d.ts +2 -2
  14. package/dist/esm/activities/chat/mcp/manager.js +90 -77
  15. package/dist/esm/activities/chat/mcp/manager.js.map +1 -1
  16. package/dist/esm/activities/chat/mcp/types.d.ts +2 -2
  17. package/dist/esm/activities/chat/messages.js +397 -346
  18. package/dist/esm/activities/chat/messages.js.map +1 -1
  19. package/dist/esm/activities/chat/middleware/builder.js +17 -15
  20. package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
  21. package/dist/esm/activities/chat/middleware/capabilities.js +78 -43
  22. package/dist/esm/activities/chat/middleware/capabilities.js.map +1 -1
  23. package/dist/esm/activities/chat/middleware/compose.d.ts +94 -1
  24. package/dist/esm/activities/chat/middleware/compose.js +623 -531
  25. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  26. package/dist/esm/activities/chat/middleware/define.js +12 -5
  27. package/dist/esm/activities/chat/middleware/define.js.map +1 -1
  28. package/dist/esm/activities/chat/middleware/index.d.ts +5 -1
  29. package/dist/esm/activities/chat/middleware/locks.d.ts +50 -0
  30. package/dist/esm/activities/chat/middleware/locks.js +71 -0
  31. package/dist/esm/activities/chat/middleware/locks.js.map +1 -0
  32. package/dist/esm/activities/chat/middleware/pending-turn.d.ts +15 -0
  33. package/dist/esm/activities/chat/middleware/pending-turn.js +35 -0
  34. package/dist/esm/activities/chat/middleware/pending-turn.js.map +1 -0
  35. package/dist/esm/activities/chat/middleware/run-disconnect.d.ts +23 -0
  36. package/dist/esm/activities/chat/middleware/run-disconnect.js +42 -0
  37. package/dist/esm/activities/chat/middleware/run-disconnect.js.map +1 -0
  38. package/dist/esm/activities/chat/middleware/run-store.d.ts +283 -0
  39. package/dist/esm/activities/chat/middleware/run-store.js +176 -0
  40. package/dist/esm/activities/chat/middleware/run-store.js.map +1 -0
  41. package/dist/esm/activities/chat/middleware/sandbox-runtime.js +14 -8
  42. package/dist/esm/activities/chat/middleware/sandbox-runtime.js.map +1 -1
  43. package/dist/esm/activities/chat/middleware/tool-cache-middleware.js +79 -70
  44. package/dist/esm/activities/chat/middleware/tool-cache-middleware.js.map +1 -1
  45. package/dist/esm/activities/chat/middleware/types.d.ts +59 -2
  46. package/dist/esm/activities/chat/middleware/validate.js +23 -28
  47. package/dist/esm/activities/chat/middleware/validate.js.map +1 -1
  48. package/dist/esm/activities/chat/stream/json-parser.js +39 -25
  49. package/dist/esm/activities/chat/stream/json-parser.js.map +1 -1
  50. package/dist/esm/activities/chat/stream/message-updaters.js +275 -234
  51. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  52. package/dist/esm/activities/chat/stream/processor.d.ts +24 -4
  53. package/dist/esm/activities/chat/stream/processor.js +1341 -1542
  54. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  55. package/dist/esm/activities/chat/stream/strategies.js +69 -53
  56. package/dist/esm/activities/chat/stream/strategies.js.map +1 -1
  57. package/dist/esm/activities/chat/tools/approval-schema.d.ts +19 -0
  58. package/dist/esm/activities/chat/tools/approval-schema.js +117 -0
  59. package/dist/esm/activities/chat/tools/approval-schema.js.map +1 -0
  60. package/dist/esm/activities/chat/tools/lazy-tool-manager.js +164 -191
  61. package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
  62. package/dist/esm/activities/chat/tools/lazy-tools.js +24 -12
  63. package/dist/esm/activities/chat/tools/lazy-tools.js.map +1 -1
  64. package/dist/esm/activities/chat/tools/schema-converter.js +293 -146
  65. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  66. package/dist/esm/activities/chat/tools/tool-calls.d.ts +18 -2
  67. package/dist/esm/activities/chat/tools/tool-calls.js +522 -531
  68. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  69. package/dist/esm/activities/chat/tools/tool-definition.d.ts +75 -16
  70. package/dist/esm/activities/chat/tools/tool-definition.js +95 -23
  71. package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
  72. package/dist/esm/activities/error-payload.js +85 -47
  73. package/dist/esm/activities/error-payload.js.map +1 -1
  74. package/dist/esm/activities/generateAudio/adapter.js +22 -15
  75. package/dist/esm/activities/generateAudio/adapter.js.map +1 -1
  76. package/dist/esm/activities/generateAudio/index.d.ts +4 -0
  77. package/dist/esm/activities/generateAudio/index.js +141 -105
  78. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  79. package/dist/esm/activities/generateImage/adapter.js +22 -15
  80. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  81. package/dist/esm/activities/generateImage/index.d.ts +4 -0
  82. package/dist/esm/activities/generateImage/index.js +155 -111
  83. package/dist/esm/activities/generateImage/index.js.map +1 -1
  84. package/dist/esm/activities/generateSpeech/adapter.js +22 -15
  85. package/dist/esm/activities/generateSpeech/adapter.js.map +1 -1
  86. package/dist/esm/activities/generateSpeech/index.d.ts +4 -0
  87. package/dist/esm/activities/generateSpeech/index.js +159 -110
  88. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  89. package/dist/esm/activities/generateTranscription/adapter.js +22 -15
  90. package/dist/esm/activities/generateTranscription/adapter.js.map +1 -1
  91. package/dist/esm/activities/generateTranscription/index.d.ts +4 -0
  92. package/dist/esm/activities/generateTranscription/index.js +159 -100
  93. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  94. package/dist/esm/activities/generateVideo/adapter.js +36 -29
  95. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  96. package/dist/esm/activities/generateVideo/index.d.ts +143 -19
  97. package/dist/esm/activities/generateVideo/index.js +456 -279
  98. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  99. package/dist/esm/activities/generateVideo/snap.js +60 -48
  100. package/dist/esm/activities/generateVideo/snap.js.map +1 -1
  101. package/dist/esm/activities/index.js +8 -34
  102. package/dist/esm/activities/middleware/index.d.ts +1 -1
  103. package/dist/esm/activities/middleware/run.d.ts +10 -0
  104. package/dist/esm/activities/middleware/run.js +53 -29
  105. package/dist/esm/activities/middleware/run.js.map +1 -1
  106. package/dist/esm/activities/middleware/types.d.ts +44 -6
  107. package/dist/esm/activities/stream-generation-result.d.ts +4 -1
  108. package/dist/esm/activities/stream-generation-result.js +79 -44
  109. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  110. package/dist/esm/activities/summarize/adapter.js +22 -15
  111. package/dist/esm/activities/summarize/adapter.js.map +1 -1
  112. package/dist/esm/activities/summarize/chat-stream-summarize.js +252 -202
  113. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  114. package/dist/esm/activities/summarize/index.d.ts +27 -0
  115. package/dist/esm/activities/summarize/index.js +268 -102
  116. package/dist/esm/activities/summarize/index.js.map +1 -1
  117. package/dist/esm/adapter-internals.d.ts +2 -1
  118. package/dist/esm/adapter-internals.js +4 -11
  119. package/dist/esm/client.d.ts +25 -3
  120. package/dist/esm/client.js +131 -64
  121. package/dist/esm/client.js.map +1 -1
  122. package/dist/esm/custom-events.d.ts +76 -0
  123. package/dist/esm/custom-events.js +37 -0
  124. package/dist/esm/custom-events.js.map +1 -0
  125. package/dist/esm/delivery-detach.d.ts +50 -0
  126. package/dist/esm/delivery-detach.js +71 -0
  127. package/dist/esm/delivery-detach.js.map +1 -0
  128. package/dist/esm/delivery-disconnect.d.ts +62 -0
  129. package/dist/esm/delivery-disconnect.js +81 -0
  130. package/dist/esm/delivery-disconnect.js.map +1 -0
  131. package/dist/esm/extend-adapter.js +19 -17
  132. package/dist/esm/extend-adapter.js.map +1 -1
  133. package/dist/esm/index.d.ts +23 -5
  134. package/dist/esm/index.js +30 -97
  135. package/dist/esm/interrupt-resume.d.ts +71 -0
  136. package/dist/esm/interrupt-resume.js +438 -0
  137. package/dist/esm/interrupt-resume.js.map +1 -0
  138. package/dist/esm/interrupt-serialization.d.ts +12 -0
  139. package/dist/esm/interrupt-serialization.js +178 -0
  140. package/dist/esm/interrupt-serialization.js.map +1 -0
  141. package/dist/esm/interrupts.d.ts +84 -0
  142. package/dist/esm/interrupts.js +31 -0
  143. package/dist/esm/interrupts.js.map +1 -0
  144. package/dist/esm/locks.d.ts +10 -0
  145. package/dist/esm/locks.js +2 -0
  146. package/dist/esm/logger/console-logger.js +101 -78
  147. package/dist/esm/logger/console-logger.js.map +1 -1
  148. package/dist/esm/logger/internal-logger.js +104 -89
  149. package/dist/esm/logger/internal-logger.js.map +1 -1
  150. package/dist/esm/logger/resolve.js +54 -49
  151. package/dist/esm/logger/resolve.js.map +1 -1
  152. package/dist/esm/logger/types.d.ts +1 -1
  153. package/dist/esm/middlewares/content-guard.js +142 -148
  154. package/dist/esm/middlewares/content-guard.js.map +1 -1
  155. package/dist/esm/middlewares/index.js +2 -6
  156. package/dist/esm/middlewares/otel.js +598 -732
  157. package/dist/esm/middlewares/otel.js.map +1 -1
  158. package/dist/esm/middlewares/usage-attributes.js +47 -40
  159. package/dist/esm/middlewares/usage-attributes.js.map +1 -1
  160. package/dist/esm/realtime/event-emitter.js +24 -25
  161. package/dist/esm/realtime/event-emitter.js.map +1 -1
  162. package/dist/esm/realtime/index.d.ts +5 -9
  163. package/dist/esm/realtime/index.js +29 -6
  164. package/dist/esm/realtime/index.js.map +1 -1
  165. package/dist/esm/scope.d.ts +47 -0
  166. package/dist/esm/stream-durability.d.ts +171 -0
  167. package/dist/esm/stream-durability.js +295 -0
  168. package/dist/esm/stream-durability.js.map +1 -0
  169. package/dist/esm/stream-to-response.d.ts +178 -13
  170. package/dist/esm/stream-to-response.js +663 -115
  171. package/dist/esm/stream-to-response.js.map +1 -1
  172. package/dist/esm/strip-to-spec-middleware.js +30 -16
  173. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  174. package/dist/esm/system-prompts.js +27 -21
  175. package/dist/esm/system-prompts.js.map +1 -1
  176. package/dist/esm/tool-registry.js +72 -45
  177. package/dist/esm/tool-registry.js.map +1 -1
  178. package/dist/esm/tools/provider-tool.js +14 -5
  179. package/dist/esm/tools/provider-tool.js.map +1 -1
  180. package/dist/esm/types.d.ts +332 -21
  181. package/dist/esm/types.js +2 -0
  182. package/dist/esm/utilities/ag-ui-wire.js +79 -93
  183. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  184. package/dist/esm/utilities/chat-params.d.ts +26 -4
  185. package/dist/esm/utilities/chat-params.js +218 -92
  186. package/dist/esm/utilities/chat-params.js.map +1 -1
  187. package/dist/esm/utilities/errors.js +28 -18
  188. package/dist/esm/utilities/errors.js.map +1 -1
  189. package/dist/esm/utilities/media-prompt.js +46 -41
  190. package/dist/esm/utilities/media-prompt.js.map +1 -1
  191. package/dist/esm/utilities/numbers.js +13 -10
  192. package/dist/esm/utilities/numbers.js.map +1 -1
  193. package/dist/esm/utilities/provider-executed.js +20 -11
  194. package/dist/esm/utilities/provider-executed.js.map +1 -1
  195. package/dist/esm/utilities/sampling-keys.js +31 -19
  196. package/dist/esm/utilities/sampling-keys.js.map +1 -1
  197. package/dist/esm/utilities/tool-result.js +42 -30
  198. package/dist/esm/utilities/tool-result.js.map +1 -1
  199. package/dist/esm/utilities/usage.js +27 -9
  200. package/dist/esm/utilities/usage.js.map +1 -1
  201. package/dist/esm/utils.js +26 -18
  202. package/dist/esm/utils.js.map +1 -1
  203. package/package.json +10 -6
  204. package/skills/ai-core/SKILL.md +69 -18
  205. package/skills/ai-core/adapter-configuration/SKILL.md +44 -21
  206. package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +1 -3
  207. package/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +148 -0
  208. package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +2 -6
  209. package/skills/ai-core/adapter-configuration/references/groq-adapter.md +2 -6
  210. package/skills/ai-core/adapter-configuration/references/openai-adapter.md +1 -3
  211. package/skills/ai-core/ag-ui-protocol/SKILL.md +1 -1
  212. package/skills/ai-core/chat-experience/SKILL.md +156 -11
  213. package/skills/ai-core/client-persistence/SKILL.md +277 -0
  214. package/skills/ai-core/custom-backend-integration/SKILL.md +1 -1
  215. package/skills/ai-core/debug-logging/SKILL.md +1 -1
  216. package/skills/ai-core/locks/SKILL.md +143 -0
  217. package/skills/ai-core/media-generation/SKILL.md +144 -12
  218. package/skills/ai-core/middleware/SKILL.md +258 -33
  219. package/skills/ai-core/structured-outputs/SKILL.md +1 -1
  220. package/skills/ai-core/tool-calling/SKILL.md +54 -59
  221. package/src/activities/chat/agent-loop-strategies.ts +10 -4
  222. package/src/activities/chat/cancel.ts +81 -0
  223. package/src/activities/chat/index.ts +1152 -153
  224. package/src/activities/chat/mcp/manager.ts +4 -4
  225. package/src/activities/chat/mcp/types.ts +2 -2
  226. package/src/activities/chat/messages.ts +5 -3
  227. package/src/activities/chat/middleware/builder.ts +1 -1
  228. package/src/activities/chat/middleware/compose.ts +186 -9
  229. package/src/activities/chat/middleware/index.ts +26 -0
  230. package/src/activities/chat/middleware/locks.ts +102 -0
  231. package/src/activities/chat/middleware/pending-turn.ts +47 -0
  232. package/src/activities/chat/middleware/run-disconnect.ts +62 -0
  233. package/src/activities/chat/middleware/run-store.ts +412 -0
  234. package/src/activities/chat/middleware/types.ts +62 -1
  235. package/src/activities/chat/stream/processor.ts +189 -5
  236. package/src/activities/chat/tools/approval-schema.ts +205 -0
  237. package/src/activities/chat/tools/tool-calls.ts +106 -13
  238. package/src/activities/chat/tools/tool-definition.ts +210 -39
  239. package/src/activities/generateAudio/index.ts +20 -3
  240. package/src/activities/generateImage/index.ts +20 -3
  241. package/src/activities/generateSpeech/index.ts +25 -3
  242. package/src/activities/generateTranscription/index.ts +26 -3
  243. package/src/activities/generateVideo/index.ts +345 -82
  244. package/src/activities/middleware/index.ts +2 -0
  245. package/src/activities/middleware/run.ts +31 -0
  246. package/src/activities/middleware/types.ts +49 -5
  247. package/src/activities/stream-generation-result.ts +30 -2
  248. package/src/activities/summarize/chat-stream-summarize.ts +5 -0
  249. package/src/activities/summarize/index.ts +200 -10
  250. package/src/adapter-internals.ts +10 -1
  251. package/src/client.ts +244 -0
  252. package/src/custom-events.ts +107 -0
  253. package/src/delivery-detach.ts +72 -0
  254. package/src/delivery-disconnect.ts +84 -0
  255. package/src/index.ts +138 -0
  256. package/src/interrupt-resume.ts +824 -0
  257. package/src/interrupt-serialization.ts +183 -0
  258. package/src/interrupts.ts +146 -0
  259. package/src/locks.ts +17 -0
  260. package/src/logger/types.ts +1 -1
  261. package/src/middlewares/otel.ts +1 -0
  262. package/src/realtime/index.ts +5 -9
  263. package/src/scope.ts +47 -0
  264. package/src/stream-durability.ts +598 -0
  265. package/src/stream-to-response.ts +1051 -95
  266. package/src/strip-to-spec-middleware.ts +3 -3
  267. package/src/types.ts +416 -24
  268. package/src/utilities/chat-params.ts +245 -55
  269. package/dist/esm/activities/index.js.map +0 -1
  270. package/dist/esm/adapter-internals.js.map +0 -1
  271. package/dist/esm/index.js.map +0 -1
  272. package/dist/esm/middlewares/index.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import type { ServerTool } from '../tools/tool-definition'
1
+ import type { AnyServerTool } from '../tools/tool-definition'
2
2
  import type { ChatMCPOptions, MCPToolSource } from './types'
3
3
 
4
4
  /**
@@ -18,7 +18,7 @@ import type { ChatMCPOptions, MCPToolSource } from './types'
18
18
  * drains. If discovery results were ever cached and reused across runs, this
19
19
  * would bind a closure over an already-closed source — bind onto a copy then.
20
20
  */
21
- function bindReadResource(tool: ServerTool, source: MCPToolSource): void {
21
+ function bindReadResource(tool: AnyServerTool, source: MCPToolSource): void {
22
22
  if (!source.readResource) return
23
23
  const meta = (
24
24
  tool.metadata as { mcp?: { uiResourceUri?: string } } | undefined
@@ -71,13 +71,13 @@ export class MCPManager {
71
71
  * (no `onDiscoveryError`, or it re-threw) or a duplicate tool name; in that
72
72
  * case it first closes any connected sources when the policy is 'close'.
73
73
  */
74
- async discover(): Promise<Array<ServerTool>> {
74
+ async discover(): Promise<Array<AnyServerTool>> {
75
75
  if (this.#sources.length === 0) return []
76
76
  try {
77
77
  const settled = await Promise.allSettled(
78
78
  this.#sources.map((s) => s.tools({ lazy: this.#lazyTools })),
79
79
  )
80
- const tools: Array<ServerTool> = []
80
+ const tools: Array<AnyServerTool> = []
81
81
  const zipped = this.#sources.map(
82
82
  (source, i) => [source, settled[i]] as const,
83
83
  )
@@ -1,4 +1,4 @@
1
- import type { ServerTool } from '../tools/tool-definition'
1
+ import type { AnyServerTool } from '../tools/tool-definition'
2
2
 
3
3
  /**
4
4
  * The shape `readResource` resolves to — a structural subset of MCP's
@@ -26,7 +26,7 @@ export interface MCPToolSource {
26
26
  // Keep the options shape in sync with ai-mcp's `ToolsOptions` — extra
27
27
  // optional fields added there still match structurally, but chat() only
28
28
  // forwards what is declared here.
29
- tools: (options?: { lazy?: boolean }) => Promise<Array<ServerTool>>
29
+ tools: (options?: { lazy?: boolean }) => Promise<Array<AnyServerTool>>
30
30
  close: () => Promise<void>
31
31
  /**
32
32
  * Reads an MCP resource by URI. Used by the chat manager to eagerly fetch
@@ -211,6 +211,7 @@ function isToolCallIncluded(part: ToolCallPart): boolean {
211
211
  return (
212
212
  part.state === 'input-complete' ||
213
213
  part.state === 'complete' ||
214
+ part.state === 'approval-requested' ||
214
215
  part.state === 'approval-responded' ||
215
216
  part.state === 'error' ||
216
217
  part.output !== undefined
@@ -614,12 +615,13 @@ export function modelMessagesToUIMessages(
614
615
  })
615
616
  } else {
616
617
  // No assistant message to merge into, create a standalone one
617
- const toolResultUIMessage = modelMessageToUIMessage(msg)
618
+ const toolResultUIMessage = modelMessageToUIMessage(msg, msg.id)
618
619
  uiMessages.push(toolResultUIMessage)
619
620
  }
620
621
  } else {
621
- // Regular message
622
- const uiMessage = modelMessageToUIMessage(msg)
622
+ // Regular message. Preserve a persisted stable id so a hydrated message
623
+ // keeps the same identity as its live stream (enables in-place resume).
624
+ const uiMessage = modelMessageToUIMessage(msg, msg.id)
623
625
  uiMessages.push(uiMessage)
624
626
 
625
627
  // Track assistant messages for potential tool result merging
@@ -104,6 +104,6 @@ export function createChatMiddleware(): ChatMiddlewareBuilder<
104
104
  // object reused across `.use()` calls, but the type accumulates `TProvided`
105
105
  // and `TList` per call — TypeScript cannot derive that from runtime values, so
106
106
  // a structural `as` is impossible and the double assertion is irreducible.
107
- // eslint-disable-next-line no-restricted-syntax -- irreducible: type-level accumulation cannot be expressed from a single runtime object
107
+ // oxlint-disable-next-line eslint-js/no-restricted-syntax -- irreducible: type-level accumulation cannot be expressed from a single runtime object
108
108
  return builder as unknown as ChatMiddlewareBuilder<readonly [], never>
109
109
  }
@@ -1,5 +1,5 @@
1
1
  import { aiEventClient } from '@tanstack/ai-event-client'
2
- import type { StreamChunk } from '../../../types'
2
+ import type { AgentLoopState, StreamChunk } from '../../../types'
3
3
  import type { InternalLogger } from '../../../logger/internal-logger'
4
4
  import type {
5
5
  AbortInfo,
@@ -18,6 +18,12 @@ import type {
18
18
  UsageInfo,
19
19
  } from './types'
20
20
 
21
+ /** One middleware's terminal-hook throw, captured instead of propagated. */
22
+ interface HookFailure {
23
+ middleware: string
24
+ error: unknown
25
+ }
26
+
21
27
  /** Check if a middleware should be skipped for instrumentation events. */
22
28
  function shouldSkipInstrumentation(mw: ChatMiddleware<any>): boolean {
23
29
  return mw.name === 'devtools' || mw.name === 'strip-to-spec'
@@ -474,18 +480,106 @@ export class MiddlewareRunner<TContext = unknown> {
474
480
  }
475
481
  }
476
482
 
483
+ /**
484
+ * Await ONE terminal hook and RETURN its throw instead of letting it escape
485
+ * the caller's loop, logging it on the `errors` channel first so the failure
486
+ * is never invisible. `undefined` means the hook completed.
487
+ *
488
+ * Capturing (rather than swallowing at this level) is what lets isolation and
489
+ * reporting coexist: every caller gives every middleware its turn, and then
490
+ * each decides on its own whether the collected failures are worth telling the
491
+ * caller about. See {@link runOnFinish} vs {@link runOnAbort} /
492
+ * {@link runOnError}.
493
+ */
494
+ private async captureTerminalHook(
495
+ mw: ChatMiddleware<TContext>,
496
+ hookName: 'onFinish' | 'onAbort' | 'onError',
497
+ invoke: () => void | Promise<void>,
498
+ ): Promise<HookFailure | undefined> {
499
+ try {
500
+ await invoke()
501
+ return undefined
502
+ } catch (error) {
503
+ this.logger.errors(`middleware ${hookName} hook failed`, {
504
+ middleware: mw.name ?? 'unnamed',
505
+ hook: hookName,
506
+ error,
507
+ })
508
+ return { middleware: mw.name ?? 'unnamed', error }
509
+ }
510
+ }
511
+
477
512
  /**
478
513
  * Run onFinish on all middleware in order.
514
+ *
515
+ * ISOLATED **and** REPORTED. `onFinish` is the only terminal fan-out on the
516
+ * SUCCESS path, and it is where `withPersistence.onFinish` writes the
517
+ * assistant turn through the store. So the two properties are needed together
518
+ * and neither may be traded for the other:
519
+ *
520
+ * - ISOLATION: every middleware's hook runs even if an earlier one threw, so a
521
+ * transient store error cannot skip a later middleware's own bookkeeping.
522
+ * Each failure is captured by {@link captureTerminalHook}, not propagated
523
+ * mid-loop.
524
+ * - REPORTING: after the loop, the failures are rethrown. `chat()`'s catch
525
+ * treats what we throw as a genuine error (it is not a
526
+ * `MiddlewareAbortError`, and `structuralInterruptFailure` does not match
527
+ * it) and rethrows it out of the generator.
528
+ *
529
+ * What that rethrow can and cannot achieve depends on the transport, because
530
+ * this fan-out is awaited AFTER the adapter's `RUN_FINISHED` has already been
531
+ * yielded (`chat()` yields terminal chunks while streaming, then awaits this
532
+ * hook on its way out). The success terminal is therefore already gone; the
533
+ * rethrow can only append to what the consumer saw, never retract it:
534
+ *
535
+ * - NON-DURABLE transport: the throw escapes the generator mid-response, and
536
+ * the SSE / HTTP-stream encoder turns it into a TRAILING `RUN_ERROR` on the
537
+ * wire carrying the store's own message and `code`. `ai-client` surfaces
538
+ * that as an error status, so the user is not told the turn was saved when
539
+ * it was not.
540
+ * - DURABLE transport: the throw reaches the durability sink instead. The
541
+ * terminal was already persisted AND forwarded, so the sink deliberately
542
+ * does NOT append a second, contradictory terminal, and `terminalForwarded`
543
+ * (see `stream-to-response.ts`) suppresses the rethrow to the live consumer.
544
+ * The `RUN_FINISHED` stands and the failure is RECORDED SERVER-SIDE on the
545
+ * sink's `errors` channel. That is the intended outcome, not a gap: the save
546
+ * failed, not the run — the consumer did receive the complete stream, so
547
+ * telling it the run errored would be the lie. What the rethrow buys here is
548
+ * that the sink sees the failure at all; while this loop swallowed, the only
549
+ * trace anywhere was {@link captureTerminalHook}'s log line.
550
+ *
551
+ * Either way, swallowing is the one option ruled out: a failed
552
+ * `messages.append` would otherwise leave a `completed` run record with the
553
+ * assistant turn missing from storage and nothing beyond a middleware log
554
+ * line, and the client would go on to send a history the server has no record
555
+ * of.
556
+ *
557
+ * A single failure is rethrown AS-IS so the store's own error — its message,
558
+ * `cause`, `code` and `instanceof` identity — is what reaches the caller and
559
+ * the wire; wrapping the common case would bury it. Two or more become an
560
+ * `AggregateError` (never a `MiddlewareAbortError`, so it cannot be mistaken
561
+ * for an abort) rather than picking a winner and dropping the rest.
479
562
  */
480
563
  async runOnFinish(
481
564
  ctx: ChatMiddlewareContext<TContext>,
482
565
  info: FinishInfo,
483
566
  ): Promise<void> {
567
+ const failures: Array<HookFailure> = []
568
+ let firstFailure: HookFailure | undefined
569
+
484
570
  for (const mw of this.middlewares) {
485
- if (mw.onFinish) {
571
+ const hook = mw.onFinish
572
+ if (hook) {
486
573
  const skip = shouldSkipInstrumentation(mw)
487
574
  const start = Date.now()
488
- await mw.onFinish(ctx, info)
575
+ const failure = await this.captureTerminalHook(mw, 'onFinish', () =>
576
+ hook.call(mw, ctx, info),
577
+ )
578
+ if (failure !== undefined) {
579
+ firstFailure ??= failure
580
+ failures.push(failure)
581
+ continue
582
+ }
489
583
  if (!skip) {
490
584
  this.logger.middleware(
491
585
  `hook=onFinish middleware=${mw.name ?? 'unnamed'}`,
@@ -502,21 +596,49 @@ export class MiddlewareRunner<TContext = unknown> {
502
596
  }
503
597
  }
504
598
  }
599
+
600
+ if (firstFailure !== undefined) {
601
+ throw failures.length === 1
602
+ ? firstFailure.error
603
+ : new AggregateError(
604
+ failures.map((f) => f.error),
605
+ `${failures.length} middleware onFinish hooks failed: ` +
606
+ failures.map((f) => f.middleware).join(', '),
607
+ )
608
+ }
505
609
  }
506
610
 
507
611
  /**
508
612
  * Run onAbort on all middleware in order.
613
+ *
614
+ * ISOLATED and DELIBERATELY SWALLOWED. `onAbort` is a pure teardown fan-out
615
+ * released from `chat()`'s `finally`, on a path where the outcome is already
616
+ * decided: the run stopped, and the caller is being told why. A throw here has
617
+ * nothing better to report than the abort reason it would DISPLACE — the
618
+ * `finally` would surface a flaky store's error in place of "client
619
+ * disconnected" — so failures are logged on the `errors` channel and go no
620
+ * further. That is not a silent failure; it is refusing to let teardown
621
+ * rewrite an outcome it did not produce.
622
+ *
623
+ * Isolation matters independently: these hooks release PER-MIDDLEWARE
624
+ * resources (`withSandbox.onAbort` detaches or destroys the sandbox and stamps
625
+ * `detachedSince`; `withPersistence.onAbort` records the run status through the
626
+ * store), so an unguarded loop turns one transient store error into a
627
+ * permanently leaked sandbox for every middleware ordered after it.
509
628
  */
510
629
  async runOnAbort(
511
630
  ctx: ChatMiddlewareContext<TContext>,
512
631
  info: AbortInfo,
513
632
  ): Promise<void> {
514
633
  for (const mw of this.middlewares) {
515
- if (mw.onAbort) {
634
+ const hook = mw.onAbort
635
+ if (hook) {
516
636
  const skip = shouldSkipInstrumentation(mw)
517
637
  const start = Date.now()
518
- await mw.onAbort(ctx, info)
519
- if (!skip) {
638
+ const failure = await this.captureTerminalHook(mw, 'onAbort', () =>
639
+ hook.call(mw, ctx, info),
640
+ )
641
+ if (failure === undefined && !skip) {
520
642
  this.logger.middleware(
521
643
  `hook=onAbort middleware=${mw.name ?? 'unnamed'}`,
522
644
  { middleware: mw.name ?? 'unnamed', hook: 'onAbort' },
@@ -536,17 +658,32 @@ export class MiddlewareRunner<TContext = unknown> {
536
658
 
537
659
  /**
538
660
  * Run onError on all middleware in order.
661
+ *
662
+ * ISOLATED and DELIBERATELY SWALLOWED, for the same reason as
663
+ * {@link runOnAbort} and NOT merely because it is teardown: the run has
664
+ * already failed, `info.error` IS that failure, and `chat()` rethrows it to the
665
+ * caller the moment this fan-out returns. A propagated hook throw could only
666
+ * REPLACE the run's real error with a teardown artifact — strictly less
667
+ * information for the caller, who is already learning the run failed. Reporting
668
+ * would buy nothing and cost the diagnosis, so failures are logged on the
669
+ * `errors` channel and stop there.
670
+ *
671
+ * Contrast {@link runOnFinish}, where nothing else is telling the caller
672
+ * anything is wrong — which is why that one reports.
539
673
  */
540
674
  async runOnError(
541
675
  ctx: ChatMiddlewareContext<TContext>,
542
676
  info: ErrorInfo,
543
677
  ): Promise<void> {
544
678
  for (const mw of this.middlewares) {
545
- if (mw.onError) {
679
+ const hook = mw.onError
680
+ if (hook) {
546
681
  const skip = shouldSkipInstrumentation(mw)
547
682
  const start = Date.now()
548
- await mw.onError(ctx, info)
549
- if (!skip) {
683
+ const failure = await this.captureTerminalHook(mw, 'onError', () =>
684
+ hook.call(mw, ctx, info),
685
+ )
686
+ if (failure === undefined && !skip) {
550
687
  this.logger.middleware(
551
688
  `hook=onError middleware=${mw.name ?? 'unnamed'}`,
552
689
  { middleware: mw.name ?? 'unnamed', hook: 'onError' },
@@ -595,6 +732,46 @@ export class MiddlewareRunner<TContext = unknown> {
595
732
  }
596
733
  }
597
734
 
735
+ /**
736
+ * Run onShouldContinue through middleware in order (AND semantics).
737
+ * Any explicit `false` stops further iterations; `true` / void / undefined pass.
738
+ * Called after `agentLoopStrategy` has already approved continuation.
739
+ */
740
+ async runOnShouldContinue(
741
+ ctx: ChatMiddlewareContext<TContext>,
742
+ state: AgentLoopState,
743
+ ): Promise<boolean> {
744
+ for (const mw of this.middlewares) {
745
+ if (mw.onShouldContinue) {
746
+ const skip = shouldSkipInstrumentation(mw)
747
+ const start = Date.now()
748
+ const result = await mw.onShouldContinue(ctx, state)
749
+ if (!skip) {
750
+ this.logger.middleware(
751
+ `hook=onShouldContinue middleware=${mw.name ?? 'unnamed'}`,
752
+ {
753
+ middleware: mw.name ?? 'unnamed',
754
+ hook: 'onShouldContinue',
755
+ result,
756
+ },
757
+ )
758
+ aiEventClient.emit('middleware:hook:executed', {
759
+ ...instrumentCtx(ctx),
760
+ middlewareName: mw.name || 'unnamed',
761
+ hookName: 'onShouldContinue',
762
+ iteration: ctx.iteration,
763
+ duration: Date.now() - start,
764
+ hasTransform: result === false,
765
+ })
766
+ }
767
+ if (result === false) {
768
+ return false
769
+ }
770
+ }
771
+ }
772
+ return true
773
+ }
774
+
598
775
  /**
599
776
  * Run onToolPhaseComplete on all middleware in order.
600
777
  * Called after all tool calls in an iteration have been processed.
@@ -3,6 +3,8 @@ export type {
3
3
  ChatMiddlewareContext,
4
4
  ChatMiddlewarePhase,
5
5
  ChatMiddlewareConfig,
6
+ ChatResumeToolState,
7
+ ChatResumeGenericResolution,
6
8
  StructuredOutputMiddlewareConfig,
7
9
  ToolCallHookContext,
8
10
  BeforeToolCallDecision,
@@ -39,3 +41,27 @@ export type {
39
41
  } from './builder'
40
42
  export { validateCapabilities } from './validate'
41
43
  export type { AnyChatMiddleware } from './types'
44
+
45
+ export {
46
+ LocksCapability,
47
+ getLocks,
48
+ provideLocks,
49
+ InMemoryLockStore,
50
+ withLocks,
51
+ defineLock,
52
+ } from './locks'
53
+ export type { LockStore } from './locks'
54
+
55
+ export {
56
+ isRunStatus,
57
+ isTerminalRunStatus,
58
+ defineRunStore,
59
+ InMemoryRunStore,
60
+ } from './run-store'
61
+ export type {
62
+ RunStatus,
63
+ TerminalRunStatus,
64
+ RunRecord,
65
+ RunError,
66
+ RunStore,
67
+ } from './run-store'
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Distributed-mutex primitive — the neutral home for the `'locks'` capability.
3
+ *
4
+ * Capability identity is by object reference (see `createCapability`). Any
5
+ * middleware may PROVIDE a {@link LockStore} via {@link withLocks} /
6
+ * {@link provideLocks}; consumers (notably `@tanstack/ai-sandbox` `ensure`)
7
+ * read it with {@link getLocks}. Coordination, not state persistence.
8
+ */
9
+ import { createCapability } from './capabilities'
10
+ import { defineChatMiddleware } from './define'
11
+ import type { ChatMiddleware, ChatMiddlewareContext } from './types'
12
+
13
+ /**
14
+ * Mutual exclusion around a critical section keyed by `key`. A distributed
15
+ * backend (e.g. a Cloudflare Durable Object) is the only kind safe across
16
+ * instances; the in-memory default is correct within a single process only.
17
+ * Lease-backed implementations abort `signal` as soon as ownership can no longer
18
+ * be guaranteed; the callback must stop externally visible mutations when it
19
+ * aborts. Callbacks that ignore `signal` (e.g. the sandbox `ensure` critical
20
+ * section) remain valid — a `() => Promise<T>` is assignable to the
21
+ * signal-taking parameter.
22
+ */
23
+ export interface LockStore {
24
+ withLock: <T>(
25
+ key: string,
26
+ fn: (signal: AbortSignal) => Promise<T>,
27
+ ) => Promise<T>
28
+ }
29
+
30
+ /**
31
+ * Type a {@link LockStore} implementation inline: pass the object and get
32
+ * autocomplete + contract checking, with no separate `: LockStore` annotation.
33
+ * Hand the result to {@link withLocks}.
34
+ */
35
+ export function defineLock(lock: LockStore): LockStore {
36
+ return lock
37
+ }
38
+
39
+ /**
40
+ * The lock capability. Provided by {@link withLocks} or any middleware that
41
+ * calls {@link provideLocks}.
42
+ */
43
+ export const LocksCapability = createCapability<LockStore>()('locks')
44
+
45
+ /** Destructured accessors: `getLocks(ctx)` / `provideLocks(ctx, store)`. */
46
+ export const [getLocks, provideLocks] = LocksCapability
47
+
48
+ /**
49
+ * In-memory {@link LockStore} — a per-key promise chain. Correct within a single
50
+ * process; multi-instance correctness needs a distributed lock backend.
51
+ */
52
+ export class InMemoryLockStore implements LockStore {
53
+ private readonly chains = new Map<string, Promise<unknown>>()
54
+
55
+ withLock<T>(
56
+ key: string,
57
+ fn: (signal: AbortSignal) => Promise<T>,
58
+ ): Promise<T> {
59
+ const prior = this.chains.get(key) ?? Promise.resolve()
60
+ const runCriticalSection = () => fn(new AbortController().signal)
61
+ // Chain after the prior holder regardless of how it settled.
62
+ const run = prior.then(runCriticalSection, runCriticalSection)
63
+ // Swallow rejections so one failure doesn't poison the lock, then drop the
64
+ // chain entry once this tail is still the latest — otherwise long-lived
65
+ // processes accumulate settled promises for every distinct key forever.
66
+ const settled = run.then(
67
+ () => undefined,
68
+ () => undefined,
69
+ )
70
+ this.chains.set(key, settled)
71
+ void settled.then(() => {
72
+ if (this.chains.get(key) === settled) {
73
+ this.chains.delete(key)
74
+ }
75
+ })
76
+ return run
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Provide a {@link LockStore} on the chat middleware capability bus.
82
+ *
83
+ * Coordination only — independent of chat state persistence. A lock provided
84
+ * here reaches any later middleware that reads {@link LocksCapability}
85
+ * (including `withSandbox`).
86
+ *
87
+ * ```ts
88
+ * middleware: [
89
+ * withLocks(distributedLocks),
90
+ * withSandbox(sandbox),
91
+ * ]
92
+ * ```
93
+ */
94
+ export function withLocks(locks: LockStore): ChatMiddleware {
95
+ return defineChatMiddleware({
96
+ name: 'locks',
97
+ provides: [LocksCapability],
98
+ setup(ctx: ChatMiddlewareContext) {
99
+ provideLocks(ctx, locks)
100
+ },
101
+ })
102
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Internal seam that lets a SLOW middleware ask the persistence layer to store
3
+ * the user's pending turn NOW, before that middleware starts its slow work.
4
+ *
5
+ * WHY THIS EXISTS. Chat persistence stores the pending turn from `onStart`, which
6
+ * is the earliest hook that holds the merged message list. `onStart` runs after
7
+ * EVERY middleware `setup`, and that is normally a few milliseconds. `withSandbox`
8
+ * breaks the assumption: its `setup` creates a sandbox and clones a repository,
9
+ * which takes minutes. For that whole window the thread holds nothing, so a reload
10
+ * — or a second device — asks the server for the conversation and is told it is
11
+ * empty. The user sees no sign of the message they just sent.
12
+ *
13
+ * The provider owns the rule for WHAT to store. A caller must not rebuild that
14
+ * rule: `saveThread` replaces the whole thread, so a caller that stored only the
15
+ * newly-sent message would delete the history. Asking the owner to store the turn
16
+ * keeps the merge in one place.
17
+ *
18
+ * OPT-IN, so nothing changes for a fast run. Persistence offers this seam on every
19
+ * durable run; only a middleware that is about to be slow calls it. A run with no
20
+ * such middleware never calls it, and the `onStart` store stays the only one.
21
+ *
22
+ * A SEAM RATHER THAN A NEW MIDDLEWARE HOOK, deliberately. A lifecycle hook that
23
+ * runs before `setup` would be public API, and it would have to explain itself to
24
+ * every middleware author. This concern has one provider (`withPersistence`) and
25
+ * one caller (`withSandbox`), and it reaches across packages through the same
26
+ * internal channel the sandbox layer already uses for its runtime.
27
+ */
28
+ import { createCapability } from './capabilities'
29
+
30
+ export interface PendingTurnSnapshot {
31
+ /**
32
+ * Store the user's pending turn now.
33
+ *
34
+ * Idempotent: the later `onStart` store replaces the thread with the same or a
35
+ * more complete list, so calling this changes what is visible EARLIER without
36
+ * changing what is visible at the end.
37
+ *
38
+ * Rejects only if the store itself fails. Callers treat that as non-fatal — a
39
+ * run that cannot pre-store its turn is still a run worth doing.
40
+ */
41
+ snapshot: () => Promise<void>
42
+ }
43
+
44
+ export const PendingTurnCapability =
45
+ createCapability<PendingTurnSnapshot>()('pending-turn')
46
+
47
+ export const [getPendingTurn, providePendingTurn] = PendingTurnCapability
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Internal seam the chat engine PROVIDES so middleware can learn that the
3
+ * DELIVERY socket closed while the run is still going — and, crucially, learn it
4
+ * WITHOUT the run being cancelled.
5
+ *
6
+ * WHY THIS EXISTS. Before it, the only way a disconnect reached middleware was for
7
+ * the application to mirror `request.signal` into `chat()`'s `abortController`,
8
+ * which ABORTS THE RUN. For a durable run that is precisely wrong, and wrong in
9
+ * the most expensive direction: `chat()` returns at its `isCancelled()` check
10
+ * immediately after middleware `setup`, so the harness adapter's `chatStream` is
11
+ * never called and the agent in the sandbox that `setup` just spent minutes
12
+ * creating is NEVER LAUNCHED. The user switched away during "starting the
13
+ * sandbox", came back, and found an empty log belonging to a run that had done
14
+ * nothing — with no takeover able to recover it, because an agent that never
15
+ * started wrote no journal to replay. Applications were forced to choose between
16
+ * "the middleware learns about the disconnect" and "the run survives it".
17
+ *
18
+ * So a disconnect is delivered as a NOTIFICATION. `withSandbox` uses it to stamp
19
+ * `detachedSince`/`sandboxKey` and publish the detach verdict while the run keeps
20
+ * producing into its still-open durable log — which is exactly what a re-attaching
21
+ * client tails to catch up.
22
+ *
23
+ * A SUBSCRIPTION RATHER THAN A MIDDLEWARE HOOK, deliberately. `ChatMiddleware` is
24
+ * public API and a new lifecycle hook there is a permanent commitment — including
25
+ * the obligation to explain that it is the one hook that is NOT terminal. This
26
+ * concern has exactly one consumer in the tree (`withSandbox`) and it reaches it
27
+ * through the same internal channel the sandbox layer already uses for its runtime
28
+ * (`SandboxRuntimeCapability`), so it ships with no public surface at all.
29
+ *
30
+ * DISPATCHED FROM THE TRANSPORT, NOT FROM THE RUN'S UNWINDING. That is what makes
31
+ * it prompt. A run suspended inside a minutes-wide `setup` cannot dispatch anything
32
+ * from its own `finally`, because the `finally` is reached only once the generator
33
+ * unwinds — which is what made `detachedSince` land three minutes late.
34
+ */
35
+ import { createCapability } from './capabilities'
36
+
37
+ /**
38
+ * Registry of disconnect listeners for one run.
39
+ *
40
+ * Listeners must do BOOKKEEPING ONLY. The run is still executing, so releasing
41
+ * anything it depends on — stopping a file watcher, destroying a sandbox — breaks
42
+ * a healthy run. Teardown belongs in the terminal hooks, which still run exactly
43
+ * once afterwards.
44
+ *
45
+ * A listener may return a promise; the engine awaits all of them before the run
46
+ * finishes, so bookkeeping cannot be lost to a race with the run's own completion.
47
+ */
48
+ export interface RunDisconnect {
49
+ /**
50
+ * Register `listener`, called at most once per run when the delivery socket
51
+ * closes. Registering after the socket has ALREADY closed calls `listener`
52
+ * immediately — otherwise a middleware whose `setup` was still running during
53
+ * the disconnect would silently never hear about it, which is the exact window
54
+ * the common disconnect lands in.
55
+ */
56
+ subscribe: (listener: () => void | Promise<void>) => void
57
+ }
58
+
59
+ export const RunDisconnectCapability =
60
+ createCapability<RunDisconnect>()('run-disconnect')
61
+
62
+ export const [getRunDisconnect, provideRunDisconnect] = RunDisconnectCapability