iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

AIコーディングエージェントの理解を深めるために自作してみた

こんにちは!iimonでCTOをしているもりごです。
本記事はiimon Advent Calendar 20259日目の記事となります!

最近ではClaude Code、Cursor、CodexなどAIコーディングエージェントを使用してコードを書くことが当たり前の様になっています。
こういったAIコーディングエージェントがどの様に動いているのか中身を知らずに使うよりも、仕組みを理解して使った方がうまく使えるのではないかという事で最小限の機能で実際に作ってみました。

今回コマンドラインツールを(個人的に)実装しやすいGo言語で作成してみました。

全体像

まず、AIコーディングエージェントを作る際の用語の整理をします。

ツール

ファイルの読み込み、ファイルの書き込みなどの機能を関数として定義しておき、LLMに呼び出し可能なツールとして認識させます。

Function Calling

Function Calling(Claudeの場合はTool Useと呼ばれている)は、LLMが外部ツールの呼び出し意図を構造化された形式で出力できるようにする仕組みです。これによって、LLMの出力を確実にパースしてツールを実行し、その結果をLLMにフィードバックする連携が容易になります。

Function Callingとはどういうものかについて下記の記事が分かりやすかったので知りたい方はぜひ参照してみてください。
OpenAIのFunctionCallingを理解する

処理の流れとしてはざっくり下記の様になっています。

  1. 要件を受け取ったらLLMへ投げます
  2. LLMがFunction Callingという方法を使ってツールを指定します
  3. Function Callingで指定されたらその関数をプログラム側で実行します
  4. 過去のやり取りを参照出来るようにメッセージ履歴に実行結果を追加し、Function Callingで関数が呼び出され続ける限り続けます(繰り返しの回数上限設定はしています)
  5. Function Callingで関数が呼び出されなくなったら処理の終了と認識してプログラムを終了させます

作っていく!

大まかな流れが分かったところで実装をしていこうと思います。今回作るツールの名前をCommandline Coding Agent、略してCCAと命名することとします。
また、AIコーディングエージェントを理解するに当たって実装は最小限の機能としたかったので下記の要件にしています。

  • 使用するAIのAPIをGeminiのAPIのみの対応、モデルを gemini-2.5-flash としました。 今回仕組みを理解する為に使用するので性能は最低限で良いと考え、安いgemini-2.5-flash にしました(無料枠もある)
  • ツールはファイルの読み込みとファイルの書き込みの機能のみ

*その他、一般的なエージェントだと様々な機能を持っていますが最低限動く機能に絞っています

一気に作るのではなく順を追って3段階に分けて作っていきます。

  1. まずはGeminiAPIへプロンプトのリクエストを送って返ってくる事を確認
  2. ファイルの読み込みをして要約が出来るようにします
  3. 読み込んで内容を把握したあと書き込みを行えるようにします

それでは始めます!

GeminiAPIへリクエストを送る

まずはGeminiAPIへリクエストを送ってみます。Google AI Studioで取得したGeminiのAPIキーを環境変数GEMINI_API_KEYに設定して実行します。

package main
import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/google/generative-ai-go/genai"
    "google.golang.org/api/option"
)

func main() {
    apiKey := os.Getenv("GEMINI_API_KEY")
    if apiKey == "" {
        log.Fatal("GEMINI_API_KEY environment variable is not set")
    }

    ctx := context.Background()

    client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey))
    if err != nil {
        log.Fatalf("Failed to create client: %v", err)
    }
    defer client.Close()

    model := client.GenerativeModel("gemini-2.5-flash")

    prompt := "あなたはコーディングアシスタントです。"

    resp, err := model.GenerateContent(ctx, genai.Text(prompt))
    if err != nil {
        log.Fatalf("Failed to generate content: %v", err)
    }

    if len(resp.Candidates) > 0 && len(resp.Candidates[0].Content.Parts) > 0 {
        fmt.Println("Gemini Response:")
        fmt.Println(resp.Candidates[0].Content.Parts[0])
    } else {
        fmt.Println("No response generated")
    }
}

ビルドはgo build -o cca、実行方法は./ccaです。

実行すると下記の様に返ってきました(毎回レスポンスは少しずつ変わります)

はい、その通りです!

私はあなたのコーディングアシスタントです。プログラミングに関する様々なタスクでお手伝いできます。例えば、以下のようなことができます。

*   **コードの生成:** 特定の機能やロジックのためのコードスニペットを作成します。
*   **デバッグ支援:** エラーの原因を特定し、解決策を提案します。
*   **コードの説明:** 複雑なコードや見慣れないコードの動作を解説します。
*   **リファクタリングの提案:** コードの品質、可読性、保守性を向上させる方法をアドバイスします。
*   **ベストプラクティス:** 特定の言語やフレームワークにおける効果的なコーディング手法を提案します。
*   **アルゴリズムやデータ構造:** 概念の解説や、具体的な実装例を提供します。
*   **技術選定のアドバイス:** 特定の要件に合った技術スタックやライブラリの検討をお手伝いします。

どんなことでもお気軽にお声がけください。早速、お手伝いできることはありますか?

まずは、LLMへ問い合わせをすることが出来るようになりました。これを元に機能を追加していこうと思います!

ファイル読み込みのツールを追加して、コードを要約させる

次はファイル読み込みを行う read_file というツールを追加して、ファイルをLLMに読み込ませて要約してもらうというところを目指します。

ファイル構成は下記の様にフラットにしています。

.
├── agent.go
├── client.go
├── go.mod
├── go.sum
├── main.go
└── tools.go

main.go

main.goでは先程作成したGeminiのAPIを叩く部分のコードを下記の様に変えました。

  • CCAへ指示したいことをコマンドライン引数から渡せるようにする
  • Geminiへの問い合わせ部分をclient.goへ移動
  • ツールの登録
  • エージェントループ(Geminiへの問い合わせ時にFunction Callingが返ってきたら実行して実行結果を返すというのを繰り返す)の処理の実行
package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "strings"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Println("Usage: cca \"<指示内容を入力してください>\"")
        os.Exit(1)
    }

    userInstruction := strings.Join(os.Args[1:], " ")
    ctx := context.Background()

    client, err := NewClient(ctx)
    if err != nil {
        log.Fatalf("Failed to create LLM client: %v", err)
    }
    defer client.Close()

    // ツールの登録
    availableTools := []Tool{
        NewReadFileTool(),
    }
    client.SetTools(availableTools)
    agent := NewAgent(client, ctx)

    systemPrompt := `あなたはコーディングアシスタントです。タスクを達成するために、必要に応じて利用可能なツールを使用してください。`
    prompt := fmt.Sprintf("%s\n\nユーザーの指示: %s", systemPrompt, userInstruction)

    // エージェントの実行
    response, err := agent.Run(prompt)
    if err != nil {
        log.Fatalf("Failed to run agent: %v", err)
    }

    fmt.Printf("Response:\n%s\n", response)
}

tools.go

tools.goでは使用できるツールの定義と実行が出来るようにしています。Executeメソッドではファイルパスを受け取ったらそのファイルを読み取って中身を文字列で返します。

package main

import (
    "fmt"
    "os"

    "github.com/google/generative-ai-go/genai"
)

type Tool interface {
    Name() string
    Description() string
    GetDeclaration() *genai.FunctionDeclaration

    Execute(args map[string]any) (string, error)
}
type ToolResult struct {
    ToolName string
    Success  bool
    Output   string
    Error    string
}

type ReadFileTool struct{}

func NewReadFileTool() *ReadFileTool {
    return &ReadFileTool{}
}

func (t *ReadFileTool) Name() string {
    return "read_file"
}

func (t *ReadFileTool) Description() string {
    return "ファイルの内容を読み取ります。ファイルパスを指定してください。"
}

func (t *ReadFileTool) GetDeclaration() *genai.FunctionDeclaration {
    return &genai.FunctionDeclaration{
        Name:        t.Name(),
        Description: t.Description(),
        Parameters: &genai.Schema{
            Type: genai.TypeObject,
            Properties: map[string]*genai.Schema{
                "path": {
                    Type:        genai.TypeString,
                    Description: "読み取るファイルのパス",
                },
            },
            Required: []string{"path"},
        },
    }
}

func (t *ReadFileTool) Execute(args map[string]any) (string, error) {
    pathVal, ok := args["path"]
    if !ok {
        return "", fmt.Errorf("missing required argument: path")
    }

    path, ok := pathVal.(string)
    if !ok {
        return "", fmt.Errorf("path argument must be a string")
    }

    fmt.Printf("[read_file]読み取るファイル: %s\n", path)
    content, err := os.ReadFile(path)
    if err != nil {
        return "", fmt.Errorf("failed to read file: %w", err)
    }

    return string(content), nil
}

// tool名(name)と引数(args)でツールを実行する
func (a *Agent) executeTool(name string, args map[string]any) (string, error) {
    for _, tool := range a.client.GetTools() {
        if tool.Name() == name {
            return tool.Execute(args)
        }
    }
    return "", fmt.Errorf("tool not found: %s", name)
}

client.go

client.goではGeminiへリクエストを送る部分の実装です。SetToolsメソッドでtools.goで定義したツールをGeminiへ送れるように設定も出来るようになっています。

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/google/generative-ai-go/genai"
    "google.golang.org/api/option"
)

type Client struct {
    genaiClient *genai.Client
    model       *genai.GenerativeModel
    ctx         context.Context
    tools       []Tool
}

func NewClient(ctx context.Context) (*Client, error) {
    apiKey := os.Getenv("GEMINI_API_KEY")
    if apiKey == "" {
        return nil, fmt.Errorf("GEMINI_API_KEY environment variable is not set")
    }

    genaiClient, err := genai.NewClient(ctx, option.WithAPIKey(apiKey))
    if err != nil {
        return nil, fmt.Errorf("failed to create genai client: %w", err)
    }

    model := genaiClient.GenerativeModel("gemini-2.5-flash")

    return &Client{
        genaiClient: genaiClient,
        model:       model,
        ctx:         ctx,
    }, nil
}

func (c *Client) Generate(prompt string) (string, error) {
    resp, err := c.model.GenerateContent(c.ctx, genai.Text(prompt))
    if err != nil {
        return "", fmt.Errorf("failed to generate content: %w", err)
    }

    if len(resp.Candidates) > 0 && len(resp.Candidates[0].Content.Parts) > 0 {
        return fmt.Sprintf("%v", resp.Candidates[0].Content.Parts[0]), nil
    }

    return "", fmt.Errorf("no response generated")
}

func (c *Client) SetTools(toolList []Tool) {
    c.tools = toolList

    var declarations []*genai.FunctionDeclaration
    for _, tool := range toolList {
        declarations = append(declarations, tool.GetDeclaration())
    }

    if len(declarations) > 0 {
        c.model.Tools = []*genai.Tool{
            {
                FunctionDeclarations: declarations,
            },
        }
    }
}

func (c *Client) GetTools() []Tool {
    return c.tools
}

func (c *Client) GetModel() *genai.GenerativeModel {
    return c.model
}

func (c *Client) Close() error {
    if c.genaiClient != nil {
        return c.genaiClient.Close()
    }
    return nil
}

agent.go

ここがメインの処理と言っても過言ではありません。

最初の図でも説明した通り、プロンプトを投げて、Function Callingが返ってきたらツールの呼び出しを行います。ツールの呼び出し結果を履歴も含めて再度Geminiへ投げます。その際、chatオブジェクトでGeminiとのやり取りの履歴を保存して毎回すべての履歴を送っています。

これをFunction Callingが返ってくる限り繰り返し、Textが返ってきたら表示して終了させるようにしています。また、永遠に繰り返されるとお金がかかるので最大でも5回のループに制限しています。

結果をちゃんとGeminiへ返さないと何度もツールの呼び出しが行われてしまい、履歴を送っていないと何をすべきなのか分からなくなってしまい適切なレスポンスが返ってこなかった為この2点でハマって苦労しました。

package main

import (
    "context"
    "fmt"

    "github.com/google/generative-ai-go/genai"
)

type Agent struct {
    client *Client
    ctx    context.Context
}

func NewAgent(client *Client, ctx context.Context) *Agent {
    return &Agent{
        client: client,
        ctx:    ctx,
    }
}

func (agent *Agent) Run(prompt string) (string, error) {
    const maxIterations = 5

    model := agent.client.GetModel()
    chat := model.StartChat()

    // 現在送信するメッセージ(最初はユーザープロンプト)
    currentMessage := []genai.Part{genai.Text(prompt)}

    for i := 0; i < maxIterations; i++ {
        fmt.Printf("\nSending to LLM (Iteration %d):\n", i+1)
        for idx, msg := range currentMessage {
            switch m := msg.(type) {
            case genai.Text:
                fmt.Printf("[%d] Text: %s\n", idx+1, string(m))
            case genai.FunctionResponse:
                fmt.Printf("[%d] FunctionResponse: %s\n", idx+1, m.Name)
                fmt.Printf("    Output: %v\n", m.Response)
            default:
                fmt.Printf("[%d] Other: %T\n", idx+1, msg)
            }
        }
        fmt.Println()

        // Geminiへリクエストを送る(SendMessageで会話履歴が自動管理される)
        resp, err := chat.SendMessage(agent.ctx, currentMessage...)
        if err != nil {
            return "", fmt.Errorf("failed to generate content: %w", err)
        }

        if len(resp.Candidates) == 0 {
            return "", fmt.Errorf("no candidates in response")
        }

        candidate := resp.Candidates[0]
        if candidate.Content == nil {
            return "", fmt.Errorf("no content in candidate")
        }

        var functionResponses []genai.Part

        for _, part := range candidate.Content.Parts {
            if fc, ok := part.(genai.FunctionCall); ok {
                // FunctionCallの場合

                fmt.Println("--- FunctionCall ---")
                fmt.Printf("Tool Call: %s\n", fc.Name)
                fmt.Printf("Arguments: %v\n", fc.Args)

                // FunctionCallの場合はLLMから指定されたツールと引数でツールを実行する
                toolOutput, err := agent.executeTool(fc.Name, fc.Args)
                if err != nil {
                    toolOutput = fmt.Sprintf("Error: %v", err)
                    fmt.Printf("Error: %v\n", err)
                } else {
                    fmt.Println("Success")
                }

                functionResponses = append(functionResponses, genai.FunctionResponse{
                    Name: fc.Name,
                    Response: map[string]any{
                        "output": toolOutput,
                    },
                })

            } else if text, ok := part.(genai.Text); ok {
                // テキストレスポンスの場合は処理を終了
                fmt.Println("--- テキストレスポンス ---")
                textStr := string(text)
                if textStr == "" {
                    return "", fmt.Errorf("Text response is empty")
                }
                return textStr, nil
            }
        }

        // FunctionCallで呼び出されたツールの結果を次のメッセージとして設定
        if len(functionResponses) > 0 {
            currentMessage = functionResponses
        }
    }

    return "", fmt.Errorf("最大ループ回数に達したので処理を停止しました")
}

ファイルを読み込ませて要約させてみる

引数に2つの整数値を受け取り合計を返す下記のような関数をcalc.goとして置いておきます。

package main

func Sum(a, b int) int {
    return a + b
}

ビルドして実行すると、FunctionCallでread_fileのツールが呼び出されてcalc.goが読み取られているのが分かります。その後読み取った結果をGeminiへ送っており、正しい要約がGeminiから返って来て終了しているのが確認できました。

$ ./cca "calc.goの内容を要約してください"

Sending to LLM (Iteration 1):
[1] Text: あなたはコーディングアシスタントです。タスクを達成するために、必要に応じて利用可能なツールを使用してください。

ユーザーの指示: calc.goの内容を要約してください

--- FunctionCall ---
Tool Call: read_file
Arguments: map[path:calc.go]
[read_file]読み取るファイル: calc.go
Success

Sending to LLM (Iteration 2):
[1] FunctionResponse: read_file
    Output: map[output:package main

func Sum(a, b int) int {
    return a + b
}
]

--- テキストレスポンス ---
Response:
calc.goファイルはGo言語の`main`パッケージに属しており、`Sum`という関数を定義しています。この関数は2つの整数`a`と`b`を受け取り、その合計を返します。

ファイル書き込みツールを追加してコードを書き換えさせる

読み込んで要約が出来たので書き込みツールを追加してcalc.goに掛け算の関数を追加出来るようにしてみます。
書き込みツールの追加だけなので差分のみ記載していきます。

tools.go

tools.goに書き込みツールを追記します。その際、"path/filepath""strings"のimportも追加が必要です。

今回書き込み処理なので万が一にでも重要なファイルに書き込んでしまうと困るので、書き込み指定されたファイルが実行ディレクトリ以下のファイルかどうかを確認してから書き込むようにしています。
メジャーなAIコーディングエージェントでは許可を求めると思いますが、処理を簡略化するためにディレクトリのチェックのみにしています。

type WriteFileTool struct {
    baseDir string // 書き込みを許可するベースディレクトリ
}

func NewWriteFileTool() *WriteFileTool {
    cwd, _ := os.Getwd()
    return &WriteFileTool{baseDir: cwd}
}

func (t *WriteFileTool) Name() string {
    return "write_file"
}

func (t *WriteFileTool) Description() string {
    return "ファイルに内容を書き込みます。現在のディレクトリ以下のファイルのみ対象です。"
}

func (t *WriteFileTool) GetDeclaration() *genai.FunctionDeclaration {
    return &genai.FunctionDeclaration{
        Name:        t.Name(),
        Description: t.Description(),
        Parameters: &genai.Schema{
            Type: genai.TypeObject,
            Properties: map[string]*genai.Schema{
                "path": {
                    Type:        genai.TypeString,
                    Description: "書き込むファイルのパス",
                },
                "content": {
                    Type:        genai.TypeString,
                    Description: "書き込む内容",
                },
            },
            Required: []string{"path", "content"},
        },
    }
}

// 書き込む対象のファイルが実行ディレクトリ以下のファイルかどうか確認
func (t *WriteFileTool) isPathAllowed(path string) (string, error) {
    // パスを絶対パスに変換
    absPath, err := filepath.Abs(path)
    if err != nil {
        return "", fmt.Errorf("Failed to resolve path: %w", err)
    }

    // パスを正規化してbaseDirで始まるかチェック
    cleanPath := filepath.Clean(absPath)
    if !strings.HasPrefix(cleanPath, t.baseDir) {
        return "", fmt.Errorf("Security Error: Write access to %s is denied", path)
    }

    return cleanPath, nil
}

func (t *WriteFileTool) Execute(args map[string]any) (string, error) {
    pathVal, ok := args["path"]
    if !ok {
        return "", fmt.Errorf("missing required argument: path")
    }
    path, ok := pathVal.(string)
    if !ok {
        return "", fmt.Errorf("path argument must be a string")
    }

    contentVal, ok := args["content"]
    if !ok {
        return "", fmt.Errorf("missing required argument: content")
    }
    content, ok := contentVal.(string)
    if !ok {
        return "", fmt.Errorf("content argument must be a string")
    }

    safePath, err := t.isPathAllowed(path)
    if err != nil {
        return "", err
    }

    fmt.Printf("[write_file] 書き込むファイル: %s\n", safePath)

    if err := os.WriteFile(safePath, []byte(content), 0644); err != nil {
        return "", fmt.Errorf("Failed to write file: %w", err)
    }

    return fmt.Sprintf("ファイルを書き込みました: %s", safePath), nil
}

これだけだと、まだ書き込みツールを認識していないのでmain.goのavailableToolsにNewWriteFileTool()を登録します。

availableTools := []Tool{
    NewReadFileTool(),
    NewWriteFileTool(),
}

書き込み時に一度確認のTextが送られてきます。現在の実装ではTextが来ると処理が終了してしまうので確認しないようにプロンプトを書き換えます。

systemPrompt := `あなたはコーディングアシスタントです。タスクを達成するために、必要に応じて利用可能なツールを使用してください。ファイルの変更が必要な場合は、確認を求めずに write_file ツールを使って直接書き込んでください。`

ビルドして「calc.goに引数で受け取った整数値を掛け算して返す関数を追加してください」という命令で実行してみます(ログに含まれるパスは一部マスクしています)

読み取ってほしいと伝えていなくても、ちゃんと追記するという事を理解して最初にread_fileツールが呼び出されてファイルを読み取り、読み取った文字列に掛け算の関数を追記した文字列をwrite_fileを呼び出して書き込もうとしていることが分かります。

$ ./cca "calc.goに引数で受け取った整数値を掛け算して返す関数を追加してください"

Sending to LLM (Iteration 1):
[1] Text: あなたはコーディングアシスタントです。タスクを達成するために、必要に応じて利用可能なツールを使用してください。ファイルの変更が必要な場合は、確認を求めずに write_file ツールを使って直接書き込んでください。

ユーザーの指示: calc.goに引数で受け取った整数値を掛け算して返す関数を追加してください

--- FunctionCall ---
Tool Call: read_file
Arguments: map[path:calc.go]
[read_file]読み取るファイル: calc.go
Success

Sending to LLM (Iteration 2):
[1] FunctionResponse: read_file
    Output: map[output:package main

func Sum(a, b int) int {
    return a + b
}
]

--- FunctionCall ---
Tool Call: write_file
Arguments: map[content:package main

func Sum(a, b int) int {
    return a + b
}

func Multiply(a, b int) int {
    return a * b
}
 path:calc.go]
[write_file] 書き込むファイル: /xxxxx/xxxxx/calc.go
Success

Sending to LLM (Iteration 3):
[1] FunctionResponse: write_file
    Output: map[output:ファイルを書き込みました: /xxxxx/xxxxx/calc.go]

--- テキストレスポンス ---
Response:
calc.goに引数で受け取った整数値を掛け算して返す`Multiply`関数を追加しました。

実際calc.goがどうなっていたかというと、下記の様に希望通りMultiplyという関数が追加されていました!

package main

func Sum(a, b int) int {
  return a + b
}

func Multiply(a, b int) int {
  return a * b
}

まとめ

自作して分かったこと

AIコーディングエージェントを触っているとエージェントが試行錯誤しながら頑張っているなとしか思っていなかったのですが、実際作ってみてログを出しながら動かしてみると、どのツールを選択するのか、選択したツールの実行結果を元に次何をするかを考えてまた別のツールを実行するということを繰り返している事が分かりました。

適切なツールを揃えること、LLMに迷わせないためのプロンプトの工夫によってエージェントの挙動が大きく変わることを実感しました。

ツール部分についてもコードが公開されているClineの実装を調べてみると、ファイルの読み書き以外にもwebサイトへのアクセスやコマンドの実行などたくさんのツールが用意されていました。
cline/src/core/prompts/system-prompt/tools/index.ts at main · cline/cline · GitHub

ツールの説明についても今回実装したものは「ファイルの内容を読み取ります。ファイルパスを指定してください。」という説明だけでしたがClineでは下記の様になっていました(DeepLで日本語に翻訳しています)

指定されたパスのファイルの内容を読み取る要求。既存ファイルの内容が不明な場合に内容を調査する必要がある場合に使用します。例えば、コードの分析、テキストファイルのレビュー、設定ファイルからの情報抽出などに利用できます。PDFおよびDOCXファイルから生のテキストを自動的に抽出します。生の内容を文字列として返すため、他の種類のバイナリファイルには適さない場合があります。ディレクトリの内容を一覧表示するためにこのツールを使用しないでください。ファイルに対してのみ使用してください。

cline/src/core/prompts/system-prompt/tools/read_file.ts at main · cline/cline · GitHub

少し前にMCPが策定されて色々なサービスやアプリケーションに接続出来るようになりましたが、MCPとして提供されていてもちゃんと説明されていないとうまく利用出来ない可能性がありそうです。

また、AIコーディングエージェントを利用する側の視点としては、LLMは曖昧な指示だと現状を把握するために手探りでツールを呼んで調べる必要があるので、最初から具体的にファイルパスや目的を指定して読み込ませた方がエージェントが迷わずうまく動いてくれるのかなと思いました。

さいごに

最後まで読んでくださりありがとうございます!

弊社ではエンジニアを募集しております。 ご興味がありましたらカジュアル面談も可能ですので、下記リンクより是非ご応募ください!
iimon採用サイト / Wantedly

次は技術力と安定感で信頼を集めるしらみずくんの記事です!どんな記事か楽しみです!!

参考

github.com

github.com

zenn.dev