🚀

Cloudflare Workers+TypescriptでGitHub Copilot ChatのAIエージェントを作ろう

2024/12/13に公開

この記事は SMat Advent Calendar 2024 の12月13日分の記事です。

こんにちは、株式会社エスマットでSREをしているbiosugar0です。

2024年9月、GitHub Copilotの機能を拡張する「GitHub Copilot Extensions」のパブリックβ版が全ユーザー向けに公開されました。
これにより、個人やサードパーティの開発者がVS Codeなどで独自のAIエージェント(以下、エージェント)を開発できるようになりました。

GitHub Copilotは定額で使えるうえに拡張性も追加されたのは嬉しいアップデートですね。

この記事では、Cloudflare WorkersとTypescriptを使って、GitHub Copilot Chatのエージェントを実装する方法を紹介します。Cloudflare Workersを使うことで、サーバー構築のコストを抑えつつ手軽に試せるため、非常に有用です。

以下はVS CodeのCopilot Chatの画面で @perplexityai を使ってみた例です。
agent_example1
ここでは、Perplexityが提供するAI エージェントが最新情報を検索して応答してくれています。
利用する場合はmarketplaceからインストールする必要があります。

Cloudflare Workersでホストする利点

GitHub Copilot Extensionsでエージェントを自作する場合、サーバーをホスティングする必要があります。
Cloudflare Workersを使えば、ほぼ無料でエージェントを公開できます。

この記事では、公式の海賊風エージェントcopilot-extensions/blackbeard-extensionを改造したbiosugar0/blackbeard-extensionを例に、エージェントをCloudflare Workersで動かす手順を紹介します。

GitHub Copilot Chat Extensionsの基本フロー

GitHub Copilot Chat Extensionsは、基本的に以下のようなフローで動作します。

  1. ユーザーがVS Codeなどのクライアントからリクエストを送信
  2. クライアントがリクエストをCopilot Platformへ送信
  3. Copilot Platformがそのリクエストを拡張機能サーバーへ転送
  4. 拡張機能サーバーがリクエストを処理後、ストリーミングレスポンス(SSE)をCopilot Platformへ返す
  5. Copilot Platformはストリーミングレスポンスをクライアントへ返す
  6. クライアントがレスポンスをユーザーに表示

今回は、このフロー中の「拡張機能サーバー」を自作します。

事前準備

エージェント開発にあたり、以下の準備が必要です。

1. ローカル検証用のURLを作成

ローカルサーバーをインターネット経由でアクセス可能にするため、cloudflaredを使います。

公式ドキュメントはngrokを使用していますが、ここではcloudflaredを使います。
Cloudflare系のツールで統一したい、という理由からです。もちろん、ngrokなど他のツールでも問題ありません。

Macの場合、以下でcloudflaredをインストール可能です。

brew install cloudflared

cloudflaredでローカル環境を公開するには、以下のコマンドを実行します。

cloudflared tunnel --url http://localhost:8787

ログに https://********.trycloudflare.com のようにURLが表示され、このURLから外部アクセスが可能になります。

2. GitHub Appの作成

GitHubの設定画面からGitHub Appを作成します。

  • GitHub App name: kurohige-sample(任意の名称でOK)
  • Webhookはオフ(Activeのチェックを外す)
  • Homepage URL: http://localhost (適当でOK)
  • Callback URLに 1. ローカル検証用のURLを作成で作成したURLを入力
  • Permissions > Account permissions でCopilot Chat をRead-only に設定`
  • (option) Permissions > Account permissions でCopilot Editor Context をRead-only に設定

上記の設定でGitHub Appを作成します。

3. GitHub AppsのCopilot設定

2. GitHub Appの作成 で作成したGitHub Appの設定にCopilot Chatの設定を追加します。

copilot_config

設定変更の画面からCopilotを選び、以下の設定を追加します。

  • Inference description: エージェントの説明(例: This agent is a pirate.

これで設定を保存後、Install App からインストールして準備完了です。

ローカル環境での動作確認

biosugar0/blackbeard-extensionを例に、ローカルで動かしてみます。

1. リポジトリのクローンと依存モジュールのインストール

パッケージマネージャーとしてpnpmを利用します。
Homebrewでのインストール例:

brew install pnpm

リポジトリをクローンして依存モジュールをインストールします。

git clone https://github.com/biosugar0/blackbeard-extension.git && cd blackbeard-extension
pnpm install

2. サーバーの起動

サーバーを立ち上げます。

pnpm dev

> [email protected] dev /Users/ykimura/go/src/github.com/biosugar0/blackbeard-extension
> wrangler dev


 ⛅️ wrangler 3.91.0
-------------------

⎔ Starting local server...
[wrangler:inf] Ready on http://localhost:8787

これでローカル環境でエージェントが動いているので、GitHub Copilot Chatから呼び出せるようになりました。

agent_example_call
VS CodeのCopilot Chatから @kurohige-sample を使ってみた例

Cloudflare Workersへのデプロイ

Cloudflare Workersにデプロイしてみましょう。
まずCloudflareにログインします(アカウントがない場合は作成してください)。

pnpm wrangler login

続いてデプロイします。

pnpm wrangler deploy

 ⛅️ wrangler 3.91.0
-------------------

Total Upload: 463.38 KiB / gzip: 85.80 KiB
Worker Startup Time: 16 ms
Uploaded blackbeard-extension (3.60 sec)
Deployed blackbeard-extension triggers (0.38 sec)
  https://********************.********.workers.dev
Current Version ID: ********-****-*********-************

表示されたURLをGitHub App設定内のCallback URLおよびCopilotのURLとして再設定してください。

  • General のCallback URL
  • Copilot のURL

これでCloudflare Workers上にデプロイされたエージェントに向き先が切り替わりました!

Cloudflare Workersは基本的に無料で使えるため、個人で保有するCopilot エージェントを動かすのに最適です。
また、Cloudflare WorkersでホストできるということはWorkers AI で他のLLMモデルを呼び出したり、Cloudflare KV, D1などのサービスと組み合わせることができるということです。
自由度が高く夢が広がりますね!

実装上のTIPS

ここからはいくつかの実装上のポイントを紹介します。

エージェントが利用できるモデル

2024年12月1日時点で、GitHub Copilot Chatのエージェントが利用可能なモデルは以下の通りです。
GET https://api.githubcopilot.com/models で取得できます。

  • gpt-3.5-turbo
  • gpt-3.5-turbo-0613
  • gpt-4
  • gpt-4-0613
  • gpt-4o
  • gpt-4o-2024-05-13
  • gpt-4-o-preview
  • gpt-4o-2024-08-06
  • o1-preview
  • o1-preview-2024-09-12
  • o1-mini
  • o1-mini-2024-09-12
  • text-embedding-ada-002
  • text-embedding-3-small
  • text-embedding-3-small-inference

GitHub Copilot Chat APIにOpenAI ライブラリでアクセスする

GitHub Copilot Chat APIはほぼOpenAIのAPI互換のため、OpenAIのクライアントライブラリで利用可能です。

  • baseURLhttps://api.githubcopilot.com に設定
  • apiKeyX-GitHub-Token ヘッダーから取得したトークンを設定

すると利用できます。
OpenAIのライブラリを使い慣れている人にとっては嬉しいのではないでしょうか。

const baseUrl = 'https://api.githubcopilot.com';
const openai = new OpenAI({
    baseURL: baseUrl,
    apiKey: tokenForUser,
});

const stream = await openai.chat.completions.create({
    messages: messages,
    model: 'gpt-4o',
    stream: true,
});

GitHub Copilotにリクエストできるmessageロール

検証したところ、2024年12月1日時点では最近のOpenAI APIで使えるtoolは使えないようです。
使用可能なroleは以下の通りです。

  • system
  • user
  • assistant

Function Calling

OpenAIのAPIと同様に、GitHub Copilot Chat APIではFunction Callingを使うことができます。
biosugar0/blackbeard-extensionでは(ハードコードされた)天気を取得するToolを使えるように実装してあるので詳細はコードを確認してみてください。

const toolResponse = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages: messages,
    tools: tools,
    tool_choice: 'auto',
    stream: false,
});

リクエストがGitHubから来ていることを確認する

特に認証認可の仕組みを作らずにエージェントのサーバーを公開すると、誰でもエージェントを呼び出すことができてしまいます。少なくともリクエストをGitHubからのものに限定する仕組みを導入しましょう。

すべてのエージェントへのリクエストにはGithub-Public-Key-IdentifierGithub-Public-Key-Signatureヘッダーが付与されており、これらを用いて署名検証します。

  • Github-Public-Key-Identifier: 使用する公開鍵を識別するためのキーID。
  • Github-Public-Key-Signature: リクエストペイロードの署名。

これらのヘッダーを利用することで、受信したリクエストがGitHubから送信されたものであり、改ざんされていないことを確認できます。

具体的な署名検証の手順は以下の通りです:

  1. GitHubの公開鍵を取得
    Copilot APIで使われる公開鍵はGitHubのエンドポイント(https://api.github.com/meta/public_keys/copilot_api)から取得します。このエンドポイントには複数の公開鍵が格納されており、Github-Public-Key-Identifierに一致する公開鍵を選びます。

  2. リクエストボディと署名を取得
    受信したリクエストボディとGithub-Public-Key-Signatureヘッダーに含まれる署名データを取り出します。

  3. 署名をASN.1形式でパース
    署名データはASN.1形式でエンコードされています。この形式を解析し、ECDSAの署名検証用に適切な形式に変換します(具体的にはrsのコンポーネントに分解し、それぞれ32バイトのデータに調整します)。

  4. 署名の検証
    Web Crypto APIを使用し、ECDSA-NIST-P256V1-SHA256アルゴリズムで署名を検証します。具体的には、以下を比較します:

    • リクエストボディに基づくハッシュ値
    • 公開鍵を用いて復元された署名のハッシュ値

署名が正しい場合、リクエストはGitHubからのものであると確認できます。署名が無効な場合は、リクエストを拒否してください。

この仕組みを実装することで、エージェントを安全に公開し、GitHubからの正当なリクエストのみを受け付けることができます。コード例については、biosugar0/blackbeard-extensionを参照してください。

Hono のMiddlewareを使う

HonoのMiddlewareのように、フレームワークの機能を使えば、署名検証を本体ロジックから分離できます。
以下はHonoのMiddlewareを使った例です。verifySignature が署名検証の処理を行った後、handlePost がメインロジックを実行します。

import { Hono } from 'hono';
import { verifySignature } from './middlewares/verifySignature';
import { handlePost } from './routes/index';

const app = new Hono();

app.get('/', (c) => {
	return c.html(`
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>kurohige-sample</title>
  <link rel="icon" href="https://cdn.jsdelivr.net/gh/twitter/[email protected]/assets/72x72/1f3f4-200d-2620-fe0f.png" type="image/png">
  <style>
    body {
      font-family: Arial, sans-serif;
      text-align: center;
      margin-top: 50px;
    }
    h1 {
      color: #333;
    }
  </style>
</head>
<body>
  <h1>Blackbeard is ready!</h1>
  <footer>
    <p>This is a sample app for github copilot extension agent</p>
  </footer>
</body>
</html>
  `);
});

app.post('/', verifySignature, handlePost);

export default app;

レスポンスはServer-Sent Events(SSE) を使う

SSEは、サーバーからクライアントへ一方向に継続的なデータストリームを送信する仕組みです。これにより、クライアント側がサーバーへ定期的にリクエストを送らなくても、サーバー側から新しいデータがプッシュ配信され、イベントとして受け取ることが可能になります。

Copilot エージェントはSSEを利用してCopilot Platformおよびクライアントとやり取りをします。
TypeScriptでの実装例は以下の通りです。

SSEでは、サーバーからクライアントにデータを送信する際、メッセージの区切りとして2回の改行を使用します。
これにより、クライアント側は1つのメッセージが終了したことを認識できます。

const stream = await openai.chat.completions.create({
    messages: messages,
    model: 'gpt-4o',
    stream: true,
});

const encoder = new TextEncoder();
const readableStream = new ReadableStream({
    async start(controller) {
        for await (const chunk of stream) {
            if (chunk.choices?.[0]?.finish_reason) {
                const data = JSON.stringify(chunk);
                const payload = `data: ${data}\n\n`;
                controller.enqueue(encoder.encode(payload));
                console.log('Stream completed.');
                controller.close();
                break;
            }

            const data = JSON.stringify(chunk);
            const payload = `data: ${data}\n\n`;
            controller.enqueue(encoder.encode(payload));
        }
        controller.close();
    },
});

return new Response(readableStream, {
    headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
    },
});

RAG(Retrieval-Augmented Generation)的な処理をする場合はcopilot_referencesイベントで参考文献を返すことも可能です。

copilot_references
perplextiyai はcopilot_referencesで参考文献を返しています。
VS CodeのCopilot Chatではそれを使って参考文献を表示しています。

また、Function Callingの際にユーザー承認ステップを挟むなど、より複雑なロジックも実装できます。biosugar0/blackbeard-extensionでもFunction Callingのツール実行の承認を得る例を実装しています。
copilot_confirmation

以下のようにcopilot_confirmationイベントをレスポンスすることで、ユーザーに承認を求めることができます。
confirmation Objectの要素は、id以外は任意の要素を含めることができ、承認結果がリクエストされてきた時にも返ってくるので実行したいツールのidや名前、引数などを含めると良いでしょう。

const confirmationData = {
    type: 'action',
    title: 'kurohige-sample が天気調査能力を発動しようとしています。',
    message: `${city}の天気を調べることを許可しますか?`,
    confirmation: {
        id: `${toolCall.function.name}`,
        city: `${city}`,
    },
};

const encoder = new TextEncoder();
const readableStream = new ReadableStream({
    start(controller) {
        const payload = `event: copilot_confirmation\ndata: ${JSON.stringify(confirmationData)}\n\n`;
        controller.enqueue(encoder.encode(payload));
        controller.close();
    },
});

return new Response(readableStream, {
    headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
    },
});

承認時のリクエスト時にはmessagesの最後にcopilot_confirmationsが付与されるので、それを確認してツールを実行します。
先ほどの承認を求めるレスポンスで返したconfirmationの内容もcopilot_confirmationsの中に含まれています。
ツールの実行結果をmessageに含める際には、tool が使えずに system ロールを使うようです。

let toolExecuted = false;
if (lastMessage?.copilot_confirmations) {
    for (const confirmation of lastMessage.copilot_confirmations) {
        if (confirmation.state === 'accepted') {
            // ユーザーが承認したのでツールを実行
            const toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall = {
                id: confirmation.confirmation.id,
                type: 'function',
                function: {
                    name: confirmation.confirmation.id,
                    arguments: JSON.stringify({ city: confirmation.confirmation.city }),
                },
            };

            const toolResultContent = await callTool(toolCall);
            const toolResultMessage = `This agent has activated the ability of ${confirmation.confirmation.id} and returned the following result: ${toolResultContent}`;
            messages.push({ content: toolResultMessage, role: 'system' });
            toolExecuted = true;
        }
    }
}

より詳細な仕様については、Configuring your Copilot agent to communicate with the Copilot platform を参照してください。

GitHub Copilot Extensions にローカルコンテキストを渡す

2024年12月に発表された機能で、GitHub Copilot Extensionsは、エディターなどのクライアントのローカルコンテキストにアクセス できるようになりました。

以下のように、エージェントに渡されるメッセージのcopilot_references内にローカルコンテキストが含まれます。
ファイルの内容や、エディターで今どこを選択しているかなどのコンテキスト情報を含めることでより高度なエージェントを作成できます。
エージェントへのリクエストにコンテキスト情報を含めるには、GitHub AppsのPermissions設定でCopilot Editor Contextを Read-only に設定する必要があります。

{
  "role": "user",
  "content": "Hi!",
  "copilot_references": [
    {
      "type": "client.file",
      "data": {
        "content": "import { Hono } from 'hono';.....\n",
        "language": "typescript"
      },
      "id": "app.ts",
      "is_implicit": true,
      "metadata": {
        "display_name": "",
        "display_icon": "",
        "display_url": ""
      }
    },
    {
      "type": "client.selection",
      "data": {
        "content": "import { Hono } from 'hono';\n",
        "end": {
          "col": 0,
          "line": 1
        },
        "start": {
          "col": 0,
          "line": 0
        }
      },
      "id": "app.ts",
      "is_implicit": true,
      "metadata": {
        "display_name": "",
        "display_icon": "",
        "display_url": ""
      }
    },
    {
      "type": "github.repository",
      "data": {
        "type": "repository",
        "id": 892509524,
        "name": "blackbeard-extension",
        "ownerLogin": "biosugar0",
        "ownerType": "",
        "readmePath": "",
        "description": "",
        "commitOID": "",
        "ref": "",
        "refInfo": {
          "name": "",
          "type": ""
        },
        "visibility": "",
        "languages": null
      },
      "id": "biosugar0/blackbeard-extension",
      "is_implicit": false,
      "metadata": {
        "display_name": "",
        "display_icon": "",
        "display_url": ""
      }
    }
  ],
  "copilot_confirmations": null
}

エージェントのロジックでcopilot_referencesからローカルコンテキストを取得して利用しましょう。

おまけ: NeovimでもGitHub Copilot Chat

GitHub Copilot Chatは便利なのですが、私は普段Neovimしか使わないのでCopilotChat.nvimというプラグインでNeovimでも使えるようにしています。

これを使うと、今回のように自作したエージェントもNeovimから呼び出すことができます!

agent_example_call_neovim

最後に

GitHub Copilot Chatのエージェントを自作してCloudflare Workersで動かす方法を紹介しました。
自由度が高く自分専用のAI エージェントを作ることができるので、ぜひ試してみてください。
色々な便利な事例が出てくると嬉しいです!

株式会社エスマット

Discussion