じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

ACPでAgentに行動させる

はじめに

こんにちは!今回は、コードエディタや各種開発ツールとAIエージェント間の通信を標準化する Agent Client Protocol (ACP) について、その内部実装と実践的な使用方法を詳しく解説します。

github.com

最近の界隈では、Model Context Protocol(MCP)が大きな注目を集めていますが、その陰で着実に重要性を増している技術があります。それがACPです。MCPのような華やかさはないものの、実際にエディタプラグインやコーディングエージェントを開発する際には、ACPの理解が不可欠になってきています。

なお、ACPを理解する前提としてMCPの基礎知識があると理解が深まります。MCPについては以下の記事で詳しく解説していますので、ぜひ参照してください。

syu-m-5151.hatenablog.com

また、みのるんさんから献本いただいたこちらの書籍も、MCPの入門書として非常に参考になりました。実践的な内容が分かりやすくまとめられており、おすすめです。

同名のスライドでも良いのでMCPがわからない人は触れておくと良いと思います。

speakerdeck.com

コード開発におけるAI支援ツールが急速に普及する中、実はエディタとAIツールの間には興味深い技術的課題が潜んでいます。それは、エディタごとに個別対応が必要で、使いたいツールの組み合わせが制限されるという問題です。正直なところ、多くの開発者はCopilotやCursorなどの既製品で満足しているでしょうし、この問題を意識することもないかもしれません。

しかし、エディタプラグインを自作したい人独自のAIエージェントを開発したい人、あるいは技術的な仕組みに興味がある人にとって、ACPは実に興味深い技術です。「エディタとAIエージェント間のLSP」として機能するこのプロトコルは、知らなくても困らないけれど、知っていると開発の可能性が大きく広がる、そんな技術と言えるでしょう。

本記事では、このややマニアックながらも将来性のあるACPの実装詳細を通じて、プロトコル設計の面白さや、Rustによる非同期通信の実装テクニックなど、技術的に興味深いポイントを深掘りしていきます。

ACPとは何か?

記事を始める前に、まず ACP (Agent Client Protocol) について簡単に説明しましょう。ACP についてより詳しい情報は、公式GitHubリポジトリ や公式サイトを参照してください。ACPは、Zed Industriesが開発したオープンソースの標準プロトコルで、コードエディタとAIコーディングエージェント間の通信を標準化します。Language Server Protocol(LSP)がプログラミング言語サーバーの統合を革命的に変えたように、ACPは「LSPのAIエージェント版」として、AIツールの統合に同様の変革をもたらすことを目指しています。

agentclientprotocol.com

ACPの仕組み

ACP は基本的に JSON-RPC 2.0 ベースのプロトコルで、主要な構成要素は以下のとおりです。

  1. クライアント(Client):コードエディタ(Zed、Neovim など)
  2. エージェント(Agent):AIコーディング支援プログラム(Claude Code、Gemini CLI など)
  3. セッション(Session):会話の単位、複数のセッションを並行して管理可能

エージェントはエディタのサブプロセスとして実行され、標準入出力(stdin/stdout)を通じて通信を行います。

agentclientprotocol.com

ACPとMCPの関係

ACPの技術仕様において重要なのは、Model Context Protocol(MCPとの関係です。MCPは、LLMが外部サービスやローカルリソースにアクセスするためのプロトコルです。ACPは可能な限りMCPの型を再利用し、エディタが既存のMCPサーバー設定を持つ場合、その設定をエージェントに渡すことができます。

agentclientprotocol.com

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem"],
      "env": {
        "ALLOWED_PATHS": "/path/to/project"
      }
    }
  }
}

JSON-RPC の基本

ACP は JSON-RPC 2.0 仕様に基づいており、以下の3種類のメッセージ形式が使われます。

agentclientprotocol.com

  1. リクエス:クライアントからエージェントへの要求
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "prompt",
  "params": {
    "sessionId": "session-123",
    "prompt": [{"type": "text", "text": "Hello Agent!"}]
  }
}
  1. レスポンス:エージェントからクライアントへの応答
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "stopReason": "endTurn",
    "meta": null
  }
}
  1. 通知:レスポンスを必要としない一方向メッセージ
{
  "jsonrpc": "2.0",
  "method": "session/notification",
  "params": {
    "sessionId": "session-123",
    "update": {
      "type": "agentMessageChunk",
      "content": {"type": "text", "text": "Processing..."}
    }
  }
}

Rustで実装するACPの詳細解説

それでは、実際のRustコードを通じてACPの動作原理を深く理解していきましょう。公式リポジトリの実装例(agent.rsclient.rs)を詳しく解説します。

agentclientprotocol.com

実装の準備と実行

まず、ACPの実装を実際に動かすための手順を確認しましょう:

# リポジトリのクローン
git clone https://github.com/zed-industries/agent-client-protocol
cd agent-client-protocol/rust

# エージェントのビルド
RUST_LOG=info cargo build --example agent

# クライアントの実行(エージェントを自動起動)
cargo run --example client -- ../target/debug/examples/agent

# 実行時の対話例
> Hello, Agent!
| Agent: Client sent: Hello, Agent!
> How can you help me with coding?
| Agent: Client sent: How can you help me with coding!

この実行により、以下の通信フローが発生します。

  1. 初期化フェーズ: プロトコルバージョンのネゴシエーション
  2. セッション確立: 作業ディレクトリとMCPサーバー設定の共有
  3. メッセージループ: プロンプトの送信と応答のストリーミング
  4. グレースフルシャットダウン: プロセス終了時のリソースクリーンアップ

エージェント側の実装(agent.rs)

基本構造とトレイト実装

use std::cell::Cell;
use agent_client_protocol::{
    self as acp, AuthenticateResponse, Client, ExtNotification, 
    ExtRequest, ExtResponse, SessionNotification, SetSessionModeResponse,
};
use tokio::sync::{mpsc, oneshot};

struct ExampleAgent {
    session_update_tx: mpsc::UnboundedSender<(acp::SessionNotification, oneshot::Sender<()>)>,
    next_session_id: Cell<u64>,
}

構造体の設計思想

  • session_update_tx:非同期チャネルの送信側で、セッション更新をバックグラウンドタスクに送信
  • next_session_idCell<u64>による内部可変性パターンで、&selfの不変参照でも値を更新可能

ACPトレイトの実装

#[async_trait::async_trait(?Send)]
impl acp::Agent for ExampleAgent {
    async fn initialize(
        &self,
        arguments: acp::InitializeRequest,
    ) -> Result<acp::InitializeResponse, acp::Error> {
        log::info!("Received initialize request {arguments:?}");
        Ok(acp::InitializeResponse {
            protocol_version: acp::V1,
            agent_capabilities: acp::AgentCapabilities::default(),
            auth_methods: Vec::new(),
            meta: None,
        })
    }

重要なポイント

  • async_trait(?Send):非Sendなfutureを許可し、LocalSet環境での実行を可能に
  • プロトコルバージョンの明示的な宣言
  • ケイパビリティ交換による機能のネゴシエーション

セッション管理

async fn new_session(
    &self,
    arguments: acp::NewSessionRequest,
) -> Result<acp::NewSessionResponse, acp::Error> {
    log::info!("Received new session request {arguments:?}");
    let session_id = self.next_session_id.get();
    self.next_session_id.set(session_id + 1);
    Ok(acp::NewSessionResponse {
        session_id: acp::SessionId(session_id.to_string().into()),
        modes: None,
        meta: None,
    })
}

セッションの概念

  • 各セッションは独立した会話コンテキスト
  • MCPサーバー設定の引き継ぎ
  • 作業ディレクトリの設定

プロンプト処理とストリーミング

async fn prompt(
    &self,
    arguments: acp::PromptRequest,
) -> Result<acp::PromptResponse, acp::Error> {
    log::info!("Received prompt request {arguments:?}");
    
    for content in ["Client sent: ".into()].into_iter().chain(arguments.prompt) {
        let (tx, rx) = oneshot::channel();
        
        // セッション更新の非同期送信
        self.session_update_tx
            .send((
                SessionNotification {
                    session_id: arguments.session_id.clone(),
                    update: acp::SessionUpdate::AgentMessageChunk { content },
                    meta: None,
                },
                tx,
            ))
            .map_err(|_| acp::Error::internal_error())?;
        
        // バックプレッシャー制御
        rx.await.map_err(|_| acp::Error::internal_error())?;
    }
    
    Ok(acp::PromptResponse {
        stop_reason: acp::StopReason::EndTurn,
        meta: None,
    })
}

ストリーミング設計

  • チャンク単位でのメッセージ送信
  • oneshot::channel()による同期制御
  • バックプレッシャーによる流量制御

メインループとタスク管理

メインループは、非同期ランタイムの中核部分であり、実際にエージェントが起動される場所です。

#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
    env_logger::init();  // RUST_LOG環境変数でログレベルを制御

    let outgoing = tokio::io::stdout().compat_write();
    let incoming = tokio::io::stdin().compat();

    let local_set = tokio::task::LocalSet::new();
    local_set
        .run_until(async move {
            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
            
            // エージェント接続の確立
            let (conn, handle_io) = acp::AgentSideConnection::new(
                ExampleAgent::new(tx), 
                outgoing, 
                incoming, 
                |fut| {
                    tokio::task::spawn_local(fut);
                }
            );
            
            // セッション通知処理タスク
            tokio::task::spawn_local(async move {
                while let Some((session_notification, tx)) = rx.recv().await {
                    let result = conn.session_notification(session_notification).await;
                    if let Err(e) = result {
                        log::error!("{e}");
                        break;
                    }
                    tx.send(()).ok();
                }
            });
            
            handle_io.await
        })
        .await
}

非同期ランタイムの設計

  • LocalSet:シングルスレッド実行環境(current_threadフレーバーと連携)
  • spawn_local:非Sendタスクの実行
  • チャネルによるタスク間通信
  • RUST_LOG=info環境変数でログ出力を制御(デバッグ時はRUST_LOG=debug

クライアント側の実装(client.rs)

プロセス管理とライフサイクル

クライアントは、エージェントをサブプロセスとして起動し管理します。実行時はコマンドライン引数でエージェントのパスを指定します:

# 実行例:ビルド済みのエージェントを指定
cargo run --example client -- target/debug/examples/agent
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
    let command = std::env::args().collect::<Vec<_>>();
    let (outgoing, incoming, child) = match command.as_slice() {
        [_, program, args @ ..] => {
            let mut child = tokio::process::Command::new(program)
                .args(args.iter())
                .stdin(std::process::Stdio::piped())
                .stdout(std::process::Stdio::piped())
                .kill_on_drop(true)  // 自動クリーンアップ
                .spawn()?;
            
            (
                child.stdin.take().unwrap().compat_write(),
                child.stdout.take().unwrap().compat(),
                child,
            )
        }
        _ => bail!("Usage: client AGENT_PROGRAM AGENT_ARG..."),
    };

プロセス管理のベストプラクティス

  • kill_on_drop(true):親プロセス終了時の自動クリーンアップ(孤児プロセスを防ぐ)
  • ストリーム所有権の明示的な管理(take()メソッド)
  • エラー時のグレースフルシャットダウン
  • エージェントプログラムへの引数の柔軟な受け渡し

プロトコル初期化

// 接続の確立
let (conn, handle_io) = acp::ClientSideConnection::new(
    ExampleClient {}, 
    outgoing, 
    incoming, 
    |fut| {
        tokio::task::spawn_local(fut);
    }
);

// バックグラウンドI/O処理
tokio::task::spawn_local(handle_io);

// 初期化ハンドシェイク
conn.initialize(acp::InitializeRequest {
    protocol_version: acp::V1,
    client_capabilities: acp::ClientCapabilities::default(),
    meta: None,
})
.await?;

// セッション作成
let response = conn
    .new_session(acp::NewSessionRequest {
        mcp_servers: Vec::new(),  // MCPサーバー設定
        cwd: std::env::current_dir()?,
        meta: None,
    })
    .await?;

対話的REPLの実装

Rustylineを使用した対話的インターフェースにより、ユーザーはエージェントと直接対話できます:

// Rustylineによる対話インターフェース
let mut rl = rustyline::DefaultEditor::new()?;
while let Ok(line) = rl.readline("> ") {
    let result = conn
        .prompt(acp::PromptRequest {
            session_id: response.session_id.clone(),
            prompt: vec![line.into()],
            meta: None,
        })
        .await;
    
    if let Err(e) = result {
        log::error!("{e}");
    }
}

REPLの動作例

> Hello, Agent!
| Agent: Client sent: Hello, Agent!
> What's the weather like?
| Agent: Client sent: What's the weather like?
> exit

Rustylineの利点

  • 履歴管理(上下矢印キーで過去の入力を参照)
  • カーソル移動とテキスト編集機能
  • Ctrl+C/Ctrl+Dによる適切な終了処理
  • 将来的な自動補完機能の追加が可能

セッション通知の処理

#[async_trait::async_trait(?Send)]
impl acp::Client for ExampleClient {
    async fn session_notification(
        &self,
        args: acp::SessionNotification,
    ) -> anyhow::Result<(), acp::Error> {
        match args.update {
            acp::SessionUpdate::AgentMessageChunk { content } => {
                let text = match content {
                    acp::ContentBlock::Text(text_content) => text_content.text,
                    acp::ContentBlock::Image(_) => "<image>".into(),
                    acp::ContentBlock::Audio(_) => "<audio>".into(),
                    acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri,
                    acp::ContentBlock::Resource(_) => "<resource>".into(),
                };
                println!("| Agent: {text}");
            }
            acp::SessionUpdate::ToolCall(tool_call) => {
                println!("| Tool call: {}", tool_call.name);
            }
            acp::SessionUpdate::Plan(plan) => {
                println!("| Plan: {}", plan.description);
            }
            _ => {}
        }
        Ok(())
    }

エラーハンドリングとプロトコルの堅牢性

タイムアウトとリトライの実装

use tokio::time::{timeout, Duration};

async fn prompt_with_timeout(
    conn: &ClientSideConnection,
    request: PromptRequest,
    timeout_secs: u64,
) -> Result<PromptResponse, Error> {
    match timeout(
        Duration::from_secs(timeout_secs),
        conn.prompt(request)
    ).await {
        Ok(Ok(response)) => Ok(response),
        Ok(Err(e)) => {
            log::error!("Prompt error: {}", e);
            Err(e)
        }
        Err(_) => {
            log::error!("Prompt timeout after {} seconds", timeout_secs);
            Err(Error::request_timeout())
        }
    }
}

エクスポネンシャルバックオフ

async fn reconnect_with_backoff(
    max_retries: u32,
) -> Result<Connection, Error> {
    let mut delay = Duration::from_secs(1);
    
    for attempt in 1..=max_retries {
        match establish_connection().await {
            Ok(conn) => {
                log::info!("Connected on attempt {}", attempt);
                return Ok(conn);
            }
            Err(e) if attempt < max_retries => {
                log::warn!("Attempt {} failed: {}", attempt, e);
                tokio::time::sleep(delay).await;
                delay *= 2;  // エクスポネンシャルバックオフ
            }
            Err(e) => return Err(e),
        }
    }
    
    Err(Error::max_retries_exceeded())
}

実践的な統合例

Claude Code ACPの設定

Claude Code ACP は、AnthropicのClaude AIをACPプロトコル経由で利用可能にする実装です。

github.com

{
  "agent_servers": {
    "Claude Code": {
      "command": "npx",
      "args": ["@zed-industries/claude-code-acp"],
      "env": {
        "ANTHROPIC_API_KEY": "your-api-key",
        "ACP_PERMISSION_MODE": "acceptEdits"
      }
    }
  }
}

Avante.nvimの設定

Avante.nvim は、NeovimでACPを利用するための実装です。

github.com

{
  "yetone/avante.nvim",
  event = "VeryLazy",
  build = "make",
  opts = {
    provider = "claude",
    mode = "agentic",
    acp_providers = {
      ["claude-code"] = {
        command = "npx",
        args = { "@zed-industries/claude-code-acp" },
        env = { ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") }
      }
    }
  }
}

ccswarm での実装

筆者が開発している ccswarm プロジェクトでは、当初は独自の仮想ターミナル実装を使用していましたが、ACPの登場を機に、より標準化されたアプローチへの移行を決定しました。

github.com

セキュリティに関する考慮事項

ACPを使用する際には、以下の点に注意が必要です。

ACPのセキュリティリスク

作っていて思ったのですが、ACPはエージェントにローカル環境への強いアクセス権を付与するので、本質的にセキュリティ上の懸念があります:

  • サードパーティエージェントのリスク: 信頼できない「野良エージェント」をインストールすると、マルウェアや情報漏洩のリスクが高まります
  • 権限の過剰付与: エージェントが必要以上の権限を持つと、システムリソースへの不正アクセスの可能性があります
  • データ漏洩のリスク: ローカルファイルやクレデンシャルなどの機密情報が、エージェントを通じて外部に漏洩する可能性があります
  • プロンプトインジェクション攻撃: 悪意あるプロンプトを通じて、エージェントに予期しない操作を実行させるリスクがあります

安全なACP利用のための対策

  1. 信頼できるソースからのみエージェントをインストール: 公式リポジトリや信頼できる開発者からのエージェントのみを使用
  2. 最小権限の原則を適用: エージェントには必要最小限の権限のみを付与
  3. サンドボックス環境での実行: 可能であれば、エージェントを隔離された環境で実行
  4. 監査ログの有効化: エージェントを通じて実行されたすべてのコマンドや操作を記録
  5. 機密情報のフィルタリング: APIキーやパスワードなどの機密情報を検出・削除するメカニズムを実装
  6. 定期的なセキュリティレビュー: エージェントの設定やコードを定期的にレビュー
  7. 確実なテストの実行: 本番環境に導入する前に、テスト環境で動作を徹底的に検証

ACPのメリットと今後の展望

開発者にもたらす価値

  1. ベンダーロックインからの解放: どのACP対応エディタでも、どのACP対応エージェントでも使用可能
  2. 開発効率の向上: 統一されたプロトコルにより、新しいAIエージェントの導入が簡単に
  3. エコシステムの成長: 標準化により、開発者はそれぞれの得意分野に集中可能

実践的な活用シナリオ

  • 大規模リファクタリング: プロジェクト全体の構造改善
  • バグ修正フロー: エラー解析から修正まで一貫した支援
  • コードレビュー自動化: セキュリティや品質の包括的チェック
  • プロジェクト横断的な分析: アーキテクチャレベルの改善提案

おわりに

Agent Client Protocolは、AIコーディング支援ツールの統合における新たな標準として、着実に開発者コミュニティで採用が進んでいます。MCPが大きな話題を集めた一方で、ACPはそこまで注目を浴びていないかもしれません。しかし、エディタ開発者やコーディングエージェントを実装したい開発者にとって、ACPは極めて実用的で学ぶ価値の高い技術です。

本記事で詳しく解説したRustの実装例は、ACPの設計思想を理解し、独自のエージェントを開発するための出発点となるでしょう。特に注目すべきは、Rustの所有権システムとACPの非同期通信モデルが見事に調和している点です。LocalSetによる非Sendなfutureの処理、mpsconeshotチャネルを組み合わせた確実な通信、kill_on_dropによる安全なプロセス管理など、これらの技術的選択は、堅牢で効率的なACP実装の基礎となります。

ACPの魅力は、JSON-RPCベースのシンプルなプロトコル設計により、数百行のコードで基本的なエージェントを実装できる敷居の低さにあります。一度ACPに対応すれば、Zed、Neovim、その他のACP対応エディタですぐに利用可能になり、独自のコーディングアシスタントやドメイン特化型エージェントの開発も容易になります。エディタとAIエージェントの統合は今後も加速することが予想され、ACPの知識は長期的な資産となるでしょう。

一般的な開発者にとって重要なのは、ACPが派手さはないものの、日々のコーディング作業を着実に改善する実用的な基盤技術であるという点です。MCPのような革新的な印象はないかもしれませんが、LSPがそうであったように、気がつけば開発環境に不可欠な存在となっているでしょう。

特に、独自のエディタプラグインやAIコーディングツールを開発したいと考えている方は、ぜひACPの仕様を学び、実装してみることをお勧めします。この標準化されたプロトコルは、あなたのツールを幅広いエコシステムに接続する架け橋となるはずです。

参考リソース

zed.dev