@tanstack/ai 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/activities/chat/index.js +3 -4
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.d.ts +13 -6
- package/dist/esm/activities/chat/messages.js +115 -86
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/stream/processor.d.ts +143 -26
- package/dist/esm/activities/chat/stream/processor.js +205 -77
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/tools/tool-calls.js +2 -3
- package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
- package/dist/esm/activities/generateImage/adapter.d.ts +8 -2
- package/dist/esm/activities/generateImage/adapter.js.map +1 -1
- package/dist/esm/activities/generateImage/index.d.ts +8 -6
- package/dist/esm/activities/generateImage/index.js.map +1 -1
- package/dist/esm/activities/generateVideo/adapter.d.ts +11 -5
- package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
- package/dist/esm/activities/generateVideo/index.d.ts +20 -14
- package/dist/esm/activities/generateVideo/index.js.map +1 -1
- package/dist/esm/extend-adapter.d.ts +114 -0
- package/dist/esm/extend-adapter.js +15 -0
- package/dist/esm/extend-adapter.js.map +1 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/types.d.ts +10 -10
- package/package.json +1 -1
- package/src/activities/chat/index.ts +12 -9
- package/src/activities/chat/messages.ts +211 -138
- package/src/activities/chat/stream/processor.ts +240 -116
- package/src/activities/chat/tools/tool-calls.ts +1 -4
- package/src/activities/generateImage/adapter.ts +9 -2
- package/src/activities/generateImage/index.ts +19 -9
- package/src/activities/generateVideo/adapter.ts +19 -4
- package/src/activities/generateVideo/index.ts +30 -16
- package/src/extend-adapter.ts +182 -0
- package/src/index.ts +4 -0
- package/src/types.ts +10 -8
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"extend-adapter.js","sources":["../../src/extend-adapter.ts"],"sourcesContent":["import type { Modality } from './types'\n\n// ===========================\n// Extended Model Definition\n// ===========================\n\n/**\n * Definition for a custom model to add to an adapter.\n *\n * @template TName - The model name as a literal string type\n * @template TInput - Array of supported input modalities\n * @template TOptions - Provider options type for this model\n *\n * @example\n * ```typescript\n * const customModels = [\n * createModel('my-custom-model', ['text', 'image']),\n * ] as const\n * ```\n */\nexport interface ExtendedModelDef<\n TName extends string = string,\n TInput extends ReadonlyArray<Modality> = ReadonlyArray<Modality>,\n TOptions = unknown,\n> {\n /** The model name identifier */\n name: TName\n /** Supported input modalities for this model */\n input: TInput\n /** Type brand for provider options - use `{} as YourOptionsType` */\n modelOptions: TOptions\n}\n\n/**\n * Creates a custom model definition for use with `extendAdapter`.\n *\n * This is a helper function that provides proper type inference without\n * requiring manual `as const` casts on individual properties.\n *\n * @template TName - The model name (inferred from argument)\n * @template TInput - The input modalities array (inferred from argument)\n *\n * @param name - The model name identifier (literal string)\n * @param input - Array of supported input modalities\n * @returns A properly typed model definition for use with `extendAdapter`\n *\n * @example\n * ```typescript\n * import { extendAdapter, createModel } from '@tanstack/ai'\n * import { openaiText } from '@tanstack/ai-openai'\n *\n * // Define custom models with full type inference\n * const customModels = [\n * createModel('my-fine-tuned-gpt4', ['text', 'image']),\n * createModel('local-llama', ['text']),\n * ] as const\n *\n * const myOpenai = extendAdapter(openaiText, customModels)\n * ```\n */\nexport function createModel<\n const TName extends string,\n const TInput extends ReadonlyArray<Modality>,\n>(name: TName, input: TInput): ExtendedModelDef<TName, TInput> {\n return {\n name,\n input,\n modelOptions: {} as unknown,\n }\n}\n\n// ===========================\n// Type Extraction Utilities\n// ===========================\n\n/**\n * Extract the model name union from an array of model definitions.\n */\ntype ExtractCustomModelNames<TDefs extends ReadonlyArray<ExtendedModelDef>> =\n TDefs[number]['name']\n\n// ===========================\n// Factory Type Inference\n// ===========================\n\n/**\n * Infer the model parameter type from an adapter factory function.\n * For generic functions like `<T extends Union>(model: T)`, this gets `T` which\n * TypeScript treats as the constraint union when used in parameter position.\n */\ntype InferFactoryModels<TFactory> = TFactory extends (\n model: infer TModel,\n ...args: Array<any>\n) => any\n ? TModel extends string\n ? TModel\n : string\n : string\n\n/**\n * Infer the config parameter type from an adapter factory function.\n */\ntype InferConfig<TFactory> = TFactory extends (\n model: any,\n config?: infer TConfig,\n) => any\n ? TConfig\n : undefined\n\n/**\n * Infer the adapter return type from a factory function.\n */\ntype InferAdapterReturn<TFactory> = TFactory extends (\n ...args: Array<any>\n) => infer TReturn\n ? TReturn\n : never\n\n// ===========================\n// extendAdapter Function\n// ===========================\n\n/**\n * Extends an existing adapter factory with additional custom models.\n *\n * The extended adapter accepts both original models (with full original type inference)\n * and custom models (with types from your definitions).\n *\n * At runtime, this simply passes through to the original factory - no validation is performed.\n * The original factory's signature is fully preserved, including any config parameters.\n *\n * @param factory - The original adapter factory function (e.g., `openaiText`, `anthropicText`)\n * @param models - Array of custom model definitions with `name` and `input`\n * @returns A new factory function that accepts both original and custom models\n *\n * @example\n * ```typescript\n * import { extendAdapter, createModel } from '@tanstack/ai'\n * import { openaiText } from '@tanstack/ai-openai'\n *\n * // Define custom models\n * const customModels = [\n * createModel('my-fine-tuned-gpt4', ['text', 'image']),\n * createModel('local-llama', ['text']),\n * ] as const\n *\n * // Create extended adapter\n * const myOpenai = extendAdapter(openaiText, customModels)\n *\n * // Use with original models - full type inference preserved\n * const gpt4 = myOpenai('gpt-4o')\n *\n * // Use with custom models\n * const custom = myOpenai('my-fine-tuned-gpt4')\n *\n * // Type error: 'invalid-model' is not a valid model\n * // myOpenai('invalid-model')\n *\n * // Works with chat()\n * chat({\n * adapter: myOpenai('my-fine-tuned-gpt4'),\n * messages: [...]\n * })\n * ```\n */\nexport function extendAdapter<\n TFactory extends (...args: Array<any>) => any,\n const TDefs extends ReadonlyArray<ExtendedModelDef>,\n>(\n factory: TFactory,\n _customModels: TDefs,\n): (\n model: InferFactoryModels<TFactory> | ExtractCustomModelNames<TDefs>,\n ...args: InferConfig<TFactory> extends undefined\n ? []\n : [config?: InferConfig<TFactory>]\n) => InferAdapterReturn<TFactory> {\n // At runtime, we simply pass through to the original factory.\n // The _customModels parameter is only used for type inference.\n // No runtime validation - users are trusted to pass valid model names.\n return factory as any\n}\n"],"names":[],"mappings":"AA4DO,SAAS,YAGd,MAAa,OAAgD;AAC7D,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,cAAc,CAAA;AAAA,EAAC;AAEnB;AAgGO,SAAS,cAId,SACA,eAMgC;AAIhC,SAAO;AACT;"}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -17,3 +17,5 @@ export * from './event-client.js';
|
|
|
17
17
|
export { convertMessagesToModelMessages, generateMessageId, uiMessageToModelMessages, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, } from './activities/chat/messages.js';
|
|
18
18
|
export { StreamProcessor, createReplayStream, ImmediateStrategy, PunctuationStrategy, BatchStrategy, WordBoundaryStrategy, CompositeStrategy, PartialJSONParser, defaultJSONParser, parsePartialJSON, } from './activities/chat/stream/index.js';
|
|
19
19
|
export type { ChunkStrategy, ChunkRecording, InternalToolCallState, ProcessorResult, ProcessorState, StreamProcessorEvents, StreamProcessorOptions, ToolCallState, ToolResultState, JSONParser, } from './activities/chat/stream/index.js';
|
|
20
|
+
export { createModel, extendAdapter } from './extend-adapter.js';
|
|
21
|
+
export type { ExtendedModelDef } from './extend-adapter.js';
|
package/dist/esm/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { combineStrategies, maxIterations, untilFinishReason } from "./activitie
|
|
|
12
12
|
import { detectImageMimeType } from "./utils.js";
|
|
13
13
|
import { aiEventClient } from "./event-client.js";
|
|
14
14
|
import { convertMessagesToModelMessages, generateMessageId, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, uiMessageToModelMessages } from "./activities/chat/messages.js";
|
|
15
|
+
import { createModel, extendAdapter } from "./extend-adapter.js";
|
|
15
16
|
import { StreamProcessor, createReplayStream } from "./activities/chat/stream/processor.js";
|
|
16
17
|
import { BatchStrategy, CompositeStrategy, ImmediateStrategy, PunctuationStrategy, WordBoundaryStrategy } from "./activities/chat/stream/strategies.js";
|
|
17
18
|
import { PartialJSONParser, defaultJSONParser, parsePartialJSON } from "./activities/chat/stream/json-parser.js";
|
|
@@ -31,6 +32,7 @@ export {
|
|
|
31
32
|
convertSchemaToJsonSchema,
|
|
32
33
|
createChatOptions,
|
|
33
34
|
createImageOptions,
|
|
35
|
+
createModel,
|
|
34
36
|
createReplayStream,
|
|
35
37
|
createSpeechOptions,
|
|
36
38
|
createSummarizeOptions,
|
|
@@ -38,6 +40,7 @@ export {
|
|
|
38
40
|
createVideoOptions,
|
|
39
41
|
defaultJSONParser,
|
|
40
42
|
detectImageMimeType,
|
|
43
|
+
extendAdapter,
|
|
41
44
|
generateImage,
|
|
42
45
|
generateMessageId,
|
|
43
46
|
generateSpeech,
|
package/dist/esm/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;"}
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -652,9 +652,9 @@ export interface TextMessageContentEvent extends BaseAGUIEvent {
|
|
|
652
652
|
type: 'TEXT_MESSAGE_CONTENT';
|
|
653
653
|
/** Message identifier */
|
|
654
654
|
messageId: string;
|
|
655
|
-
/** The incremental content token
|
|
656
|
-
delta
|
|
657
|
-
/** Full accumulated content so far */
|
|
655
|
+
/** The incremental content token */
|
|
656
|
+
delta: string;
|
|
657
|
+
/** Full accumulated content so far (optional, for debugging) */
|
|
658
658
|
content?: string;
|
|
659
659
|
}
|
|
660
660
|
/**
|
|
@@ -721,8 +721,8 @@ export interface StepFinishedEvent extends BaseAGUIEvent {
|
|
|
721
721
|
/** Step identifier */
|
|
722
722
|
stepId: string;
|
|
723
723
|
/** Incremental thinking content */
|
|
724
|
-
delta
|
|
725
|
-
/** Full accumulated thinking content */
|
|
724
|
+
delta: string;
|
|
725
|
+
/** Full accumulated thinking content (optional, for debugging) */
|
|
726
726
|
content?: string;
|
|
727
727
|
}
|
|
728
728
|
/**
|
|
@@ -793,7 +793,7 @@ export interface SummarizationResult {
|
|
|
793
793
|
* Options for image generation.
|
|
794
794
|
* These are the common options supported across providers.
|
|
795
795
|
*/
|
|
796
|
-
export interface ImageGenerationOptions<TProviderOptions extends object = object> {
|
|
796
|
+
export interface ImageGenerationOptions<TProviderOptions extends object = object, TSize extends string = string> {
|
|
797
797
|
/** The model to use for image generation */
|
|
798
798
|
model: string;
|
|
799
799
|
/** Text description of the desired image(s) */
|
|
@@ -801,7 +801,7 @@ export interface ImageGenerationOptions<TProviderOptions extends object = object
|
|
|
801
801
|
/** Number of images to generate (default: 1) */
|
|
802
802
|
numberOfImages?: number;
|
|
803
803
|
/** Image size in WIDTHxHEIGHT format (e.g., "1024x1024") */
|
|
804
|
-
size?:
|
|
804
|
+
size?: TSize;
|
|
805
805
|
/** Model-specific options for image generation */
|
|
806
806
|
modelOptions?: TProviderOptions;
|
|
807
807
|
}
|
|
@@ -839,13 +839,13 @@ export interface ImageGenerationResult {
|
|
|
839
839
|
*
|
|
840
840
|
* @experimental Video generation is an experimental feature and may change.
|
|
841
841
|
*/
|
|
842
|
-
export interface VideoGenerationOptions<TProviderOptions extends object = object> {
|
|
842
|
+
export interface VideoGenerationOptions<TProviderOptions extends object = object, TSize extends string = string> {
|
|
843
843
|
/** The model to use for video generation */
|
|
844
844
|
model: string;
|
|
845
845
|
/** Text description of the desired video */
|
|
846
846
|
prompt: string;
|
|
847
|
-
/** Video size
|
|
848
|
-
size?:
|
|
847
|
+
/** Video size — format depends on the provider (e.g., "16:9", "1280x720") */
|
|
848
|
+
size?: TSize;
|
|
849
849
|
/** Video duration in seconds */
|
|
850
850
|
duration?: number;
|
|
851
851
|
/** Model-specific options for video generation */
|
package/package.json
CHANGED
|
@@ -555,15 +555,6 @@ class TextEngine<
|
|
|
555
555
|
})
|
|
556
556
|
}
|
|
557
557
|
|
|
558
|
-
// Don't overwrite a tool_calls finishReason with a stop finishReason
|
|
559
|
-
if (
|
|
560
|
-
this.finishedEvent?.finishReason === 'tool_calls' &&
|
|
561
|
-
chunk.finishReason === 'stop'
|
|
562
|
-
) {
|
|
563
|
-
this.lastFinishReason = chunk.finishReason
|
|
564
|
-
return
|
|
565
|
-
}
|
|
566
|
-
|
|
567
558
|
this.finishedEvent = chunk
|
|
568
559
|
this.lastFinishReason = chunk.finishReason
|
|
569
560
|
}
|
|
@@ -800,6 +791,18 @@ class TextEngine<
|
|
|
800
791
|
} catch {
|
|
801
792
|
output = message.content
|
|
802
793
|
}
|
|
794
|
+
// Skip approval response messages (they have pendingExecution marker)
|
|
795
|
+
// These are NOT real client tool results — they are synthetic tool messages
|
|
796
|
+
// created by uiMessageToModelMessages for approved-but-not-yet-executed tools.
|
|
797
|
+
// Treating them as results would prevent the server from requesting actual
|
|
798
|
+
// client-side execution after approval (see GitHub issue #225).
|
|
799
|
+
if (
|
|
800
|
+
output &&
|
|
801
|
+
typeof output === 'object' &&
|
|
802
|
+
(output as any).pendingExecution === true
|
|
803
|
+
) {
|
|
804
|
+
continue
|
|
805
|
+
}
|
|
803
806
|
clientToolResults.set(message.toolCallId, output)
|
|
804
807
|
}
|
|
805
808
|
}
|
|
@@ -1,27 +1,22 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
AudioPart,
|
|
3
2
|
ContentPart,
|
|
4
|
-
DocumentPart,
|
|
5
|
-
ImagePart,
|
|
6
3
|
MessagePart,
|
|
7
4
|
ModelMessage,
|
|
8
5
|
TextPart,
|
|
9
6
|
ToolCallPart,
|
|
10
|
-
ToolResultPart,
|
|
11
7
|
UIMessage,
|
|
12
|
-
VideoPart,
|
|
13
8
|
} from '../../types'
|
|
14
9
|
// ===========================
|
|
15
10
|
// Message Converters
|
|
16
11
|
// ===========================
|
|
17
12
|
|
|
18
13
|
/**
|
|
19
|
-
*
|
|
14
|
+
* Check if a MessagePart is a content part (text, image, audio, video, document)
|
|
15
|
+
* that maps directly to a ModelMessage ContentPart.
|
|
20
16
|
*/
|
|
21
|
-
function
|
|
22
|
-
part: MessagePart,
|
|
23
|
-
): part is ImagePart | AudioPart | VideoPart | DocumentPart {
|
|
17
|
+
function isContentPart(part: MessagePart): part is ContentPart {
|
|
24
18
|
return (
|
|
19
|
+
part.type === 'text' ||
|
|
25
20
|
part.type === 'image' ||
|
|
26
21
|
part.type === 'audio' ||
|
|
27
22
|
part.type === 'video' ||
|
|
@@ -30,19 +25,34 @@ function isMultimodalPart(
|
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
28
|
+
* Collapse an array of ContentParts into the most compact ModelMessage content:
|
|
29
|
+
* - Empty array → null
|
|
30
|
+
* - All text parts → joined string (or null if empty)
|
|
31
|
+
* - Mixed content → ContentPart array as-is
|
|
35
32
|
*/
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
function collapseContentParts(
|
|
34
|
+
parts: Array<ContentPart>,
|
|
35
|
+
): string | null | Array<ContentPart> {
|
|
36
|
+
if (parts.length === 0) return null
|
|
37
|
+
|
|
38
|
+
const allText = parts.every((p) => p.type === 'text')
|
|
39
|
+
if (allText) {
|
|
40
|
+
const joined = parts.map((p) => p.content).join('')
|
|
41
|
+
return joined || null
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
|
|
44
|
+
return parts
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract text content from ModelMessage content (string, null, or ContentPart array).
|
|
49
|
+
* Used when only the text portion is needed (e.g., tool result content).
|
|
50
|
+
*/
|
|
51
|
+
function getTextContent(content: string | null | Array<ContentPart>): string {
|
|
52
|
+
if (content === null) return ''
|
|
53
|
+
if (typeof content === 'string') return content
|
|
44
54
|
return content
|
|
45
|
-
.filter((part) => part.type === 'text')
|
|
55
|
+
.filter((part): part is TextPart => part.type === 'text')
|
|
46
56
|
.map((part) => part.content)
|
|
47
57
|
.join('')
|
|
48
58
|
}
|
|
@@ -69,157 +79,214 @@ export function convertMessagesToModelMessages(
|
|
|
69
79
|
/**
|
|
70
80
|
* Convert a UIMessage to ModelMessage(s)
|
|
71
81
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
82
|
+
* Walks the parts array IN ORDER to preserve the interleaving of text,
|
|
83
|
+
* tool calls, and tool results. This is critical for multi-round tool
|
|
84
|
+
* flows where the model generates text, calls a tool, gets the result,
|
|
85
|
+
* then generates more text and calls another tool.
|
|
86
|
+
*
|
|
87
|
+
* The output preserves the sequential structure:
|
|
88
|
+
* text1 → toolCall1 → toolResult1 → text2 → toolCall2 → toolResult2
|
|
89
|
+
* becomes:
|
|
90
|
+
* assistant: {content: "text1", toolCalls: [toolCall1]}
|
|
91
|
+
* tool: toolResult1
|
|
92
|
+
* assistant: {content: "text2", toolCalls: [toolCall2]}
|
|
93
|
+
* tool: toolResult2
|
|
77
94
|
*
|
|
78
95
|
* @param uiMessage - The UIMessage to convert
|
|
79
|
-
* @returns An array of ModelMessages
|
|
96
|
+
* @returns An array of ModelMessages preserving part ordering
|
|
80
97
|
*/
|
|
81
98
|
export function uiMessageToModelMessages(
|
|
82
99
|
uiMessage: UIMessage,
|
|
83
100
|
): Array<ModelMessage> {
|
|
84
|
-
const messageList: Array<ModelMessage> = []
|
|
85
|
-
|
|
86
101
|
// Skip system messages - they're handled via systemPrompts, not ModelMessages
|
|
87
102
|
if (uiMessage.role === 'system') {
|
|
88
|
-
return
|
|
103
|
+
return []
|
|
89
104
|
}
|
|
90
105
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
> = []
|
|
97
|
-
const toolCallParts: Array<ToolCallPart> = []
|
|
98
|
-
const toolResultParts: Array<ToolResultPart> = []
|
|
106
|
+
// For non-assistant messages (user), use the simpler path since they
|
|
107
|
+
// don't have tool calls or tool results to interleave
|
|
108
|
+
if (uiMessage.role !== 'assistant') {
|
|
109
|
+
return [buildUserOrToolMessage(uiMessage)]
|
|
110
|
+
}
|
|
99
111
|
|
|
112
|
+
// For assistant messages, walk parts in order to preserve interleaving
|
|
113
|
+
return buildAssistantMessages(uiMessage)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a single ModelMessage for user messages (simple path).
|
|
118
|
+
* Preserves ordering of text and multimodal content parts.
|
|
119
|
+
*/
|
|
120
|
+
function buildUserOrToolMessage(uiMessage: UIMessage): ModelMessage {
|
|
121
|
+
const contentParts: Array<ContentPart> = []
|
|
100
122
|
for (const part of uiMessage.parts) {
|
|
101
|
-
if (part
|
|
102
|
-
|
|
103
|
-
} else if (isMultimodalPart(part)) {
|
|
104
|
-
multimodalParts.push(part)
|
|
105
|
-
} else if (part.type === 'tool-call') {
|
|
106
|
-
toolCallParts.push(part)
|
|
107
|
-
} else if (part.type === 'tool-result') {
|
|
108
|
-
toolResultParts.push(part)
|
|
123
|
+
if (isContentPart(part)) {
|
|
124
|
+
contentParts.push(part)
|
|
109
125
|
}
|
|
110
|
-
// thinking parts are skipped - they're UI-only
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
return {
|
|
129
|
+
role: uiMessage.role as 'user' | 'assistant' | 'tool',
|
|
130
|
+
content: collapseContentParts(contentParts),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Accumulator for building an assistant segment (content + tool calls)
|
|
135
|
+
interface AssistantSegment {
|
|
136
|
+
contentParts: Array<ContentPart>
|
|
137
|
+
toolCalls: Array<{
|
|
138
|
+
id: string
|
|
139
|
+
type: 'function'
|
|
140
|
+
function: { name: string; arguments: string }
|
|
141
|
+
}>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createSegment(): AssistantSegment {
|
|
145
|
+
return { contentParts: [], toolCalls: [] }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isToolCallIncluded(part: ToolCallPart): boolean {
|
|
149
|
+
return (
|
|
150
|
+
part.state === 'input-complete' ||
|
|
151
|
+
part.state === 'approval-responded' ||
|
|
152
|
+
part.output !== undefined
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build ModelMessages for an assistant UIMessage, preserving the
|
|
158
|
+
* sequential interleaving of text, tool calls, and tool results.
|
|
159
|
+
*
|
|
160
|
+
* Walks parts in order. Text and tool-call parts accumulate into the
|
|
161
|
+
* current "segment". When a tool-result part is encountered, the
|
|
162
|
+
* current segment is flushed as an assistant message, then the tool
|
|
163
|
+
* result is emitted as a tool message.
|
|
164
|
+
*/
|
|
165
|
+
function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
|
|
166
|
+
const messageList: Array<ModelMessage> = []
|
|
167
|
+
let current = createSegment()
|
|
168
|
+
|
|
169
|
+
// Track emitted tool result IDs to avoid duplicates.
|
|
170
|
+
// A tool call can have BOTH an explicit tool-result part AND an output
|
|
171
|
+
// field on the tool-call part. We only want one per tool call ID.
|
|
172
|
+
const emittedToolResultIds = new Set<string>()
|
|
173
|
+
|
|
174
|
+
function flushSegment(): void {
|
|
175
|
+
const content = collapseContentParts(current.contentParts)
|
|
176
|
+
const hasContent = content !== null
|
|
177
|
+
const hasToolCalls = current.toolCalls.length > 0
|
|
178
|
+
|
|
179
|
+
if (hasContent || hasToolCalls) {
|
|
180
|
+
messageList.push({
|
|
181
|
+
role: 'assistant',
|
|
182
|
+
content,
|
|
183
|
+
...(hasToolCalls && { toolCalls: current.toolCalls }),
|
|
184
|
+
})
|
|
126
185
|
}
|
|
127
|
-
|
|
128
|
-
} else {
|
|
129
|
-
// Simple string content for text-only messages
|
|
130
|
-
content = textParts.map((p) => p.content).join('') || null
|
|
186
|
+
current = createSegment()
|
|
131
187
|
}
|
|
132
188
|
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
189
|
+
for (const part of uiMessage.parts) {
|
|
190
|
+
switch (part.type) {
|
|
191
|
+
case 'text':
|
|
192
|
+
case 'image':
|
|
193
|
+
case 'audio':
|
|
194
|
+
case 'video':
|
|
195
|
+
case 'document':
|
|
196
|
+
current.contentParts.push(part)
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
case 'tool-call':
|
|
200
|
+
if (isToolCallIncluded(part)) {
|
|
201
|
+
current.toolCalls.push({
|
|
202
|
+
id: part.id,
|
|
144
203
|
type: 'function' as const,
|
|
145
204
|
function: {
|
|
146
|
-
name:
|
|
147
|
-
arguments:
|
|
205
|
+
name: part.name,
|
|
206
|
+
arguments: part.arguments,
|
|
148
207
|
},
|
|
149
|
-
})
|
|
150
|
-
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
break
|
|
151
211
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (uiMessage.role !== 'assistant' || hasContent || !toolCalls) {
|
|
156
|
-
messageList.push({
|
|
157
|
-
role: uiMessage.role,
|
|
158
|
-
content,
|
|
159
|
-
...(toolCalls && toolCalls.length > 0 && { toolCalls }),
|
|
160
|
-
})
|
|
161
|
-
} else if (toolCalls.length > 0) {
|
|
162
|
-
// Assistant message with only tool calls
|
|
163
|
-
messageList.push({
|
|
164
|
-
role: 'assistant',
|
|
165
|
-
content,
|
|
166
|
-
toolCalls,
|
|
167
|
-
})
|
|
168
|
-
}
|
|
212
|
+
case 'tool-result':
|
|
213
|
+
// Flush the current assistant segment before emitting the tool result
|
|
214
|
+
flushSegment()
|
|
169
215
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
216
|
+
// Emit the tool result
|
|
217
|
+
if (
|
|
218
|
+
(part.state === 'complete' || part.state === 'error') &&
|
|
219
|
+
!emittedToolResultIds.has(part.toolCallId)
|
|
220
|
+
) {
|
|
221
|
+
messageList.push({
|
|
222
|
+
role: 'tool',
|
|
223
|
+
content: part.content,
|
|
224
|
+
toolCallId: part.toolCallId,
|
|
225
|
+
})
|
|
226
|
+
emittedToolResultIds.add(part.toolCallId)
|
|
227
|
+
}
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
// thinking parts are skipped - they're UI-only
|
|
231
|
+
default:
|
|
232
|
+
break
|
|
185
233
|
}
|
|
186
234
|
}
|
|
187
235
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
236
|
+
// Flush any remaining accumulated content
|
|
237
|
+
flushSegment()
|
|
238
|
+
|
|
239
|
+
// Emit tool results from client tool-call parts with output or approval,
|
|
240
|
+
// but only if not already covered by an explicit tool-result part above.
|
|
241
|
+
// These are appended at the end since they don't have explicit tool-result
|
|
242
|
+
// parts in the parts array to trigger inline emission.
|
|
243
|
+
for (const part of uiMessage.parts) {
|
|
244
|
+
if (part.type !== 'tool-call') continue
|
|
245
|
+
|
|
246
|
+
// Client tool with output - add as tool result (if not already emitted)
|
|
247
|
+
if (
|
|
248
|
+
part.output !== undefined &&
|
|
249
|
+
!part.approval &&
|
|
250
|
+
!emittedToolResultIds.has(part.id)
|
|
251
|
+
) {
|
|
193
252
|
messageList.push({
|
|
194
253
|
role: 'tool',
|
|
195
|
-
content: JSON.stringify(
|
|
196
|
-
toolCallId:
|
|
254
|
+
content: JSON.stringify(part.output),
|
|
255
|
+
toolCallId: part.id,
|
|
197
256
|
})
|
|
257
|
+
emittedToolResultIds.add(part.id)
|
|
198
258
|
}
|
|
199
259
|
|
|
200
260
|
// Approval response - add as tool result for iteration tracking
|
|
201
|
-
// For APPROVED: includes pendingExecution marker so the tool still executes
|
|
202
|
-
// For DENIED: just marks the tool as complete (no execution needed)
|
|
203
261
|
if (
|
|
204
|
-
|
|
205
|
-
|
|
262
|
+
part.state === 'approval-responded' &&
|
|
263
|
+
part.approval?.approved !== undefined &&
|
|
264
|
+
!emittedToolResultIds.has(part.id)
|
|
206
265
|
) {
|
|
207
|
-
const approved =
|
|
266
|
+
const approved = part.approval.approved
|
|
208
267
|
messageList.push({
|
|
209
268
|
role: 'tool',
|
|
210
269
|
content: JSON.stringify({
|
|
211
270
|
approved,
|
|
212
|
-
// Mark approved tools as pending execution - they still need to run
|
|
213
271
|
...(approved && { pendingExecution: true }),
|
|
214
272
|
message: approved
|
|
215
273
|
? 'User approved this action'
|
|
216
274
|
: 'User denied this action',
|
|
217
275
|
}),
|
|
218
|
-
toolCallId:
|
|
276
|
+
toolCallId: part.id,
|
|
219
277
|
})
|
|
278
|
+
emittedToolResultIds.add(part.id)
|
|
220
279
|
}
|
|
221
280
|
}
|
|
222
281
|
|
|
282
|
+
// If no messages were produced (e.g., empty parts), emit a minimal assistant message
|
|
283
|
+
if (messageList.length === 0) {
|
|
284
|
+
messageList.push({
|
|
285
|
+
role: 'assistant',
|
|
286
|
+
content: null,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
223
290
|
return messageList
|
|
224
291
|
}
|
|
225
292
|
|
|
@@ -241,13 +308,29 @@ export function modelMessageToUIMessage(
|
|
|
241
308
|
): UIMessage {
|
|
242
309
|
const parts: Array<MessagePart> = []
|
|
243
310
|
|
|
244
|
-
// Handle
|
|
245
|
-
|
|
246
|
-
if (
|
|
311
|
+
// Handle tool results (when role is "tool") - only produce tool-result part,
|
|
312
|
+
// not a text part (the content IS the tool result, not display text)
|
|
313
|
+
if (modelMessage.role === 'tool' && modelMessage.toolCallId) {
|
|
247
314
|
parts.push({
|
|
248
|
-
type: '
|
|
249
|
-
|
|
315
|
+
type: 'tool-result',
|
|
316
|
+
toolCallId: modelMessage.toolCallId,
|
|
317
|
+
content: getTextContent(modelMessage.content),
|
|
318
|
+
state: 'complete',
|
|
250
319
|
})
|
|
320
|
+
} else if (Array.isArray(modelMessage.content)) {
|
|
321
|
+
// Multimodal content - preserve all content parts as MessageParts
|
|
322
|
+
for (const part of modelMessage.content) {
|
|
323
|
+
parts.push(part)
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
// String or null content
|
|
327
|
+
const textContent = getTextContent(modelMessage.content)
|
|
328
|
+
if (textContent) {
|
|
329
|
+
parts.push({
|
|
330
|
+
type: 'text',
|
|
331
|
+
content: textContent,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
251
334
|
}
|
|
252
335
|
|
|
253
336
|
// Handle tool calls
|
|
@@ -263,16 +346,6 @@ export function modelMessageToUIMessage(
|
|
|
263
346
|
}
|
|
264
347
|
}
|
|
265
348
|
|
|
266
|
-
// Handle tool results (when role is "tool")
|
|
267
|
-
if (modelMessage.role === 'tool' && modelMessage.toolCallId) {
|
|
268
|
-
parts.push({
|
|
269
|
-
type: 'tool-result',
|
|
270
|
-
toolCallId: modelMessage.toolCallId,
|
|
271
|
-
content: getTextContent(modelMessage.content),
|
|
272
|
-
state: 'complete',
|
|
273
|
-
})
|
|
274
|
-
}
|
|
275
|
-
|
|
276
349
|
return {
|
|
277
350
|
id: id || generateMessageId(),
|
|
278
351
|
role: modelMessage.role === 'tool' ? 'assistant' : modelMessage.role,
|