@convex-dev/agent
Version:
A agent component for Convex.
1,541 lines (1,512 loc) • 61.8 kB
text/typescript
import type { EmbeddingModelV1, LanguageModelV1 } from "@ai-sdk/provider";
import type {
CoreMessage,
DeepPartial,
FilePart,
GenerateObjectResult,
GenerateTextResult,
ImagePart,
StepResult,
StreamObjectResult,
StreamTextResult,
ToolSet,
} from "ai";
import { generateObject, generateText, streamObject, streamText } from "ai";
import { assert } from "convex-helpers";
import {
internalActionGeneric,
internalMutationGeneric,
type PaginationOptions,
type PaginationResult,
type WithoutSystemFields,
} from "convex/server";
import { v } from "convex/values";
import type { MessageDoc, ThreadDoc } from "../component/schema.js";
import {
validateVectorDimension,
type VectorDimension,
} from "../component/vector/tables.js";
import {
type AIMessageWithoutId,
deserializeMessage,
promptOrMessagesToCoreMessages,
serializeMessage,
serializeNewMessagesInStep,
serializeObjectResult,
} from "../mapping.js";
import {
DEFAULT_MESSAGE_RANGE,
DEFAULT_RECENT_MESSAGES,
extractText,
isTool,
} from "../shared.js";
import {
type MessageWithMetadata,
type MessageStatus,
type ProviderMetadata,
type SearchOptions,
type StreamArgs,
type Usage,
vMessageWithMetadata,
vSafeObjectArgs,
vTextArgs,
} from "../validators.js";
import { createTool, wrapTools } from "./createTool.js";
import {
DeltaStreamer,
mergeTransforms,
type StreamingOptions,
} from "./streaming.js";
import type {
AgentComponent,
ContextOptions,
GenerationOutputMetadata,
Options,
OurObjectArgs,
OurStreamObjectArgs,
RawRequestResponseHandler,
RunActionCtx,
RunMutationCtx,
RunQueryCtx,
ActionCtx,
StorageOptions,
StreamingTextArgs,
SyncStreamsReturnValue,
TextArgs,
Thread,
UsageHandler,
} from "./types.js";
export { storeFile, getFile } from "./files.js";
export { serializeDataOrUrl } from "../mapping.js";
export { vMessageDoc, vThreadDoc } from "../component/schema.js";
export {
vAssistantMessage,
vContextOptions,
vMessage,
vPaginationResult,
vProviderMetadata,
vStorageOptions,
vStreamArgs,
vSystemMessage,
vToolMessage,
vUsage,
vUserMessage,
} from "../validators.js";
export type { ToolCtx } from "./createTool.js";
export { createTool, extractText, isTool };
export type {
AgentComponent,
ContextOptions,
MessageDoc,
ProviderMetadata,
StorageOptions,
SyncStreamsReturnValue,
Thread,
ThreadDoc,
Usage,
UsageHandler,
};
export class Agent<AgentTools extends ToolSet> {
constructor(
public component: AgentComponent,
public options: {
/**
* The name for the agent. This will be attributed on each message
* created by this agent.
*/
name?: string;
/**
* The LLM model to use for generating / streaming text and objects.
* e.g.
* import { openai } from "@ai-sdk/openai"
* const myAgent = new Agent(components.agent, {
* chat: openai.chat("gpt-4o-mini"),
*/
chat: LanguageModelV1;
/**
* The model to use for text embeddings. Optional.
* If specified, it will use this for generating vector embeddings
* of chats, and can opt-in to doing vector search for automatic context
* on generateText, etc.
* e.g.
* import { openai } from "@ai-sdk/openai"
* const myAgent = new Agent(components.agent, {
* textEmbedding: openai.embedding("text-embedding-3-small")
*/
textEmbedding?: EmbeddingModelV1<string>;
/**
* The default system prompt to put in each request.
* Override per-prompt by passing the "system" parameter.
*/
instructions?: string;
/**
* Tools that the agent can call out to and get responses from.
* They can be AI SDK tools (import {tool} from "ai")
* or tools that have Convex context
* (import { createTool } from "@convex-dev/agent")
*/
tools?: AgentTools;
/**
* Options to determine what messages are included as context in message
* generation. To disable any messages automatically being added, pass:
* { recentMessages: 0 }
*/
contextOptions?: ContextOptions;
/**
* Determines whether messages are automatically stored when passed as
* arguments or generated.
*/
storageOptions?: StorageOptions;
/**
* When generating or streaming text with tools available, this
* determines the default max number of iterations.
*/
maxSteps?: number;
/**
* The maximum number of calls to make to an LLM in case it fails.
* This can be overridden at each generate/stream callsite.
*/
maxRetries?: number;
/**
* The usage handler to use for this agent.
*/
usageHandler?: UsageHandler;
/**
* Called for each LLM request/response, so you can do things like
* log the raw request body or response headers to a table, or logs.
*/
rawRequestResponseHandler?: RawRequestResponseHandler;
}
) {}
/**
* Start a new thread with the agent. This will have a fresh history, though if
* you pass in a userId you can have it search across other threads for relevant
* messages as context for the LLM calls.
* @param ctx The context of the Convex function. From an action, you can thread
* with the agent. From a mutation, you can start a thread and save the threadId
* to pass to continueThread later.
* @param args The thread metadata.
* @returns The threadId of the new thread and the thread object.
*/
async createThread<ThreadTools extends ToolSet | undefined = undefined>(
ctx: RunActionCtx,
args?: {
/**
* The userId to associate with the thread. If not provided, the thread will be
* anonymous.
*/
userId?: string;
/**
* The title of the thread. Not currently used for anything.
*/
title?: string;
/**
* The summary of the thread. Not currently used for anything.
*/
summary?: string;
/**
* The usage handler to use for this thread. Overrides any handler
* set in the agent constructor.
*/
usageHandler?: UsageHandler;
/**
* The tools to use for this thread.
* Overrides any tools passed in the agent constructor.
*/
tools?: ThreadTools;
}
): Promise<{
threadId: string;
thread: Thread<ThreadTools extends undefined ? AgentTools : ThreadTools>;
}>;
/**
* Start a new thread with the agent. This will have a fresh history, though if
* you pass in a userId you can have it search across other threads for relevant
* messages as context for the LLM calls.
* @param ctx The context of the Convex function. From a mutation, you can
* start a thread and save the threadId to pass to continueThread later.
* @param args The thread metadata.
* @returns The threadId of the new thread.
*/
async createThread<ThreadTools extends ToolSet | undefined = undefined>(
ctx: RunMutationCtx,
args?: {
/**
* The userId to associate with the thread. If not provided, the thread will be
* anonymous.
*/
userId?: string;
/**
* The title of the thread. Not currently used for anything.
*/
title?: string;
/**
* The summary of the thread. Not currently used for anything.
*/
summary?: string;
/**
* The usage handler to use for this thread. Overrides any handler
* set in the agent constructor.
*/
usageHandler?: UsageHandler;
/**
* The tools to use for this thread.
* Overrides any tools passed in the agent constructor.
*/
tools?: ThreadTools;
}
): Promise<{
threadId: string;
}>;
async createThread<ThreadTools extends ToolSet | undefined = undefined>(
ctx: ActionCtx | RunMutationCtx,
args?: {
userId: string;
title?: string;
summary?: string;
usageHandler?: UsageHandler;
tools?: ThreadTools;
}
): Promise<{
threadId: string;
thread?: Thread<ThreadTools extends undefined ? AgentTools : ThreadTools>;
}> {
const threadDoc = await ctx.runMutation(
this.component.threads.createThread,
{
userId: args?.userId,
title: args?.title,
summary: args?.summary,
}
);
if (!("runAction" in ctx)) {
return { threadId: threadDoc._id };
}
const { thread } = await this.continueThread(ctx, {
threadId: threadDoc._id,
userId: args?.userId,
usageHandler: args?.usageHandler,
tools: args?.tools,
});
return {
threadId: threadDoc._id,
thread,
};
}
/**
* Continues a thread using this agent. Note: threads can be continued
* by different agents. This is a convenience around calling the various
* generate and stream functions with explicit userId and threadId parameters.
* @param ctx The ctx object passed to the action handler
* @param { threadId, userId }: the thread and user to associate the messages with.
* @returns Functions bound to the userId and threadId on a `{thread}` object.
*/
async continueThread<ThreadTools extends ToolSet | undefined = undefined>(
ctx: ActionCtx,
args: {
/**
* The associated thread created by {@link createThread}
*/
threadId: string;
/**
* If supplied, the userId can be used to search across other threads for
* relevant messages from the same user as context for the LLM calls.
*/
userId?: string;
/**
* The usage handler to use for this thread. Overrides any handler
* set in the agent constructor.
*/
usageHandler?: UsageHandler;
/**
* The tools to use for this thread.
* Overrides any tools passed in the agent constructor.
*/
tools?: ThreadTools;
}
): Promise<{
thread: Thread<ThreadTools extends undefined ? AgentTools : ThreadTools>;
}> {
return {
thread: {
threadId: args.threadId,
getMetadata: this.getThreadMetadata.bind(this, ctx, {
threadId: args.threadId,
}),
updateMetadata: (patch: Partial<WithoutSystemFields<ThreadDoc>>) =>
ctx.runMutation(this.component.threads.updateThread, {
threadId: args.threadId,
patch,
}),
generateText: this.generateText.bind(this, ctx, args),
streamText: this.streamText.bind(this, ctx, args),
generateObject: this.generateObject.bind(this, ctx, args),
streamObject: this.streamObject.bind(this, ctx, args),
} as Thread<ThreadTools extends undefined ? AgentTools : ThreadTools>,
};
}
/**
* This behaves like {@link generateText} from the "ai" package except that
* it add context based on the userId and threadId and saves the input and
* resulting messages to the thread, if specified.
* Use {@link continueThread} to get a version of this function already scoped
* to a thread (and optionally userId).
* @param ctx The context passed from the action function calling this.
* @param { userId, threadId }: The user and thread to associate the message with
* @param args The arguments to the generateText function, along with extra controls
* for the {@link ContextOptions} and {@link StorageOptions}.
* @returns The result of the generateText function.
*/
async generateText<
TOOLS extends ToolSet | undefined = undefined,
OUTPUT = never,
OUTPUT_PARTIAL = never,
>(
ctx: ActionCtx,
{
userId: argsUserId,
threadId,
usageHandler,
tools: threadTools,
}: {
userId?: string;
threadId?: string;
/**
* The usage handler to use for this thread. Overrides any handler
* set in the agent constructor.
*/
usageHandler?: UsageHandler;
/** @deprecated Pass `tools` in the next parameter instead. This is only intended to pass through thread-default tools. */
tools?: ToolSet;
},
args: TextArgs<AgentTools, TOOLS, OUTPUT, OUTPUT_PARTIAL>,
options?: Options
): Promise<
GenerateTextResult<TOOLS extends undefined ? AgentTools : TOOLS, OUTPUT> &
GenerationOutputMetadata
> {
const context = await this._saveMessagesAndFetchContext(ctx, args, {
userId: argsUserId,
threadId,
...options,
});
const { args: aiArgs, messageId, userId } = context;
const toolCtx = { ...ctx, userId, threadId, messageId, agent: this };
const tools = wrapTools(
toolCtx,
args.tools ?? threadTools ?? this.options.tools
) as TOOLS extends undefined ? AgentTools : TOOLS;
const saveOutputMessages = this._shouldSaveOutputMessages(
options?.storageOptions
);
const trackUsage = usageHandler ?? this.options.usageHandler;
try {
const result = (await generateText({
// Can be overridden
maxSteps: this.options.maxSteps,
...aiArgs,
tools,
onStepFinish: async (step) => {
if (threadId && messageId && saveOutputMessages) {
await this.saveStep(ctx, {
userId,
threadId,
promptMessageId: messageId,
step,
});
}
if (this.options.rawRequestResponseHandler) {
await this.options.rawRequestResponseHandler(ctx, {
userId,
threadId,
agentName: this.options.name,
request: step.request,
response: step.response,
});
}
if (trackUsage && step.usage) {
await trackUsage(ctx, {
userId,
threadId,
agentName: this.options.name,
model: aiArgs.model.modelId,
provider: aiArgs.model.provider,
usage: step.usage,
providerMetadata: step.providerMetadata,
});
}
return args.onStepFinish?.(step);
},
})) as GenerateTextResult<
TOOLS extends undefined ? AgentTools : TOOLS,
OUTPUT
> &
GenerationOutputMetadata;
result.messageId = messageId;
return result;
} catch (error) {
if (threadId && messageId) {
console.error("RollbackMessage", messageId);
await ctx.runMutation(this.component.messages.rollbackMessage, {
messageId,
error: (error as Error).message,
});
}
throw error;
}
}
/**
* This behaves like {@link streamText} from the "ai" package except that
* it add context based on the userId and threadId and saves the input and
* resulting messages to the thread, if specified.
* Use {@link continueThread} to get a version of this function already scoped
* to a thread (and optionally userId).
*/
async streamText<
TOOLS extends ToolSet | undefined = undefined,
OUTPUT = never,
PARTIAL_OUTPUT = never,
>(
ctx: ActionCtx,
{
userId: argsUserId,
threadId,
usageHandler,
/**
* @deprecated Pass `tools` in the next parameter instead.
* This is only intended to pass through thread-default tools.
*/
tools: threadTools,
}: {
userId?: string;
threadId?: string;
usageHandler?: UsageHandler;
tools?: ToolSet;
},
/**
* The arguments to the streamText function, similar to the ai `streamText` function.
*/
args: StreamingTextArgs<AgentTools, TOOLS, OUTPUT, PARTIAL_OUTPUT>,
/**
* The {@link ContextOptions} and {@link StorageOptions}
* options to use for fetching contextual messages and saving input/output messages.
*/
options?: Options & {
/**
* Whether to save incremental data (deltas) from streaming responses.
* Defaults to false.
* If false, it will not save any deltas to the database.
* If true, it will save deltas with {@link DEFAULT_STREAMING_OPTIONS}.
*
* Regardless of this option, when streaming you are able to use this
* `streamText` function as you would with the "ai" package's version:
* iterating over the text, streaming it over HTTP, etc.
*/
saveStreamDeltas?: boolean | StreamingOptions;
}
): Promise<
StreamTextResult<
TOOLS extends undefined ? AgentTools : TOOLS,
PARTIAL_OUTPUT
> &
GenerationOutputMetadata
> {
const context = await this._saveMessagesAndFetchContext(ctx, args, {
userId: argsUserId,
threadId,
...options,
});
const { args: aiArgs, messageId, order, stepOrder, userId } = context;
const toolCtx = { ...ctx, userId, threadId, messageId, agent: this };
const tools = wrapTools(
toolCtx,
args.tools ?? threadTools ?? this.options.tools
) as TOOLS extends undefined ? AgentTools : TOOLS;
const saveOutputMessages = this._shouldSaveOutputMessages(
options?.storageOptions
);
const trackUsage = usageHandler ?? this.options.usageHandler;
const streamer =
threadId && options?.saveStreamDeltas
? new DeltaStreamer(this.component, ctx, options.saveStreamDeltas, {
threadId,
userId,
agentName: this.options.name,
model: aiArgs.model.modelId,
provider: aiArgs.model.provider,
providerOptions: aiArgs.providerOptions,
order,
stepOrder,
abortSignal: aiArgs.abortSignal,
})
: undefined;
const result = streamText({
// Can be overridden
maxSteps: this.options.maxSteps,
...aiArgs,
tools,
abortSignal: streamer?.abortController.signal ?? aiArgs.abortSignal,
experimental_transform: mergeTransforms(
options?.saveStreamDeltas,
args.experimental_transform
),
onChunk: async (event) => {
await streamer?.addParts([event.chunk]);
// console.log("onChunk", chunk);
return args.onChunk?.(event);
},
onError: async (error) => {
console.error("onError", error);
if (threadId && messageId && saveOutputMessages) {
await ctx.runMutation(this.component.messages.rollbackMessage, {
messageId,
error: (error.error as Error).message,
});
}
return args.onError?.(error);
},
onStepFinish: async (step) => {
// console.log("onStepFinish", step);
// TODO: compare delta to the output. internally drop the deltas when committing
if (threadId && messageId) {
const saved = await this.saveStep(ctx, {
userId,
threadId,
promptMessageId: messageId,
step,
});
// TODO: figure out pending/not
await streamer?.finish(saved.messages);
}
if (this.options.rawRequestResponseHandler) {
await this.options.rawRequestResponseHandler(ctx, {
userId,
threadId,
agentName: this.options.name,
request: step.request,
response: step.response,
});
}
if (trackUsage && step.usage) {
await trackUsage(ctx, {
userId,
threadId,
agentName: this.options.name,
model: aiArgs.model.modelId,
provider: aiArgs.model.provider,
usage: step.usage,
providerMetadata: step.providerMetadata,
});
}
return args.onStepFinish?.(step);
},
}) as StreamTextResult<
TOOLS extends undefined ? AgentTools : TOOLS,
PARTIAL_OUTPUT
> &
GenerationOutputMetadata;
result.messageId = messageId;
return result;
}
/**
* This behaves like {@link generateObject} from the "ai" package except that
* it add context based on the userId and threadId and saves the input and
* resulting messages to the thread, if specified.
* Use {@link continueThread} to get a version of this function already scoped
* to a thread (and optionally userId).
*/
async generateObject<T>(
ctx: RunActionCtx,
{
userId: argsUserId,
threadId,
usageHandler,
}: { userId?: string; threadId?: string; usageHandler?: UsageHandler },
/**
* The arguments to the generateObject function, similar to the ai.generateObject function.
*/
args: OurObjectArgs<T>,
/**
* The {@link ContextOptions} and {@link StorageOptions}
* options to use for fetching contextual messages and saving input/output messages.
*/
options?: Options
): Promise<GenerateObjectResult<T> & GenerationOutputMetadata> {
const context = await this._saveMessagesAndFetchContext(ctx, args, {
userId: argsUserId,
threadId,
...options,
});
const { args: aiArgs, messageId, userId } = context;
const trackUsage = usageHandler ?? this.options.usageHandler;
const saveOutputMessages = this._shouldSaveOutputMessages(
options?.storageOptions
);
try {
const result = (await generateObject(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiArgs as any
)) as GenerateObjectResult<T> & GenerationOutputMetadata;
if (threadId && messageId && saveOutputMessages) {
await this.saveObject(ctx, {
threadId,
promptMessageId: messageId,
result,
userId,
});
}
result.messageId = messageId;
if (this.options.rawRequestResponseHandler) {
await this.options.rawRequestResponseHandler(ctx, {
userId,
threadId,
agentName: this.options.name,
request: result.request,
response: result.response,
});
}
if (trackUsage && result.usage) {
await trackUsage(ctx, {
userId,
threadId,
agentName: this.options.name,
model: aiArgs.model.modelId,
provider: aiArgs.model.provider,
usage: result.usage,
providerMetadata: result.providerMetadata,
});
}
return result;
} catch (error) {
if (threadId && messageId) {
await ctx.runMutation(this.component.messages.rollbackMessage, {
messageId,
error: (error as Error).message,
});
}
throw error;
}
}
/**
* This behaves like `streamObject` from the "ai" package except that
* it add context based on the userId and threadId and saves the input and
* resulting messages to the thread, if specified.
* Use {@link continueThread} to get a version of this function already scoped
* to a thread (and optionally userId).
*/
async streamObject<T>(
ctx: RunActionCtx,
{
userId: argsUserId,
threadId,
usageHandler,
}: { userId?: string; threadId?: string; usageHandler?: UsageHandler },
/**
* The arguments to the streamObject function, similar to the ai `streamObject` function.
*/
args: OurStreamObjectArgs<T>,
/**
* The {@link ContextOptions} and {@link StorageOptions}
* options to use for fetching contextual messages and saving input/output messages.
*/
options?: Options
): Promise<
StreamObjectResult<DeepPartial<T>, T, never> & GenerationOutputMetadata
> {
// TODO: unify all this shared code between all the generate* and stream* functions
const context = await this._saveMessagesAndFetchContext(ctx, args, {
userId: argsUserId,
threadId,
...options,
});
const { args: aiArgs, messageId, userId } = context;
const trackUsage = usageHandler ?? this.options.usageHandler;
const saveOutputMessages = this._shouldSaveOutputMessages(
options?.storageOptions
);
const stream = streamObject<T>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(aiArgs as any),
onError: async (error) => {
console.error("onError", error);
return args.onError?.(error);
},
onFinish: async (result) => {
if (threadId && messageId && saveOutputMessages) {
await this.saveObject(ctx, {
userId,
threadId,
promptMessageId: messageId,
result: {
object: result.object,
finishReason: "stop",
usage: result.usage,
warnings: result.warnings,
request: await stream.request,
response: result.response,
providerMetadata: result.providerMetadata,
experimental_providerMetadata:
result.experimental_providerMetadata,
logprobs: undefined,
toJsonResponse: stream.toTextStreamResponse,
},
});
}
if (trackUsage && result.usage) {
await trackUsage(ctx, {
userId,
threadId,
agentName: this.options.name,
model: aiArgs.model.modelId,
provider: aiArgs.model.provider,
usage: result.usage,
providerMetadata: result.providerMetadata,
});
}
if (this.options.rawRequestResponseHandler) {
await this.options.rawRequestResponseHandler(ctx, {
userId,
threadId,
agentName: this.options.name,
request: await stream.request,
response: result.response,
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return args.onFinish?.(result as any);
},
}) as StreamObjectResult<DeepPartial<T>, T, never> &
GenerationOutputMetadata;
stream.messageId = messageId;
return stream;
}
/**
* Save a message to the thread.
* @param ctx A ctx object from a mutation or action.
* @param args The message and what to associate it with (user / thread)
* You can pass extra metadata alongside the message, e.g. associated fileIds.
* @returns The messageId of the saved message.
*/
async saveMessage(
ctx: RunMutationCtx,
args: {
threadId: string;
userId?: string;
/**
* Metadata to save with the messages. Each element corresponds to the
* message at the same index.
*/
metadata?: Omit<MessageWithMetadata, "message">;
/**
* If true, it will not generate embeddings for the message.
* Useful if you're saving messages in a mutation where you can't run `fetch`.
* You can generate them asynchronously by using the scheduler to run an
* action later that calls `agent.generateAndSaveEmbeddings`.
*/
skipEmbeddings?: boolean;
} & (
| {
prompt?: undefined;
/**
* The message to save.
*/
message: CoreMessage;
}
| {
/*
* The prompt to save with the message.
*/
prompt: string;
message?: undefined;
}
)
) {
const { lastMessageId, messages } = await this.saveMessages(ctx, {
threadId: args.threadId,
userId: args.userId,
messages:
args.prompt !== undefined
? [{ role: "user", content: args.prompt }]
: [args.message],
metadata: args.metadata ? [args.metadata] : undefined,
skipEmbeddings: args.skipEmbeddings,
});
return { messageId: lastMessageId, message: messages.at(-1)! };
}
/**
* Explicitly save messages associated with the thread (& user if provided)
* @param ctx The ctx parameter to a mutation or action.
* @param args The messages and context to save
* @returns
*/
async saveMessages(
ctx: RunMutationCtx | RunActionCtx,
args: {
threadId: string;
userId?: string;
/**
* The message that these messages are in response to. They will be
* the same "order" as this message, at increasing stepOrder(s).
*/
promptMessageId?: string;
/**
* The messages to save.
*/
messages: CoreMessageMaybeWithId[];
/**
* Metadata to save with the messages. Each element corresponds to the
* message at the same index.
*/
metadata?: Omit<MessageWithMetadata, "message">[];
/**
* If false, it will "commit" the messages immediately.
* If true, it will mark them as pending until the final step has finished.
* Defaults to false.
*/
pending?: boolean;
/**
* If true, it will fail any pending steps.
* Defaults to false.
*/
failPendingSteps?: boolean;
/**
* Skip generating embeddings for the messages. Useful if you're
* saving messages in a mutation where you can't run `fetch`.
* You can generate them asynchronously by using the scheduler to run an
* action later that calls `agent.generateAndSaveEmbeddings`.
*/
skipEmbeddings?: boolean;
}
): Promise<{
lastMessageId: string;
messages: MessageDoc[];
}> {
let embeddings:
| {
vectors: (number[] | null)[];
dimension: VectorDimension;
model: string;
}
| undefined;
if (args.skipEmbeddings || !("runAction" in ctx)) {
embeddings = undefined;
if (!args.skipEmbeddings && this.options.textEmbedding) {
console.warn(
"You're trying to save messages and generate embeddings, but you're in a mutation. " +
"Pass `skipEmbeddings: true` to skip generating embeddings in the mutation and skip this warning. " +
"They will be generated lazily when you generate or stream text / objects. " +
"You can explicitly generate them asynchronously by using the scheduler to run an action later that calls `agent.generateAndSaveEmbeddings`."
);
}
} else {
embeddings = await this.generateEmbeddings(
ctx,
{
userId: args.userId,
threadId: args.threadId,
},
args.messages
);
}
const result = await ctx.runMutation(this.component.messages.addMessages, {
threadId: args.threadId,
userId: args.userId,
agentName: this.options.name,
promptMessageId: args.promptMessageId,
embeddings,
messages: await Promise.all(
args.messages.map(async (m, i) => {
const { message, fileIds } = await serializeMessage(
ctx,
this.component,
m
);
return {
...args.metadata?.[i],
message,
fileIds,
} as MessageWithMetadata;
})
),
failPendingSteps: args.failPendingSteps ?? false,
pending: args.pending ?? false,
});
return {
lastMessageId: result.messages.at(-1)!._id,
messages: result.messages,
};
}
/**
* List messages from a thread.
* @param ctx A ctx object from a query, mutation, or action.
* @param args.threadId The thread to list messages from.
* @param args.paginationOpts Pagination options (e.g. via usePaginatedQuery).
* @param args.excludeToolMessages Whether to exclude tool messages.
* False by default.
* @param args.statuses What statuses to include. All by default.
* @returns The MessageDoc's in a format compatible with usePaginatedQuery.
*/
async listMessages(
ctx: RunQueryCtx,
args: {
threadId: string;
paginationOpts: PaginationOptions;
excludeToolMessages?: boolean;
statuses?: MessageStatus[];
}
): Promise<PaginationResult<MessageDoc>> {
if (args.paginationOpts.numItems === 0) {
return {
page: [],
isDone: true,
continueCursor: args.paginationOpts.cursor ?? "",
};
}
return ctx.runQuery(this.component.messages.listMessagesByThreadId, {
order: "desc",
...args,
});
}
/**
* A function that handles fetching stream deltas, used with the React hooks
* `useThreadMessages` or `useStreamingThreadMessages`.
* @param ctx A ctx object from a query, mutation, or action.
* @param args.threadId The thread to sync streams for.
* @param args.streamArgs The stream arguments with per-stream cursors.
* @returns The deltas for each stream from their existing cursor.
*/
async syncStreams(
ctx: RunQueryCtx,
args: {
threadId: string;
streamArgs: StreamArgs | undefined;
}
): Promise<SyncStreamsReturnValue | undefined> {
if (!args.streamArgs) return undefined;
if (args.streamArgs.kind === "list") {
return {
kind: "list",
messages: await ctx.runQuery(this.component.streams.list, {
threadId: args.threadId,
}),
};
} else {
return {
kind: "deltas",
deltas: await ctx.runQuery(this.component.streams.listDeltas, {
threadId: args.threadId,
cursors: args.streamArgs.cursors,
}),
};
}
}
/**
* Fetch the context messages for a thread.
* @param ctx Either a query, mutation, or action ctx.
* If it is not an action context, you can't do text or
* vector search.
* @param args The associated thread, user, message
* @returns
*/
async fetchContextMessages(
ctx: RunQueryCtx | RunActionCtx,
args: {
userId: string | undefined;
threadId: string | undefined;
messages: CoreMessage[];
/**
* If provided, it will search for messages up to and including this message.
* Note: if this is far in the past, text and vector search results may be more
* limited, as it's post-filtering the results.
*/
upToAndIncludingMessageId?: string;
contextOptions: ContextOptions | undefined;
}
): Promise<MessageDoc[]> {
assert(args.userId || args.threadId, "Specify userId or threadId");
// Fetch the latest messages from the thread
let included: Set<string> | undefined;
const opts = this._mergedContextOptions(args.contextOptions);
const contextMessages: MessageDoc[] = [];
if (
args.threadId &&
(opts.recentMessages !== 0 || args.upToAndIncludingMessageId)
) {
const { page } = await ctx.runQuery(
this.component.messages.listMessagesByThreadId,
{
threadId: args.threadId,
excludeToolMessages: opts.excludeToolMessages,
paginationOpts: {
numItems: opts.recentMessages ?? DEFAULT_RECENT_MESSAGES,
cursor: null,
},
upToAndIncludingMessageId: args.upToAndIncludingMessageId,
order: "desc",
statuses: ["success"],
}
);
included = new Set(page.map((m) => m._id));
contextMessages.push(
// Reverse since we fetched in descending order
...page.reverse()
);
}
if (opts.searchOptions?.textSearch || opts.searchOptions?.vectorSearch) {
const targetMessage = contextMessages.find(
(m) => m._id === args.upToAndIncludingMessageId
)?.message;
const messagesToSearch = targetMessage
? [targetMessage, ...args.messages]
: args.messages;
if (!("runAction" in ctx)) {
throw new Error("searchUserMessages only works in an action");
}
const searchMessages = await ctx.runAction(
this.component.messages.searchMessages,
{
searchAllMessagesForUserId: opts?.searchOtherThreads
? args.userId ??
(args.threadId &&
(
await ctx.runQuery(this.component.threads.getThread, {
threadId: args.threadId,
})
)?.userId)
: undefined,
threadId: args.threadId,
beforeMessageId: args.upToAndIncludingMessageId,
...(await this._searchOptionsWithEmbeddingAndDefaults(
ctx,
{ userId: args.userId, threadId: args.threadId },
opts,
messagesToSearch
)),
}
);
// TODO: track what messages we used for context
contextMessages.unshift(
...searchMessages.filter((m) => !included?.has(m._id))
);
}
// Ensure we don't include tool messages without a corresponding tool call
return filterOutOrphanedToolMessages(
contextMessages.sort((a, b) =>
// Sort the raw MessageDocs by order and stepOrder
a.order === b.order ? a.stepOrder - b.stepOrder : a.order - b.order
)
);
}
/**
* Get the metadata for a thread.
* @param ctx A ctx object from a query, mutation, or action.
* @param args.threadId The thread to get the metadata for.
* @returns The metadata for the thread.
*/
async getThreadMetadata(
ctx: RunQueryCtx,
args: { threadId: string }
): Promise<ThreadDoc> {
const thread = await ctx.runQuery(this.component.threads.getThread, {
threadId: args.threadId,
});
if (!thread) {
throw new Error("Thread not found");
}
return thread;
}
/**
* Update the metadata for a thread.
* @param ctx A ctx object from a mutation or action.
* @param args.threadId The thread to update the metadata for.
* @param args.patch The patch to apply to the thread.
* @returns The updated thread metadata.
*/
async updateThreadMetadata(
ctx: RunMutationCtx,
args: { threadId: string; patch: Partial<WithoutSystemFields<ThreadDoc>> }
): Promise<ThreadDoc> {
const thread = await ctx.runMutation(
this.component.threads.updateThread,
args
);
return thread;
}
/**
* Get the embeddings for a set of messages.
* @param messages The messages to get the embeddings for.
* @returns The embeddings for the messages.
*/
async generateEmbeddings(
ctx: RunActionCtx,
{
userId,
threadId,
}: {
userId: string | undefined;
threadId: string | undefined;
},
messages: CoreMessage[]
) {
if (!this.options.textEmbedding) {
return undefined;
}
let embeddings:
| {
vectors: (number[] | null)[];
dimension: VectorDimension;
model: string;
}
| undefined;
const messageTexts = messages.map((m) => !isTool(m) && extractText(m));
// Find the indexes of the messages that have text.
const textIndexes = messageTexts
.map((t, i) => (t ? i : undefined))
.filter((i) => i !== undefined);
if (textIndexes.length === 0) {
return undefined;
}
// Then embed those messages.
const textEmbeddings = await this.doEmbed(ctx, {
userId,
threadId,
values: messageTexts.filter((t): t is string => !!t),
});
// TODO: record usage of embeddings
// Then assemble the embeddings into a single array with nulls for the messages without text.
const embeddingsOrNull = Array(messages.length).fill(null);
textIndexes.forEach((i, j) => {
embeddingsOrNull[i] = textEmbeddings.embeddings[j];
});
if (textEmbeddings.embeddings.length > 0) {
const dimension = textEmbeddings.embeddings[0].length;
validateVectorDimension(dimension);
embeddings = {
vectors: embeddingsOrNull,
dimension,
model: this.options.textEmbedding.modelId,
};
}
return embeddings;
}
/**
* Generate embeddings for a set of messages, and save them to the database.
* It will not generate or save embeddings for messages that already have an
* embedding.
* @param ctx The ctx parameter to an action.
* @param args The messageIds to generate embeddings for.
*/
async generateAndSaveEmbeddings(
ctx: RunActionCtx,
args: {
messageIds: string[];
}
) {
const messages = (
await ctx.runQuery(this.component.messages.getMessagesByIds, {
messageIds: args.messageIds,
})
).filter((m): m is NonNullable<typeof m> => m !== null);
if (messages.length !== args.messageIds.length) {
throw new Error(
"Some messages were not found: " +
args.messageIds
.filter((id) => !messages.some((m) => m?._id === id))
.join(", ")
);
}
if (messages.some((m) => !m.message)) {
throw new Error(
"Some messages don't have a message: " +
args.messageIds
.map((id, i) => (!messages[i].message ? id : undefined))
.filter((id): id is string => id !== undefined)
.join(", ")
);
}
const messagesMissingEmbeddings = messages.filter((m) => !m.embeddingId);
if (messagesMissingEmbeddings.length === 0) {
return;
}
const embeddings = await this.generateEmbeddings(
ctx,
{
userId: messagesMissingEmbeddings[0]!.userId,
threadId: messagesMissingEmbeddings[0]!.threadId,
},
messagesMissingEmbeddings.map((m) => m!.message!)
);
if (!embeddings) {
if (!this.options.textEmbedding) {
throw new Error(
"No embeddings were generated for the messages. You must pass a textEmbedding model to the agent constructor."
);
}
throw new Error(
"No embeddings were generated for these messages: " +
messagesMissingEmbeddings.map((m) => m!._id).join(", ")
);
}
await ctx.runMutation(this.component.vector.index.insertBatch, {
vectorDimension: embeddings.dimension,
vectors: messagesMissingEmbeddings
.map((m, i) => ({
messageId: m!._id,
model: embeddings.model,
table: "messages",
userId: m.userId,
threadId: m.threadId,
vector: embeddings.vectors[i],
}))
.filter(
(v): v is Extract<typeof v, { vector: number[] }> => v.vector !== null
),
});
}
/**
* Explicitly save a "step" created by the AI SDK.
* @param ctx The ctx argument to a mutation or action.
* @param args The Step generated by the AI SDK.
*/
async saveStep<TOOLS extends ToolSet>(
ctx: ActionCtx,
args: {
userId?: string;
threadId: string;
/**
* The message this step is in response to.
*/
promptMessageId: string;
/**
* The step to save, possibly including multiple tool calls.
*/
step: StepResult<TOOLS>;
/**
* The model used to generate the step.
* Defaults to the chat model for the Agent.
*/
model?: string;
/**
* The provider of the model used to generate the step.
* Defaults to the chat provider for the Agent.
*/
provider?: string;
}
): Promise<{ messages: MessageDoc[]; pending?: MessageDoc }> {
const messages = await serializeNewMessagesInStep(
ctx,
this.component,
args.step,
{
provider: args.provider ?? this.options.chat.provider,
model: args.model ?? this.options.chat.modelId,
}
);
const embeddings = await this.generateEmbeddings(
ctx,
{ userId: args.userId, threadId: args.threadId },
messages.map((m) => m.message)
);
const saved = await ctx.runMutation(this.component.messages.addMessages, {
userId: args.userId,
threadId: args.threadId,
agentName: this.options.name,
promptMessageId: args.promptMessageId,
messages,
embeddings,
failPendingSteps: false,
});
return saved;
}
/**
* Manually save the result of a generateObject call to the thread.
* This happens automatically when using {@link generateObject} or {@link streamObject}
* from the `thread` object created by {@link continueThread} or {@link createThread}.
* @param ctx The context passed from the mutation or action function calling this.
* @param args The arguments to the saveObject function.
*/
async saveObject(
ctx: RunActionCtx,
args: {
userId: string | undefined;
threadId: string;
promptMessageId: string;
result: GenerateObjectResult<unknown>;
metadata?: Omit<MessageWithMetadata, "message">;
}
): Promise<void> {
const { messages } = serializeObjectResult(args.result, {
model: this.options.chat.modelId,
provider: this.options.chat.provider,
});
const embeddings = await this.generateEmbeddings(
ctx,
{ userId: args.userId, threadId: args.threadId },
messages.map((m) => m.message)
);
await ctx.runMutation(this.component.messages.addMessages, {
userId: args.userId,
threadId: args.threadId,
promptMessageId: args.promptMessageId,
failPendingSteps: false,
messages,
embeddings,
agentName: this.options.name,
pending: false,
});
}
/**
* Commit or rollback a message that was pending.
* This is done automatically when saving messages by default.
* If creating pending messages, you can call this when the full "transaction" is done.
* @param ctx The ctx argument to your mutation or action.
* @param args What message to save. Generally the parent message sent into
* the generateText call.
*/
async completeMessage(
ctx: RunMutationCtx,
args: {
threadId: string;
messageId: string;
result: { kind: "error"; error: string } | { kind: "success" };
}
): Promise<void> {
const result = args.result;
if (result.kind === "success") {
await ctx.runMutation(this.component.messages.commitMessage, {
messageId: args.messageId,
});
} else {
await ctx.runMutation(this.component.messages.rollbackMessage, {
messageId: args.messageId,
error: result.error,
});
}
}
async _saveMessagesAndFetchContext<
T extends {
id?: string;
prompt?: string;
messages?: CoreMessage[] | AIMessageWithoutId[];
system?: string;
promptMessageId?: string;
model?: LanguageModelV1;
maxRetries?: number;
},
>(
ctx: RunActionCtx,
args: T,
{
userId: argsUserId,
threadId,
contextOptions,
storageOptions,
}: {
userId: string | undefined;
threadId: string | undefined;
} & Options
): Promise<{
args: T & { model: LanguageModelV1 };
userId: string | undefined;
messageId: string | undefined;
order: number | undefined;
stepOrder: number | undefined;
}> {
contextOptions ||= this.options.contextOptions;
storageOptions ||= this.options.storageOptions;
// If only a messageId is provided, this will be empty.
const messages = args.promptMessageId
? []
: promptOrMessagesToCoreMessages(args);
const userId =
argsUserId ??
(threadId &&
(await ctx.runQuery(this.component.threads.getThread, { threadId }))
?.userId);
assert(
!args.promptMessageId || !(args.prompt || args.messages),
"you can't specify a prompt or message if you specify a promptMessageId"
);
// If only a messageId is provided, this will add that message to the end.
const contextMessages = await this.fetchContextMessages(ctx, {
userId,
threadId,
upToAndIncludingMessageId: args.promptMessageId,
messages,
contextOptions,
});
// Lazily generate embeddings for the prompt message, if it doesn't have
// embeddings yet. This can happen if the message was saved in a mutation
// where the LLM is not available.
if (
args.promptMessageId &&
!contextMessages.at(-1)?.embeddingId &&
this.options.textEmbedding
) {
await this.generateAndSaveEmbeddings(ctx, {
messageIds: [args.promptMessageId],
});
}
let messageId = args.promptMessageId;
let order = args.promptMessageId
? contextMessages.at(-1)?.order
: undefined;
let stepOrder = args.promptMessageId
? contextMessages.at(-1)?.stepOrder
: undefined;
if (
threadId &&
messages.length &&
storageOptions?.saveMessages !== "none" &&
storageOptions?.saveAnyInputMessages !== false
) {
const saveAll = storageOptions?.saveMessages === "all";
const coreMessages = saveAll ? messages : messages.slice(-1);
const saved = await this.saveMessages(ctx, {
threadId,
userId,
messages: coreMessages,
metadata: coreMessages.length === 1 ? [{ id: args.id }] : undefined,
pending: true,
failPendingSteps: true,
});
messageId = saved.lastMessageId;
order = saved.messages.at(-1)?.order;
stepOrder = saved.messages.at(-1)?.stepOrder;
}
let processedMessages = [
...contextMessages.map((m) => deserializeMessage(m.message!)),
...messages,
];
// Process messages to inline localhost files (if not, file urls pointing to localhost will be sent to LLM providers)
if (process.env.CONVEX_CLOUD_URL?.startsWith("http://127.0.0.1")) {
processedMessages = await this._inlineMessagesFiles(processedMessages);
}
const { prompt: _, model, ...rest } = args;
return {
args: {
...rest,
maxRetries: args.maxRetries ?? this.options.maxRetries,
model: model ?? this.options.chat,
system: args.system ?? this.options.instructions,
messages: processedMessages,
} as T & { model: LanguageModelV1 },
userId,
messageId,
order,
stepOrder,
};
}
_shouldSaveOutputMessages(storageOpts?: StorageOptions): boolean {
const opts = storageOpts ?? this.options.storageOptions;
return opts?.saveOutputMessages !== false && opts?.saveMessages !== "none";
}
_mergedContextOptions(opts: ContextOptions | undefined): ContextOptions {
const searchOptions = {
...this.options.contextOptions?.searchOptions,
...opts?.searchOptions,
};
return {
...this.options.contextOptions,
...opts,
searchOptions: searchOp