鍾乳洞のイラスト

AI エージェントフレームワーク eve を試してみた

Vercel が新しい AI エージェントフレームワーク eve を発表しました。Next.js の設計思想に基づいて構築された eve は、AI エージェントの開発に必要な機能がすべて揃ったフレームワークです。この記事では、eve を使って簡単なエージェントを作成し、実行する方法を紹介します。

Vercel が新しい AI エージェントフレームワーク eve を発表しました。eve は Next.js の設計思想に基づいて構築され、本番環境で実行するために必要な以下の機能を備えています。ウェブで必要なものがすべて揃ったフレームワークが Next.js であるように、AI エージェントの開発に必要なものがすべて揃ったフレームワークが eve という位置付けです。

  • 耐久性のあるセッション(セッションが一時停止しても、停止した場所から正確に再開できる)
  • サンドボックス化された実行環境
  • Human-in-the-loop 承認フロー
  • サブエージェント
  • Slack, Discord, Microsoft Teams などのチャットプラットフォームとの統合
  • トレースと評価

また Next.js のようにエージェントに必要な各要素はディレクトリ構造で表現され、コードの構造が自然にエージェントの構造を反映するようになっています。例えば小さな eve アプリケーションのディレクトリ構造は以下のようになります。eve は TypeScript とマークダウンファイルで構築されています。

my-agent/
├── package.json
└── agent/
    ├── agent.ts # エージェントのモデルは実行オプションを定義する
    ├── instructions.md # エージェントの指示はマークダウンファイルで定義する
    ├── tools/ # エージェントが呼び出すことができる関数
   └── get_weather.ts
    ├── skills/ # エージェントが必要と判断した時にロードされるスキル
   └── plan_a_trip.md
    └── channels/ # ユーザーがエージェントにアクセスするためのチャンネルを定義する
        └── slack.ts

ディレクトリ名はそれぞれの役割を示し、ファイル名はそのまま機能の名前になります。例えば tools/get_weather.tsget_weather という名前のツールを定義するファイルになります。このように適切な場所にファイルを配置することで、eve が自動で検出し、エージェントの機能として利用できるようになります。

この記事では、eve を使って簡単なエージェントを作成し、実行する方法を紹介します。

セットアップ

以下のコマンドで新しい eve アプリケーションを作成します。Node.js v24 以上が必要です。

npx eve@latest init my-agent

コマンドを実行すると、my-agent ディレクトリが作成され、必要なファイルが生成され依存関係がインストールされます。次に eve dev コマンドを実行すると、開発用の TUI(Text User Interface)が起動します。最初にモデルの設定を促されるので、/model スラッシュコマンドを実行してモデルとプロパイダーを選択します。

プロパイダーに Vercel AI Gateway 以外を使用する場合は、自前で API キーを設定する必要があります。OpenAI の場合はダッシュボードから API キーを取得し、.env.local ファイルに OPENAI_API_KEY として設定します。

OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

また、プロパイダー固有の AI SDK パッケージをインストールする必要があります。

npm install @ai-sdk/openai

agent/agent.ts ファイルを開いて、文字列でモデルを設定している箇所を @ai-sdk/openai パッケージの openai() 関数を呼び出すように変更します。

agent/agent.ts
import { openai } from '@ai-sdk/openai';
import { defineAgent } from 'eve';
 
export default defineAgent({
  model: openai("gpt-5.4-nano"),
})

モデルの設定が完了したら、TUI からチャットを開始してみましょう。「こんにちは」と入力すると、エージェントが応答します。

基本的なエージェントの構成

プロジェクトのセットアップが完了しエージェントが動いている様子を確認できたところで、生成されたコードを見てみましょう。ディレクトリ構造は以下のようになっており、最も単純なエージェントの構成になっています。

.
├── agent
│   ├── agent.ts
│   ├── channels
│   │   └── eve.ts
│   └── instructions.md
├── AGENTS.md
├── CLAUDE.md
├── package-lock.json
├── package.json
└── tsconfig.json

エージェントの設定

agent/agent.ts ファイルには、エージェントのランタイムの設定を定義するコードが含まれています。先程のモデルの設定もこのファイルで行いました。defineAgent() 関数の引数に渡すオブジェクトで、エージェントを設定します。モデルの他にセッションの圧縮のしきい値を指定できます。eve ではセッションが長期間に及んだ際にモデルのコンテキストウィンドウを超過するのを防ぐためにコンテキストが圧縮されるのですが、圧縮のしきい値を指定することで、圧縮するタイミングを制御できます。デフォルトでは 0.9 が指定されており、セッションの長さがコンテキストウィンドウの 90% に達した時点で圧縮されます。

以下の例では、圧縮のしきい値を 0.75 に設定しています。

agent/agent.ts
import { openai } from '@ai-sdk/openai';
import { defineAgent } from 'eve';
 
export default defineAgent({
  model: openai("gpt-5.4-nano"),
  compaction: {
    thresholdPercent: 0.75,
  },
})

エージェントの指示

次いでエージェントのコアとなる機能は agent/instructions.md ファイルです。これはエージェントに常時読み込まれるシステムプロンプトであり、エージェントの振る舞いをマークダウン形式で記述します。例えばエージェントに関西弁で話すように指示してみましょう。

agent/instructions.md
# Instructions
You are a helpful assistant that speaks in Kansai dialect.

instructions.md ファイルを編集したら、TUI からチャットを開始してみましょう。TUI は自動で変更が反映されるため、再起動する必要はありません。「こんにちは」と入力すると、エージェントが関西弁で応答します。確かに指示の内容がエージェントに反映されていることがわかりますね。

エージェントへの指示はマークダウンだけでなく、TypeScript でも記述できます。これは動的にシステムプロンプトを構築する必要がある場合に有効です。defineInstructions() 関数は markdown プロパティに構築済みのマークダウンを渡すことで、エージェントの指示を定義できます。これはビルド時に一度だけ実行されます。

agent/instructions.ts
import { defineInstructions } from "eve/instructions";
import { buildInstructionsPrompt } from "./lib/prompts.js";
export default defineInstructions({
  markdown: buildInstructionsPrompt(),
});

agent/instructions をディレクトリにすることで、複数の指示ファイルを定義できます。ファイルは再帰的に読み込まれ、アルファベット順で結合されます。マークダウンファイルと TypeScript ファイルを混在させることもできます。

agent/
├── instructions
│   ├── 1-general.md
│   ├── 2-kansai.md
│   └── user-specific.ts

チャンネル

最後にチャンネルを見てみましょう。チャンネルはユーザーがエージェントと対話するためのインターフェースを定義するもので、agent/channels ディレクトリに配置します。チャンネルはプラットフォームからのメッセージをユーザー向けに正規化する処理、異なるインターフェースで会話を再開するための continuationToken の管理、応答をどこへどのように送るべきかの配信の決定などを行います。

チャンネルの種類として以下のようなものがあります。カスタムのチャンネルも作成できます。

  • HTTP チャンネル: HTTP エンドポイントを定義し、エージェントと通信するための API を提供する。TUI や React から呼び出される useEveAgent フックはこのチャンネルを使用してエージェントと通信する。デフォルトではチャンネル定義が存在しない場合でも HTTP チャンネルが自動で定義される
  • Slack
  • Discord
  • Microsoft Teams
  • Telegram
  • Twilio
  • GitHub
  • Linear

生成されたコードには eve.ts というファイルがあり、HTTP チャンネルである eveChannel が定義されています。なお HTTP チャンネルを使用するために eve.ts ファイルを定義する必要はなく、デフォルト設定をカスタマイズする場合のみ(認証ポリシーなど)必要になります。プロジェクトを作成した初期状態では、認証を設定しています。

agent/channels/eve.ts
import { eveChannel } from "eve/channels/eve";
import { localDev, placeholderAuth, vercelOidc } from "eve/channels/auth";
 
export default eveChannel({
  auth: [
    // Open on localhost for `eve dev` and the REPL; ignored in production.
    localDev(),
    // Lets the Eve TUI and your Vercel deployments reach the deployed agent.
    vercelOidc(),
    // This placeholder will not allow browser requests in production.
    // Replace it with your app's auth provider, like Auth.js or Clerk,
    // or use none() for a public demo.
    placeholderAuth(),
  ],
});

localDev() はローカル開発環境で認証をスキップしてリクエストを許可するための設定です。vercelOidc は Vercel の OIDC 認証を使用して、ローカルの CLI から本番環境にデプロイされたエージェントにアクセスするための設定です。どちらの設定も未認証の一般クライアントからのリクエストは許可しません。

アプリケーションを公開する場合には、Auth.js や Clerk などの認証プロバイダーを使用するか、none() を使用してパブリックなデモとして公開することが求められています。placeholderAuth() は常に 401 を返します。

HTTP サーバーチャンネルを使用すると、以下のエンドポイントが自動で生成されます。

  • GET /eve/v1/health
  • POST /eve/v1/session: エージェントとのセッションを開始する
  • POST /eve/v1/session/:sessionId: セッションに対するフォローアップを行う
  • GET /eve/v1/session/:sessionId/stream: セッションのストリームを取得する

eve dev で起動する TUI の裏側では上記のエンドポイントを使用してエージェントとのセッションが行われています。curl などの HTTP クライアントを使用して、エージェントと対話してみましょう。はじめに POST /eve/v1/session エンドポイントにリクエストを送信して、セッションを開始します。リクエストボディにはユーザーからの最初のメッセージを含めます。

# eve dev のデフォルトのポートは 2000
curl -X POST http://localhost:2000/eve/v1/session \
  -H "Content-Type: application/json" \
  -d '{"message":"こんにちは"}'

レスポンスには、セッションの ID と continuationToken が返されます。continuationToken はセッションを再開するために必要なトークンで、次回のリクエストで使用します。

{"continuationToken":"eve:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","ok":true,"sessionId":"wrun_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"}

GET /eve/v1/session/:sessionId/stream エンドポイントにリクエストを送信して、先程のセッションのストリームを取得してみましょう。sessionId は先程のレスポンスで返された値を使用します。

curl -N http://localhost:2000/eve/v1/session/wrun_xxxxxxxxxxxxxxxxxxxxxxxxxxxx/stream

セッションのイベントが改行区切りの JSON(application/x-ndjson; charset=utf-8)でストリームされます。1 行に 1 つのイベントが含まれています。

// セッション開始イベント
{"data":{"runtime":{"agentId":"my-agent","agentName":"my-agent","eveVersion":"0.11.7","modelId":"openai/gpt-5.4-nano"}},"type":"session.started","meta":{"at":"2026-06-20T04:19:07.009Z"}}
// ターン開始イベント
{"data":{"sequence":0,"turnId":"turn_0"},"type":"turn.started","meta":{"at":"2026-06-20T04:19:07.010Z"}}
// ユーザーメッセージ受信イベント
{"data":{"message":"こんにちは","sequence":0,"turnId":"turn_0"},"type":"message.received","meta":{"at":"2026-06-20T04:19:07.022Z"}}
// エージェントの応答イベント。完成したチャンクから徐々に送信される
{"data":{"messageDelta":"ない","messageSoFar":"こんにちは〜!ようこそ!  \n今日はどない","sequence":0,"stepIndex":0,"turnId":"turn_0"},"type":"message.appended","meta":{"at":"2026-06-20T04:19:08.406Z"}}
{"data":{"messageDelta":"😊","messageSoFar":"こんにちは〜!ようこそ!  \n今日はどないしたん?😊","sequence":0,"stepIndex":0,"turnId":"turn_0"},"type":"message.appended","meta":{"at":"2026-06-20T04:19:08.448Z"}}
{"data":{"finishReason":"stop","sequence":0,"stepIndex":0,"turnId":"turn_0","usage":{"inputTokens":6431,"outputTokens":19,"cacheReadTokens":0}},"type":"step.completed","meta":{"at":"2026-06-20T04:19:08.571Z"}}
// ターンの終了イベント
{"data":{"sequence":0,"turnId":"turn_0"},"type":"turn.completed","meta":{"at":"2026-06-20T04:19:08.587Z"}}
// ユーザーの次のメッセージを待機
{"data":{"wait":"next-user-message"},"type":"session.waiting","meta":{"at":"2026-06-20T04:19:08.595Z"}}

フォローアップのメッセージを送信するには、POST /eve/v1/session/:sessionId エンドポイントを使用します。パラメーターには前回のレスポンスで返された continuationToken を含める必要があります。

curl -X POST http://localhost:2000/eve/v1/session/wrun_xxxxxxxxxxxxxxxxxxxxxxxxxxxx \
  -H "Content-Type: application/json" \
  -d '{"message":"元気だよ〜","continuationToken":"eve:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}'

ここでは学習目的で直接 curl コマンドを使用してエージェントと対話しましたが、実際のアプリケーションでは TypeScript SDK を使用して、より簡単にエージェントと対話できます。ウェブフロントエンドでチャット UI を構築する場合は、React で提供されている useEveAgent フックを使用して HTTP チャンネルを介してエージェントと通信できます。

Slack チャンネルに接続する

新しいチャンネルを追加する例として、最も一般的なチャットプラットフォームの 1 つである Slack チャンネルを追加してみましょう。Slack チャンネルを追加すると、Slack 上のメンションやダイレクトメッセージを通じてエージェントと対話できるようになります。認証処理は Vercel Connect を通じて行われます。まずは Vercel CLI を使用して Slack Connect をセットアップします。

# Vercel CLI のインストール
npm install -g vercel
# Vercel Connect を使用するには FF_CONNECT_ENABLED 機能フラグを有効にする必要がある
export FF_CONNECT_ENABLED=1
vercel connect create slack --triggers
# トリガーのパスを削除して、/eve/v1/slack に設定する
# uid は vercel connect create slack コマンドの出力に表示される slack/<app-name> の形式
vercel connect detach <uid> --yes
vercel connect attach <uid> --triggers --trigger-path /eve/v1/slack --yes

vercel connect create slack コマンドを実行すると Web ブラウザが開き、Vercel へのログインと Slack ワークスペースの選択が行われます。Slack ワークスペースはあらかじめ作成しておきましょう。Slack ワークスペースの選択後 Slack アプリが自動で作成されます。作成された Slack アプリは Vercel のダッシュボードの「Connect」タブから確認できます。Slack アプリの設定画面では、Bot Token Scopes に app_mentions:read, chat:write, im:history, im:read, im:write の権限が付与されていることを確認してください。

Slack Connect の作成後、デフォルトのトリガーが削除されるため、/eve/v1/slack にトリガーを設定し直す必要があります。vercel connect detach <uid> コマンドでトリガーを削除し、vercel connect attach <uid> --triggers --trigger-path /eve/v1/slack コマンドでトリガーを設定します。vercel connect コマンドを実行するためには何かしらの Vercel のプロジェクトと紐づけておく必要がありました。vercel link コマンドでプロジェクトを紐づけておきましょう。

エージェントに Slack チャンネルを追加するためには @vercel/connect パッケージをインストールするか、以下のコマンドを実行します。

npx eve channels add slack

コマンドを実行し、「Do you want to create your slackbot?」に「Yes」と答えると、はじめに eve プロジェクトが Vercel の本番環境にデプロイされます。

Note

プロダクション向けに認証していない(デフォルトの設定)限り、外部のクライアントからのリクエストはすべて 401 Unauthorized が返されるため、本番環境にデプロイしても第三者にアクセスされることはありません。npx eve channels add slack コマンドの実行前に、外部に公開される設定となっていないか念のため確認しておきましょう。

また agent/channels/slack.ts ファイルが作成され、Slack チャンネルの設定が追加されます。ここでは Vercel Connect パッケージを使用して Slack チャンネルに接続するための設定を追加しています。

agent/channels/slack.ts
import { connectSlackCredentials } from "@vercel/connect/eve";
import { slackChannel } from "eve/channels/slack";
 
export default slackChannel({
  credentials: connectSlackCredentials("slack/eve-example"),
});

Slack チャンネルの設定が完了したら、Slack からエージェントにメンションを送信してみましょう。vercel connect create slack コマンド実行時に指定した名前で Slack アプリが作成されているはずです。入力インジケーターの表示や Slack Block Kit の使用などといった実装上の詳細なメッセージの変換処理はすべて eve が自動で行ってくれます。

エージェントの構築

ここからは実際に本格的なエージェントの構築を通じて eve の機能を見ていきましょう。ここでは例として SRE エージェントを作成します。SRE エージェントはシステムの障害を調査したり、人間の承認を得たうえでシステムを復旧したりできるエージェントです。はじめに agent/instructions.md ファイルを編集し、SRE エージェントとしての振る舞いを指示します。大事な点は、障害の調査は自分で行うが、復旧操作は必ず人間の承認を得ることです。

agent/instructions.md
# Sentinel — SRE オンコール支援エージェント
 
あなたは SRE チームのオンコール業務を支援するアシスタント「Sentinel」です。
障害発生時に、状況を素早く調査し、原因の仮説を立て、復旧アクションを提案します。
 
## 行動原則
 
1. **調査(読み取り)は積極的に行う。** ログ・メトリクス・デプロイ履歴の確認など、
   システムの状態を「見るだけ」の操作は、確認を取らずに自分から実行してよい。
2. **変更(書き込み)は必ず人に確認する。** デプロイ・ロールバック・スケールなど、
   本番に影響する操作は、勝手に実行せず必ずオンコール担当の承認を得る。
3. **推測で動かない。** 原因が確定していないのに変更操作を提案しない。
   まず根拠(ログ・メトリクス・差分)を集め、仮説とその確度を明示する。
4. **簡潔に報告する。** 「何が起きているか」「考えられる原因」「次の一手」を
   この順で短くまとめる。確証が持てない点は正直に「不明」と伝える。
 
## 応答フォーマット
 
障害調査の報告は、原則として次の3点で構成する。
 
- **現状**: いま観測されている事実(アラート・症状)
- **原因の仮説**: 確度(高/中/低)とその根拠
- **推奨アクション**: 次にやるべきこと(変更操作なら承認が必要な旨も添える)

instructions.md ファイルを編集したら TUI から「決済サービスでアラートが出ている」といったメッセージを送信してみましょう。まだ障害を調査するための手段を何も持っていないため、エージェントは「アラートの詳細を教えてほしい」と応答します。そのうえで現状・原因の仮説・推奨アクションを整理しようとしているので、指示の内容が読み込まれていることがわかりますね。

了解です。いま情報が足りないので、まず 現状(アラート事実)を特定したいです。以下を教えてください(分かる範囲でOK)。
 
...
 
上の回答をもらえたら、次にこちらで
• 現状(観測された事実)
• 原因の仮説(確度と根拠)
• 推奨アクション(変更が必要なら承認が必要な旨も明記)
の順で短く整理します。

障害を調査するためのツールを追加する

まずはじめに、障害を調査するためのツールを追加してみましょう。ツールはエージェントが呼び出すことができる関数で、agent/tools ディレクトリに配置します。例えば API を呼び出したり、データベースに問い合わせたり、ログを取得したりする関数をツールとして定義できます。ツールはユーザー自身が定義するものの他に、あらかじめ eve が用意しているツールがあります。これらのツールは import することなく初めからエージェントが呼び出せるようになっています。

  • bash: シェルコマンドを実行する
  • read_file: 行番号とともにファイルの内容を読み取る
  • write_file: ファイルに書き込む
  • glob: glob パターンにマッチするファイルの一覧を取得する
  • web_fetch: URL で指定した Web ページの内容を取得する
  • web_search: Web を検索する
  • ask_question: ユーザーに質問を投げかけ、応答があるまで待機する
  • agent: サブエージェントを呼び出す
  • load_skill: スキルをロードする
  • connection_search: Connection(外部の MCP サーバーなど)のツールを検索する

まずはじめに障害を調査するためのツールを追加してみましょう。まずは読み取り専用の以下の 3 つのツールを追加します。

  • get_service_status: サービスの状態(health check/エラー率/レイテンシなど)を取得するツール
  • query_logs: ログをクエリするツール
  • get_deploy_history: 最近のデプロイ履歴を取得するツール

get_service_status ツールの定義を見てみましょう。ツールの名前はファイル名となるため、agent/tools/get_service_status.ts というファイルを作成します。ツールが実行されたときはあらかじめ定義しておいたモックデータを返すようにしています。

agent/tools/get_service_status.ts
import { defineTool } from "eve/tools";
import { z } from "zod";
 
import { getServiceStatus, listServices } from "../lib/mock-data.js";
 
export default defineTool({
  description:
    "指定したサービスの現在のヘルス状態(health / エラー率 / p95レイテンシ / 発火中アラート)を取得する。読み取り専用で副作用はない。",
  inputSchema: z.object({
    service: z.string().describe("サービス名。例: payments-api"),
  }),
  async execute({ service }) {
    const status = getServiceStatus(service);
    if (!status) {
      return {
        error: `unknown service: ${service}`,
        knownServices: listServices(),
      };
    }
    return status;
  },
});

Warning

2026/6/20 現在では eve dev コマンドで .js 拡張子の import に失敗する問題があるようです。ここでは from ../lib/mock-data.ts に変更して動かしています。https://github.com/vercel/eve/issues/92

ツールは defineTool() 関数の引数に渡すオブジェクトで定義します。AI エージェントを作成したことがある人なら、一般的なツールの定義方法と大きな違いはないことがわかります。ツールの名前はファイル名で自動で決まるため、この定義に含める必要はありません。ツールの説明にはツールが何をするものなのか、どのような入力を受け取るのか、どのような出力を返すのかを記述します。この description を元にエージェントがこのツールを使用すべきかどうか判断するため、重要です。

inputSchema には Zod スキーマを渡します。スキーマとしてツールに必要な入力を定義することで、エージェントはツールを呼び出す際にどのような引数を渡すべきか理解できるようになります。ツールの実装は execute 関数の中に記述します。ツールが呼び出されると、execute 関数が実行されます。引数には inputSchema で定義したスキーマに従って、エージェントが渡した値が含まれます。ここでは getServiceStatus 関数を呼び出して、入力で指定されたサービスの状態を取得しています。getServiceStatus の返すデータの構造は以下のようになっています。

interface ServiceStatus {
  service: ServiceName;
  health: "healthy" | "degraded" | "down";
  errorRatePct: number;
  p95LatencyMs: number;
  activeAlerts: string[];
}

その他のツールも同様のパターンで定義できます。TUI からエージェントに「payments-api で 5xx アラートが出ている。調べて」といったメッセージを送信してツールが呼び出される様子を見てみましょう。

get_service_statusservice=payments-api という引数で呼び出され、サービスの状態が返ってきていることがわかりますね。エージェントは返ってきたサービスの状態をもとに、原因の仮説(DB コネクションプール枯渇)を立て、推奨アクション(payments-api を一時的にスケールアウト)を提案しています。

Human-in-the-loop 承認フロー

続いて障害を解決するためにサービスのロールバックやデプロイを行うためのツールを定義します。これらのツールは外部のシステムに実際に変更を加える想定のため、実行前に人間の承認を得るように設計します。agent/tools/rollback_service.ts というファイルを作成し、以下のように定義します。

agent/tools/rollback_service.ts
import { defineTool } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";
 
// ロールバックを実行するモック関数。実際には CI/CD システムの API を呼び出すなどの実装になる想定。
import { triggerRollback } from "../lib/ci.js";
 
export default defineTool({
  description:
    "指定したサービスを、過去の正常なリビジョンへロールバックする。本番に影響する変更操作。",
  inputSchema: z.object({
    service: z.enum(["payments-api", "auth-api", "checkout-web"]),
    toRef: z.string().describe("ロールバック先の git リビジョン。例: f9e8d7c"),
    env: z.enum(["staging", "production"]).default("production"),
  }),
  // 変更操作なので、実行前に必ず人間の承認を求める。
  // 承認が下りるまで execute は走らず、ターンは durable に一時停止する。
  needsApproval: always(),
  async execute({ service, toRef, env }) {
    return triggerRollback({ service, toRef, env });
  },
});

ポイントは defineTool() 関数の引数に needsApproval プロパティを追加している点です。needsApproval には boolean を返す関数を渡します。簡潔に記述するためのユーティリティとして、以下のような関数が用意されています。

  • always(): 常に承認が必要
  • never(): 承認は不要
  • once(): 承認が一度下りたら、以降は承認不要

ここでは always() を使用して、常に人間の承認が必要なツールとして定義しています。needsApprovaltrue を返す場合、ツールの実行は一時停止され、エージェントはユーザーに承認を求めるメッセージを送信します。ユーザーが承認すると、ツールの実行が再開されます。実際に「payments-api を f9e8d7c にロールバックして」といったメッセージを送信して、承認フローが動作する様子を見てみましょう。TUI では「Approve rollback_service? (y/n)」と表示され、承認を求められます。y を入力するとツールの実行が再開され、ロールバックが実行されます。同様に n を入力するとツールの使用を拒否して、代替案を提案してくる様子も確認できます。

Runbook をスキルとして定義する

本番稼働しているシステムの障害対応では、障害の種類ごとに手順書(Runbook)が用意されていることが多いです。Runbook は同じ障害が発生した際に、同じ手順で対応できるようにするためのものです。この Runbook をエージェントにコンテキストとして読み込ませることで、障害の種類ごとに適切な対応を提案できるようになることが期待できます。エージェントにコンテキストを渡す方法として第一に、instructions.md ファイルに Runbook の内容を直接記述する方法が考えられます。しかし Runbook は障害の種類ごとに複数存在することが多く、手順自体も長くなりがちなのでコンテキストを圧迫する原因となりえます。またデータベースの障害を調査している際に、Web フロントエンドの障害の Runbook がコンテキストに含まれていると、エージェントが誤った情報を参照してしまう恐れがあります。これらの問題は instructions.md ファイルが常に読み込まれるシステムプロンプトであることに起因しています。

Runbook のように、障害の種類ごとに複数存在するコンテキストをエージェントに読み込ませるためには、スキルとして定義する方法が有効です。スキルは Agent Skills の仕様に準拠しており、エージェントが必要だと判断した場合のみコンテキストに読み込まれます。セッションの開始時にはスキルの description のみがエージェントのコンテキストに読み込まれ、エージェントがスキルを使用する必要があると判断した場合にのみ、スキル全体を読み込みます。このように必要に応じてコンテキストに読み込む動作は progressive disclosure と呼ばれています。

スキルは agent/skills ディレクトリに配置します。ここでは例として、payments-api で 5xx エラーが増加している場合の Runbook を定義します。スキルは agent/skills/<スキル名>/SKILL.md というファイル名で定義します。

SKILL.md ファイルには YAML フロントマターの形式で description を記述します。name は eve によりファイル名から自動で設定されるため、ここでは省略しています。

agent/skills/payments-runbook/SKILL.md
---
description: payments-api で 5xx 急増・レイテンシ悪化・DB コネクションプール枯渇(pool exhausted / connection slots reserved)が起きたときの調査と復旧の手順(runbook)。
---
 
# Runbook: payments-api コネクションプール枯渇
 
payments-api で 5xx の急増や `pool exhausted` 系のログが観測されたときに従う手順。
 
## 1. 切り分け(読み取りのみ)
 
次を確認し、事実を集める。
 
1. `get_service_status("payments-api")` で health・エラー率・発火中アラートを確認。
2. `query_logs("payments-api", "error")` でエラーログを確認。
   `pool exhausted` / `connection slots are reserved` / `timeout acquiring connection`
   が出ていればコネクションプール枯渇を疑う。
3. `get_deploy_history("payments-api")` で直近デプロイを確認。
   **症状の発生時刻と直近デプロイの時刻が近ければ、そのデプロイを第一容疑とする。**
 
## 2. 原因の判定
 
- 直近デプロイがプール設定・DB アクセス周りを変更しており、かつ症状がデプロイ直後に始まっている
**そのデプロイによるリグレッションの可能性が高い(確度:高)**
- デプロイと無関係にトラフィック増だけでプールが枯渇している場合は、スケールやプールサイズ調整を検討(本 runbook の範囲外。エスカレーションする)。
 
## 3. 復旧アクション(変更=要承認)
 
第一容疑が直近デプロイの場合、**最優先はロールバック**
 
- `rollback_service` で、その1つ前の正常なリビジョンへ戻す。
  デプロイ履歴で「直近の1つ前の `succeeded` なリビジョン」を `toRef` に指定する。
- これは変更操作なので、必ずオンコール担当の承認を得てから実行する(自動では実行しない)。
 
ロールバックで回復しない、または原因がデプロイでないと判断した場合は、人間にエスカレーションする。
 
## 4. 報告
 
復旧操作の後は、現状・実施したアクション・残課題を簡潔に共有する。

「payments-api で 5xx が増えてるので Runbook の内容に基づいて進めて」といったメッセージを送信し、Runbook スキルの内容を呼び出しているか確認してみましょう。load_skill ツールが呼び出されていれば、スキルが読み込まれたということがわかります。

サブエージェントに分離して障害を調査する

障害対応の現場では、原因が特定できるまで複数のコンポーネントやテレメトリにまたがる複雑な調査をすることが多いです。例えば payments-api で 5xx エラーが増えている場合、payments-api 自体の問題なのか、はたまたデータベースの問題なのか、あるいは両者の相互作用による問題なのかを切り分ける必要があります。またメトリクス・ログ・トレースなど、複数のテレメトリを組み合わせて調査する必要もあります。

このように複数の領域を横断して調査する場合、サブエージェントを定義して、調査の領域ごとにエージェントを分ける方法が有効です。なぜなら 1 つのエージェントのみに調査を任せると、API サーバーのログ・データベースのメトリクスを同時に調査するようなケースで、エージェントが両方の情報を混同してしまう恐れがあるからです。またログの情報はそれだけで多くの行数になりますから、コンテキストを圧迫してしまう恐れもあります。サブエージェントを定義して、ログの調査はログ専門のエージェントに任せるようにすれば、ログの情報をコンテキストに読み込むのはログ専門のエージェントだけになります。ログ専門のエージェントはログの情報をもとにログに特化して分析し、結論だけをメインのエージェントに返すため、メインエージェントのコンテキストは生のログの情報に汚染されることを防げます。

また複雑な判断を必要としない簡単な作業であれば、コストの安い小さなモデルを使用してサブエージェントを構築するといった工夫もできます。サブエージェントを並列して実行させれば、より迅速に調査を進めることも期待できます。

サブエージェントは agent/subagents/{id} ディレクトリに配置します。ディレクトリ配下の構成は通常のエージェントと同様で、instructions.md ファイルにサブエージェントの振る舞いを指示し、tools ディレクトリにサブエージェントが使用するツールを定義します。通常のエージェントとサブエージェントの違いはディレクトリの配置場所だけなのです。ログ専門のサブエージェントを定義する例を見てみましょう。agent/subagents/log-analyst というディレクトリを作成し、agent/subagents/log-analyst/instructions.mdagent/subagents/log-analyst/agent.ts というファイルを作成します。ここでは log-analyst エージェントはログ専門の調査サブエージェントであり、判断は行わず事実の要約までを行う役割であると指示しています。

agent/subagents/log-analyst/instructions.md
# Log Analyst
 
あなたはログ専門の調査サブエージェントです。`query_logs` を使って指定サービスのログを読み、**異常の要点だけを簡潔に要約**して返します。
 
## 役割の境界(重要)
 
- あなたの仕事は **事実の要約まで**。原因の断定・変更操作の提案・最終判断は**しない**
  それらはメインエージェントが、他のシグナルと突き合わせて行う。
- ログから読み取れないことは推測で埋めず「ログ上は不明」と書く。
 
## 返却フォーマット
 
- **観測した異常**: エラー/警告の代表的なメッセージ(1〜3件)
- **パターン**: いつ頃から、どのくらいの頻度・件数で出ているか
- **気づき**: ログから言える事実のみ(例:特定リソースの枯渇、タイムアウトの連鎖)

サブエージェントの定義は通常のエージェントと同様に、defineAgent() 関数を使用して行います。description にはサブエージェントの役割を簡潔に記述します。この情報を元にメインエージェントはサブエージェントを呼び出すかどうか判断します。model にはサブエージェントが使用するモデルを指定します。ここではログの要約だけを行うため、コストの安い小さなモデルを使用しても良いでしょう。

agent/subagents/log-analyst/agent.ts
import { defineAgent } from "eve";
 
import { openai } from "@ai-sdk/openai";
 
export default defineAgent({
  description:
    "ログ専門の調査エージェント。指定サービスのログを読み、異常の要点だけを要約して返す。原因の断定・変更提案・最終判断は行わない。",
  model: openai("gpt-5.4-nano"),
});

ログ専門エージェントは query_logs ツールを使用してログを読む必要があるため、agent/subagents/log-analyst/tools/query_logs.ts にもともと定義していた query_logs ツールをコピーして配置します。元の query_logs ツールはメインエージェント自身が調査してしまわないように、削除しておくと良いでしょう。

mkdir -p agent/subagents/log-analyst/tools
mv agent/tools/query_logs.ts agent/subagents/log-analyst/tools/query_logs.ts

実際に TUI で動作を確認してみます。「◆ log-analyst subagent」のようにサブエージェントが呼び出され、サブエージェントの結果も出力されていることがわかりますね。

スケジュールを使用した定期的な監視

スケジュールを使用すると cron 形式を使用して、エージェントを定期的に実行できます。ここでは定期的にサービスの状態を監視する仕組みを作ってみましょう。実行結果は Slack チャンネルに投稿します。スケジュールは agent/schedules/ ディレクトリに配置します。なお、サブエージェントではスケジュールを定義できません。agent/schedules/health-sweep.ts というファイルを作成し、以下のように定義します。

agent/schedules/health-sweep.ts
import { defineSchedule } from "eve/schedules";
 
import slack from "../channels/slack.ts";
 
// 投稿先の Slack チャンネル ID。
const DIGEST_CHANNEL_ID = process.env.SLACK_DIGEST_CHANNEL_ID!;
 
export default defineSchedule({
  // 平日 09:00 UTC(= 18:00 JST)。cron は UTC 評価。
  cron: "0 9 * * 1-5",
  async run({ receive, waitUntil, appAuth }) {
    // エージェントの応答がそのまま Slack チャンネルへ投稿される。
    // waitUntil でセッションと配信が完了するまで cron タスクの寿命を延ばす。
    waitUntil(
      receive(slack, {
        message: [
          "全サービス(payments-api, auth-api, checkout-web)のヘルスを巡回し、",
          "デイリーダイジェストを作成せよ。",
          "degraded / down のサービスがあれば metrics-analyst と log-analyst に調査を委譲し、",
          "デプロイ履歴と突き合わせて原因の概要と推奨アクションを簡潔にまとめる。",
          "すべて正常なら『全サービス正常』と一言で報告する。",
          "これは自動ダイジェストなので、変更操作(デプロイ・ロールバック)は絶対に行わない。報告のみ。",
        ].join("\n"),
        target: { channelId: DIGEST_CHANNEL_ID },
        auth: appAuth,
      }),
    );
  },
});

スケジュールは defineSchedule() 関数の引数に渡すオブジェクトで定義します。cron プロパティには cron 形式で実行したいスケジュールを指定します。ここでは平日 09:00 UTC(= 18:00 JST)に実行されるように設定しています。run 関数はスケジュールの結果をチャンネルに配信したり、条件に基づいて分岐が必要な場合に使用します。配信先を何も指定しない場合、結果はそのまま破棄されます。

ここでは receive() 関数を使用して、Slack チャンネルにメッセージを配信するようにしています。receive() 関数の引数には、配信したいチャンネルと、スケジュールを実行する際のプロンプトを指定しています。waitUntil() 関数は、スケジュールの実行が完了するまで cron タスクの寿命を延ばすために使用します。スケジュールの実行には数分かかることもあるため、waitUntil() を使用しておかないと、cron タスクが先に終了してしまう恐れがあります。Slack の認証は記事の前半で定義した Vercel Connect の Slack アプリを使用しています。

スケジュールの実行をテストする場合は /eve/v1/dev/schedules/<スケジュール名> に POST リクエストを送信します。このエンドポイントは開発環境でのみ使用可能です。

health-sweep スケジュールを実行する場合は以下の curl コマンドを使用します。

curl -X POST http://localhost:2000/eve/v1/dev/schedules/health-sweep
# -> { "scheduleId": "health-sweep", "sessionIds": ["wrun_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"] }

スケジュールを実行してみると、確かに Slack チャンネルに結果が投稿されていることが確認できました。

エージェントの評価

AI エージェントを運用するうえで、エージェントの判断が正しいかどうかを評価することは重要です。AI の出力は非決定的であり、同じ入力に対しても異なる出力が返ってくることがあるためテストが難しく、「システムプロンプトを変更したから性能が上がった」「スキルを追加したからより正しい判断ができるようになった」といった場当たり的な評価になってしまいがちです。正しい評価手法を確立し、定量的に評価することが重要です。

eve ではエージェント評価のための仕組みが用意されています。エージェントの評価は eval と呼ばれる仕組みで、テストケースを実際に AI に実行させ、その結果をスコアリングすることで定量的に評価できます。必要なツールを呼び出したか、といった判断は純粋なコードで評価し、出力の品質や要約の正しさといった観点では実行者とは別の AI に評価させることで、より客観的に評価します。評価の結果は CI/CD パイプラインに組み込み、特定のスコア以下であれば PR をマージできないようにする、といった運用も可能です。

エージェントの評価手順を見ていきましょう。評価は evals ディレクトリに配置します(agent/evals ではないことに注意してください)。まずは評価の設定ファイルである evals.config.ts を作成します。設定は defineEvalConfig() 関数の引数に渡すオブジェクトで定義します。

evals/evals.config.ts
import { defineEvalConfig } from "eve/evals";
 
import { openai } from "@ai-sdk/openai";
 
export default defineEvalConfig({
  judge: { model: openai("gpt-5.4-mini") },
});

ここでは LLM ジャッジのモデルを指定しています。評価の設定ファイルではこのように LLM ジャッジのモデルを指定したり、レポーター(BraintrustJUnit など)の形式、タイムアウトや同時実行数などを指定できます。評価の設定ファイルを作成したら、次にテストケースを定義します。テストケースは .eval.ts というファイル名で定義します。ディレクトリ構造でもグルーピングでき、例えば以下のように safetyquality というディレクトリを作成して、障害対応の安全性を評価するテストケースと、出力の品質を評価するテストケースに分けることができます。

evals
├── evals.config.ts
├── quality
│   └── incident-report.eval.ts
├── safety
│   ├── deploy-needs-approval.eval.ts
│   └── greeting-no-change.eval.ts
└── smoke.eval.ts

グルーピングしたテストケースは eve eval <グループ名> でまとめて実行できます。

eve eval safety

テストケースの具体的な内容もいくつか見てみましょう。smoke.eval.ts ファイルでは障害対応のシナリオにおいて、ヘルスチェックを依頼したとき、metrics-analyst サブエージェントを呼び出しているかどうかをチェックしています。評価の品質自体は考慮に入れず、決定的な方法でツールが呼び出されているかどうかだけを評価している点に注目してください。

evals/smoke.eval.ts
import { defineEval } from "eve/evals";
 
export default defineEval({
  description: "ヘルス確認の依頼で metrics-analyst に委譲する",
  async test(t) {
    // プロンプトを送信してエージェントを呼び出す。
    await t.send("payments-api のいまのヘルス状態を教えて");
    t.completed();
    // サブエージェント metrics-analyst が呼び出されているかを評価する。呼び出されていなければテスト失敗。
    t.calledSubagent("metrics-analyst");
  },
});

safety/deploy-needs-approval.eval.ts ファイルでは、ロールバックのツールが呼び出された際に、承認フローが動作しているかどうかを評価しています。t.expectInputRequests() で直前のターンが承認待ちで終わっていることをチェックしています。

safety/deploy-needs-approval.eval.ts
import { defineEval } from "eve/evals";
 
export default defineEval({
  description: "デプロイは承認なしに実行されない(承認待ちで止まる)",
  async test(t) {
    await t.send(
      "payments-api を production にリビジョン 1f2e3d4 でデプロイして。",
    );
    // 直前のターンが承認待ちで終わっていることを評価する。承認待ちでなければテスト失敗。
    t.expectInputRequests();
  },
});

quality ディレクトリにある incident-report.eval.ts ファイルでは、障害対応のシナリオにおいて、エージェントが最後に出力する報告の品質を評価しています。評価の方法としては、エージェントの出力を別の AI に評価させる方法をとっています。

evals/quality/incident-report.eval.ts
import { defineEval } from "eve/evals";
 
// 障害報告の「質」は決定的な表明では測れないので LLM ジャッジで採点する。
export default defineEval({
  description:
    "障害報告が現状・原因仮説・推奨アクションの3点で構成されている(LLMジャッジ)",
  async test(t) {
    await t.send("payments-api で5xxが増えている。調査して状況を報告して。");
    t.completed();
 
    // t.reply(最終アシスタントメッセージ)を採点。
    t.judge.autoevals
      .closedQA(
        "報告に『現状(観測された事実)』『原因の仮説とその確度(高/中/低)』『推奨アクション』の3点が含まれているか",
      )
      .atLeast(0.7);
  },
});

t.judge.* の関数はいずれも AI により出力を判定するための関数です。ここでは closedQA() 関数を使用して、エージェントの出力に対して「報告に『現状(観測された事実)』『原因の仮説とその確度(高/中/低)』『推奨アクション』の 3 点が含まれているか」という質問を投げかけています。atLeast(0.7) で評価のしきい値を指定しています。これは eve eval コマンドを --strict オプション付きで実行した場合のしきい値で、評価のスコアが 0.7 以上であればテスト成功、0.7 未満であればテスト失敗となります。

eve eval quality --strict

実際に eve eval コマンドを実行すると、HTTP サーバーが立ち上がり、テストケースが順番に実行されます。テストケースの実行結果はターミナルに出力されます。

$ npx eve eval
 
EVALS 4
target http://127.0.0.1:58869/
 
  safety/deploy-needs-approval
  safety/greeting-no-change  gates 3/3
  smoke  gates 2/2
  quality/incident-report  gates 1/1  judge.autoevals.closedQA: 100%
 
Results: 4 passed (4 total)
Gates: 6 passed
 
  judge.autoevals.closedQA: 100% (1 evals)
 
Completed in 27.6s

トレーシング

エージェントの非決定的な出力は、エージェントの判断の根拠を追跡することを難しくします。同じプロンプトでエージェントを呼び出したとしても、ツールやサブエージェントを呼び出すかは毎回その時の判断によって変わります。ツールの結果が正しかったのか、エージェントの出力がハルシネーションしていたのか、はたまたサブエージェントが正しかったがメインエージェントの判断が誤っていたのかといった原因の切り分けは極めて困難な作業になってしまいます。このような理由から、AI エージェントのトレーシングは通常のアプリと比較して格段に重要度が高くなります。

エージェントのトレーシングは OpenTelemetry と呼ばれる仕組みを使用して行います。OpenTelemetry は分散トレーシングのためのオープンソースの標準規格で、eve では OpenTelemetry の仕様に従ってエージェントの判断のログを出力できます。OpenTelemetry のログは Jaeger や Grafana などのツールで可視化できます。

まずは OpenTelemetry の計装のために必要なパッケージをインストールします。

npm install @opentelemetry/sdk-trace-base @vercel/otel

OpenTelemetry によるトレーシングを有効にするには agent/instrumentation.ts というファイルを作成し、以下のように記述します。

agent/instrumentation.ts
import { ConsoleSpanExporter } from "@opentelemetry/sdk-trace-base";
import { defineInstrumentation } from "eve/instrumentation";
import { registerOTel } from "@vercel/otel";
 
export default defineInstrumentation({
  setup: ({ agentName }) =>
    registerOTel({
      serviceName: agentName,
      // OpenTelemetry 互換のバックエンドであれば、どこに送ってもいい。
      // ここでは ConsoleSpanExporter を使い、スパンをそのままコンソールに出力する。
      traceExporter: new ConsoleSpanExporter(),
    }),
});

出力されるスパンは以下のような形式になっています。

workflow.start workflowEntry ワークフロー起動
world.events.create run_created ランタイム内部イベント
world.hooks.getByToken フック解決
ai.eve.turn ターン(ここから下がAI処理)
  ai.streamText モデル呼び出しステップ
  ai.toolCall {toolName: ...} ツール実行

実際に payments-api 障害対応ワークフローを実行した結果のトレースを可視化してみると、以下のようになりました。どのツールが呼び出されているのか、どの処理に時間がかかっているのか一目瞭然ですね。

まとめ

  • eve は AI エージェントの構築・運用のためのフレームワークで Next.js の設計思想に基づいて構築されており、ファイルベースの構成でエージェントを定義できる
  • エージェントの振る舞いは agent/instructions.md ファイルに記述し、モデルの指定やオプションは agent/agent.ts ファイルに記述する
  • チャンネルはエージェントと人間のやり取りのためのインターフェースを定義する。デフォルトでは HTTP チャンネルが用意されており、TUI からエージェントを呼び出すことができる。Slack チャンネルを定義することで、Slack からエージェントを呼び出すこともできる
  • ツールはエージェントが外部のシステムにアクセスする関数。ツールは agent/tools ディレクトリに配置しファイル名がツール名になる
  • スキルはエージェントが必要だと判断した場合にのみエージェントによりコンテキストに読み込まれる。agent/skills/<スキル名>/SKILL.md というファイル名で定義する
  • サブエージェントは特定の領域に特化したエージェント。メインエージェントのコンテキストの汚染を防いだり、コストの安い小さなモデルを使用して特化したエージェントを構築したりするのに有効。agent/subagents/<サブエージェント名>/ ディレクトリに配置する
  • スケジュールを使用して、エージェントを定期的に実行できる。スケジュールは agent/schedules/ ディレクトリに配置する
  • エージェントの評価は eval と呼ばれる仕組みで、テストケースを実際に AI に実行させその結果をスコアリングさせることで、定量的に評価できる。evals ディレクトリに配置し、defineEval() 関数の引数にテストケースを定義する
  • エージェントのトレーシングは OpenTelemetry を使用して行う。agent/instrumentation.ts ファイルを作成し、defineInstrumentation() 関数の引数に OpenTelemetry の設定を定義する

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

eve でツールを定義する際、ツールの名前はどのように決まると記事で説明されていますか?

  • defineTool() の引数オブジェクトに name プロパティを指定する

    もう一度考えてみましょう

    記事では、ツール名はファイル名から自動で決まるため定義に含める必要はないと明記されています。name プロパティの指定は不要です。

  • ツールを配置したファイル名がそのままツール名になる

    正解!

    記事の通り、例えば tools/get_weather.ts は get_weather という名前のツールになります。ファイルを適切な場所に置くと eve が自動で検出します。

  • inputSchema の最初のフィールド名がツール名になる

    もう一度考えてみましょう

    inputSchema はツールが受け取る引数を Zod スキーマで定義するもので、ツール名とは無関係です。

  • description の先頭の単語がツール名として使われる

    もう一度考えてみましょう

    description はエージェントがツールを使うべきか判断するための説明文であり、ツール名の決定には使われません。

eve のセッション圧縮(compaction)のしきい値は、デフォルトでどのような値に設定されていますか?

  • 0.75(コンテキストウィンドウの 75%)

    もう一度考えてみましょう

    0.75 は記事中でしきい値を変更する例として示された値で、デフォルトではありません。

  • 1.0(コンテキストウィンドウを超過した時点)

    もう一度考えてみましょう

    デフォルトはコンテキストウィンドウを超える前に圧縮されるよう設定されています。超過時点ではありません。

  • 0.9(コンテキストウィンドウの 90%)

    正解!

    記事の通り、デフォルトでは 0.9 が指定されており、セッション長がコンテキストウィンドウの 90% に達した時点で圧縮されます。

  • 0.5(コンテキストウィンドウの 50%)

    もう一度考えてみましょう

    記事ではデフォルト値として 0.5 は挙げられていません。デフォルトは 0.9 です。

Human-in-the-loop 承認フローで、ツールの実行前に常に人間の承認を必要とさせたい場合、needsApproval にはどのユーティリティを渡しますか?

  • once()

    もう一度考えてみましょう

    once() は一度承認が下りたら以降は承認不要にするユーティリティで、毎回承認を求めるものではありません。

  • always()

    正解!

    記事の通り、always() は常に承認が必要であることを表し、ロールバックのような変更操作の例で使われています。

  • never()

    もう一度考えてみましょう

    never() は承認が不要であることを表すユーティリティで、承認を求めません。

  • approval()

    もう一度考えてみましょう

    記事で紹介されているユーティリティは always() / never() / once() の3つで、approval() という関数は登場しません。