ai
Version:
AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.
1,557 lines (1,361 loc) • 46.4 kB
text/typescript
import {
generateId as generateIdFunc,
type FlexibleSchema,
type IdGenerator,
type InferSchema,
} from '@ai-sdk/provider-utils';
import { InvalidArgumentError } from '../error/invalid-argument-error';
import type { FinishReason } from '../types/language-model';
import type { UIMessageChunk } from '../ui-message-stream/ui-message-chunks';
import { consumeStream } from '../util/consume-stream';
import { createResolvablePromise } from '../util/create-resolvable-promise';
import { SerialJobExecutor } from '../util/serial-job-executor';
import type { ChatTransport } from './chat-transport';
import { convertFileListToFileUIParts } from './convert-file-list-to-file-ui-parts';
import { DefaultChatTransport } from './default-chat-transport';
import {
createStreamingUIMessageState,
processUIMessageStream,
type StreamingUIMessageState,
type UIMessageStreamWriteOptions,
} from './process-ui-message-stream';
import {
isToolUIPart,
type DataUIPart,
type FileUIPart,
type InferUIMessageData,
type InferUIMessageMetadata,
type InferUIMessageTools,
type UIDataTypes,
type UIMessage,
type InferUIMessageToolCall,
type UIMessagePart,
type UITools,
} from './ui-messages';
export type CreateUIMessage<UI_MESSAGE extends UIMessage> = Omit<
UI_MESSAGE,
'id' | 'role'
> & {
id?: UI_MESSAGE['id'];
role?: UI_MESSAGE['role'];
};
export type UIDataPartSchemas = Record<string, FlexibleSchema>;
export type UIDataTypesToSchemas<T extends UIDataTypes> = {
[K in keyof T]: FlexibleSchema<T[K]>;
};
export type InferUIDataParts<T extends UIDataPartSchemas> = {
[K in keyof T]: InferSchema<T[K]>;
};
export type ChatRequestOptions = {
/**
* Additional headers that should be to be passed to the API endpoint.
*/
headers?: Record<string, string> | Headers;
/**
* Additional body JSON properties that should be sent to the API endpoint.
*/
body?: object; // TODO JSONStringifyable
metadata?: unknown;
};
/**
* Function that can be called to add a tool approval response to the chat.
*/
export type ChatAddToolApproveResponseFunction = ({
id,
approved,
reason,
options,
}: {
id: string;
/**
* Flag indicating whether the approval was granted or denied.
*/
approved: boolean;
/**
* Optional reason for the approval or denial.
*/
reason?: string;
/**
* Optional request options to be used if `sendAutomaticallyWhen` callback returns true.
*/
options?: ChatRequestOptions;
}) => void | PromiseLike<void>;
/**
* Function that can be called to add a tool output to the chat.
*/
export type ChatAddToolOutputFunction<UI_MESSAGE extends UIMessage> = <
TOOL extends keyof InferUIMessageTools<UI_MESSAGE>,
>({
state,
tool,
toolCallId,
output,
errorText,
options,
}: {
/**
* Name of the tool that was called.
*/
tool: TOOL;
/**
* Identifier of the tool call to add output for.
*/
toolCallId: string;
/**
* Optional request options to be used if `sendAutomaticallyWhen` callback returns true.
*/
options?: ChatRequestOptions;
} & (
| {
state?: 'output-available';
output: InferUIMessageTools<UI_MESSAGE>[TOOL]['output'];
errorText?: never;
}
| {
state: 'output-error';
output?: never;
errorText: string;
}
)) => void | PromiseLike<void>;
export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error';
type ActiveResponse<UI_MESSAGE extends UIMessage> = {
state: StreamingUIMessageState<UI_MESSAGE>;
abortController: AbortController;
completionPromise: Promise<void>;
};
type ActiveResumeRequest = {
abortController: AbortController;
completionPromise: Promise<void>;
};
type MakeRequestOptions = {
trigger: 'submit-message' | 'resume-stream' | 'regenerate-message';
messageId?: string;
} & ChatRequestOptions;
export interface ChatState<UI_MESSAGE extends UIMessage> {
status: ChatStatus;
error: Error | undefined;
messages: UI_MESSAGE[];
pushMessage: (message: UI_MESSAGE) => void;
popMessage: () => void;
replaceMessage: (index: number, message: UI_MESSAGE) => void;
/**
* Experimental. Messages that are waiting to be sent when
* `experimental_queue` is enabled. States that don't implement it keep the
* queue in the chat, without change notifications.
*/
experimental_queuedMessages?: UI_MESSAGE[];
snapshot: <T>(thing: T) => T;
}
export type ChatOnErrorCallback = (error: Error) => void;
export type ChatOnToolCallCallback<UI_MESSAGE extends UIMessage = UIMessage> =
(options: {
toolCall: InferUIMessageToolCall<UI_MESSAGE>;
}) => void | PromiseLike<void>;
export type ChatOnDataCallback<UI_MESSAGE extends UIMessage> = (
dataPart: DataUIPart<InferUIMessageData<UI_MESSAGE>>,
) => void;
/**
* Function that is called when the assistant response has finished streaming.
*
* @param message The assistant message that was streamed.
* @param messages The full chat history, including the assistant message.
*
* @param isAbort Indicates whether the request has been aborted.
* @param isDisconnect Indicates whether the request has been ended by a network error.
* @param isError Indicates whether the request has been ended by an error.
* @param finishReason The reason why the generation finished.
*/
export type ChatOnFinishCallback<UI_MESSAGE extends UIMessage> = (options: {
message: UI_MESSAGE;
messages: UI_MESSAGE[];
isAbort: boolean;
isDisconnect: boolean;
isError: boolean;
finishReason?: FinishReason;
}) => void;
export interface ChatInit<UI_MESSAGE extends UIMessage> {
/**
* A unique identifier for the chat. If not provided, a random one will be
* generated when first accessed.
*/
id?: string;
messageMetadataSchema?: FlexibleSchema<UI_MESSAGE['metadata']>;
dataPartSchemas?: UIDataTypesToSchemas<InferUIMessageData<UI_MESSAGE>>;
messages?: UI_MESSAGE[];
/**
* A way to provide a function that is going to be used for ids for messages and the chat.
* If not provided the default AI SDK `generateId` is used.
*/
generateId?: IdGenerator;
transport?: ChatTransport<UI_MESSAGE>;
/**
* Callback function to be called when an error is encountered.
*/
onError?: ChatOnErrorCallback;
/**
* Optional callback function that is invoked when a tool call is received.
* Intended for automatic client-side tool execution.
*
* To add the tool output, call `addToolOutput` without awaiting it inside
* this callback. The callback's return value is not used.
*/
onToolCall?: ChatOnToolCallCallback<UI_MESSAGE>;
/**
* Function that is called when the assistant response has finished streaming.
*/
onFinish?: ChatOnFinishCallback<UI_MESSAGE>;
/**
* Optional callback function that is called when a data part is received.
*
* @param data The data part that was received.
*/
onData?: ChatOnDataCallback<UI_MESSAGE>;
/**
* When provided, this function will be called when the stream is finished or a tool call is added
* to determine if the current messages should be resubmitted.
*/
sendAutomaticallyWhen?: (options: {
messages: UI_MESSAGE[];
}) => boolean | PromiseLike<boolean>;
/**
* Experimental. Queue messages that are sent while a response is in progress
* or while the assistant waits for a tool result or approval, and send them
* one at a time after each response finishes. Stopping, an error or an
* interrupted response pauses the queue until the next message is sent.
* Editing a message or regenerating clears the queue.
*
* @default false
*/
experimental_queue?: boolean;
}
export abstract class AbstractChat<UI_MESSAGE extends UIMessage> {
private _id: string | undefined;
get id(): string {
return (this._id ??= this.generateId());
}
readonly generateId: IdGenerator;
protected state: ChatState<UI_MESSAGE>;
private messageMetadataSchema:
| FlexibleSchema<UI_MESSAGE['metadata']>
| undefined;
private dataPartSchemas:
| UIDataTypesToSchemas<InferUIMessageData<UI_MESSAGE>>
| undefined;
private readonly transport: ChatTransport<UI_MESSAGE>;
private onError?: ChatInit<UI_MESSAGE>['onError'];
private onToolCall?: ChatInit<UI_MESSAGE>['onToolCall'];
private onFinish?: ChatInit<UI_MESSAGE>['onFinish'];
private onData?: ChatInit<UI_MESSAGE>['onData'];
private sendAutomaticallyWhen?: ChatInit<UI_MESSAGE>['sendAutomaticallyWhen'];
private queueEnabled: boolean;
private queuedMessagesFallback: UI_MESSAGE[] = [];
private queuedMessageOptions = new Map<string, ChatRequestOptions>();
// a stop or an error pauses the queue. a paused response is not waiting for
// the user, so resuming the queue skips that check:
private isQueuePaused = false;
private pendingAutomaticDecisions: number[] = [];
// a user send that has to wait to send an answered approval first, with the
// request options of that send:
private deferredApprovalSend: { options?: ChatRequestOptions } | undefined;
private pendingMessagePreparations = new Set<AbortController>();
private pendingApprovalMessageId: string | undefined;
// Keep interruption state after activeResponse is cleared so late tool updates
// cannot automatically continue an interrupted message.
private interruptedMessageIds = new Set<string>();
private activeResponse: ActiveResponse<UI_MESSAGE> | undefined = undefined;
private activeResumeRequest: ActiveResumeRequest | undefined = undefined;
private resumableStreamState:
| StreamingUIMessageState<UI_MESSAGE>
| undefined = undefined;
private jobExecutor = new SerialJobExecutor();
private activeStopCount = 0;
private stopGeneration = 0;
constructor({
generateId = generateIdFunc,
id,
transport = new DefaultChatTransport(),
messageMetadataSchema,
dataPartSchemas,
state,
onError,
onToolCall,
onFinish,
onData,
sendAutomaticallyWhen,
experimental_queue = false,
}: Omit<ChatInit<UI_MESSAGE>, 'messages'> & {
state: ChatState<UI_MESSAGE>;
}) {
this._id = id;
this.transport = transport;
this.generateId = generateId;
this.messageMetadataSchema = messageMetadataSchema;
this.dataPartSchemas = dataPartSchemas;
this.state = state;
this.onError = onError;
this.onToolCall = onToolCall;
this.onFinish = onFinish;
this.onData = onData;
this.sendAutomaticallyWhen = sendAutomaticallyWhen;
this.queueEnabled = experimental_queue;
}
/**
* Hook status:
*
* - `submitted`: The message has been sent to the API and we're awaiting the start of the response stream.
* - `streaming`: The response is actively streaming in from the API, receiving chunks of data.
* - `ready`: The full response has been received and processed; a new user message can be submitted.
* - `error`: An error occurred during the API request, preventing successful completion.
*/
get status(): ChatStatus {
return this.state.status;
}
protected setStatus({
status,
error,
}: {
status: ChatStatus;
error?: Error;
}) {
if (this.status === status) {
if (this.error !== error) {
this.state.error = error;
}
return;
}
this.state.status = status;
this.state.error = error;
}
get error() {
return this.state.error;
}
get messages(): UI_MESSAGE[] {
return this.state.messages;
}
get lastMessage(): UI_MESSAGE | undefined {
return this.state.messages[this.state.messages.length - 1];
}
/**
* Experimental. Messages that wait to be sent when `experimental_queue` is
* enabled, in the order they will be sent.
*/
get experimental_queuedMessages(): UI_MESSAGE[] {
return (
this.state.experimental_queuedMessages ?? this.queuedMessagesFallback
);
}
private setQueuedMessages(experimental_queuedMessages: UI_MESSAGE[]) {
if ('experimental_queuedMessages' in this.state) {
this.state.experimental_queuedMessages = experimental_queuedMessages;
} else {
this.queuedMessagesFallback = experimental_queuedMessages;
}
}
/**
* Experimental. Removes a queued message so it is not sent.
*/
experimental_removeQueuedMessage = (id: string): void => {
this.queuedMessageOptions.delete(id);
this.setQueuedMessages(
this.experimental_queuedMessages.filter(message => message.id !== id),
);
};
/**
* Experimental. Stops the response in progress and sends the queued message
* with the given id next. The rest of the queue is sent after it.
*/
experimental_sendQueuedMessageNow = async (id: string): Promise<void> => {
const message = this.experimental_queuedMessages.find(
message => message.id === id,
);
if (message == null) {
return;
}
this.setQueuedMessages([
message,
...this.experimental_queuedMessages.filter(queued => queued.id !== id),
]);
const stopGeneration = this.stopGeneration + 1;
await this.stop();
if (stopGeneration !== this.stopGeneration) {
return;
}
await this.resumeQueue({
skipWaitForUser: true,
options: this.queuedMessageOptions.get(id),
});
};
// the user sending a message resumes a paused queue (after a stop, an error
// or an interruption). an answered approval that was not sent yet goes
// first, the queue continues when its response finishes:
private async resumeQueue({
skipWaitForUser,
options,
}: {
skipWaitForUser: boolean;
options?: ChatRequestOptions;
}): Promise<void> {
this.isQueuePaused = false;
if (this.hasUnsentApprovalResponse()) {
if (!this.isRequestInProgress()) {
await this.sendMessage(undefined, options);
return;
}
// a pending sendAutomaticallyWhen decision can still send the approval.
// if it does not, the queue sends it first when it settles:
this.deferredApprovalSend = { options };
}
await this.settleQueue({ skipWaitForUser });
}
// editing a message or regenerating rewrites the history that the queued
// messages were sent after, so they are dropped together with the response
// in progress:
private async clearQueueForHistoryChange(): Promise<void> {
if (!this.queueEnabled) {
return;
}
this.queuedMessageOptions.clear();
this.setQueuedMessages([]);
this.deferredApprovalSend = undefined;
if (this.isRequestInProgress()) {
await this.stop();
}
}
// a pending sendAutomaticallyWhen decision can still start a request, so the
// queue waits for it like for a response in progress. a stop invalidates the
// decisions that were pending, so they no longer hold the queue:
private isRequestInProgress(): boolean {
return (
this.pendingAutomaticDecisions.includes(this.stopGeneration) ||
this.activeResponse != null ||
this.status === 'submitted' ||
this.status === 'streaming'
);
}
// the assistant waits for the user when the last message has a tool call
// without a result or an approval that has not been sent yet:
private isWaitingForUser(): boolean {
const lastMessage = this.lastMessage;
return (
lastMessage?.role === 'assistant' &&
lastMessage.parts.some(
part =>
isToolUIPart(part) &&
(part.state === 'approval-requested' ||
part.state === 'approval-responded' ||
(!part.providerExecuted &&
(part.state === 'input-streaming' ||
part.state === 'input-available'))),
)
);
}
// an answered approval has to reach the model before the next user message,
// otherwise its tool call is left without a result:
private hasUnsentApprovalResponse(): boolean {
const lastMessage = this.lastMessage;
return (
lastMessage?.role === 'assistant' &&
lastMessage.parts.some(
part => isToolUIPart(part) && part.state === 'approval-responded',
) &&
!lastMessage.parts.some(
part =>
isToolUIPart(part) &&
(part.state === 'approval-requested' ||
(!part.providerExecuted &&
(part.state === 'input-streaming' ||
part.state === 'input-available'))),
)
);
}
// every path that can leave the chat idle ends here. the queue then either
// sends its next message, stays paused until the user sends again, or waits
// for the user to answer a tool call. a stop that happened after the caller
// captured `stopGeneration` cancels the send:
private async settleQueue({
stopGeneration = this.stopGeneration,
skipWaitForUser = false,
}: {
stopGeneration?: number;
skipWaitForUser?: boolean;
} = {}): Promise<void> {
if (
this.isQueuePaused ||
this.isRequestInProgress() ||
this.activeStopCount > 0 ||
stopGeneration !== this.stopGeneration
) {
return;
}
const deferredApprovalSend = this.deferredApprovalSend;
this.deferredApprovalSend = undefined;
if (deferredApprovalSend != null && this.hasUnsentApprovalResponse()) {
await this.sendMessage(undefined, deferredApprovalSend.options);
return;
}
const [message, ...rest] = this.experimental_queuedMessages;
if (message == null || (!skipWaitForUser && this.isWaitingForUser())) {
return;
}
const options = this.queuedMessageOptions.get(message.id);
this.queuedMessageOptions.delete(message.id);
this.setQueuedMessages(rest);
this.state.pushMessage(message);
await this.makeRequest({ trigger: 'submit-message', ...options });
}
set messages(messages: UI_MESSAGE[]) {
this.state.messages = messages;
}
/**
* Appends or replaces a user message to the chat list. This triggers the API call to fetch
* the assistant's response.
*
* If a messageId is provided, the message will be replaced.
*/
sendMessage = async (
message?:
| (CreateUIMessage<UI_MESSAGE> & {
text?: never;
files?: never;
messageId?: string;
})
| {
text: string;
files?: FileList | FileUIPart[];
metadata?: InferUIMessageMetadata<UI_MESSAGE>;
parts?: never;
messageId?: string;
}
| {
files: FileList | FileUIPart[];
metadata?: InferUIMessageMetadata<UI_MESSAGE>;
parts?: never;
messageId?: string;
},
options?: ChatRequestOptions,
): Promise<void> => {
if (message == null) {
let messageId = this.pendingApprovalMessageId;
if (messageId == null) {
messageId = this.lastMessage?.id;
// When hydrating a chat with an already-responded approval, continue
// the most recent matching assistant message so result chunks can
// resolve its tool invocation.
for (let i = this.state.messages.length - 1; i >= 0; i--) {
const candidate = this.state.messages[i];
if (
candidate.role === 'assistant' &&
candidate.parts.some(
part => isToolUIPart(part) && part.state === 'approval-responded',
)
) {
messageId = candidate.id;
break;
}
}
}
const consumesPendingApproval =
messageId != null && messageId === this.pendingApprovalMessageId;
const pendingApprovalMessageIndex = consumesPendingApproval
? this.state.messages.findIndex(message => message.id === messageId)
: -1;
await this.makeRequestForToolApproval({
messageId,
messageIndex: pendingApprovalMessageIndex,
...options,
});
return;
}
let uiMessage: CreateUIMessage<UI_MESSAGE>;
if ('text' in message || 'files' in message) {
const abortController = new AbortController();
this.pendingMessagePreparations.add(abortController);
let fileParts: FileUIPart[];
try {
fileParts = Array.isArray(message.files)
? message.files
: await convertFileListToFileUIParts(message.files);
} finally {
this.pendingMessagePreparations.delete(abortController);
}
if (abortController.signal.aborted) {
return;
}
uiMessage = {
parts: [
...fileParts,
...('text' in message && message.text != null
? [{ type: 'text' as const, text: message.text }]
: []),
],
} as UI_MESSAGE;
} else {
uiMessage = message;
}
if (
this.queueEnabled &&
message.messageId == null &&
(this.isRequestInProgress() ||
this.isWaitingForUser() ||
this.experimental_queuedMessages.length > 0)
) {
const queuedMessage = {
...uiMessage,
id: uiMessage.id ?? this.generateId(),
role: uiMessage.role ?? 'user',
metadata: message.metadata,
} as UI_MESSAGE;
if (options != null) {
this.queuedMessageOptions.set(queuedMessage.id, options);
}
this.setQueuedMessages([
...this.experimental_queuedMessages,
queuedMessage,
]);
// a stopped response is not waiting for the user anymore, so resuming a
// paused queue skips that check:
await this.resumeQueue({
skipWaitForUser: this.isQueuePaused,
options,
});
return;
}
if (message.messageId != null) {
await this.clearQueueForHistoryChange();
const messageIndex = this.state.messages.findIndex(
m => m.id === message.messageId,
);
if (messageIndex === -1) {
throw new InvalidArgumentError({
parameter: 'message.messageId',
value: message.messageId,
message: `message with id ${message.messageId} not found`,
});
}
if (this.state.messages[messageIndex].role !== 'user') {
throw new InvalidArgumentError({
parameter: 'message.messageId',
value: message.messageId,
message: `message with id ${message.messageId} is not a user message`,
});
}
// remove all messages after the message with the given id
this.state.messages = this.state.messages.slice(0, messageIndex + 1);
// update the message with the new content
this.state.replaceMessage(messageIndex, {
id: message.messageId,
...uiMessage,
role: uiMessage.role ?? 'user',
metadata: message.metadata,
} as UI_MESSAGE);
} else {
this.state.pushMessage({
...uiMessage,
id: uiMessage.id ?? this.generateId(),
role: uiMessage.role ?? 'user',
metadata: message.metadata,
} as UI_MESSAGE);
}
// sending a new message is the user taking over after a stop or an error:
this.isQueuePaused = false;
await this.makeRequest({
trigger: 'submit-message',
messageId: message.messageId,
...options,
});
};
/**
* Regenerate the assistant message with the provided message id.
* If no message id is provided, the last assistant message will be regenerated.
*/
regenerate = async ({
messageId,
...options
}: {
messageId?: string;
} & ChatRequestOptions = {}): Promise<void> => {
await this.clearQueueForHistoryChange();
const messageIndex =
messageId == null
? this.state.messages.length - 1
: this.state.messages.findIndex(message => message.id === messageId);
if (messageIndex === -1) {
throw new InvalidArgumentError({
parameter: 'messageId',
value: messageId,
message: `message ${messageId} not found`,
});
}
// set the messages to the message before the assistant message
this.state.messages = this.state.messages.slice(
0,
// if the message is a user message, we need to include it in the request:
this.messages[messageIndex].role === 'assistant'
? messageIndex
: messageIndex + 1,
);
await this.makeRequest({
trigger: 'regenerate-message',
messageId,
...options,
});
};
/**
* Attempt to resume an ongoing streaming response.
*/
resumeStream = async (options: ChatRequestOptions = {}): Promise<void> => {
await this.makeRequest({ trigger: 'resume-stream', ...options });
};
/**
* Resume the stream if the latest response ended with a resumable network
* disconnect. If a response is still active, wait for it to settle first so
* disconnects reported shortly after the caller's trigger are handled.
*
* @internal
*/
'~resumeStreamIfDisconnected' = async ({
shouldResume = () => true,
}: {
shouldResume?: () => boolean;
} = {}): Promise<void> => {
const activeResponse = this.activeResponse;
if (this.resumableStreamState == null && activeResponse != null) {
await activeResponse.completionPromise;
if (this.resumableStreamState !== activeResponse.state) {
return;
}
}
if (this.resumableStreamState != null && shouldResume()) {
await this.resumeStream();
}
};
/**
* Clear the error state and set the status to ready if the chat is in an error state.
*/
clearError = () => {
if (this.status === 'error') {
this.state.error = undefined;
this.setStatus({ status: 'ready' });
}
};
addToolApprovalResponse: ChatAddToolApproveResponseFunction = async ({
id,
approved,
reason,
options,
}) =>
this.jobExecutor.run(async () => {
const stopGeneration = this.stopGeneration;
const messages = this.state.messages;
const updatePart = (
part: UIMessagePart<UIDataTypes, UITools>,
): UIMessagePart<UIDataTypes, UITools> =>
isToolUIPart(part) &&
part.state === 'approval-requested' &&
part.approval.id === id
? {
...part,
state: 'approval-responded',
approval: { ...part.approval, id, approved, reason },
}
: part;
const messageIndex = messages.findIndex(message =>
message.parts.some(
part =>
isToolUIPart(part) &&
part.state === 'approval-requested' &&
part.approval.id === id,
),
);
if (messageIndex !== -1) {
const message = messages[messageIndex];
// update the message to trigger an immediate UI update
this.state.replaceMessage(messageIndex, {
...message,
parts: message.parts.map(updatePart),
});
this.pendingApprovalMessageId = message.id;
}
// update the active response if it exists
if (this.activeResponse) {
this.activeResponse.state.message.parts =
this.activeResponse.state.message.parts.map(updatePart);
}
// automatically send the message if the sendAutomaticallyWhen function returns true
if (
messageIndex !== -1 &&
this.status !== 'streaming' &&
this.status !== 'submitted' &&
this.sendAutomaticallyWhen
) {
const shouldSend = await this.shouldSendAutomatically(
messages[messageIndex].id,
);
if (shouldSend) {
// no await to avoid deadlocking
void this.runAutomaticRequest(() => {
const messageId =
messageIndex === -1
? this.lastMessage?.id
: messages[messageIndex].id;
return this.makeRequestForToolApproval({
messageId,
messageIndex,
...options,
});
}, shouldSend);
return;
}
}
// no await to avoid deadlocking
void this.settleQueue({ stopGeneration });
});
addToolOutput: ChatAddToolOutputFunction<UI_MESSAGE> = async ({
state,
toolCallId,
output,
errorText,
options,
}) =>
this.jobExecutor.run(async () => {
const stopGeneration = this.stopGeneration;
const messages = this.state.messages;
const updatePart = (
part: UIMessagePart<UIDataTypes, UITools>,
): UIMessagePart<UIDataTypes, UITools> => {
if (!isToolUIPart(part) || part.toolCallId !== toolCallId) {
return part;
}
// Output states can only retain approvals that were granted.
const { approval: existingApproval, ...toolPart } = part;
const approval =
existingApproval?.approved === true
? {
approval: {
...existingApproval,
approved: existingApproval.approved,
},
}
: {};
return state === 'output-error'
? {
...toolPart,
...approval,
state,
input: part.input,
output: undefined,
errorText,
}
: {
...toolPart,
...approval,
state: 'output-available',
input: part.input,
output,
errorText: undefined,
};
};
const messageIndex = messages.findIndex(message =>
message.parts.some(
part => isToolUIPart(part) && part.toolCallId === toolCallId,
),
);
if (messageIndex !== -1) {
const message = messages[messageIndex];
// update the message to trigger an immediate UI update
this.state.replaceMessage(messageIndex, {
...message,
parts: message.parts.map(updatePart),
});
}
// update the active response if it exists
if (this.activeResponse) {
this.activeResponse.state.message.parts =
this.activeResponse.state.message.parts.map(updatePart);
}
// automatically send the message if the sendAutomaticallyWhen function returns true
if (
messageIndex !== -1 &&
this.status !== 'streaming' &&
this.status !== 'submitted' &&
this.sendAutomaticallyWhen
) {
const shouldSend = await this.shouldSendAutomatically(
messages[messageIndex].id,
);
if (shouldSend) {
// no await to avoid deadlocking
void this.runAutomaticRequest(
() =>
this.makeRequest({
trigger: 'submit-message',
messageId: messages[messageIndex].id,
...options,
}),
shouldSend,
);
return;
}
}
// the tool result can be what the queue was waiting for. no await to
// avoid deadlocking:
void this.settleQueue({ stopGeneration });
});
/** @deprecated Use addToolOutput */
addToolResult = this.addToolOutput;
/**
* Abort the current request, keep the generated tokens if any, and wait for
* the request pipeline to finish.
*/
stop = async () => {
this.activeStopCount++;
this.stopGeneration++;
this.deferredApprovalSend = undefined;
try {
const activeResumeRequest = this.activeResumeRequest;
const activeResponse = this.activeResponse;
for (const controller of this.pendingMessagePreparations) {
controller.abort();
}
activeResumeRequest?.abortController.abort();
activeResponse?.abortController.abort();
await Promise.all([
activeResumeRequest?.completionPromise,
activeResponse?.completionPromise,
]);
// Stream cancellation can complete while a processing job is still
// blocked in onToolCall. Drain that job and any message update it queued.
await this.jobExecutor.waitForIdle();
// a stop pauses the queue even when no response was running, e.g. while
// the assistant waits for a tool approval:
this.isQueuePaused = true;
} finally {
this.activeStopCount--;
}
};
/**
* Stops active work and releases resources owned by the chat transport.
*/
dispose = async (): Promise<void> => {
await this.stop();
this.transport.close?.();
};
private async shouldSendAutomatically(
messageId = this.lastMessage?.id,
): Promise<boolean> {
const isInterrupted = (id: string | undefined) =>
id != null && this.interruptedMessageIds.has(id);
if (
!this.sendAutomaticallyWhen ||
isInterrupted(messageId) ||
isInterrupted(this.lastMessage?.id)
) {
return false;
}
const stopGeneration = this.stopGeneration;
this.pendingAutomaticDecisions.push(stopGeneration);
let result: boolean;
try {
result = await this.sendAutomaticallyWhen({
messages: this.state.messages,
});
} finally {
this.pendingAutomaticDecisions.splice(
this.pendingAutomaticDecisions.indexOf(stopGeneration),
1,
);
}
return (
result &&
!isInterrupted(messageId) &&
!isInterrupted(this.lastMessage?.id)
);
}
private async runAutomaticRequest(
request: () => Promise<void>,
shouldSend?: boolean,
): Promise<boolean> {
const stopGeneration = this.stopGeneration;
const startedWhileStopping = this.activeStopCount > 0;
if (!(shouldSend ?? (await this.shouldSendAutomatically()))) {
return false;
}
// A stop invalidates automatic requests that were pending when it started,
// including requests queued by callback-driven tool updates.
if (
startedWhileStopping ||
stopGeneration !== this.stopGeneration ||
this.activeStopCount > 0
) {
return false;
}
await request();
return true;
}
private async makeRequestForToolApproval({
messageId,
messageIndex,
...options
}: {
messageId?: string;
messageIndex: number;
} & ChatRequestOptions) {
this.deferredApprovalSend = undefined;
const consumesPendingApproval =
messageId != null && messageId === this.pendingApprovalMessageId;
if (consumesPendingApproval) {
this.pendingApprovalMessageId = undefined;
}
await this.makeRequest({
trigger: 'submit-message',
messageId,
...options,
});
if (
consumesPendingApproval &&
this.status === 'error' &&
this.pendingApprovalMessageId == null
) {
this.pendingApprovalMessageId =
this.state.messages[messageIndex]?.id ?? messageId;
}
}
private async makeRequest(options: MakeRequestOptions) {
const completion = createResolvablePromise<void>();
try {
await this.makeRequestImpl({
...options,
completionPromise: completion.promise,
});
} finally {
completion.resolve();
}
}
private async makeRequestImpl({
trigger,
metadata,
headers,
body,
messageId,
completionPromise,
}: MakeRequestOptions & {
completionPromise: Promise<void>;
}) {
if (trigger !== 'resume-stream') {
this.resumableStreamState = undefined;
}
const abortController = new AbortController();
const activeResumeRequest =
trigger === 'resume-stream'
? { abortController, completionPromise }
: undefined;
if (activeResumeRequest) {
this.activeResumeRequest?.abortController.abort();
this.activeResumeRequest = activeResumeRequest;
}
const isCurrentRequest = () =>
activeResumeRequest == null ||
this.activeResumeRequest === activeResumeRequest;
const clearActiveResumeRequest = () => {
if (this.activeResumeRequest === activeResumeRequest) {
this.activeResumeRequest = undefined;
}
};
// For resume-stream, check if there's an active stream before
// changing status. This avoids a brief flash of 'submitted' status
// when there is no stream to resume (e.g. on page load).
let resumeStream: ReadableStream<UIMessageChunk> | undefined;
if (trigger === 'resume-stream') {
try {
const reconnect = await this.transport.reconnectToStream({
chatId: this.id,
abortSignal: abortController.signal,
metadata,
headers,
body,
});
if (abortController.signal.aborted || !isCurrentRequest()) {
await reconnect?.cancel().catch(() => {});
if (isCurrentRequest()) {
this.setStatus({ status: 'ready' });
this.resumableStreamState = undefined;
}
clearActiveResumeRequest();
return;
}
if (reconnect == null) {
this.setStatus({ status: 'ready' });
this.resumableStreamState = undefined;
clearActiveResumeRequest();
return; // no active stream found, so we do not resume
}
resumeStream = reconnect;
} catch (err) {
if (
abortController.signal.aborted ||
(err as { name?: string }).name === 'AbortError'
) {
if (isCurrentRequest()) {
this.setStatus({ status: 'ready' });
this.resumableStreamState = undefined;
}
clearActiveResumeRequest();
return;
}
if (!isCurrentRequest()) {
return;
}
if (this.onError && err instanceof Error) {
this.onError(err);
}
this.setStatus({ status: 'error', error: err as Error });
clearActiveResumeRequest();
return;
}
}
this.setStatus({ status: 'submitted', error: undefined });
const lastMessage = this.lastMessage;
const responseMessageIndex =
trigger === 'submit-message' && messageId != null
? this.state.messages.findIndex(message => message.id === messageId)
: this.state.messages.length - 1;
const responseMessage =
responseMessageIndex === -1
? lastMessage
: this.state.messages[responseMessageIndex];
const originalResponseMessageId = responseMessage?.id;
// The continued stream can start with input deltas or a tool result.
// Keep unfinished tool parts so result chunks can find their tool call.
const resumableResponseMessage =
trigger === 'resume-stream' &&
responseMessage?.role === 'assistant' &&
responseMessage.parts.some(
part =>
isToolUIPart(part) &&
(part.state === 'input-streaming' ||
(part.state === 'input-available' && part.providerExecuted) ||
part.state === 'approval-responded'),
)
? this.state.snapshot(responseMessage)
: undefined;
const usesEarlierAssistantMessage =
responseMessageIndex !== -1 &&
responseMessageIndex < this.state.messages.length - 1 &&
responseMessage?.role === 'assistant';
let isAbort = false;
let isDisconnect = false;
let isError = false;
let activeResponse: ActiveResponse<UI_MESSAGE> | undefined;
try {
const response = {
state:
trigger === 'resume-stream' && this.resumableStreamState != null
? this.resumableStreamState
: createStreamingUIMessageState({
lastMessage:
trigger === 'resume-stream'
? resumableResponseMessage
: trigger === 'regenerate-message'
? undefined
: this.state.snapshot(responseMessage),
messageId: this.generateId(),
}),
abortController,
completionPromise,
} as ActiveResponse<UI_MESSAGE>;
activeResponse = response;
response.abortController.signal.addEventListener('abort', () => {
isAbort = true;
});
this.activeResponse = response;
let stream: ReadableStream<UIMessageChunk>;
if (trigger === 'resume-stream') {
stream = resumeStream!;
} else {
stream = await this.transport.sendMessages({
chatId: this.id,
messages: this.state.messages,
abortSignal: response.abortController.signal,
metadata,
headers,
body,
trigger,
messageId,
});
}
const runUpdateMessageJob = (
job: (options: {
state: StreamingUIMessageState<UI_MESSAGE>;
write: (options?: UIMessageStreamWriteOptions) => void;
}) => Promise<void>,
) =>
// serialize the job execution to avoid race conditions:
this.jobExecutor.run(() => {
if (response.abortController.signal.aborted) {
return Promise.resolve();
}
return job({
state: response.state,
write: ({ updateStatus = true } = {}) => {
if (response.abortController.signal.aborted) {
return;
}
if (updateStatus) {
this.setStatus({ status: 'streaming' });
}
const existingMessageIndex = this.state.messages.findLastIndex(
message => message.id === response.state.message.id,
);
if (existingMessageIndex !== -1) {
this.state.replaceMessage(
existingMessageIndex,
response.state.message,
);
} else if (usesEarlierAssistantMessage) {
const originalMessageIndex = this.state.messages.findLastIndex(
message => message.id === originalResponseMessageId,
);
if (originalMessageIndex !== -1) {
this.state.replaceMessage(
originalMessageIndex,
response.state.message,
);
} else {
this.state.pushMessage(response.state.message);
}
} else {
this.state.pushMessage(response.state.message);
}
},
});
});
await consumeStream({
stream: processUIMessageStream({
stream,
resetStateOnMessageIdChange: trigger === 'resume-stream',
resetStateOnFirstMessageStart:
trigger === 'resume-stream' && this.transport.resumeStreamIsReplay,
onToolCall: this.onToolCall,
onData: this.onData,
messageMetadataSchema: this.messageMetadataSchema,
dataPartSchemas: this.dataPartSchemas,
runUpdateMessageJob,
onError: error => {
throw error;
},
}),
abortSignal: response.abortController.signal,
onError: error => {
throw error;
},
});
if (isAbort) {
if (isCurrentRequest()) {
this.setStatus({ status: 'ready' });
if (this.resumableStreamState === response.state) {
this.resumableStreamState = undefined;
}
}
return null;
}
if (isCurrentRequest()) {
this.setStatus({ status: 'ready' });
if (this.resumableStreamState === response.state) {
this.resumableStreamState = undefined;
}
}
} catch (err) {
// Ignore abort errors as they are expected.
if (isAbort || (err as any).name === 'AbortError') {
isAbort = true;
if (isCurrentRequest()) {
this.setStatus({ status: 'ready' });
if (this.resumableStreamState === activeResponse?.state) {
this.resumableStreamState = undefined;
}
}
return null;
}
if (!isCurrentRequest()) {
return null;
}
isError = true;
if (err instanceof TypeError) {
const message = err.message.toLowerCase();
isDisconnect =
// Chromium request failures; Node.js fetch failures.
message.includes('fetch') ||
// Firefox request failures; Chromium response-body failures.
message.includes('network') ||
// Safari/WebKit request and response-body failures.
message === 'load failed';
}
if (isDisconnect) {
this.resumableStreamState = activeResponse?.state;
} else if (this.resumableStreamState === activeResponse?.state) {
this.resumableStreamState = undefined;
}
if (this.onError && err instanceof Error) {
this.onError(err);
}
this.setStatus({ status: 'error', error: err as Error });
} finally {
try {
if (activeResponse) {
const { message, finishReason } = activeResponse.state;
if (finishReason === 'interrupted') {
this.interruptedMessageIds.add(message.id);
} else if (!isAbort && !isError) {
this.interruptedMessageIds.delete(message.id);
}
this.onFinish?.({
message: activeResponse.state.message,
messages: this.state.messages,
isAbort,
isDisconnect,
isError,
finishReason: activeResponse.state.finishReason,
});
}
} finally {
// a response that did not finish (stopped, failed or interrupted)
// pauses the queue until the user sends again. a response that finishes
// on its own (e.g. an automatic resume) never clears the pause:
if (
isAbort ||
isError ||
activeResponse?.state.finishReason === 'interrupted'
) {
this.isQueuePaused = true;
}
if (this.activeResponse === activeResponse) {
this.activeResponse = undefined;
}
clearActiveResumeRequest();
}
}
// automatically send the message if the sendAutomaticallyWhen function returns true
if (
!isAbort &&
!isError &&
activeResponse?.state.finishReason !== 'interrupted'
) {
const stopGeneration = this.stopGeneration;
const sentAutomatically = await this.runAutomaticRequest(() =>
this.makeRequest({
trigger: 'submit-message',
messageId: this.lastMessage?.id,
metadata,
headers,
body,
}),
);
// the automatic request settles the queue when it finishes. a stop while
// sendAutomaticallyWhen was pending cancels the send too, so a stopped or
// replaced chat never sends queued messages on its own:
if (!sentAutomatically) {
await this.settleQueue({ stopGeneration });
}
}
}
}