@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.
- package/dist/esm/activities/chat/adapter.d.ts +9 -0
- package/dist/esm/activities/chat/adapter.js +1 -0
- package/dist/esm/activities/chat/adapter.js.map +1 -1
- package/dist/esm/activities/chat/index.js +134 -22
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.js +5 -1
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/stream/message-updaters.js +9 -2
- package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
- package/dist/esm/activities/chat/stream/processor.d.ts +0 -1
- package/dist/esm/activities/chat/stream/processor.js +15 -12
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/tools/tool-calls.js +2 -0
- package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
- package/dist/esm/activities/embed/adapter.d.ts +7 -0
- package/dist/esm/activities/embed/adapter.js +1 -0
- package/dist/esm/activities/embed/adapter.js.map +1 -1
- package/dist/esm/activities/embed/index.js +2 -0
- package/dist/esm/activities/embed/index.js.map +1 -1
- package/dist/esm/activities/files/adapter.d.ts +97 -0
- package/dist/esm/activities/files/adapter.js +45 -0
- package/dist/esm/activities/files/adapter.js.map +1 -0
- package/dist/esm/activities/files/index.d.ts +66 -0
- package/dist/esm/activities/files/index.js +78 -0
- package/dist/esm/activities/files/index.js.map +1 -0
- package/dist/esm/activities/generateImage/adapter.d.ts +8 -0
- package/dist/esm/activities/generateImage/adapter.js +1 -0
- package/dist/esm/activities/generateImage/adapter.js.map +1 -1
- package/dist/esm/activities/generateImage/index.js +2 -0
- package/dist/esm/activities/generateImage/index.js.map +1 -1
- package/dist/esm/activities/generateVideo/adapter.d.ts +8 -0
- package/dist/esm/activities/generateVideo/adapter.js +1 -0
- package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
- package/dist/esm/activities/generateVideo/index.js +3 -0
- package/dist/esm/activities/generateVideo/index.js.map +1 -1
- package/dist/esm/activities/generateWorld/adapter.d.ts +4 -2
- package/dist/esm/activities/generateWorld/adapter.js.map +1 -1
- package/dist/esm/activities/generateWorld/index.d.ts +4 -3
- package/dist/esm/activities/generateWorld/index.js +5 -4
- package/dist/esm/activities/generateWorld/index.js.map +1 -1
- package/dist/esm/activities/index.d.ts +6 -3
- package/dist/esm/activities/index.js +13 -11
- package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +2 -0
- package/dist/esm/activities/summarize/chat-stream-summarize.js +8 -8
- package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
- package/dist/esm/client.d.ts +2 -0
- package/dist/esm/client.js +2 -1
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.d.ts +3 -2
- package/dist/esm/index.js +4 -2
- package/dist/esm/types.d.ts +72 -13
- package/dist/esm/utilities/ag-ui-wire.js +34 -13
- package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
- package/dist/esm/utilities/content-source.d.ts +60 -0
- package/dist/esm/utilities/content-source.js +85 -0
- package/dist/esm/utilities/content-source.js.map +1 -0
- package/dist/esm/utilities/provider-executed.d.ts +7 -0
- package/dist/esm/utilities/provider-executed.js +10 -1
- package/dist/esm/utilities/provider-executed.js.map +1 -1
- package/dist/esm/utilities/tool-result.d.ts +12 -2
- package/dist/esm/utilities/tool-result.js +23 -3
- package/dist/esm/utilities/tool-result.js.map +1 -1
- package/package.json +2 -2
- package/skills/ai-core/adapter-configuration/SKILL.md +62 -0
- package/skills/ai-core/chat-experience/SKILL.md +14 -0
- package/skills/ai-core/media-generation/SKILL.md +8 -0
- package/src/activities/chat/adapter.ts +10 -0
- package/src/activities/chat/index.ts +226 -40
- package/src/activities/chat/messages.ts +12 -1
- package/src/activities/chat/stream/message-updaters.ts +24 -2
- package/src/activities/chat/stream/processor.ts +24 -22
- package/src/activities/chat/tools/tool-calls.ts +6 -0
- package/src/activities/embed/adapter.ts +7 -0
- package/src/activities/embed/index.ts +5 -0
- package/src/activities/files/adapter.ts +120 -0
- package/src/activities/files/index.ts +113 -0
- package/src/activities/generateImage/adapter.ts +8 -0
- package/src/activities/generateImage/index.ts +4 -0
- package/src/activities/generateVideo/adapter.ts +8 -0
- package/src/activities/generateVideo/index.ts +7 -0
- package/src/activities/generateWorld/adapter.ts +4 -2
- package/src/activities/generateWorld/index.ts +7 -6
- package/src/activities/index.ts +25 -1
- package/src/activities/summarize/chat-stream-summarize.ts +22 -12
- package/src/client.ts +7 -0
- package/src/index.ts +16 -0
- package/src/types.ts +76 -13
- package/src/utilities/ag-ui-wire.ts +60 -16
- package/src/utilities/content-source.ts +138 -0
- package/src/utilities/provider-executed.ts +13 -0
- 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
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
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
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
...(
|
|
488
|
+
...(nextSignature && { signature: nextSignature }),
|
|
467
489
|
}
|
|
468
490
|
|
|
469
491
|
if (thinkingPartIndex >= 0) {
|