@tanstack/ai 0.59.0 → 0.61.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 (91) hide show
  1. package/dist/esm/activities/chat/adapter.d.ts +9 -0
  2. package/dist/esm/activities/chat/adapter.js +1 -0
  3. package/dist/esm/activities/chat/adapter.js.map +1 -1
  4. package/dist/esm/activities/chat/index.js +134 -22
  5. package/dist/esm/activities/chat/index.js.map +1 -1
  6. package/dist/esm/activities/chat/messages.js +5 -1
  7. package/dist/esm/activities/chat/messages.js.map +1 -1
  8. package/dist/esm/activities/chat/stream/message-updaters.js +9 -2
  9. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  10. package/dist/esm/activities/chat/stream/processor.d.ts +0 -1
  11. package/dist/esm/activities/chat/stream/processor.js +15 -12
  12. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  13. package/dist/esm/activities/chat/tools/tool-calls.js +2 -0
  14. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  15. package/dist/esm/activities/embed/adapter.d.ts +7 -0
  16. package/dist/esm/activities/embed/adapter.js +1 -0
  17. package/dist/esm/activities/embed/adapter.js.map +1 -1
  18. package/dist/esm/activities/embed/index.js +2 -0
  19. package/dist/esm/activities/embed/index.js.map +1 -1
  20. package/dist/esm/activities/files/adapter.d.ts +97 -0
  21. package/dist/esm/activities/files/adapter.js +45 -0
  22. package/dist/esm/activities/files/adapter.js.map +1 -0
  23. package/dist/esm/activities/files/index.d.ts +66 -0
  24. package/dist/esm/activities/files/index.js +78 -0
  25. package/dist/esm/activities/files/index.js.map +1 -0
  26. package/dist/esm/activities/generateImage/adapter.d.ts +8 -0
  27. package/dist/esm/activities/generateImage/adapter.js +1 -0
  28. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  29. package/dist/esm/activities/generateImage/index.js +2 -0
  30. package/dist/esm/activities/generateImage/index.js.map +1 -1
  31. package/dist/esm/activities/generateVideo/adapter.d.ts +8 -0
  32. package/dist/esm/activities/generateVideo/adapter.js +1 -0
  33. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  34. package/dist/esm/activities/generateVideo/index.js +3 -0
  35. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  36. package/dist/esm/activities/generateWorld/adapter.d.ts +4 -2
  37. package/dist/esm/activities/generateWorld/adapter.js.map +1 -1
  38. package/dist/esm/activities/generateWorld/index.d.ts +4 -3
  39. package/dist/esm/activities/generateWorld/index.js +5 -4
  40. package/dist/esm/activities/generateWorld/index.js.map +1 -1
  41. package/dist/esm/activities/index.d.ts +6 -3
  42. package/dist/esm/activities/index.js +13 -11
  43. package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +2 -0
  44. package/dist/esm/activities/summarize/chat-stream-summarize.js +8 -8
  45. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  46. package/dist/esm/client.d.ts +2 -0
  47. package/dist/esm/client.js +2 -1
  48. package/dist/esm/client.js.map +1 -1
  49. package/dist/esm/index.d.ts +3 -2
  50. package/dist/esm/index.js +4 -2
  51. package/dist/esm/types.d.ts +72 -13
  52. package/dist/esm/utilities/ag-ui-wire.js +34 -13
  53. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  54. package/dist/esm/utilities/content-source.d.ts +60 -0
  55. package/dist/esm/utilities/content-source.js +85 -0
  56. package/dist/esm/utilities/content-source.js.map +1 -0
  57. package/dist/esm/utilities/provider-executed.d.ts +7 -0
  58. package/dist/esm/utilities/provider-executed.js +10 -1
  59. package/dist/esm/utilities/provider-executed.js.map +1 -1
  60. package/dist/esm/utilities/tool-result.d.ts +12 -2
  61. package/dist/esm/utilities/tool-result.js +23 -3
  62. package/dist/esm/utilities/tool-result.js.map +1 -1
  63. package/package.json +2 -2
  64. package/skills/ai-core/adapter-configuration/SKILL.md +62 -0
  65. package/skills/ai-core/chat-experience/SKILL.md +14 -0
  66. package/skills/ai-core/media-generation/SKILL.md +8 -0
  67. package/src/activities/chat/adapter.ts +10 -0
  68. package/src/activities/chat/index.ts +226 -40
  69. package/src/activities/chat/messages.ts +12 -1
  70. package/src/activities/chat/stream/message-updaters.ts +24 -2
  71. package/src/activities/chat/stream/processor.ts +24 -22
  72. package/src/activities/chat/tools/tool-calls.ts +6 -0
  73. package/src/activities/embed/adapter.ts +7 -0
  74. package/src/activities/embed/index.ts +5 -0
  75. package/src/activities/files/adapter.ts +120 -0
  76. package/src/activities/files/index.ts +113 -0
  77. package/src/activities/generateImage/adapter.ts +8 -0
  78. package/src/activities/generateImage/index.ts +4 -0
  79. package/src/activities/generateVideo/adapter.ts +8 -0
  80. package/src/activities/generateVideo/index.ts +7 -0
  81. package/src/activities/generateWorld/adapter.ts +4 -2
  82. package/src/activities/generateWorld/index.ts +7 -6
  83. package/src/activities/index.ts +25 -1
  84. package/src/activities/summarize/chat-stream-summarize.ts +22 -12
  85. package/src/client.ts +7 -0
  86. package/src/index.ts +16 -0
  87. package/src/types.ts +76 -13
  88. package/src/utilities/ag-ui-wire.ts +60 -16
  89. package/src/utilities/content-source.ts +138 -0
  90. package/src/utilities/provider-executed.ts +13 -0
  91. package/src/utilities/tool-result.ts +38 -2
@@ -1 +1 @@
1
- {"version":3,"file":"tool-result.js","names":[],"sources":["../../../src/utilities/tool-result.ts"],"sourcesContent":["import type { ContentPart } from '../types'\n\nconst CONTENT_PART_TYPES = new Set([\n 'text',\n 'image',\n 'audio',\n 'video',\n 'document',\n])\n\n/**\n * Structural check for a single `ContentPart`. A text part must carry a string\n * `content`; every other modality must carry a `source` with `type` of\n * `'url' | 'data'` and a string `value`.\n */\nexport function isContentPart(value: unknown): value is ContentPart {\n if (typeof value !== 'object' || value === null) return false\n const part = value as Record<string, unknown>\n if (typeof part.type !== 'string' || !CONTENT_PART_TYPES.has(part.type)) {\n return false\n }\n if (part.type === 'text') {\n return typeof part.content === 'string'\n }\n const source = part.source\n if (typeof source !== 'object' || source === null) return false\n const src = source as Record<string, unknown>\n if (typeof src.value !== 'string') return false\n // `data` sources require a mimeType (matches ContentPartDataSource); `url`\n // sources don't. Requiring it here keeps the runtime guard consistent with\n // the type and avoids emitting `data:undefined;base64,...` downstream.\n if (src.type === 'data') return typeof src.mimeType === 'string'\n return src.type === 'url'\n}\n\n/**\n * True iff `value` is a NON-EMPTY array whose every element is a valid\n * `ContentPart`. Empty arrays and mixed arrays return false so they continue\n * to be treated as ordinary (stringified) data — this keeps the auto-detection\n * footgun narrow.\n */\nexport function isContentPartArray(\n value: unknown,\n): value is Array<ContentPart> {\n return Array.isArray(value) && value.length > 0 && value.every(isContentPart)\n}\n\n/**\n * Normalize a tool's return value for transport:\n * - string → unchanged\n * - ContentPart array → unchanged (multimodal, passed through to the adapter)\n * - anything else → `JSON.stringify`\n */\nexport function normalizeToolResult(\n result: unknown,\n): string | Array<ContentPart> {\n if (typeof result === 'string') return result\n if (isContentPartArray(result)) return result\n return JSON.stringify(result)\n}\n"],"mappings":";AAEA,IAAM,qCAAqB,IAAI,IAAI;CACjC;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;AAOD,SAAgB,cAAc,OAAsC;CAClE,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CACxD,MAAM,OAAO;CACb,IAAI,OAAO,KAAK,SAAS,YAAY,CAAC,mBAAmB,IAAI,KAAK,IAAI,GACpE,OAAO;CAET,IAAI,KAAK,SAAS,QAChB,OAAO,OAAO,KAAK,YAAY;CAEjC,MAAM,SAAS,KAAK;CACpB,IAAI,OAAO,WAAW,YAAY,WAAW,MAAM,OAAO;CAC1D,MAAM,MAAM;CACZ,IAAI,OAAO,IAAI,UAAU,UAAU,OAAO;CAI1C,IAAI,IAAI,SAAS,QAAQ,OAAO,OAAO,IAAI,aAAa;CACxD,OAAO,IAAI,SAAS;AACtB;;;;;;;AAQA,SAAgB,mBACd,OAC6B;CAC7B,OAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,KAAK,MAAM,MAAM,aAAa;AAC9E;;;;;;;AAQA,SAAgB,oBACd,QAC6B;CAC7B,IAAI,OAAO,WAAW,UAAU,OAAO;CACvC,IAAI,mBAAmB,MAAM,GAAG,OAAO;CACvC,OAAO,KAAK,UAAU,MAAM;AAC9B"}
1
+ {"version":3,"file":"tool-result.js","names":[],"sources":["../../../src/utilities/tool-result.ts"],"sourcesContent":["import type { ContentPart } from '../types'\n\nconst CONTENT_PART_TYPES = new Set([\n 'text',\n 'image',\n 'audio',\n 'video',\n 'document',\n])\n\n/**\n * Structural check for a single `ContentPart`. A text part must carry a string\n * `content`. Every other part carries a source with a string `value`; a file\n * source's `value` is a non-empty opaque handle, and its optional `provider`\n * is a string.\n */\nexport function isContentPart(value: unknown): value is ContentPart {\n if (typeof value !== 'object' || value === null) return false\n const part = value as Record<string, unknown>\n if (typeof part.type !== 'string' || !CONTENT_PART_TYPES.has(part.type)) {\n return false\n }\n if (part.type === 'text') {\n return typeof part.content === 'string'\n }\n const source = part.source\n if (typeof source !== 'object' || source === null) return false\n const src = source as Record<string, unknown>\n if (typeof src.value !== 'string') return false\n // `file` sources carry an opaque handle in `value`; `provider`, when set,\n // names the issuer.\n if (src.type === 'file') {\n return (\n src.value.length > 0 &&\n (src.provider === undefined || typeof src.provider === 'string')\n )\n }\n // `data` sources require a mimeType (matches ContentPartDataSource); `url`\n // sources don't. Requiring it here keeps the runtime guard consistent with\n // the type and avoids emitting `data:undefined;base64,...` downstream.\n if (src.type === 'data') return typeof src.mimeType === 'string'\n return src.type === 'url'\n}\n\n/**\n * True iff `value` is a NON-EMPTY array whose every element is a valid\n * `ContentPart`. Empty arrays and mixed arrays return false so they continue\n * to be treated as ordinary (stringified) data — this keeps the auto-detection\n * footgun narrow.\n */\nexport function isContentPartArray(\n value: unknown,\n): value is Array<ContentPart> {\n return Array.isArray(value) && value.length > 0 && value.every(isContentPart)\n}\n\n/**\n * Error text for a failed tool result: `output.error` when it is a string,\n * else the output itself when it is a string, else a generic message.\n * `StreamProcessor` and `chat()` history share it, so a reload shows the\n * same text as the live stream.\n */\nexport function toolResultErrorText(output: unknown): string {\n if (\n output &&\n typeof output === 'object' &&\n 'error' in output &&\n typeof output.error === 'string'\n ) {\n return output.error\n }\n return typeof output === 'string' ? output : 'Tool execution failed'\n}\n\n/** Parse tool result content as JSON. Plain text stays a string. */\nexport function parseToolOutput(content: string): unknown {\n try {\n return JSON.parse(content)\n } catch {\n return content\n }\n}\n\n/**\n * Normalize a tool's return value for transport:\n * - string → unchanged\n * - ContentPart array → unchanged (multimodal, passed through to the adapter)\n * - anything else → `JSON.stringify`\n */\nexport function normalizeToolResult(\n result: unknown,\n): string | Array<ContentPart> {\n if (typeof result === 'string') return result\n if (isContentPartArray(result)) return result\n return JSON.stringify(result)\n}\n"],"mappings":";AAEA,IAAM,qCAAqB,IAAI,IAAI;CACjC;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;AAQD,SAAgB,cAAc,OAAsC;CAClE,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CACxD,MAAM,OAAO;CACb,IAAI,OAAO,KAAK,SAAS,YAAY,CAAC,mBAAmB,IAAI,KAAK,IAAI,GACpE,OAAO;CAET,IAAI,KAAK,SAAS,QAChB,OAAO,OAAO,KAAK,YAAY;CAEjC,MAAM,SAAS,KAAK;CACpB,IAAI,OAAO,WAAW,YAAY,WAAW,MAAM,OAAO;CAC1D,MAAM,MAAM;CACZ,IAAI,OAAO,IAAI,UAAU,UAAU,OAAO;CAG1C,IAAI,IAAI,SAAS,QACf,OACE,IAAI,MAAM,SAAS,MAClB,IAAI,aAAa,KAAA,KAAa,OAAO,IAAI,aAAa;CAM3D,IAAI,IAAI,SAAS,QAAQ,OAAO,OAAO,IAAI,aAAa;CACxD,OAAO,IAAI,SAAS;AACtB;;;;;;;AAQA,SAAgB,mBACd,OAC6B;CAC7B,OAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,KAAK,MAAM,MAAM,aAAa;AAC9E;;;;;;;AAQA,SAAgB,oBAAoB,QAAyB;CAC3D,IACE,UACA,OAAO,WAAW,YAClB,WAAW,UACX,OAAO,OAAO,UAAU,UAExB,OAAO,OAAO;CAEhB,OAAO,OAAO,WAAW,WAAW,SAAS;AAC/C;;AAGA,SAAgB,gBAAgB,SAA0B;CACxD,IAAI;EACF,OAAO,KAAK,MAAM,OAAO;CAC3B,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;AAQA,SAAgB,oBACd,QAC6B;CAC7B,IAAI,OAAO,WAAW,UAAU,OAAO;CACvC,IAAI,mBAAmB,MAAM,GAAG,OAAO;CACvC,OAAO,KAAK,UAAU,MAAM;AAC9B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -88,7 +88,7 @@
88
88
  "@ag-ui/core": "1.0.0",
89
89
  "@standard-schema/spec": "^1.1.0",
90
90
  "partial-json": "^0.1.7",
91
- "@tanstack/ai-event-client": "^0.12.1",
91
+ "@tanstack/ai-event-client": "^0.13.0",
92
92
  "@tanstack/ai-utils": "^0.4.1"
93
93
  },
94
94
  "peerDependencies": {
@@ -456,6 +456,68 @@ compatible providers speak.
456
456
  > Verify the provider's current `baseURL` and model ids against its live docs —
457
457
  > they drift. See `docs/adapters/openai-compatible.md` for the full provider table.
458
458
 
459
+ ### 7. Files Adapters (upload once, reference by handle)
460
+
461
+ Four providers expose a native Files/storage API as a tree-shakeable `files`
462
+ adapter: `openaiFiles()`, `anthropicFiles()`, `geminiFiles()` (each reads the
463
+ same env var as the provider's text adapter; `create*Files(apiKey)` variants
464
+ take an explicit key), and `falFiles(config)`. Upload media once with
465
+ `uploadFile()`, then reference the returned `FileHandle` in messages via a
466
+ `{ type: 'file' }` content source instead of re-sending base64 each request:
467
+
468
+ ```typescript
469
+ import { chat, fileSourceFromHandle, uploadFile } from '@tanstack/ai'
470
+ import { openaiFiles, openaiText } from '@tanstack/ai-openai'
471
+ import { pdfBase64 } from './pdf-data'
472
+
473
+ const handle = await uploadFile({
474
+ adapter: openaiFiles(),
475
+ input: { data: pdfBase64, mimeType: 'application/pdf' },
476
+ })
477
+
478
+ chat({
479
+ adapter: openaiText('gpt-5.5'),
480
+ messages: [
481
+ {
482
+ role: 'user',
483
+ content: [
484
+ { type: 'text', content: 'Summarize this document' },
485
+ { type: 'document', source: fileSourceFromHandle(handle) },
486
+ ],
487
+ },
488
+ ],
489
+ })
490
+ ```
491
+
492
+ Rules agents must respect:
493
+
494
+ - **The source is one opaque handle plus its issuer.** `fileSourceFromHandle`
495
+ builds `{ type: 'file', value: 'file-…', provider: 'openai' }`, matching the
496
+ AG-UI `FileSource` arm. A handle only resolves at the provider that issued
497
+ it, so an adapter throws when `provider` names a different adapter.
498
+ `provider` is optional, as on the AG-UI wire; a source without it is taken
499
+ as-is. To use the same bytes with two providers,
500
+ upload to each and send the matching handle.
501
+ - **Adapters declare `supportsFileSources`.** For adapters that don't (Groq, Bedrock, Mistral, OpenRouter, Ollama, BytePlus, Cohere, and anything
502
+ written before this feature), `chat()` / `generateImage()` /
503
+ `generateVideo()` / `embed()` reject file sources in preflight, before any
504
+ request is built — pass `data`/`url` sources there instead.
505
+ - **Lifecycle:** `getFile()` / `deleteFile()` work for OpenAI, Anthropic,
506
+ Gemini, and Grok, and accept the handle itself (provider-literal typed, so a
507
+ foreign handle is a compile error). fal storage is upload-only, so those
508
+ calls throw for `falFiles()`. `grokFiles().get()` mints the public URL again,
509
+ so do not call it after `revokePublicUrl()`.
510
+ - **Some endpoints need raw bytes even on supporting providers:** OpenAI
511
+ `images/edits` + Sora `input_reference`, Gemini Veo, and Chat Completions
512
+ image inputs throw endpoint-specific errors for file sources.
513
+ - **A file source crosses the chat wire.** A browser that holds a handle puts
514
+ `fileSourceFromHandle(handle)` straight into the `sendMessage` content, and
515
+ the server passes the messages to `chat()` as usual. `fileSourceFromHandle`
516
+ and the `FileHandle` type are exported from the browser-safe
517
+ `@tanstack/ai/client` entry.
518
+
519
+ See `docs/advanced/files-api.md` for the full guide.
520
+
459
521
  ## Behind a proxy or gateway
460
522
 
461
523
  Every adapter's client config accepts `baseURL` and `defaultHeaders`. Use these
@@ -299,6 +299,8 @@ import type { UIMessage } from '@tanstack/ai-react'
299
299
 
300
300
  function ImagePart({ part }: { part: UIMessage['parts'][number] }) {
301
301
  if (part.type !== 'image') return null
302
+ // A provider file handle is an opaque id, so the browser cannot load it.
303
+ if (part.source.type === 'file') return null
302
304
  const src =
303
305
  part.source.type === 'url'
304
306
  ? part.source.value
@@ -307,6 +309,18 @@ function ImagePart({ part }: { part: UIMessage['parts'][number] }) {
307
309
  }
308
310
  ```
309
311
 
312
+ For media reused across turns, upload once via a provider Files adapter
313
+ (`openaiFiles()`, `anthropicFiles()`, `geminiFiles()`, `grokFiles()`,
314
+ `falFiles()`) and send a `{ type: 'file' }` source built with
315
+ `fileSourceFromHandle(handle)` instead of re-sending base64 each request. The
316
+ source is `{ type: 'file', value, provider }`: an opaque handle and the adapter
317
+ that issued it. A different provider (or one without Files API support at all)
318
+ rejects it with a clear error before any request is sent.
319
+ The source crosses the chat wire, so the browser can put it straight into the
320
+ `sendMessage` content. Import `fileSourceFromHandle` from the browser-safe
321
+ `@tanstack/ai/client` entry. See `ai-core/adapter-configuration/SKILL.md` §7
322
+ and `docs/advanced/files-api.md`.
323
+
310
324
  ### 4. Sending Audio Messages (Browser Recording)
311
325
 
312
326
  Use `useAudioRecorder` from `@tanstack/ai-react` (or `createAudioRecorder` in Svelte) to capture audio in the browser. The resolved `AudioRecording` includes a ready-to-use `part` that slots directly into `sendMessage`.
@@ -283,6 +283,14 @@ await generateVideo({
283
283
  })
284
284
  ```
285
285
 
286
+ Reference images / start frames that are reused (or arrive as inline base64 on
287
+ memory-constrained runtimes) can instead be uploaded once via the provider's
288
+ Files adapter and referenced with `source: fileSourceFromHandle(handle)` —
289
+ supported for Gemini image generation (`geminiFiles()`) and fal image/video
290
+ inputs (`falFiles()`). Endpoints that require raw bytes (OpenAI `images/edits`,
291
+ Sora `input_reference`, Gemini Veo) reject file sources with a clear error.
292
+ See `ai-core/adapter-configuration/SKILL.md` §7.
293
+
286
294
  **URL inputs that require an upload throw by default.** Most adapters pass a
287
295
  `type: 'url'` source straight through to the provider. Three paths can't —
288
296
  OpenAI `images.edit()`, OpenAI Sora `input_reference`, and Gemini **Veo** —
@@ -89,6 +89,15 @@ export interface TextAdapter<
89
89
  */
90
90
  readonly requires?: ReadonlyArray<CapabilityHandle>
91
91
 
92
+ /**
93
+ * Declares that this adapter can consume `{ type: 'file' }` content sources
94
+ * (provider Files API references). `chat()` rejects file sources in preflight
95
+ * for adapters that don't declare this, so an adapter written before the
96
+ * file arm existed fails closed instead of silently mis-mapping a reference
97
+ * onto its URL/data branch.
98
+ */
99
+ readonly supportsFileSources?: boolean
100
+
92
101
  /**
93
102
  * @internal Type-only properties for inference. Not assigned at runtime.
94
103
  */
@@ -209,6 +218,7 @@ export abstract class BaseTextAdapter<
209
218
  abstract readonly name: string
210
219
  readonly model: TModel
211
220
  readonly requires?: ReadonlyArray<CapabilityHandle> = undefined
221
+ readonly supportsFileSources: boolean = false
212
222
 
213
223
  // Type-only property - never assigned at runtime
214
224
  declare '~types': {
@@ -43,8 +43,13 @@ import { withDurabilityBatchHint } from '../../utilities/durability-batch'
43
43
  import { normalizeStreamChunk } from '../../utilities/normalize-stream-chunk'
44
44
  import { restorePublicUsage } from '../../utilities/restore-inbound-chunk'
45
45
  import type { AdapterYieldChunk } from '../../utilities/adapter-yield-chunk'
46
- import { normalizeToolResult } from '../../utilities/tool-result'
46
+ import {
47
+ normalizeToolResult,
48
+ parseToolOutput,
49
+ toolResultErrorText,
50
+ } from '../../utilities/tool-result'
47
51
  import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
52
+ import { assertMessagesFileSourceSupport } from '../../utilities/content-source'
48
53
  import { LazyToolManager } from './tools/lazy-tool-manager'
49
54
  import { assertUniqueToolNames } from './tools/unique-tool-names'
50
55
  import type { DefinedAgent } from './agents/define-agent'
@@ -170,6 +175,12 @@ import type {
170
175
  } from './runtime-context-types'
171
176
  import type { ChatMCPOptions } from './mcp/types'
172
177
 
178
+ /** One entry of the per-iteration arrival order (see `turnParts`). */
179
+ type TurnPart =
180
+ | { type: 'thinking'; index: number }
181
+ | { type: 'text'; content: string }
182
+ | { type: 'call'; id: string; providerExecuted: boolean }
183
+
173
184
  // ===========================
174
185
  // Activity Kind
175
186
  // ===========================
@@ -186,25 +197,6 @@ const interruptBindingMetadataKey = INTERRUPT_BINDING_METADATA_KEY
186
197
  /** Resume entries a subagent tool call owns. The parent run skips them. */
187
198
  const CHILD_RESUME_IDS = Symbol('tanstack.ai.childResumeIds')
188
199
 
189
- // ponytail: no adapter maps `{ type: 'file' }` yet, so every one fails closed
190
- // instead of reading the handle as a URL or base64. The Files API work
191
- // replaces this with a per-adapter capability check.
192
- function assertNoFileSources(
193
- adapterName: string,
194
- messages: ReadonlyArray<ModelMessage>,
195
- ): void {
196
- for (const message of messages) {
197
- if (!Array.isArray(message.content)) continue
198
- for (const part of message.content) {
199
- if ('source' in part && part.source.type === 'file') {
200
- throw new Error(
201
- `${adapterName} does not support provider file-handle sources ({ type: 'file' }). Pass a data or url source.`,
202
- )
203
- }
204
- }
205
- }
206
- }
207
-
208
200
  interface StructuralInterruptFailure {
209
201
  error: Error
210
202
  errors: ReadonlyArray<InterruptSubmissionError>
@@ -861,6 +853,15 @@ class TextEngine<
861
853
  private accumulatedContent = ''
862
854
  private accumulatedThinking: Array<{ content: string; signature?: string }> =
863
855
  []
856
+ /**
857
+ * Arrival order of this iteration's thinking steps, text and tool calls.
858
+ * A ModelMessage keeps `thinking` apart from `content`/`toolCalls`, so a
859
+ * provider turn that thinks between provider-executed tools would otherwise
860
+ * be recorded as "all thinking, then text, then tools" and the provider
861
+ * rejects the replay (signed thinking must keep its position). `null` once
862
+ * the order could no longer be tracked (callers fall back to one message).
863
+ */
864
+ private turnParts: Array<TurnPart> | null = []
864
865
  private currentThinkingContent = ''
865
866
  private currentThinkingSignature = ''
866
867
  private eventOptions?: Record<string, unknown> | undefined
@@ -1504,6 +1505,7 @@ class TextEngine<
1504
1505
  this.streamIdentityCaptured = false
1505
1506
  this.accumulatedContent = ''
1506
1507
  this.accumulatedThinking = []
1508
+ this.turnParts = []
1507
1509
  this.currentThinkingContent = ''
1508
1510
  this.currentThinkingSignature = ''
1509
1511
 
@@ -1571,7 +1573,12 @@ class TextEngine<
1571
1573
  )
1572
1574
  }
1573
1575
 
1574
- assertNoFileSources(this.adapter.name, this.messages)
1576
+ // Fail closed on `{ type: 'file' }` sources for adapters that haven't
1577
+ // declared support — an adapter written before the file arm existed would
1578
+ // otherwise fall through to its URL/data branch and silently mis-map the
1579
+ // reference. Checked per model call so tool results added mid-loop are
1580
+ // covered too.
1581
+ assertMessagesFileSourceSupport(this.adapter, this.messages)
1575
1582
 
1576
1583
  for await (const raw of this.adapter.chatStream({
1577
1584
  model: this.params.model,
@@ -1805,12 +1812,27 @@ class TextEngine<
1805
1812
  // Adapters still emit leftover cumulative `content` on RAW yields.
1806
1813
  // Alignment suppresses already-delivered deltas, so this snapshot is
1807
1814
  // what a takeover saves as the full assistant text.
1815
+ const before = this.accumulatedContent
1808
1816
  if (typeof extra.content === 'string' && extra.content !== '') {
1809
1817
  this.accumulatedContent = extra.content
1810
1818
  } else {
1811
1819
  this.accumulatedContent += chunk.delta
1812
1820
  }
1813
1821
  this.middlewareCtx.accumulatedContent = this.accumulatedContent
1822
+ if (!this.turnParts) return
1823
+ if (!this.accumulatedContent.startsWith(before)) {
1824
+ // A cumulative `content` snapshot rewrote earlier text; order unknown.
1825
+ this.turnParts = null
1826
+ return
1827
+ }
1828
+ const delta = this.accumulatedContent.slice(before.length)
1829
+ if (delta === '') return
1830
+ const last = this.turnParts[this.turnParts.length - 1]
1831
+ if (last && last.type === 'text') {
1832
+ last.content += delta
1833
+ } else {
1834
+ this.turnParts.push({ type: 'text', content: delta })
1835
+ }
1814
1836
  }
1815
1837
 
1816
1838
  private captureStreamMessageIdentity(messageId: string): void {
@@ -1835,6 +1857,20 @@ class TextEngine<
1835
1857
  this.captureStreamMessageIdentity(chunk.parentMessageId)
1836
1858
  }
1837
1859
  this.toolCallManager.addToolCallStartEvent(chunk)
1860
+ if (
1861
+ this.turnParts &&
1862
+ !this.turnParts.some(
1863
+ (part) => part.type === 'call' && part.id === chunk.toolCallId,
1864
+ )
1865
+ ) {
1866
+ this.turnParts.push({
1867
+ type: 'call',
1868
+ id: chunk.toolCallId,
1869
+ providerExecuted: isProviderExecutedToolCall({
1870
+ metadata: chunk.metadata,
1871
+ }),
1872
+ })
1873
+ }
1838
1874
  const metadata = chunk.metadata
1839
1875
  const thoughtSignature =
1840
1876
  metadata != null &&
@@ -1941,17 +1977,48 @@ class TextEngine<
1941
1977
  signature: this.currentThinkingSignature,
1942
1978
  }),
1943
1979
  })
1980
+ if (this.turnParts) {
1981
+ const placeholder = [...this.turnParts]
1982
+ .reverse()
1983
+ .find(
1984
+ (part): part is Extract<TurnPart, { type: 'thinking' }> =>
1985
+ part.type === 'thinking' && part.index === -1,
1986
+ )
1987
+ const index = this.accumulatedThinking.length - 1
1988
+ if (placeholder) {
1989
+ placeholder.index = index
1990
+ } else {
1991
+ this.turnParts.push({ type: 'thinking', index })
1992
+ }
1993
+ }
1944
1994
  this.currentThinkingContent = ''
1945
1995
  this.currentThinkingSignature = ''
1946
1996
  }
1947
1997
  }
1948
1998
 
1999
+ /**
2000
+ * Record where the current thinking step sits among this turn's parts. A
2001
+ * step is finalized only when the next step starts (or the turn ends), by
2002
+ * which time later tool calls have already arrived, so the position has to
2003
+ * be noted when the step's first content or signature shows up.
2004
+ */
2005
+ private noteThinkingStepPosition(): void {
2006
+ if (
2007
+ this.turnParts &&
2008
+ this.currentThinkingContent === '' &&
2009
+ this.currentThinkingSignature === ''
2010
+ ) {
2011
+ this.turnParts.push({ type: 'thinking', index: -1 })
2012
+ }
2013
+ }
2014
+
1949
2015
  private handleStepStartedEvent(): void {
1950
2016
  this.finalizeCurrentThinkingStep()
1951
2017
  }
1952
2018
 
1953
2019
  private handleStepFinishedEvent(chunk: AdapterYieldChunk): void {
1954
2020
  if (typeof chunk.signature === 'string' && chunk.signature !== '') {
2021
+ this.noteThinkingStepPosition()
1955
2022
  this.currentThinkingSignature = chunk.signature
1956
2023
  }
1957
2024
  }
@@ -1959,6 +2026,7 @@ class TextEngine<
1959
2026
  private handleReasoningMessageContentEvent(
1960
2027
  chunk: Extract<StreamChunk, { type: 'REASONING_MESSAGE_CONTENT' }>,
1961
2028
  ): void {
2029
+ this.noteThinkingStepPosition()
1962
2030
  this.currentThinkingContent += chunk.delta
1963
2031
  }
1964
2032
 
@@ -1979,6 +2047,7 @@ class TextEngine<
1979
2047
  }
1980
2048
  return
1981
2049
  }
2050
+ this.noteThinkingStepPosition()
1982
2051
  this.currentThinkingSignature = chunk.encryptedValue
1983
2052
  }
1984
2053
 
@@ -2464,21 +2533,110 @@ class TextEngine<
2464
2533
  )
2465
2534
  }
2466
2535
 
2536
+ /**
2537
+ * Split this iteration into assistant ModelMessages that keep the provider's
2538
+ * block order: a new segment starts at every thinking step that follows a
2539
+ * provider-executed tool call (the rule buildAssistantMessages applies to
2540
+ * UIMessages). Segments after the first get `${id}-segment-${n}` ids.
2541
+ * Returns null when no split is needed or the order could not be tracked,
2542
+ * so callers fall back to the single-message shape.
2543
+ */
2544
+ private buildOrderedAssistantSegments(
2545
+ toolCalls: ReadonlyArray<ToolCall>,
2546
+ id: string | undefined,
2547
+ createdAt: Date | undefined,
2548
+ ): Array<ModelMessage> | null {
2549
+ const parts = this.turnParts
2550
+ if (!parts) return null
2551
+ const providerCallIds = new Set(
2552
+ parts.flatMap((part) =>
2553
+ part.type === 'call' && part.providerExecuted ? [part.id] : [],
2554
+ ),
2555
+ )
2556
+ type Segment = {
2557
+ thinking: Array<{ content: string; signature?: string }>
2558
+ text: string
2559
+ callIds: Array<string>
2560
+ }
2561
+ let current: Segment = { thinking: [], text: '', callIds: [] }
2562
+ const segments: Array<Segment> = [current]
2563
+ let split = false
2564
+ for (const part of parts) {
2565
+ if (part.type === 'thinking') {
2566
+ const thinking = this.accumulatedThinking[part.index]
2567
+ if (!thinking) return null
2568
+ if (current.callIds.some((callId) => providerCallIds.has(callId))) {
2569
+ current = { thinking: [thinking], text: '', callIds: [] }
2570
+ segments.push(current)
2571
+ split = true
2572
+ } else {
2573
+ current.thinking.push(thinking)
2574
+ }
2575
+ } else if (part.type === 'text') {
2576
+ current.text += part.content
2577
+ } else {
2578
+ current.callIds.push(part.id)
2579
+ }
2580
+ }
2581
+ if (!split) return null
2582
+ if (
2583
+ segments.map((segment) => segment.text).join('') !==
2584
+ this.accumulatedContent
2585
+ ) {
2586
+ return null
2587
+ }
2588
+ if (
2589
+ segments.reduce((n, segment) => n + segment.thinking.length, 0) !==
2590
+ this.accumulatedThinking.length
2591
+ ) {
2592
+ return null
2593
+ }
2594
+ const placed = new Set(segments.flatMap((segment) => segment.callIds))
2595
+ for (const toolCall of toolCalls) {
2596
+ if (!placed.has(toolCall.id)) current.callIds.push(toolCall.id)
2597
+ }
2598
+ return segments.map((segment, index) => {
2599
+ const segmentCalls = toolCalls.filter((toolCall) =>
2600
+ segment.callIds.includes(toolCall.id),
2601
+ )
2602
+ return {
2603
+ role: 'assistant',
2604
+ content: segment.text || null,
2605
+ ...(segmentCalls.length > 0 && { toolCalls: segmentCalls }),
2606
+ id:
2607
+ id === undefined
2608
+ ? undefined
2609
+ : index === 0
2610
+ ? id
2611
+ : `${id}-segment-${index}`,
2612
+ createdAt,
2613
+ ...(segment.thinking.length > 0 && { thinking: segment.thinking }),
2614
+ }
2615
+ })
2616
+ }
2617
+
2467
2618
  private addAssistantToolCallMessage(toolCalls: Array<ToolCall>): void {
2468
2619
  this.finalizeCurrentThinkingStep()
2469
2620
 
2621
+ const segments = this.buildOrderedAssistantSegments(
2622
+ toolCalls,
2623
+ this.currentMessageId ?? undefined,
2624
+ this.currentMessageCreatedAt ?? undefined,
2625
+ )
2470
2626
  this.messages = [
2471
2627
  ...this.messages,
2472
- {
2473
- role: 'assistant',
2474
- content: this.accumulatedContent || null,
2475
- toolCalls,
2476
- id: this.currentMessageId ?? undefined,
2477
- createdAt: this.currentMessageCreatedAt ?? undefined,
2478
- ...(this.accumulatedThinking.length > 0 && {
2479
- thinking: this.accumulatedThinking,
2480
- }),
2481
- },
2628
+ ...(segments ?? [
2629
+ {
2630
+ role: 'assistant' as const,
2631
+ content: this.accumulatedContent || null,
2632
+ toolCalls,
2633
+ id: this.currentMessageId ?? undefined,
2634
+ createdAt: this.currentMessageCreatedAt ?? undefined,
2635
+ ...(this.accumulatedThinking.length > 0 && {
2636
+ thinking: this.accumulatedThinking,
2637
+ }),
2638
+ },
2639
+ ]),
2482
2640
  ]
2483
2641
  this.middlewareCtx.messages = this.messages
2484
2642
  }
@@ -2557,13 +2715,23 @@ class TextEngine<
2557
2715
  !currentTurnAlreadyRecorded &&
2558
2716
  (this.accumulatedContent !== '' || thinking)
2559
2717
  ) {
2560
- messages.push({
2561
- role: 'assistant',
2562
- content: this.accumulatedContent || null,
2563
- id: this.currentMessageId ?? this.createId('msg'),
2564
- createdAt: this.currentMessageCreatedAt ?? new Date(),
2565
- ...(thinking ? { thinking } : {}),
2566
- })
2718
+ const id = this.currentMessageId ?? this.createId('msg')
2719
+ const createdAt = this.currentMessageCreatedAt ?? new Date()
2720
+ messages.push(
2721
+ ...(this.buildOrderedAssistantSegments(
2722
+ this.toolCallManager.getToolCalls(),
2723
+ id,
2724
+ createdAt,
2725
+ ) ?? [
2726
+ {
2727
+ role: 'assistant',
2728
+ content: this.accumulatedContent || null,
2729
+ id,
2730
+ createdAt,
2731
+ ...(thinking ? { thinking } : {}),
2732
+ },
2733
+ ]),
2734
+ )
2567
2735
  }
2568
2736
  if (structuredOutput) {
2569
2737
  messages.push({
@@ -3097,6 +3265,8 @@ class TextEngine<
3097
3265
  const approvalRequests: Array<ApprovalRequest> = []
3098
3266
  const clientRequests: Array<ClientToolRequest> = []
3099
3267
  for (const toolCall of toolCalls) {
3268
+ // Provider-executed calls are complete; never surface them as client work.
3269
+ if (isProviderExecutedToolCall(toolCall)) continue
3100
3270
  const tool = this.resolveExecutableTools([toolCall]).find(
3101
3271
  (candidate) => candidate.name === toolCall.function.name,
3102
3272
  ) as RuntimeToolWithApproval | undefined
@@ -3254,6 +3424,9 @@ class TextEngine<
3254
3424
  role: 'tool',
3255
3425
  content,
3256
3426
  toolCallId: result.toolCallId,
3427
+ ...(result.state === 'output-error' && {
3428
+ error: toolResultErrorText(parseToolOutput(wireContent)),
3429
+ }),
3257
3430
  }
3258
3431
 
3259
3432
  if (placeholderIdx >= 0) {
@@ -3589,7 +3762,11 @@ class TextEngine<
3589
3762
  // Apply merged config back to engine state
3590
3763
  this.applyMiddlewareConfig(postOnConfig)
3591
3764
 
3592
- assertNoFileSources(this.adapter.name, this.messages)
3765
+ // Schema-only structured output with no tools skips the agent loop, so
3766
+ // `streamModelResponse` never runs this check. Middleware can also
3767
+ // replace `this.messages` above. Fail closed here before the
3768
+ // structured-output adapter call.
3769
+ assertMessagesFileSourceSupport(this.adapter, this.messages)
3593
3770
 
3594
3771
  // Build the StructuredOutputOptions the adapter expects.
3595
3772
  // `this.adapter` is already `TAdapter extends AnyTextAdapter` per the
@@ -3932,7 +4109,16 @@ class TextEngine<
3932
4109
  const yieldChunks = this.finalStructuredOutput.yieldChunks
3933
4110
  const source = this.finalStructuredOutput.source ?? 'text'
3934
4111
 
3935
- if (source === 'event') {
4112
+ // A final turn cut off at the output cap holds truncated JSON, or none
4113
+ // for a reasoning model that spent the budget. Report the token limit
4114
+ // instead of a parse or missing-result error (#1426).
4115
+ if (this.lastFinishReason === 'length') {
4116
+ this.finalizationError = {
4117
+ message:
4118
+ 'The response was cut off because the maximum token limit was reached (finish_reason=length); raise the output token limit.',
4119
+ code: 'max_tokens',
4120
+ }
4121
+ } else if (source === 'event') {
3936
4122
  if (!this.structuredOutputResult) {
3937
4123
  this.finalizationError = {
3938
4124
  message: 'missing structured result',
@@ -1,4 +1,7 @@
1
- import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
1
+ import {
2
+ isAssistantSegmentOf,
3
+ isProviderExecutedToolCall,
4
+ } from '../../utilities/provider-executed'
2
5
  import {
3
6
  isContentPartArray,
4
7
  normalizeToolResult,
@@ -1259,6 +1262,14 @@ export function modelMessagesToUIMessages(
1259
1262
  // Regular message. Preserve a persisted stable id so a hydrated message
1260
1263
  // keeps the same identity as its live stream (enables in-place resume).
1261
1264
  const uiMessage = modelMessageToUIMessage(msg, msg.id)
1265
+ if (
1266
+ msg.role === 'assistant' &&
1267
+ currentAssistantMessage &&
1268
+ isAssistantSegmentOf(msg.id, currentAssistantMessage.id)
1269
+ ) {
1270
+ currentAssistantMessage.parts.push(...uiMessage.parts)
1271
+ continue
1272
+ }
1262
1273
  uiMessages.push(uiMessage)
1263
1274
 
1264
1275
  // Track assistant messages for potential tool result merging
@@ -455,15 +455,37 @@ export function updateThinkingPart(
455
455
  }
456
456
 
457
457
  const parts = [...msg.parts]
458
- const thinkingPartIndex = parts.findIndex(
458
+ let thinkingPartIndex = parts.findIndex(
459
459
  (p) => p.type === 'thinking' && p.stepId === stepId,
460
460
  )
461
461
 
462
+ // A hydrated message carries its thinking without a stepId: the stored form
463
+ // has no field for one, so `modelMessageToUIMessage` cannot put it back.
464
+ // When a run is rejoined mid-stream the replayed reasoning is keyed by
465
+ // stepId, matches nothing, and gets appended -- leaving a second thinking
466
+ // part sitting after the answer text. Adopt the first stepId-less thinking
467
+ // part instead, so the replay lands on the part it belongs to. Live
468
+ // streaming always writes a stepId, so the only parts this can match are
469
+ // hydrated ones.
470
+ let adopted: ThinkingPart | undefined
471
+ if (thinkingPartIndex < 0) {
472
+ thinkingPartIndex = parts.findIndex(
473
+ (p) => p.type === 'thinking' && p.stepId === undefined,
474
+ )
475
+ const candidate = parts[thinkingPartIndex]
476
+ if (candidate?.type === 'thinking') adopted = candidate
477
+ }
478
+
479
+ // Keep the signature the hydrated part already had when this update does
480
+ // not carry one; losing it would strip the provider's encrypted reasoning
481
+ // from a message that is about to be sent back.
482
+ const nextSignature = signature ?? adopted?.signature
483
+
462
484
  const thinkingPart: ThinkingPart = {
463
485
  type: 'thinking',
464
486
  content,
465
487
  stepId,
466
- ...(signature && { signature }),
488
+ ...(nextSignature && { signature: nextSignature }),
467
489
  }
468
490
 
469
491
  if (thinkingPartIndex >= 0) {