👌

コーディングエージェントの仕組みを理解する - マルチAIプロバイダー対応の実装から学ぶ

に公開

GitHub

はじめに

AIコーディングエージェント、使っているけど中身はどうなっているの?

日々進化するAIコーディング支援ツール。便利に使っているものの、その裏側でどのような仕組みが動いているのか、気になったことはありませんか?

本記事では、実際に手を動かしてコーディングエージェントを作りながら、その動作原理を深く理解することを目指します。Anthropic ClaudeとOpenAI GPTの2つのAIを扱える本格的なエージェントを、TypeScriptで一から実装していきます。

この記事で理解できること

  • 🤖 AIエージェントの基本構造 - メッセージループ、ツール呼び出し、会話管理の仕組み
  • 🔧 Function Callingの実装 - AIがツールを呼び出す仕組みを実装レベルで理解
  • 🎯 プロバイダー抽象化 - 異なるAI APIを統一的に扱う設計パターン
  • 💬 会話履歴の管理方法 - コンテキストの保持と最適化の実践
  • 🛠️ 実用的なツールの作り方 - ファイル操作、コマンド実行の安全な実装
  • 🔄 エージェントループの制御 - いつ処理を続け、いつ止めるかの判断

なぜ自作するのか?

既存のツールを使うだけでなく、自分で実装することには大きな学習効果があります:

1. 深い理解が得られる

  • ブラックボックスを開ける: AIエージェントの内部動作が手に取るようにわかる
  • トラブルシューティング力: 問題が起きたときに原因を推測できる
  • カスタマイズ力: 自分の用途に合わせて改造できる

2. 実践的なスキルが身につく

  • AI APIの使い方: 実際のAPIの仕様と癖を学べる
  • 非同期処理: ツール実行とAI応答の連携を実装
  • エラーハンドリング: 実運用で必要な堅牢性の実装方法

3. 応用範囲が広がる

  • 独自エージェントの開発: 学んだ知識で自分だけのエージェントを作成
  • 既存ツールの理解: 商用ツールの動作を推測できるようになる
  • 最新技術へのキャッチアップ: 新しいAIモデルへの対応方法がわかる

本記事の進め方

本記事は、段階的に機能を追加しながら理解を深めていくスタイルで構成されています:

  1. 基本構造の理解 - シンプルなエージェントから始める
  2. ツールシステムの実装 - AIが外部機能を使う仕組みを作る
  3. マルチプロバイダー対応 - 設計パターンを学びながら拡張
  4. 実用性の向上 - CLIやセットアップの自動化

各セクションでは、なぜこの設計にしたのかという判断理由も詳しく解説します。

想定読者

  • AIコーディングツールを使っていて、仕組みを知りたい方
  • TypeScript/Node.jsの基本的な知識がある方
  • AI APIを使ったアプリケーション開発に興味がある方
  • 自分でカスタマイズ可能なエージェントを作りたい方

完成するもの

本記事を読み終えると、以下の機能を持つコーディングエージェントが完成します:

  • ✅ 複数のAIプロバイダー(Claude、GPT)を切り替えて使用可能
  • ✅ ファイル操作、コマンド実行、TODO管理などの実用的なツール
  • ✅ 対話的なチャットモードとワンショット実行モード
  • ✅ 簡単にセットアップできる自動化スクリプト
  • ✅ 新しいツールやプロバイダーを追加できる拡張性

それでは、AIエージェントの内側を覗いていきましょう!

プロジェクト構成

coding-agent-sample/
├── src/
│   ├── index.ts              # CLIエントリーポイント
│   ├── agent.ts              # メインエージェントクラス
│   ├── providers/            # AIプロバイダー抽象化層
│   │   ├── types.ts          # プロバイダーの型定義
│   │   ├── anthropic.ts      # Anthropicプロバイダー実装
│   │   ├── openai.ts         # OpenAIプロバイダー実装
│   │   ├── factory.ts        # プロバイダーファクトリー
│   │   └── index.ts          # エクスポート
│   └── tools/                # ツール実装
│       ├── types.ts          # ツールの型定義
│       ├── fileTools.ts      # ファイル操作ツール
│       ├── commandTool.ts    # コマンド実行ツール
│       ├── todoTool.ts       # TODO管理ツール
│       └── toolManager.ts    # ツール管理クラス
├── setup.sh                  # セットアップスクリプト(Unix)
├── setup.bat                 # セットアップスクリプト(Windows)
├── package.json
├── tsconfig.json
├── .env.example
├── .gitignore
├── README.md
├── QUICKSTART.md             # クイックスタートガイド
├── building-multi-provider-coding-agent.md  # 技術記事
└── CHANGELOG.md              # 変更履歴

1. マルチプロバイダーアーキテクチャの設計

1.1 プロバイダー抽象化の必要性

AnthropicとOpenAIは、それぞれ独自のAPI形式を持っています:

  • メッセージ形式の違い: Anthropicはsystemを独立したパラメータとして扱い、OpenAIはメッセージ配列の一部として扱う
  • ツール呼び出しの違い: Anthropicはtool_usetool_result、OpenAIはtool_callstoolロール
  • レスポンス構造の違い: コンテンツブロックの表現方法が異なる

これらの違いを吸収するため、共通のインターフェースを定義します。

1.2 AIProviderインターフェース

すべてのプロバイダーが実装すべき共通インターフェース:

export interface AIProvider {
  name: string;
  
  sendMessage(
    messages: Message[],
    systemPrompt: string,
    tools: any[]
  ): Promise<ProviderResponse>;
  
  createToolResultMessage(toolCallId: string, result: string): any;
}

設計のポイント:

  • sendMessage: 統一されたインターフェースでメッセージ送信
  • createToolResultMessage: プロバイダー固有のツール結果形式を生成
  • name: プロバイダー識別のためのプロパティ

1.3 共通レスポンス形式

export interface ProviderResponse {
  content: string;
  toolCalls?: ToolCall[];
  stopReason?: string;
}

export interface ToolCall {
  id: string;
  name: string;
  arguments: Record<string, any>;
}

異なるプロバイダーのレスポンスを統一的な形式に変換することで、エージェントロジックをプロバイダーから独立させます。

2. プロバイダーの実装

2.1 Anthropicプロバイダー

export class AnthropicProvider implements AIProvider {
  name = 'anthropic';
  private client: Anthropic;
  private model: string;
  private maxTokens: number;

  constructor(apiKey: string, model = 'claude-sonnet-4-20250514', maxTokens = 4096) {
    this.client = new Anthropic({ apiKey });
    this.model = model;
    this.maxTokens = maxTokens;
  }

  async sendMessage(
    messages: Message[],
    systemPrompt: string,
    tools: any[]
  ): Promise<ProviderResponse> {
    const response = await this.client.messages.create({
      model: this.model,
      max_tokens: this.maxTokens,
      system: systemPrompt,  // Anthropic特有: systemは独立したパラメータ
      messages: messages as any,
      tools: tools as any
    });

    // レスポンスを共通形式に変換
    const textContents: string[] = [];
    const toolCalls: ToolCall[] = [];

    for (const block of response.content) {
      if (block.type === 'text') {
        textContents.push(block.text);
      } else if (block.type === 'tool_use') {
        toolCalls.push({
          id: block.id,
          name: block.name,
          arguments: block.input as Record<string, any>
        });
      }
    }

    return {
      content: textContents.join('\n'),
      toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
      stopReason: response.stop_reason
    };
  }

  createToolResultMessage(toolCallId: string, result: string): any {
    return {
      type: 'tool_result',
      tool_use_id: toolCallId,
      content: result
    };
  }
}

Anthropic特有の処理:

  • systemプロンプトを独立したパラメータとして送信
  • content配列からtexttool_useブロックを抽出
  • tool_result形式でツール結果を返す

2.2 OpenAIプロバイダー

export class OpenAIProvider implements AIProvider {
  name = 'openai';
  private client: OpenAI;
  private model: string;
  private maxTokens: number;

  constructor(apiKey: string, model = 'gpt-4-turbo-preview', maxTokens = 4096) {
    this.client = new OpenAI({ apiKey });
    this.model = model;
    this.maxTokens = maxTokens;
  }

  async sendMessage(
    messages: Message[],
    systemPrompt: string,
    tools: any[]
  ): Promise<ProviderResponse> {
    // OpenAI特有: systemプロンプトをメッセージ配列の先頭に追加
    const formattedMessages = [
      { role: 'system' as const, content: systemPrompt },
      ...messages.map(msg => ({
        role: msg.role as 'user' | 'assistant',
        content: typeof msg.content === 'string' 
          ? msg.content 
          : JSON.stringify(msg.content)
      }))
    ];

    const response = await this.client.chat.completions.create({
      model: this.model,
      max_tokens: this.maxTokens,
      messages: formattedMessages,
      tools: tools.length > 0 ? tools : undefined,
      tool_choice: tools.length > 0 ? 'auto' : undefined
    });

    const message = response.choices[0].message;
    const content = message.content || '';
    
    // ツール呼び出しを抽出
    const toolCalls: ToolCall[] = [];
    if (message.tool_calls) {
      for (const toolCall of message.tool_calls) {
        toolCalls.push({
          id: toolCall.id,
          name: toolCall.function.name,
          arguments: JSON.parse(toolCall.function.arguments)
        });
      }
    }

    return {
      content,
      toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
      stopReason: response.choices[0].finish_reason
    };
  }

  createToolResultMessage(toolCallId: string, result: string): any {
    return {
      role: 'tool',
      tool_call_id: toolCallId,
      content: result
    };
  }
}

OpenAI特有の処理:

  • systemプロンプトをメッセージ配列の一部として送信
  • tool_callsからツール呼び出し情報を抽出
  • role: 'tool'でツール結果を返す

2.3 ツール定義の変換

AnthropicとOpenAIではツール定義の形式も異なります:

// Anthropic用のツール定義(基本形式)
{
  name: 'read_file',
  description: 'Read the contents of a file',
  input_schema: {
    type: 'object',
    properties: { ... },
    required: [ ... ]
  }
}

// OpenAI用に変換
{
  type: 'function',
  function: {
    name: 'read_file',
    description: 'Read the contents of a file',
    parameters: {
      type: 'object',
      properties: { ... },
      required: [ ... ]
    }
  }
}

各プロバイダークラスに静的メソッドを用意:

export class OpenAIProvider implements AIProvider {
  static formatTools(tools: any[]): any[] {
    return tools.map(tool => ({
      type: 'function',
      function: {
        name: tool.name,
        description: tool.description,
        parameters: tool.input_schema
      }
    }));
  }
}

3. プロバイダーファクトリー

プロバイダーの作成と管理を一元化:

export class ProviderFactory {
  static create(config: ProviderConfig): AIProvider {
    switch (config.type) {
      case 'anthropic':
        return new AnthropicProvider(
          config.apiKey,
          config.model || 'claude-sonnet-4-20250514',
          config.maxTokens || 4096
        );
      
      case 'openai':
        return new OpenAIProvider(
          config.apiKey,
          config.model || 'gpt-4-turbo-preview',
          config.maxTokens || 4096
        );
      
      default:
        throw new Error(`Unknown provider type: ${config.type}`);
    }
  }

  static getDefaultModel(providerType: ProviderType): string {
    switch (providerType) {
      case 'anthropic':
        return 'claude-sonnet-4-20250514';
      case 'openai':
        return 'gpt-4-turbo-preview';
    }
  }

  static getAvailableModels(providerType: ProviderType): string[] {
    switch (providerType) {
      case 'anthropic':
        return [
          'claude-sonnet-4-5-20250514',
          'claude-sonnet-4-20250514',
          'claude-opus-4-20250514',
          'claude-3-5-sonnet-20241022',
          'claude-3-5-haiku-20241022'
        ];
      case 'openai':
        return [
          'gpt-5',
          'gpt-5-mini',
          'gpt-4-turbo-preview',
          'gpt-4',
          'gpt-4-0125-preview',
          'gpt-3.5-turbo'
        ];
    }
  }
}

Factoryパターンの利点:

  • プロバイダーの作成ロジックを一箇所に集約
  • 新しいプロバイダーの追加が容易
  • デフォルト値の管理が簡単

4. エージェントの実装

4.1 プロバイダーに依存しないエージェント

export class CodingAgent {
  private provider: AIProvider;
  private toolManager: ToolManager;
  private conversationHistory: Message[];

  constructor(provider: AIProvider) {
    this.provider = provider;
    this.toolManager = new ToolManager();
    this.conversationHistory = [];
  }

  async processMessage(userMessage: string): Promise<string> {
    this.conversationHistory.push({
      role: 'user',
      content: userMessage
    });

    let continueLoop = true;
    let finalResponse = '';

    while (continueLoop) {
      // プロバイダーを通じてメッセージを送信
      const response = await this.provider.sendMessage(
        this.conversationHistory,
        this.getSystemPrompt(),
        this.getToolDefinitions()
      );

      if (response.content) {
        console.log(chalk.blue('\n[Assistant]: ') + response.content);
        finalResponse = response.content;
      }

      // ツール呼び出しを処理
      if (response.toolCalls && response.toolCalls.length > 0) {
        const toolResults = await this.executeTools(response.toolCalls);
        
        // プロバイダー固有の履歴管理
        this.addToolCallsToHistory(response, toolResults);
        continueLoop = true;
      } else {
        this.conversationHistory.push({
          role: 'assistant',
          content: response.content
        });
        continueLoop = false;
      }
    }

    return finalResponse;
  }
}

4.2 プロバイダー固有の履歴管理

AnthropicとOpenAIで会話履歴の管理方法が異なるため、プロバイダーに応じて処理を分岐:

private addToolCallsToHistory(response: ProviderResponse, toolResults: any[]): void {
  if (this.provider.name === 'openai') {
    // OpenAI: アシスタントメッセージにtool_callsを含める
    this.conversationHistory.push({
      role: 'assistant',
      content: response.content || null
    } as any);
    
    // ツール結果を個別のメッセージとして追加
    for (const result of toolResults) {
      this.conversationHistory.push(result as any);
    }
  } else {
    // Anthropic: content配列を使用
    const contentBlocks: any[] = [];
    if (response.content) {
      contentBlocks.push({ type: 'text', text: response.content });
    }
    
    // ツール呼び出し情報を追加
    for (const toolCall of response.toolCalls!) {
      contentBlocks.push({
        type: 'tool_use',
        id: toolCall.id,
        name: toolCall.name,
        input: toolCall.arguments
      });
    }
    
    this.conversationHistory.push({
      role: 'assistant',
      content: contentBlocks
    });
    
    // ツール結果をユーザーメッセージとして追加
    this.conversationHistory.push({
      role: 'user',
      content: toolResults
    });
  }
}

重要なポイント:

  • OpenAIは各ツール結果が独立したメッセージ
  • Anthropicはツール結果を配列として1つのユーザーメッセージに含める
  • この違いを正しく処理しないと会話が続かない

5. CLIインターフェースの拡張

5.1 プロバイダー選択機能

async function selectProvider(): Promise<ProviderType> {
  const config = await loadConfig();
  
  // デフォルトプロバイダーが設定されている場合
  if (config.defaultProvider) {
    return config.defaultProvider;
  }

  // ユーザーに選択を求める
  const { provider } = await inquirer.prompt([
    {
      type: 'list',
      name: 'provider',
      message: 'Select AI provider:',
      choices: [
        { name: 'Anthropic (Claude)', value: 'anthropic' },
        { name: 'OpenAI (GPT)', value: 'openai' }
      ],
      default: 'anthropic'
    }
  ]);

  return provider;
}

5.2 複数APIキーの管理

interface Config {
  anthropicApiKey?: string;
  openaiApiKey?: string;
  defaultProvider?: ProviderType;
}

async function getApiKey(provider: ProviderType): Promise<string> {
  // 環境変数をチェック
  const envKey = provider === 'anthropic' 
    ? process.env.ANTHROPIC_API_KEY 
    : process.env.OPENAI_API_KEY;
  
  if (envKey) return envKey;

  // 設定ファイルをチェック
  const config = await loadConfig();
  const configKey = provider === 'anthropic' 
    ? config.anthropicApiKey 
    : config.openaiApiKey;
  
  if (configKey) return configKey;

  // ユーザーに入力を求める
  // ...
}

5.3 コマンドラインオプション

# プロバイダーを指定
coding-agent chat --provider anthropic
coding-agent chat --provider openai

# モデルも指定
coding-agent chat --provider openai --model gpt-4

# ワンショット実行
coding-agent run "Create a React component" --provider anthropic

# 利用可能なモデル一覧
coding-agent models --provider openai

実装:

program
  .command('chat')
  .option('-p, --provider <provider>', 'AI provider: anthropic or openai')
  .option('-m, --model <model>', 'Model to use')
  .action(async (options) => {
    const agent = await createAgent(options.provider, options.model);
    await runInteractiveMode(agent);
  });

program
  .command('models')
  .option('-p, --provider <provider>', 'AI provider: anthropic or openai')
  .action(async (options) => {
    const provider = options.provider || await selectProvider();
    const models = ProviderFactory.getAvailableModels(provider);
    
    console.log(`Available models for ${provider}:`);
    models.forEach(model => console.log(`${model}`));
  });

6. ツールシステムの互換性確保

6.1 ツール定義の統一

ツールは常にAnthropic形式で定義し、必要に応じて変換:

private getToolDefinitions(): any[] {
  const tools = this.toolManager.getAnthropicToolDefinitions();
  
  // プロバイダーに応じて変換
  if (this.provider.name === 'openai') {
    return OpenAIProvider.formatTools(tools);
  } else if (this.provider.name === 'anthropic') {
    return AnthropicProvider.formatTools(tools);
  }
  
  return tools;
}

6.2 ツール実行の統一

ツール実行ロジックはプロバイダーに依存しません:

private async executeTools(toolCalls: ToolCall[]): Promise<any[]> {
  const results: any[] = [];

  for (const toolCall of toolCalls) {
    const result = await this.toolManager.executeTool(
      toolCall.name,
      toolCall.arguments
    );

    // プロバイダー固有の結果メッセージを作成
    const resultMessage = this.provider.createToolResultMessage(
      toolCall.id,
      result.success ? result.output : (result.error || 'Failed')
    );

    results.push(resultMessage);
  }

  return results;
}

7. エラーハンドリングとデバッグ

7.1 プロバイダー固有のエラー処理

try {
  const response = await this.provider.sendMessage(...);
  // ...
} catch (error: any) {
  if (this.provider.name === 'anthropic' && error.status === 429) {
    console.error(chalk.red('Rate limit exceeded for Anthropic API'));
  } else if (this.provider.name === 'openai' && error.code === 'insufficient_quota') {
    console.error(chalk.red('Insufficient quota for OpenAI API'));
  } else {
    console.error(chalk.red(`Error: ${error.message}`));
  }
  throw error;
}

7.2 デバッグログ

if (process.env.DEBUG) {
  console.log(`[DEBUG] Provider: ${this.provider.name}`);
  console.log('[DEBUG] Request:', {
    messages: this.conversationHistory,
    tools: this.getToolDefinitions()
  });
  console.log('[DEBUG] Response:', response);
}

8. パフォーマンスとコスト最適化

8.1 トークン数の推定

class TokenEstimator {
  // 簡易的なトークン数推定(実際はtiktokenなどを使用)
  estimate(text: string): number {
    return Math.ceil(text.length / 4);
  }

  estimateMessages(messages: Message[]): number {
    return messages.reduce((total, msg) => {
      const content = typeof msg.content === 'string' 
        ? msg.content 
        : JSON.stringify(msg.content);
      return total + this.estimate(content);
    }, 0);
  }
}

8.2 会話履歴の管理

長い会話では履歴が肥大化するため、適切に管理:

class ConversationManager {
  private maxHistoryLength = 50;
  private maxTokens = 100000;

  trimHistory(messages: Message[]): Message[] {
    // メッセージ数による制限
    if (messages.length > this.maxHistoryLength) {
      messages = messages.slice(-this.maxHistoryLength);
    }

    // トークン数による制限
    const estimator = new TokenEstimator();
    const tokens = estimator.estimateMessages(messages);
    
    if (tokens > this.maxTokens) {
      // 古いメッセージから削除
      while (estimator.estimateMessages(messages) > this.maxTokens) {
        messages.shift();
      }
    }

    return messages;
  }
}

8.3 レスポンスのキャッシング

同じリクエストを繰り返さないようにキャッシュ:

class ResponseCache {
  private cache = new Map<string, ProviderResponse>();
  private maxCacheSize = 100;

  get(key: string): ProviderResponse | undefined {
    return this.cache.get(key);
  }

  set(key: string, response: ProviderResponse): void {
    if (this.cache.size >= this.maxCacheSize) {
      // 最も古いエントリを削除(LRU)
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, response);
  }

  generateKey(messages: Message[], systemPrompt: string): string {
    return JSON.stringify({ messages, systemPrompt });
  }
}

9. テスト戦略

9.1 プロバイダーのモック

class MockProvider implements AIProvider {
  name = 'mock';
  
  async sendMessage(
    messages: Message[],
    systemPrompt: string,
    tools: any[]
  ): Promise<ProviderResponse> {
    return {
      content: 'Mock response',
      toolCalls: undefined,
      stopReason: 'stop'
    };
  }

  createToolResultMessage(toolCallId: string, result: string): any {
    return {
      type: 'tool_result',
      tool_use_id: toolCallId,
      content: result
    };
  }
}

9.2 統合テスト

describe('CodingAgent with multiple providers', () => {
  it('should work with Anthropic', async () => {
    const provider = new AnthropicProvider(API_KEY);
    const agent = new CodingAgent(provider);
    
    const response = await agent.processMessage('List files in current directory');
    expect(response).toBeDefined();
  });

  it('should work with OpenAI', async () => {
    const provider = new OpenAIProvider(API_KEY);
    const agent = new CodingAgent(provider);
    
    const response = await agent.processMessage('List files in current directory');
    expect(response).toBeDefined();
  });
});

10. 本番環境への展開

10.1 環境変数の管理

# .env.production
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
DEFAULT_PROVIDER=anthropic
LOG_LEVEL=info

10.2 ロギングシステム

class Logger {
  private level: string;

  constructor(level = 'info') {
    this.level = level;
  }

  info(message: string, meta?: any): void {
    if (this.shouldLog('info')) {
      console.log(chalk.blue('[INFO]'), message, meta || '');
    }
  }

  error(message: string, error?: Error): void {
    if (this.shouldLog('error')) {
      console.error(chalk.red('[ERROR]'), message, error?.stack || '');
    }
  }

  debug(message: string, meta?: any): void {
    if (this.shouldLog('debug')) {
      console.log(chalk.gray('[DEBUG]'), message, meta || '');
    }
  }

  private shouldLog(level: string): boolean {
    const levels = ['debug', 'info', 'warn', 'error'];
    return levels.indexOf(level) >= levels.indexOf(this.level);
  }
}

10.3 モニタリングとメトリクス

class Metrics {
  private stats = {
    totalRequests: 0,
    successfulRequests: 0,
    failedRequests: 0,
    totalTokens: 0,
    providerUsage: new Map<string, number>()
  };

  recordRequest(provider: string, success: boolean, tokens: number): void {
    this.stats.totalRequests++;
    if (success) {
      this.stats.successfulRequests++;
    } else {
      this.stats.failedRequests++;
    }
    this.stats.totalTokens += tokens;
    
    const current = this.stats.providerUsage.get(provider) || 0;
    this.stats.providerUsage.set(provider, current + 1);
  }

  getStats() {
    return {
      ...this.stats,
      successRate: (this.stats.successfulRequests / this.stats.totalRequests * 100).toFixed(2) + '%',
      averageTokens: Math.round(this.stats.totalTokens / this.stats.totalRequests)
    };
  }
}

11. 新しいプロバイダーの追加

将来的に他のプロバイダー(Google Gemini、Cohere など)を追加する場合:

// providers/gemini.ts
export class GeminiProvider implements AIProvider {
  name = 'gemini';
  private client: any; // Google Gemini client

  async sendMessage(
    messages: Message[],
    systemPrompt: string,
    tools: any[]
  ): Promise<ProviderResponse> {
    // Gemini特有の実装
    // ...
  }

  createToolResultMessage(toolCallId: string, result: string): any {
    // Gemini特有のツール結果形式
    // ...
  }

  static formatTools(tools: any[]): any[] {
    // Gemini用のツール定義変換
    // ...
  }
}

// factory.tsに追加
export class ProviderFactory {
  static create(config: ProviderConfig): AIProvider {
    switch (config.type) {
      case 'anthropic':
        return new AnthropicProvider(...);
      case 'openai':
        return new OpenAIProvider(...);
      case 'gemini':
        return new GeminiProvider(...);
      // ...
    }
  }
}

12. ベストプラクティスとまとめ

12.1 重要なポイント

  1. 抽象化レイヤーの重要性: プロバイダー間の違いを適切に抽象化することで、メインロジックをシンプルに保つ

  2. エラーハンドリング: プロバイダー固有のエラーを適切に処理し、ユーザーに分かりやすいメッセージを提供

  3. 会話履歴の管理: プロバイダーによって異なる履歴形式を正しく扱う

  4. テストの充実: モックプロバイダーを使用して、実際のAPI呼び出しなしでテスト可能に

  5. パフォーマンス: トークン数の管理とキャッシング戦略

12.2 気をつけるべき点

  • API仕様の変更: プロバイダーのAPIが変更された場合、該当するプロバイダークラスのみを修正すれば良い設計にする

  • レート制限: 各プロバイダーには異なるレート制限があるため、適切に処理する

  • コスト管理: プロバイダーによって料金体系が異なるため、使用状況を監視する

  • セキュリティ: APIキーの管理、コマンド実行の制限など

12.3 拡張アイデア

  • ロードバランシング: 複数のプロバイダーを自動的に切り替えて負荷分散
  • フォールバック: プライマリプロバイダーが失敗した場合、セカンダリプロバイダーを使用
  • A/Bテスト: 同じタスクを異なるプロバイダーで実行し、結果を比較
  • プロバイダー横断分析: 各プロバイダーの得意分野を分析し、タスクに応じて最適なプロバイダーを選択

13. まとめ

マルチプロバイダー対応のコーディングエージェントを実装するには、以下の要素が重要です:

  1. 統一されたインターフェース設計: AIProviderインターフェースによる抽象化
  2. プロバイダー固有処理の分離: 各プロバイダークラスで違いを吸収
  3. ファクトリーパターン: プロバイダーの作成と管理を一元化
  4. 柔軟なCLI: ユーザーが簡単にプロバイダーを選択・切り替え可能
  5. 堅牢なエラー処理: プロバイダー固有のエラーにも対応
  6. 拡張性: 新しいプロバイダーを容易に追加できる設計

このアーキテクチャにより、単一のプロバイダーに依存せず、各プロバイダーの強みを活かしながら、柔軟で保守性の高いコーディングエージェントを構築できます。

14. セットアップの自動化

開発者体験を向上させるため、セットアップスクリプトを作成します。

14.1 Bashスクリプト(macOS/Linux)

#!/bin/bash
set -e  # エラーが発生したら即座に終了

# 色の定義
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# ログ関数
log_info() {
    echo -e "${BLUE}[INFO]${NC} $1"
}

log_success() {
    echo -e "${GREEN}[SUCCESS]${NC} $1"
}

log_error() {
    echo -e "${RED}[ERROR]${NC} $1"
}

# Node.jsのバージョンチェック
log_info "Checking Node.js version..."
if ! command -v node &> /dev/null; then
    log_error "Node.js is not installed."
    exit 1
fi

NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$NODE_VERSION" -lt 18 ]; then
    log_error "Node.js version must be 18 or higher."
    exit 1
fi

log_success "Node.js version: $(node -v)"

# 依存関係のインストール
log_info "Installing dependencies..."
npm install
log_success "Dependencies installed"

# ビルド
log_info "Building project..."
npm run build
log_success "Build completed"

# 設定ディレクトリの作成
CONFIG_DIR="$HOME/.coding-agent"
if [ ! -d "$CONFIG_DIR" ]; then
    mkdir -p "$CONFIG_DIR"
    log_success "Config directory created"
fi

# .envファイルの作成
if [ ! -f ".env" ]; then
    cp .env.example .env
    log_success ".env file created"
fi

log_success "Setup completed! 🎉"

スクリプトの機能:

  • Node.js/npmのバージョンチェック
  • 依存関係の自動インストール
  • TypeScriptの自動ビルド
  • 設定ディレクトリの作成
  • .envファイルの生成
  • カラフルなログ出力
  • エラーハンドリング

14.2 Windowsバッチスクリプト

@echo off
setlocal enabledelayedexpansion

echo ========================================
echo   Coding Agent Setup Script
echo ========================================

REM Node.jsのバージョンチェック
echo [INFO] Checking Node.js version...
where node >nul 2>nul
if %errorlevel% neq 0 (
    echo [ERROR] Node.js is not installed.
    exit /b 1
)

REM 依存関係のインストール
echo [INFO] Installing dependencies...
call npm install
if %errorlevel% neq 0 (
    echo [ERROR] Failed to install dependencies
    exit /b 1
)

REM ビルド
echo [INFO] Building project...
call npm run build

echo [SUCCESS] Setup completed!
pause

14.3 package.jsonへの統合

{
  "scripts": {
    "setup": "bash setup.sh",
    "setup:windows": "setup.bat",
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts",
    "postinstall": "npm run build"
  }
}

postinstallフック:

  • npm installの後に自動的にビルドを実行
  • 開発者がビルドコマンドを忘れるのを防ぐ
  • CI/CD環境でも自動的にビルドされる

14.4 対話型セットアップ

# グローバルインストールの確認
read -p "Do you want to install globally? [y/N]: " -n 1 -r
if [[ $REPLY =~ ^[Yy]$ ]]; then
    npm link
    log_success "Installed globally"
fi

# APIキーの設定
if [ -z "$ANTHROPIC_API_KEY" ] && [ -z "$OPENAI_API_KEY" ]; then
    log_warning "No API keys found. Please configure:"
    echo "  1. Edit .env file"
    echo "  2. Set environment variables"
    echo "  3. Run 'coding-agent config'"
fi

14.5 セットアップスクリプトのベストプラクティス

  1. 冪等性の確保
# 複数回実行しても安全
if [ ! -d "$DIR" ]; then
    mkdir -p "$DIR"
fi

if [ ! -f ".env" ]; then
    cp .env.example .env
fi
  1. エラーハンドリング
set -e  # エラーで即座に終了

if [ $? -eq 0 ]; then
    log_success "Success"
else
    log_error "Failed"
    exit 1
fi
  1. ユーザーフレンドリーな出力
# カラー出力
log_info "Processing..."
log_success "✓ Completed"
log_warning "⚠ Warning message"
log_error "✗ Error occurred"

# プログレス表示
echo -n "Installing dependencies..."
npm install > /dev/null 2>&1
echo " Done!"
  1. 環境の検証
# 必要なツールの確認
command -v node || { log_error "Node.js not found"; exit 1; }
command -v npm || { log_error "npm not found"; exit 1; }

# バージョンチェック
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
[ "$NODE_VERSION" -ge 18 ] || { log_error "Node.js >= 18 required"; exit 1; }
  1. クリーンアップ機能
# setup.shにクリーンアップオプションを追加
if [ "$1" = "--clean" ]; then
    log_info "Cleaning up..."
    rm -rf node_modules dist .env
    log_success "Cleaned"
    exit 0
fi

14.6 使用方法

# macOS/Linux
./setup.sh

# Windows
setup.bat

# npmスクリプト経由
npm run setup        # Unix系
npm run setup:windows # Windows

# クリーンアップ
./setup.sh --clean

14.7 CI/CD統合

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '18'
      - run: ./setup.sh
      - run: npm test

セットアップスクリプトの利点:

  • 開発者のオンボーディング時間を短縮
  • 環境構築の一貫性を保証
  • ドキュメントの代わりに実行可能なスクリプトを提供
  • CI/CD環境でも同じスクリプトを使用可能
  • 人的エラーの削減

参考資料

Happy Coding! 🚀

Discussion