UNPKG

@convex-dev/agent

Version:

A agent component for Convex.

1,491 lines (1,453 loc) 50 kB
import type { JSONValue } from "@ai-sdk/provider"; import type { FlexibleSchema, IdGenerator, InferSchema, } from "@ai-sdk/provider-utils"; import type { CallSettings, GenerateObjectResult, GenerateTextResult, LanguageModel, ModelMessage, StepResult, StopCondition, StreamTextResult, ToolChoice, ToolSet, } from "ai"; import { generateObject, generateText, stepCountIs, streamObject } from "ai"; import { assert, omit, pick } from "convex-helpers"; import { internalActionGeneric, internalMutationGeneric, type GenericActionCtx, type GenericDataModel, type PaginationOptions, type PaginationResult, type WithoutSystemFields, } from "convex/server"; import { convexToJson, v, type Value } from "convex/values"; import type { threadFieldsSupportingPatch } from "../component/threads.js"; import { type VectorDimension } from "../component/vector/tables.js"; import { toModelMessage, serializeMessage, serializeNewMessagesInStep, serializeObjectResult, } from "../mapping.js"; import { getModelName, getProviderName } from "../shared.js"; import { vMessageEmbeddings, vMessageWithMetadata, vSafeObjectArgs, vTextArgs, type Message, type MessageDoc, type MessageStatus, type MessageWithMetadata, type ProviderMetadata, type StreamArgs, type ThreadDoc, } from "../validators.js"; import { listMessages, saveMessages, type SaveMessageArgs, type SaveMessagesArgs, } from "./messages.js"; import { embedMany, embedMessages, fetchContextMessages, generateAndSaveEmbeddings, } from "./search.js"; import { startGeneration } from "./start.js"; import { syncStreams, type StreamingOptions } from "./streaming.js"; import { createThread, getThreadMetadata } from "./threads.js"; import type { ActionCtx, AgentComponent, Config, ContextOptions, GenerateObjectArgs, GenerationOutputMetadata, MaybeCustomCtx, ObjectMode, Options, RawRequestResponseHandler, MutationCtx, StorageOptions, StreamingTextArgs, StreamObjectArgs, SyncStreamsReturnValue, TextArgs, Thread, UsageHandler, QueryCtx, AgentPrompt, } from "./types.js"; import { streamText } from "./streamText.js"; import { errorToString, willContinue } from "./utils.js"; export { stepCountIs } from "ai"; export { docsToModelMessages, toModelMessage, //** @deprecated use toModelMessage instead */ toModelMessage as deserializeMessage, guessMimeType, serializeDataOrUrl, serializeMessage, toUIFilePart, } from "../mapping.js"; // NOTE: these are also exported via @convex-dev/agent/validators // a future version may put them all here or move these over there export { extractText, isTool, sorted } from "../shared.js"; export { vAssistantMessage, vContent, vContextOptions, vMessage, vMessageDoc, vPaginationResult, vProviderMetadata, vSource, vStorageOptions, vStreamArgs, vSystemMessage, vThreadDoc, vToolMessage, vUsage, vUserMessage, type Message, type MessageDoc, type SourcePart, type ThreadDoc, type Usage, } from "../validators.js"; export { createTool, type ToolCtx } from "./createTool.js"; export { definePlaygroundAPI, type AgentsFn, type PlaygroundAPI, } from "./definePlaygroundAPI.js"; export { getFile, storeFile } from "./files.js"; export { listMessages, listUIMessages, saveMessage, saveMessages, type SaveMessageArgs, type SaveMessagesArgs, } from "./messages.js"; export { mockModel } from "./mockModel.js"; export { fetchContextMessages, filterOutOrphanedToolMessages, fetchContextWithPrompt, generateAndSaveEmbeddings, embedMessages, embedMany, } from "./search.js"; export { startGeneration } from "./start.js"; export { DEFAULT_STREAMING_OPTIONS, DeltaStreamer, abortStream, compressUIMessageChunks, listStreams, syncStreams, vStreamMessagesReturnValue, } from "./streaming.js"; export { createThread, getThreadMetadata, searchThreadTitles, updateThreadMetadata, } from "./threads.js"; export type { ContextHandler } from "./types.js"; export { toUIMessages, fromUIMessages, type UIMessage } from "../UIMessages.js"; export type { AgentComponent, Config, ContextOptions, ProviderMetadata, RawRequestResponseHandler, StorageOptions, StreamArgs, SyncStreamsReturnValue, Thread, UsageHandler, }; export class Agent< /** * You can require that all `ctx` args to generateText & streamText * have a certain shape by passing a type here. * e.g. * ```ts * const myAgent = new Agent<{ orgId: string }>(...); * ``` * This is useful if you want to share that type in `createTool` * e.g. * ```ts * type MyCtx = ToolCtx & { orgId: string }; * const myTool = createTool({ * args: z.object({...}), * description: "...", * handler: async (ctx: MyCtx, args) => { * // use ctx.orgId * }, * }); */ CustomCtx extends object = object, AgentTools extends ToolSet = any, > { constructor( public component: AgentComponent, public options: Config & { /** * 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, { * languageModel: openai.chat("gpt-4o-mini"), */ languageModel: LanguageModel; /** * 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; /** * When generating or streaming text with tools available, this * determines when to stop. Defaults to the AI SDK default. */ stopWhen?: | StopCondition<NoInfer<AgentTools>> | Array<StopCondition<NoInfer<AgentTools>>>; }, ) {} /** * 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( ctx: ActionCtx & CustomCtx, args?: { /** * The userId to associate with the thread. If not provided, the thread will be * anonymous. */ userId?: string | null; /** * The title of the thread. Not currently used for anything. */ title?: string; /** * The summary of the thread. Not currently used for anything. */ summary?: string; }, ): Promise<{ threadId: string; thread: Thread<AgentTools> }>; /** * 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( ctx: MutationCtx, args?: { /** * The userId to associate with the thread. If not provided, the thread will be * anonymous. */ userId?: string | null; /** * The title of the thread. Not currently used for anything. */ title?: string; /** * The summary of the thread. Not currently used for anything. */ summary?: string; }, ): Promise<{ threadId: string }>; async createThread( ctx: (ActionCtx & CustomCtx) | MutationCtx, args?: { userId: string | null; title?: string; summary?: string }, ): Promise<{ threadId: string; thread?: Thread<AgentTools> }> { const threadId = await createThread(ctx, this.component, args); if (!("runAction" in ctx) || "workflowId" in ctx) { return { threadId }; } const { thread } = await this.continueThread(ctx, { threadId, userId: args?.userId, }); return { threadId, 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( ctx: ActionCtx & CustomCtx, 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 | null; }, ): Promise<{ thread: Thread<AgentTools> }> { 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<AgentTools>, }; } async start< TOOLS extends ToolSet | undefined, T extends { _internal?: { generateId?: IdGenerator }; }, >( ctx: ActionCtx & CustomCtx, /** * These are the arguments you'll pass to the LLM call such as * `generateText` or `streamText`. This function will look up the context * and provide functions to save the steps, abort the generation, and more. * The type of the arguments returned infers from the type of the arguments * you pass here. */ args: T & AgentPrompt & { /** * The tools to use for the tool calls. This will override tools specified * in the Agent constructor or createThread / continueThread. */ tools?: TOOLS; /** * The abort signal to be passed to the LLM call. If triggered, it will * mark the pending message as failed. If the generation is asynchronously * aborted, it will trigger this signal when detected. */ abortSignal?: AbortSignal; stopWhen?: | StopCondition<TOOLS extends undefined ? AgentTools : TOOLS> | Array<StopCondition<TOOLS extends undefined ? AgentTools : TOOLS>>; }, options?: Options & { userId?: string | null; threadId?: string }, ): Promise<{ args: T & { system?: string; model: LanguageModel; prompt?: never; messages: ModelMessage[]; tools?: TOOLS extends undefined ? AgentTools : TOOLS; } & CallSettings; order: number; stepOrder: number; userId: string | undefined; promptMessageId: string | undefined; updateModel: (model: LanguageModel | undefined) => void; save: <TOOLS extends ToolSet>( toSave: | { step: StepResult<TOOLS> } | { object: GenerateObjectResult<unknown> }, createPendingMessage?: boolean, ) => Promise<void>; fail: (reason: string) => Promise<void>; getSavedMessages: () => MessageDoc[]; }> { type Tools = TOOLS extends undefined ? AgentTools : TOOLS; return startGeneration<T, Tools, CustomCtx>( ctx, this.component, { ...args, tools: (args.tools ?? this.options.tools) as Tools, system: args.system ?? this.options.instructions, stopWhen: (args.stopWhen ?? this.options.stopWhen) as | StopCondition<Tools> | Array<StopCondition<Tools>>, }, { ...this.options, ...options, agentName: this.options.name, agentForToolCtx: this, }, ); } /** * 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 scope: The user and thread to associate the message with * @param generateTextArgs The arguments to the generateText function, along * with {@link AgentPrompt} options, such as promptMessageId. * @param options 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 & CustomCtx, threadOpts: { userId?: string | null; threadId?: string }, /** * The arguments to the generateText function, similar to the ai sdk's * {@link generateText} function, along with Agent prompt options. */ generateTextArgs: AgentPrompt & TextArgs<AgentTools, TOOLS, OUTPUT, OUTPUT_PARTIAL>, options?: Options, ): Promise< GenerateTextResult<TOOLS extends undefined ? AgentTools : TOOLS, OUTPUT> & GenerationOutputMetadata > { const { args, promptMessageId, order, ...call } = await this.start( ctx, generateTextArgs, { ...threadOpts, ...options }, ); type Tools = TOOLS extends undefined ? AgentTools : TOOLS; const steps: StepResult<Tools>[] = []; try { const result = (await generateText<Tools, OUTPUT, OUTPUT_PARTIAL>({ ...args, prepareStep: async (options) => { const result = await generateTextArgs.prepareStep?.(options); call.updateModel(result?.model ?? options.model); return result; }, onStepFinish: async (step) => { steps.push(step); await call.save({ step }, await willContinue(steps, args.stopWhen)); return generateTextArgs.onStepFinish?.(step); }, })) as GenerateTextResult<Tools, OUTPUT>; const metadata: GenerationOutputMetadata = { promptMessageId, order, savedMessages: call.getSavedMessages(), messageId: promptMessageId, }; return Object.assign(result, metadata); } catch (error) { await call.fail(errorToString(error)); 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 & CustomCtx, threadOpts: { userId?: string | null; threadId?: string }, /** * The arguments to the streamText function, similar to the ai sdk's * {@link streamText} function, along with Agent prompt options. */ streamTextArgs: AgentPrompt & 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 > { type Tools = TOOLS extends undefined ? AgentTools : TOOLS; return streamText<Tools, OUTPUT, PARTIAL_OUTPUT>( ctx, this.component, { ...streamTextArgs, model: streamTextArgs.model ?? this.options.languageModel, tools: (streamTextArgs.tools ?? this.options.tools) as Tools, system: streamTextArgs.system ?? this.options.instructions, stopWhen: (streamTextArgs.stopWhen ?? this.options.stopWhen) as | StopCondition<Tools> | Array<StopCondition<Tools>>, }, { ...threadOpts, ...this.options, agentName: this.options.name, agentForToolCtx: this, ...options, }, ); } /** * 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< SCHEMA extends FlexibleSchema<unknown> = FlexibleSchema<JSONValue>, OUTPUT extends ObjectMode = InferSchema<SCHEMA> extends string ? "enum" : "object", RESULT = OUTPUT extends "array" ? Array<InferSchema<SCHEMA>> : InferSchema<SCHEMA>, >( ctx: ActionCtx & CustomCtx, threadOpts: { userId?: string | null; threadId?: string }, /** * The arguments to the generateObject function, similar to the ai sdk's * {@link generateObject} function, along with Agent prompt options. */ generateObjectArgs: AgentPrompt & GenerateObjectArgs<SCHEMA, OUTPUT, RESULT>, /** * The {@link ContextOptions} and {@link StorageOptions} * options to use for fetching contextual messages and saving input/output messages. */ options?: Options, ): Promise<GenerateObjectResult<RESULT> & GenerationOutputMetadata> { const { args, promptMessageId, order, fail, save, getSavedMessages } = await this.start(ctx, generateObjectArgs, { ...threadOpts, ...options }); try { const result = (await generateObject( args, )) as GenerateObjectResult<RESULT>; await save({ object: result }); const metadata: GenerationOutputMetadata = { promptMessageId, order, savedMessages: getSavedMessages(), messageId: promptMessageId, }; return Object.assign(result, metadata); } catch (error) { await fail(errorToString(error)); 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< SCHEMA extends FlexibleSchema<unknown> = FlexibleSchema<JSONValue>, OUTPUT extends ObjectMode = InferSchema<SCHEMA> extends string ? "enum" : "object", RESULT = OUTPUT extends "array" ? Array<InferSchema<SCHEMA>> : InferSchema<SCHEMA>, >( ctx: ActionCtx & CustomCtx, threadOpts: { userId?: string | null; threadId?: string }, /** * The arguments to the streamObject function, similar to the ai sdk's * {@link streamObject} function, along with Agent prompt options. */ streamObjectArgs: AgentPrompt & StreamObjectArgs<SCHEMA, OUTPUT, RESULT>, /** * The {@link ContextOptions} and {@link StorageOptions} * options to use for fetching contextual messages and saving input/output messages. */ options?: Options, ): Promise< ReturnType<typeof streamObject<SCHEMA, OUTPUT, RESULT>> & GenerationOutputMetadata > { const { args, promptMessageId, order, fail, save, getSavedMessages } = await this.start(ctx, streamObjectArgs, { ...threadOpts, ...options }); const stream = streamObject<SCHEMA, OUTPUT, RESULT>({ ...(args as any), onError: async (error) => { console.error(" streamObject onError", error); // TODO: content that we have so far // content: stream.fullStream. await fail(errorToString(error.error)); return args.onError?.(error); }, onFinish: async (result) => { await save({ object: { object: result.object, finishReason: result.error ? "error" : "stop", usage: result.usage, warnings: result.warnings, request: await stream.request, response: result.response, providerMetadata: result.providerMetadata, toJsonResponse: stream.toTextStreamResponse, reasoning: undefined, }, }); return args.onFinish?.(result); }, }); const metadata: GenerationOutputMetadata = { promptMessageId, order, savedMessages: getSavedMessages(), messageId: promptMessageId, }; return Object.assign(stream, metadata); } /** * 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: MutationCtx | ActionCtx, args: SaveMessageArgs & { /** * 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; }, ) { const { messages } = await this.saveMessages(ctx, { threadId: args.threadId, userId: args.userId, embeddings: args.embedding ? { model: args.embedding.model, vectors: [args.embedding.vector] } : undefined, messages: args.prompt !== undefined ? [{ role: "user", content: args.prompt }] : [args.message], metadata: args.metadata ? [args.metadata] : undefined, skipEmbeddings: args.skipEmbeddings, promptMessageId: args.promptMessageId, pendingMessageId: args.pendingMessageId, }); const message = messages.at(-1)!; return { messageId: message._id, message }; } /** * Explicitly save messages associated with the thread (& user if provided) * If you have an embedding model set, it will also generate embeddings for * the messages. * @param ctx The ctx parameter to a mutation or action. * @param args The messages and context to save * @returns */ async saveMessages( ctx: MutationCtx | ActionCtx, args: SaveMessagesArgs & { /** * 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<{ messages: MessageDoc[] }> { let embeddings: { vectors: (number[] | null)[]; model: string } | undefined; const { skipEmbeddings, ...rest } = args; if (args.embeddings) { embeddings = args.embeddings; } else if (!skipEmbeddings && this.options.textEmbeddingModel) { if (!("runAction" in ctx)) { 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 if ("workflowId" in ctx) { console.warn( "You're trying to save messages and generate embeddings, but you're in a workflow. " + "Pass `skipEmbeddings: true` to skip generating embeddings in the workflow 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 ?? undefined, threadId: args.threadId }, args.messages, ); } } return saveMessages(ctx, this.component, { ...rest, agentName: this.options.name, embeddings, }); } /** * 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: QueryCtx | MutationCtx | ActionCtx, args: { threadId: string; paginationOpts: PaginationOptions; excludeToolMessages?: boolean; statuses?: MessageStatus[]; }, ): Promise<PaginationResult<MessageDoc>> { return listMessages(ctx, this.component, 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: QueryCtx | MutationCtx | ActionCtx, args: { threadId: string; streamArgs: StreamArgs | undefined; // By default, only streaming messages are included. includeStatuses?: ("streaming" | "finished" | "aborted")[]; }, ): Promise<SyncStreamsReturnValue | undefined> { return syncStreams(ctx, this.component, args); } /** * 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: QueryCtx | MutationCtx | ActionCtx, args: { userId: string | undefined; threadId: string | undefined; /** * If targetMessageId is not provided, this text will be used * for text and vector search */ searchText?: string; /** * If provided, it will use this message for text/vector search (if enabled) * and will only fetch messages up to (and including) this message's "order" */ targetMessageId?: string; /** * @deprecated use searchText and targetMessageId instead */ messages?: (ModelMessage | Message)[]; /** * @deprecated use targetMessageId instead */ upToAndIncludingMessageId?: string; contextOptions: ContextOptions | undefined; }, ): Promise<MessageDoc[]> { assert(args.userId || args.threadId, "Specify userId or threadId"); const contextOptions = { ...this.options.contextOptions, ...args.contextOptions, }; return fetchContextMessages(ctx, this.component, { ...args, contextOptions, getEmbedding: async (text) => { assert("runAction" in ctx); assert( this.options.textEmbeddingModel, "A textEmbeddingModel is required to be set on the Agent that you're doing vector search with", ); return { embedding: ( await embedMany(ctx, { ...this.options, agentName: this.options.name, userId: args.userId, threadId: args.threadId, values: [text], }) ).embeddings[0], textEmbeddingModel: this.options.textEmbeddingModel, }; }, }); } /** * 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: QueryCtx | MutationCtx | ActionCtx, args: { threadId: string }, ): Promise<ThreadDoc> { return getThreadMetadata(ctx, this.component, args); } /** * 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: MutationCtx | ActionCtx, args: { threadId: string; patch: Partial< Pick<ThreadDoc, (typeof threadFieldsSupportingPatch)[number]> >; }, ): 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: ActionCtx, args: { userId: string | undefined; threadId: string | undefined }, messages: (ModelMessage | Message)[], ): Promise< | { vectors: (number[] | null)[]; dimension: VectorDimension; model: string; } | undefined > { return embedMessages( ctx, { ...args, ...this.options, agentName: this.options.name }, messages, ); } /** * 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: ActionCtx, 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: " + messages .filter((m) => !m.message) .map((m) => m._id) .join(", "), ); } const { textEmbeddingModel } = this.options; if (!textEmbeddingModel) { throw new Error( "No embeddings were generated for the messages. You must pass a textEmbeddingModel to the agent constructor.", ); } await generateAndSaveEmbeddings( ctx, this.component, { ...this.options, agentName: this.options.name, threadId: messages[0].threadId, userId: messages[0].userId, textEmbeddingModel, }, messages, ); } /** * 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[] }> { const { messages } = await serializeNewMessagesInStep( ctx, this.component, args.step, { provider: args.provider ?? getProviderName(this.options.languageModel), model: args.model ?? getModelName(this.options.languageModel), }, ); const embeddings = await this.generateEmbeddings( ctx, { userId: args.userId, threadId: args.threadId }, messages.map((m) => m.message), ); return ctx.runMutation(this.component.messages.addMessages, { userId: args.userId, threadId: args.threadId, agentName: this.options.name, promptMessageId: args.promptMessageId, messages, embeddings, failPendingSteps: false, }); } /** * 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: ActionCtx, args: { userId: string | undefined; threadId: string; promptMessageId: string; model: string | undefined; provider: string | undefined; result: GenerateObjectResult<unknown>; metadata?: Omit<MessageWithMetadata, "message">; }, ): Promise<{ messages: MessageDoc[] }> { const { messages } = await serializeObjectResult( ctx, this.component, args.result, { model: args.model ?? args.metadata?.model ?? getModelName(this.options.languageModel), provider: args.provider ?? args.metadata?.provider ?? getProviderName(this.options.languageModel), }, ); const embeddings = await this.generateEmbeddings( ctx, { userId: args.userId, threadId: args.threadId }, messages.map((m) => m.message), ); return ctx.runMutation(this.component.messages.addMessages, { userId: args.userId, threadId: args.threadId, promptMessageId: args.promptMessageId, failPendingSteps: false, messages, embeddings, agentName: this.options.name, }); } /** * 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 finalizeMessage( ctx: MutationCtx | ActionCtx, args: { messageId: string; result: { status: "failed"; error: string } | { status: "success" }; }, ): Promise<void> { await ctx.runMutation(this.component.messages.finalizeMessage, { messageId: args.messageId, result: args.result, }); } /** * Update a message by its id. * @param ctx The ctx argument to your mutation or action. * @param args The message fields to update. */ async updateMessage( ctx: MutationCtx | ActionCtx, args: { /** The id of the message to update. */ messageId: string; patch: { /** The message to replace the existing message. */ message: ModelMessage | Message; /** The status to set on the message. */ status: "success" | "error"; /** The error message to set on the message. */ error?: string; /** * These will override the fileIds in the message. * To remove all existing files, pass an empty array. * If passing in a new message, pass in the fileIds you explicitly want to keep * from the previous message, as the new files generated from the new message * will be added to the list. * If you pass undefined, it will not change the fileIds unless new * files are generated from the message. In that case, the new fileIds * will replace the old fileIds. */ fileIds?: string[]; }; }, ): Promise<void> { const { message, fileIds } = await serializeMessage( ctx, this.component, args.patch.message, ); await ctx.runMutation(this.component.messages.updateMessage, { messageId: args.messageId, patch: { message, fileIds: args.patch.fileIds ? [...args.patch.fileIds, ...(fileIds ?? [])] : fileIds, status: args.patch.status === "success" ? "success" : "failed", error: args.patch.error, }, }); } /** * Delete multiple messages by their ids, including their embeddings * and reduce the refcount of any files they reference. * @param ctx The ctx argument to your mutation or action. * @param args The ids of the messages to delete. */ async deleteMessages( ctx: MutationCtx | ActionCtx, args: { messageIds: string[] }, ): Promise<void> { await ctx.runMutation(this.component.messages.deleteByIds, args); } /** * Delete a single message by its id, including its embedding * and reduce the refcount of any files it references. * @param ctx The ctx argument to your mutation or action. * @param args The id of the message to delete. */ async deleteMessage( ctx: MutationCtx | ActionCtx, args: { messageId: string }, ): Promise<void> { await ctx.runMutation(this.component.messages.deleteByIds, { messageIds: [args.messageId], }); } /** * Delete a range of messages by their order and step order. * Each "order" is a set of associated messages in response to the message * at stepOrder 0. * The (startOrder, startStepOrder) is inclusive * and the (endOrder, endStepOrder) is exclusive. * To delete all messages at "order" 1, you can pass: * `{ startOrder: 1, endOrder: 2 }` * To delete a message at step (order=1, stepOrder=1), you can pass: * `{ startOrder: 1, startStepOrder: 1, endOrder: 1, endStepOrder: 2 }` * To delete all messages between (1, 1) up to and including (3, 5), you can pass: * `{ startOrder: 1, startStepOrder: 1, endOrder: 3, endStepOrder: 6 }` * * If it cannot do it in one transaction, it returns information you can use * to resume the deletion. * e.g. * ```ts * let isDone = false; * let lastOrder = args.startOrder; * let lastStepOrder = args.startStepOrder ?? 0; * while (!isDone) { * // eslint-disable-next-line @typescript-eslint/no-explicit-any * ({ isDone, lastOrder, lastStepOrder } = await agent.deleteMessageRange( * ctx, * { * threadId: args.threadId, * startOrder: lastOrder, * startStepOrder: lastStepOrder, * endOrder: args.endOrder, * endStepOrder: args.endStepOrder, * } * )); * } * ``` * @param ctx The ctx argument to your mutation or action. * @param args The range of messages to delete. */ async deleteMessageRange( ctx: MutationCtx | ActionCtx, args: { threadId: string; startOrder: number; startStepOrder?: number; endOrder: number; endStepOrder?: number; }, ): Promise<{ isDone: boolean; lastOrder?: number; lastStepOrder?: number }> { return ctx.runMutation(this.component.messages.deleteByOrder, { threadId: args.threadId, startOrder: args.startOrder, startStepOrder: args.startStepOrder, endOrder: args.endOrder, endStepOrder: args.endStepOrder, }); } /** * Delete a thread and all its messages and streams asynchronously (in batches) * This uses a mutation to that processes one page and recursively queues the * next page for deletion. * @param ctx The ctx argument to your mutation or action. * @param args The id of the thread to delete and optionally the page size to use for the delete. */ async deleteThreadAsync( ctx: MutationCtx | ActionCtx, args: { threadId: string; pageSize?: number }, ): Promise<void> { await ctx.runMutation(this.component.threads.deleteAllForThreadIdAsync, { threadId: args.threadId, limit: args.pageSize, }); } /** * Delete a thread and all its messages and streams synchronously. * This uses an action to iterate through all pages. If the action fails * partway, it will not automatically restart. * @param ctx The ctx argument to your action. * @param args The id of the thread to delete and optionally the page size to use for the delete. */ async deleteThreadSync( ctx: ActionCtx, args: { threadId: string; pageSize?: number }, ): Promise<void> { await ctx.runAction(this.component.threads.deleteAllForThreadIdSync, { threadId: args.threadId, limit: args.pageSize, }); } /** * WORKFLOW UTILITIES */ /** * Create a mutation that creates a thread so you can call it from a Workflow. * e.g. * ```ts * // in convex/foo.ts * export const createThread = weatherAgent.createThreadMutation(); * * const workflow = new WorkflowManager(components.workflow); * export const myWorkflow = workflow.define({ * args: {}, * handler: async (step) => { * const { threadId } = await step.runMutation(internal.foo.createThread); * // use the threadId to generate text, object, etc. * }, * }); * ``` * @returns A mutation that creates a thread. */ createThreadMutation() { return internalMutationGeneric({ args: { userId: v.optional(v.string()), title: v.optional(v.string()), summary: v.optional(v.string()), }, handler: async (ctx, args): Promise<{ threadId: string }> => { const { threadId } = await this.createThread(ctx, args); return { threadId }; }, }); } /** * Create an action out of this agent so you can call it from workflows or other actions * without a wrapping function. * @param spec Configuration for the agent acting as an action, including * {@link ContextOptions}, {@link StorageOptions}, and {@link stopWhen}. */ asTextAction<DataModel extends GenericDataModel>( spec: MaybeCustomCtx<CustomCtx, DataModel, AgentTools> & { /** * Whether to stream the text. * If false, it will generate the text in a single call. (default) * If true or {@link StreamingOptions}, it will stream the text from the LLM * and save the chunks to the database with the options you specify, or the * defaults if you pass true. */ stream?: boolean | StreamingOptions; /** * When to stop generating text. * Defaults to the {@link Agent["options"].stopWhen} option. */ stopWhen?: StopCondition<AgentTools> | Array<StopCondition<AgentTools>>; } & Options, overrides?: CallSettings, ) { return internalActionGeneric({ args: vTextArgs, handler: async (ctx_, args) => { const stream = args.stream === true ? spec?.stream || true : (spec?.stream ?? false); const { userId, threadId, prompt, messages, maxSteps, ...rest } = args; const targetArgs = { userId, threadId }; const llmArgs = { stopWhen: spec?.stopWhen, ...overrides, ...omit(rest, ["storageOptions", "contextOptions", "stream"]), messages: messages?.map(toModelMessage), prompt: Array.isArray(prompt) ? prompt.map(toModelMessage) : prompt, toolChoice: args.toolChoice as ToolChoice<AgentTools>, } satisfies StreamingTextArgs<AgentTools>; if (maxSteps) { llmArgs.stopWhen = stepCountIs(maxSteps); } const opts = { ...pick(spec, ["contextOptions", "storageOptions"]), ...pick(args, ["contextOptions", "storageOptions"]), saveStreamDeltas: stream, }; const ctx = ( spec?.customCtx ? { ...ctx_, ...spec.customCtx(ctx_, targetArgs, llmArgs) } : ctx_ ) as GenericActionCtx<GenericDataModel> & CustomCtx; if (stream) { const result = await this.streamText<any>( ctx, targetArgs, llmArgs, opts, ); await result.consumeStream(); return { text: await result.text, promptMessageId: result.promptMessageId, order: result.order, finishReason: await result.finishReason, warnings: await result.warnings, savedMessageIds: result.savedMessages?.map((m) => m._id) ?? [], }; } else { const res = await this.generateText<any>( ctx, targetArgs, llmArgs, opts, ); return { text: res.text, promptMessageId: res.promptMessageId, order: res.order, finishReason: res.finishReason, warnings: res.warnings, savedMessageIds: res.savedMessages?.map((m) => m._id) ?? [], }; } }, }); } /** * Create an action that generates an object out of this agent so you can call * it from workflows or other actions without a wrapping function. * @param spec Configuration for the agent acting as an action, including * the normal parameters to {@link generateObject}, plus {@link ContextOptions} * and stopWhen. */ asObjectAction<T, DataModel extends GenericDataModel>( objectArgs: GenerateObjectArgs<FlexibleSchema<T>> & Partial<AgentPrompt>, options?: Options & MaybeCustomCtx<CustomCtx, DataModel, AgentTools>, ) { return internalActionGeneric({ args: vSafeObjectArgs, handler: async (ctx_, args) => { const { userId, threadId, callSettings, ...rest } = args; const overrides = pick(rest, ["contextOptions", "storageOptions"]); const targetArgs = { userId, threadId }; const llmArgs = { ...objectArgs, ...callSettings, ...omit(rest, ["storageOptions", "contextOptions"]), messages: args.messages?.map(toModelMessage), prompt: Array.isArray(args.prompt) ? args.prompt.map(toModelMessage) : args.prompt, } as GenerateObjectArgs<FlexibleSchema<T>>; const ctx = ( options?.customCtx ? { ...ctx_, ...options.customCtx(ctx_, targetArgs, llmArgs) } : ctx_ ) as GenericActionCtx<GenericDataModel> & CustomCtx; const value = await this.generateObject(ctx, targetArgs, llmArgs, { ...this.options, ...options, ...overrides, }); return { object: convexToJson(value.object as Value) as T, promptMessageId: value.promptMessageId, order: value.order, finishReason: value.finishReason, warnings: value.warnings, savedMessageIds: value.savedMessages?.map((m) => m._id) ?? [], }; }, }); } /** * @deprecated Use {@link saveMessages} directly instead. */ asSaveMessagesMutation() { return internalMutationGeneric({ args: { threadId: v.string(), userId: v.optional(v.string()), promptMessageId: v.optional(v.string()), messages: v.array(vMessageWithMetadata), failPendingSteps: v.optional(v.boolean()), embeddings: v.optional(vMessageEmbeddings), }, handler: async (ctx, args) => { const { messages } = await this.saveMessages(ctx, { ...args, messages: args.messages.map((m) => toModelMessage(m.message)), metadata: args.messages.map(({ message: _, ...m }) => m), skipEmbeddings: true, }); return { lastMessageId: messages.at(-1)!._id, messages: messages.map((m) => pick(m, ["_id", "order", "stepOrder"])), }; }, }); } }