mcpField
C
convex-agents

convex-agents

github.com/waynesuttonエージェントワークフロー4051.5k 回インストール4 週間前に更新
リポジトリ

convex-agents について

Convexと`@convex-dev/agent`を使い、チャット型またはツール呼び出し型のエージェントを構築するスキルです。会話履歴をConvexに保存し、アクション内でモデル呼び出しを実行して、リアクティブなメッセージ表示、ツール、検索、永続的な複数段階のジョブを扱えます。

主な機能

  1. 01会話スレッドの永続化
  2. 02リアクティブなメッセージ一覧
  3. 03ツール経由のクエリとミューテーション呼び出し
  4. 04ドキュメント検索ワークフロー
  5. 05永続的な複数段階のLLMジョブ

活用シーン

  1. 01履歴付きチャットの追加
  2. 02クライアントへの返信ストリーミング
  3. 03ツール経由でアプリ機能をLLMに操作させる
  4. 04ドキュメント質問応答の構築

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/waynesutton/builder-skills --skill convex-agents

インストール後、Claude Code で/convex-agentsを入力して読み込まれたか確認します。

詳細

Convex エージェント

@convex-dev/agent を利用したチャットエージェントまたはツール呼び出しエージェントを生成します。スレッド履歴は Convex に保存され、UI ではリアクティブなメッセージ一覧を表示できます。ルールはひとつです。すべての LLM 呼び出しはアクション内で実行します。ミューテーションはプロンプトを保存してアクションをスケジュールするだけで、モデルを呼び出すことはありません。

こんな場合に使います

  • 会話履歴を永続化するチャットアシスタントを追加する
  • LLM がクエリやミューテーションをツールとして呼び出せるようにする
  • モデルの応答を 1 つ以上のクライアントにストリーミングする
  • 独自のドキュメントに関する質問に回答する(RAG)
  • 複数の LLM ステップをつなぎ、再起動後も継続する永続的なジョブにする

インストールと登録

npm install @convex-dev/agent ai @ai-sdk/openai zod
npx convex env set OPENAI_API_KEY sk-...
// convex/convex.config.ts
import { defineApp } from "convex/server";
import agent from "@convex-dev/agent/convex.config";

const app = defineApp();
app.use(agent);
export default app;

エージェントを定義する前に、components.agent が生成されるよう npx convex dev を一度実行します。

エージェントを定義する

// convex/agent.ts
import { Agent, stepCountIs } from "@convex-dev/agent";
import { openai } from "@ai-sdk/openai";
import { components } from "./_generated/api";

export const supportAgent = new Agent(components.agent, {
  name: "Support Agent",
  languageModel: openai.chat("gpt-4o-mini"),
  instructions: "You are a support assistant. Answer briefly and cite docs when possible.",
  // Lets the model call tools and then respond, up to 5 steps
  stopWhen: stepCountIs(5),
});

name は保存される各メッセージに、それを書いたエージェントのタグを付けます。name 以外は、すべて呼び出しごとに上書きできます。

スレッドを作成して応答を生成する

ユーザーのプロンプトをミューテーションで保存し、応答を生成する内部アクションをスケジュールします。スレッドを購読しているクライアントには、アクションが何も返さなくても新しいメッセージが表示されます。

// convex/chat.ts
import { v } from "convex/values";
import { mutation, internalAction, QueryCtx, MutationCtx } from "./_generated/server";
import { components, internal } from "./_generated/api";
import { saveMessage } from "@convex-dev/agent";
import { supportAgent } from "./agent";

// Throws unless the signed in user owns the thread
async function authorizeThreadAccess(ctx: QueryCtx | MutationCtx, threadId: string) {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error("Not authenticated");
  const thread = await ctx.runQuery(components.agent.threads.getThread, { threadId });
  if (!thread || thread.userId !== identity.subject) throw new Error("Unauthorized");
}

export const startThread = mutation({
  args: {},
  returns: v.string(),
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");
    const { threadId } = await supportAgent.createThread(ctx, { userId: identity.subject });
    return threadId;
  },
});

export const sendMessage = mutation({
  args: { threadId: v.string(), prompt: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    await authorizeThreadAccess(ctx, args.threadId);
    const { messageId } = await saveMessage(ctx, components.agent, {
      threadId: args.threadId,
      prompt: args.prompt,
    });
    await ctx.scheduler.runAfter(0, internal.chat.generateReply, {
      threadId: args.threadId,
      promptMessageId: messageId,
    });
    return null;
  },
});

export const generateReply = internalAction({
  args: { threadId: v.string(), promptMessageId: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // promptMessageId makes retries safe: the same prompt is reused, never duplicated
    await supportAgent.generateText(
      ctx,
      { threadId: args.threadId },
      { promptMessageId: args.promptMessageId },
    );
    return null;
  },
});

スレッド ID は文字列です。テーブルはコンポーネント内にあるため、v.id(...) ではありません。

UI 用のメッセージを一覧表示する

// convex/chat.ts (continued)
import { paginationOptsValidator } from "convex/server";
import { listUIMessages } from "@convex-dev/agent";
import { query } from "./_generated/server";

export const listMessages = query({
  args: { threadId: v.string(), paginationOpts: paginationOptsValidator },
  handler: async (ctx, args) => {
    await authorizeThreadAccess(ctx, args.threadId);
    return await listUIMessages(ctx, components.agent, args);
  },
});
// src/Chat.tsx
import { useUIMessages } from "@convex-dev/agent/react";
import { api } from "../convex/_generated/api";

function Chat({ threadId }: { threadId: string }) {
  const { results, status, loadMore } = useUIMessages(
    api.chat.listMessages,
    { threadId },
    { initialNumItems: 20 },
  );
  return (
    <div>
      {results.map((m) => (
        <div key={m.key} data-role={m.role}>{m.text}</div>
      ))}
      {status === "CanLoadMore" && <button onClick={() => loadMore(20)}>Older</button>}
    </div>
  );
}

listUIMessages はツール呼び出しとその後に続くアシスタントのテキストを 1 つの UIMessage にまとめるため、レンダリングをシンプルに保てます。

ひとつのツール

ツールは createTool で定義し、runQuery、runMutation、userId、threadId を含む ctx を受け取ります。循環型エラーを避けるため、ハンドラーの戻り値の型を注釈してください。

// convex/tools.ts
import { createTool } from "@convex-dev/agent";
import { z } from "zod";
import { api } from "./_generated/api";

export const searchOrders = createTool({
  description: "Find the current user's orders that match a search term",
  args: z.object({
    term: z.string().describe("Product name or order number to look for"),
  }),
  handler: async (ctx, args): Promise<Array<{ id: string; status: string }>> => {
    return await ctx.runQuery(api.orders.search, { term: args.term });
  },
});

コンストラクターまたは呼び出し箇所で tools: { searchOrders } を指定して、エージェントに渡します。ツールのエラー処理、クロージャーを使うランタイムツール、クライアントへの差分ストリーミングについては、references/tools-and-streaming.md をご覧ください。

検索と複数ステップのジョブ

ドキュメントの埋め込み、@convex-dev/rag または独自に実装したベクトルインデックスによる検索、結果のプロンプトへの挿入、複数の LLM ステップを永続的な @convex-dev/workflow ジョブとして実行する方法については、references/rag-and-workflows.md をご覧ください。

よくある間違い

間違い問題が起きる理由代わりに行うこと
ミューテーション内で generateText を呼び出すミューテーションはネットワーク呼び出しができず、決定的でなければならないためsaveMessage でプロンプトを保存し、internalAction をスケジュールする
スレッド ID に v.id("threads") を使うスレッドテーブルはコンポーネント内にあるため、その外では ID は文字列になるv.string() を使う
app.use(agent) の後に npx convex dev を実行しないcomponents.agent が生成されず、型エラーが発生するためエージェントのコードを書く前に dev を一度実行する
アクションからクライアントに応答テキストを返すクライアントが切断すると応答が失われ、リアクティブにならないためクライアントに listUIMessages を読み込ませる。保存されたメッセージは自動的に表示される
引数に .describe() がないツールモデルが各フィールドの意味を推測するため、ツールを適切に呼び出せないすべての zod フィールドに説明を付ける
戻り値の型注釈がないツールハンドラーctx.runQuery によって TypeScript の循環性エラーが発生するためハンドラーに : Promise<...> を追加する
認証チェックなしでメッセージクエリを公開するどのクライアントからでも任意のスレッドを読み取れるため最初に authorizeThreadAccess ヘルパーを呼び出す
ツールを定義したまま stopWhen をデフォルトにするモデルがツールを呼び出した後、テキストの応答を返さずに停止するためn > 1 として stopWhen: stepCountIs(n) を設定する

チェックリスト

  • convex.config.ts に app.use(agent) を設定し、npx convex dev を実行済みです
  • プロバイダーキーは npx convex env set で保存し、クライアントコードには決して記述しません
  • Agent に name、languageModel、instructions を設定します
  • プロンプトはミューテーションで保存し、promptMessageId を指定して internalAction で返信を生成します
  • スレッド ID とメッセージ ID の型を v.string() にします
  • メッセージ一覧のクエリで、listUIMessages を呼び出す前にスレッドの所有権を確認します
  • すべてのツール引数に zod の .describe() を指定し、ハンドラーに戻り値の型を指定します
  • ツールを使用する場合は、stopWhen: stepCountIs(n) を設定します
  • クライアントでは、アクションの戻り値を読み取るのではなく useUIMessages を使用します

ドキュメント

コメント