ゼロからコーディングエージェントを作るならこんなふうに🛠️
3秒まとめ
- コーディングエージェントはXMLベースのツール定義とツールの実装で作れる
- 最低限必要なツールはListFile、ReadFile、WriteFile、AskQuestion、ExecuteCommand、Completeの6つ
- LLMにXML形式でツールを使わせることで、プログラムと会話の融合が実現できる
- 実装は意外と簡単!Go言語なら数百行で基本機能が作れる
サンプルコードはGoで書いていますが、特にコーディングエージェントを作るための言語依存はありません。また、サンプルコードは概要を示すためのもので確実な動作を保証するものではありませんのでご注意ください。
どんな人向けの記事?
- コーディングエージェントに興味がある方
- LLMを使ったコーディングエージェントを自作してみたい方
- コーディングエージェントの仕組みを作りながら学びたい方
はじめに
みなさん、Cline使ってますか?
ぼくは日々の開発でClineを使っていて、 めちゃくちゃ便利だな〜 と感じています。
精度がすごいわけではないかもしれませんが、とにかくコードを書くスピードは段違い。自分の何倍速なんだ...と思える大量のコードを生成してくれます。
さすがにプロダクトではすべてをノールックでApproveするバイブスコーディングはできませんが、個人開発レベルではバイブスしつつ、自分はClineの苦手なアーキテクチャを整えるリファクタをメインでやるとアプリ開発の速度が爆速になることを感じています。
Clineは強力なコーディングツールですが、コードを読んでみると、そこまで難しいことをしているわけではありません。
ふと思ったんです。「これ、自分でも作れるんじゃない?」って。
というわけで今回は、Clineを参考にしながら、ミニマムなコーディングエージェントの作り方を紹介します!サンプルコードはGoで書いていきますよ〜。
コーディングエージェントの仕組み
コーディングエージェントの基本的な仕組みは意外とシンプルです。
- LLMにすべてのレスポンスをXML形式のツールとして行うようシステムプロンプトを組む
- XMLのパーサーを書く
- システムプロンプトを使ってXMLでツールを定義する
- ツールをプログラムとして実装する
- メインループでLLMがCompleteツールを返すまで反復する
つまり、LLMにXML形式でツールを使わせるというのがポイントです。
例えば、LLMが以下のようなXMLを返してきたとします:
<read_file>
<path>main.go</path>
</read_file>
これをパースして、実際にmain.go
の内容を読み取るツールを実装し、その結果をLLMに返す。LLMはその結果を見て、次のツールを選択する...というループを繰り返すわけです。
必要なツール
最低限必要なツールは以下の6つです:
- ListFile: ディレクトリ構造を知るためのツール
- ReadFile: ファイルを読むためのツール
- WriteFile: ファイルに書き込むためのツール
- AskQuestion: ユーザーに質問するためのツール
- ExecuteCommand: コマンドを実行するためのツール
- Complete: タスク完了を示すツール
これだけあれば、基本的なコーディングタスクはほとんどこなせます。ファイルの読み書き、コマンド実行、ユーザーとのやり取りができれば、あとはLLMの能力次第ですからね!
ツールのXML定義
まずは、システムプロンプトでLLMにツールの使い方を教えます。各ツールのXML形式を定義しましょう
あなたはコーディングエージェントです。以下のツールを使ってタスクを完了してください:
# ListFile
ディレクトリ内のファイル一覧を取得します。
<list_file>
<path>ディレクトリのパス</path>
<recursive>true または false</recursive>
</list_file>
# ReadFile
ファイルの内容を読み取ります。
<read_file>
<path>ファイルのパス</path>
</read_file>
# WriteFile
ファイルに内容を書き込みます。
<write_file>
<path>ファイルのパス</path>
<content>
書き込む内容
</content>
</write_file>
# AskQuestion
ユーザーに質問します。
<ask_question>
<question>質問内容</question>
</ask_question>
# ExecuteCommand
コマンドを実行します。
<execute_command>
<command>実行するコマンド</command>
<requires_approval>true または false</requires_approval>
</execute_command>
# Complete
タスクの完了を示します。
<complete>
<result>タスクの結果や成果物の説明</result>
</complete>
このようなプロンプトをLLMに与えることで、LLMはXML形式でツールを使うようになります。
ほぼClineのシステムプロンプトをみて、MCP部分などを削除し、本当に必要な最小限だけを抽出しています。
もしうまく動かない場合は、Clineのプロンプトは非常に丁寧に書いてあるので必要部分を拝借すればうまくいくでしょう。
OSやシェルの環境についても追記しておくと、実行失敗の可能性を減らせるのでオススメです。
ツールの実装時に他の引数が欲しくなったらXMLにも要素を追加すればOK。
さらに、発展的なツールを実装したい場合も上記形式で追加すればLLMは必要なツールを適切に選んで実行してくれます。
ツールの実装
次に、各ツールを実際に実装していきましょう。Goでの実装例を見ていきます。
まずは、XMLをパースするための構造体を定義します
// tool.go
package main
import (
"encoding/xml"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ListFileParams はlist_fileツールのパラメータ
type ListFileParams struct {
Path string `xml:"path"`
Recursive string `xml:"recursive"`
}
// ReadFileParams はread_fileツールのパラメータ
type ReadFileParams struct {
Path string `xml:"path"`
}
// WriteFileParams はwrite_fileツールのパラメータ
type WriteFileParams struct {
Path string `xml:"path"`
Content string `xml:"content"`
}
// AskQuestionParams はask_questionツールのパラメータ
type AskQuestionParams struct {
Question string `xml:"question"`
}
// ExecuteCommandParams はexecute_commandツールのパラメータ
type ExecuteCommandParams struct {
Command string `xml:"command"`
RequiresApproval string `xml:"requires_approval"`
}
// CompleteParams はcompleteツールのパラメータ
type CompleteParams struct {
Result string `xml:"result"`
}
// ToolResponse はツールの実行結果
type ToolResponse struct {
Success bool
Message string
}
次に、各ツールの実装を行います。
面倒な部分ではありますが、このツールが実装部分のキモとなる部分です。
たとえば、ReadFile
で環境変数を読ませたくない場合は除外する。WriteFile
で書き込めるディレクトリに制限をかける。といった調整もツール実装部分を工夫することで柔軟に行えます。
// 1. ListFile - ディレクトリ内のファイル一覧を取得
func ListFile(params ListFileParams) ToolResponse {
path := params.Path
recursive := strings.ToLower(params.Recursive) == "true"
var files []string
var err error
if recursive {
err = filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
files = append(files, path)
return nil
})
} else {
entries, err := ioutil.ReadDir(path)
if err == nil {
for _, entry := range entries {
files = append(files, filepath.Join(path, entry.Name()))
}
}
}
if err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("ディレクトリの読み取りに失敗しました: %v", err),
}
}
result := fmt.Sprintf("ディレクトリ %s のファイル一覧:\n", path)
for _, file := range files {
result += fmt.Sprintf("- %s\n", file)
}
return ToolResponse{
Success: true,
Message: result,
}
}
// 2. ReadFile - ファイルの内容を読み取る
func ReadFile(params ReadFileParams) ToolResponse {
content, err := ioutil.ReadFile(params.Path)
if err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("ファイルの読み取りに失敗しました: %v", err),
}
}
return ToolResponse{
Success: true,
Message: string(content),
}
}
// 3. WriteFile - ファイルに内容を書き込む
func WriteFile(params WriteFileParams) ToolResponse {
dir := filepath.Dir(params.Path)
if err := os.MkdirAll(dir, 0755); err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("ディレクトリの作成に失敗しました: %v", err),
}
}
err := ioutil.WriteFile(params.Path, []byte(params.Content), 0644)
if err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("ファイルの書き込みに失敗しました: %v", err),
}
}
return ToolResponse{
Success: true,
Message: fmt.Sprintf("ファイル %s に書き込みました", params.Path),
}
}
// 4. AskQuestion - ユーザーに質問する
func AskQuestion(params AskQuestionParams) ToolResponse {
fmt.Printf("\n質問: %s\n回答: ", params.Question)
var answer string
fmt.Scanln(&answer)
return ToolResponse{
Success: true,
Message: fmt.Sprintf("ユーザーの回答: %s", answer),
}
}
// 5. ExecuteCommand - コマンドを実行する
func ExecuteCommand(params ExecuteCommandParams) ToolResponse {
requiresApproval := strings.ToLower(params.RequiresApproval) == "true"
if requiresApproval {
fmt.Printf("\n以下のコマンドを実行しますか?\n%s\n[y/n]: ", params.Command)
var answer string
fmt.Scanln(&answer)
if strings.ToLower(answer) != "y" {
return ToolResponse{
Success: false,
Message: "コマンドの実行がキャンセルされました",
}
}
}
cmd := exec.Command("sh", "-c", params.Command)
output, err := cmd.CombinedOutput()
if err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("コマンドの実行に失敗しました: %v\n出力: %s", err, string(output)),
}
}
return ToolResponse{
Success: true,
Message: fmt.Sprintf("コマンドの実行結果:\n%s", string(output)),
}
}
// 6. Complete - タスクの完了を示す
func Complete(params CompleteParams) ToolResponse {
return ToolResponse{
Success: true,
Message: fmt.Sprintf("タスク完了: %s", params.Result),
}
}
XMLパーサーの実装
次に、LLMからのレスポンスをパースして、適切なツールを呼び出す部分を実装します
こちらも若干実装が面倒な部分ですが、LLMに書かせるなどして手間を省略してパパっとやっちゃいましょう。
// parser.go
package main
import (
"encoding/xml"
"fmt"
"regexp"
"strings"
)
// ツールの種類を表す定数
const (
ToolTypeListFile = "list_file"
ToolTypeReadFile = "read_file"
ToolTypeWriteFile = "write_file"
ToolTypeAskQuestion = "ask_question"
ToolTypeExecuteCommand = "execute_command"
ToolTypeComplete = "complete"
)
// ParseAndExecuteTool はLLMのレスポンスをパースしてツールを実行する
func ParseAndExecuteTool(response string) (ToolResponse, string, bool) {
// XMLタグを抽出する正規表現
re := regexp.MustCompile(`<([a-z_]+)>([\s\S]*?)</\1>`)
match := re.FindStringSubmatch(response)
if len(match) < 3 {
return ToolResponse{
Success: false,
Message: "有効なツールが見つかりませんでした",
}, "", false
}
toolType := match[1]
toolContent := match[2]
switch toolType {
case ToolTypeListFile:
var params ListFileParams
if err := xml.Unmarshal([]byte(fmt.Sprintf("<%s>%s</%s>", toolType, toolContent, toolType)), ¶ms); err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("パラメータのパースに失敗しました: %v", err),
}, toolType, false
}
return ListFile(params), toolType, false
case ToolTypeReadFile:
var params ReadFileParams
if err := xml.Unmarshal([]byte(fmt.Sprintf("<%s>%s</%s>", toolType, toolContent, toolType)), ¶ms); err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("パラメータのパースに失敗しました: %v", err),
}, toolType, false
}
return ReadFile(params), toolType, false
case ToolTypeWriteFile:
var params WriteFileParams
if err := xml.Unmarshal([]byte(fmt.Sprintf("<%s>%s</%s>", toolType, toolContent, toolType)), ¶ms); err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("パラメータのパースに失敗しました: %v", err),
}, toolType, false
}
return WriteFile(params), toolType, false
case ToolTypeAskQuestion:
var params AskQuestionParams
if err := xml.Unmarshal([]byte(fmt.Sprintf("<%s>%s</%s>", toolType, toolContent, toolType)), ¶ms); err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("パラメータのパースに失敗しました: %v", err),
}, toolType, false
}
return AskQuestion(params), toolType, false
case ToolTypeExecuteCommand:
var params ExecuteCommandParams
if err := xml.Unmarshal([]byte(fmt.Sprintf("<%s>%s</%s>", toolType, toolContent, toolType)), ¶ms); err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("パラメータのパースに失敗しました: %v", err),
}, toolType, false
}
return ExecuteCommand(params), toolType, false
case ToolTypeComplete:
var params CompleteParams
if err := xml.Unmarshal([]byte(fmt.Sprintf("<%s>%s</%s>", toolType, toolContent, toolType)), ¶ms); err != nil {
return ToolResponse{
Success: false,
Message: fmt.Sprintf("パラメータのパースに失敗しました: %v", err),
}, toolType, false
}
return Complete(params), toolType, true
default:
return ToolResponse{
Success: false,
Message: fmt.Sprintf("未知のツールタイプ: %s", toolType),
}, toolType, false
}
}
メインループの実装
最後に、LLMとのやり取りを行うメインループを実装します。
メインループは簡便のためOpenAIを使っています。
// main.go
package main
import (
"context"
"fmt"
"os"
"strings"
"github.com/sashabaranov/go-openai"
)
func main() {
// OpenAI APIキーを環境変数から取得
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
fmt.Println("OPENAI_API_KEYが設定されていません")
return
}
// OpenAI APIクライアントを初期化
client := openai.NewClient(apiKey)
// システムプロンプトを設定
systemPrompt := `あなたはコーディングエージェントです。以下のツールを使ってタスクを完了してください:
# ListFile
ディレクトリ内のファイル一覧を取得します。
<list_file>
<path>ディレクトリのパス</path>
<recursive>true または false</recursive>
</list_file>
# ReadFile
ファイルの内容を読み取ります。
<read_file>
<path>ファイルのパス</path>
</read_file>
# WriteFile
ファイルに内容を書き込みます。
<write_file>
<path>ファイルのパス</path>
<content>
書き込む内容
</content>
</write_file>
# AskQuestion
ユーザーに質問します。
<ask_question>
<question>質問内容</question>
</ask_question>
# ExecuteCommand
コマンドを実行します。
<execute_command>
<command>実行するコマンド</command>
<requires_approval>true または false</requires_approval>
</execute_command>
# Complete
タスクの完了を示します。
<complete>
<result>タスクの結果や成果物の説明</result>
</complete>
必ず上記のいずれかのツールを使用してください。ツールを使わずに直接回答しないでください。`
// ユーザーからのタスク入力を受け取る
fmt.Println("コーディングエージェントにタスクを入力してください:")
var userTask string
fmt.Scanln(&userTask)
// 会話履歴を初期化
messages := []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: systemPrompt,
},
{
Role: openai.ChatMessageRoleUser,
Content: userTask,
},
}
// メインループ
isComplete := false
for !isComplete {
// LLMにリクエストを送信
resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT4,
Messages: messages,
},
)
if err != nil {
fmt.Printf("エラーが発生しました: %v\n", err)
return
}
// LLMのレスポンスを取得
assistantResponse := resp.Choices[0].Message.Content
// レスポンスをパースしてツールを実行
toolResponse, toolType, complete := ParseAndExecuteTool(assistantResponse)
// ツールの実行結果をメッセージに追加
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleAssistant,
Content: assistantResponse,
})
// ツールの実行結果をユーザーに表示
if toolType != ToolTypeAskQuestion && toolType != ToolTypeExecuteCommand {
fmt.Printf("\n[%s] %s\n", toolType, toolResponse.Message)
}
// ツールの実行結果をメッセージに追加
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: fmt.Sprintf("[%s Result] %s", toolType, toolResponse.Message),
})
// Completeツールが実行された場合はループを終了
if complete {
isComplete = true
}
}
}
実際に利用する場合はClaude3.7を推奨します。
繰り返しの処理になるためキャッシュが有効活用できるユースケースとなりますが、Claudeの場合は明示的な指定が必要です。
各SDKやリクエスト内容を参照し、キャッシュを有効にすることをオススメします。
また、各SDKには通常Usageパラメータが存在するため、そちらを参照してループごとに利用料金を出力するようにすると、精神が非常に安定します。コストが一定以上でループを中断するような仕組みを入れても良いかもしれません。
これは体感ですが、2-3ドル使いだしたら変なループに入っていて、問題も解決しないことが多いので止めても良いかなと考えています。
また、ループがCompleteしたら、コミットを作るような実装も加えておくとロールバックが非常に容易になるので組み込んでおくのもオススメです。
ツールの注意点
実際にコーディングエージェントを作る上で、いくつか注意点があります
ExecuteCommandの実装
ExecuteCommandは、コマンド実行前にユーザーの許可を取ることが重要です。
今回はrequires_approval
パラメータを使って実装していますが、LLMが行うこのオプションの判定は結構微妙なので信頼しない方がよいかもしれません。
事前許可済みのコマンド以外はユーザーの許可を入れるなどでより正確な制御を行うことも可能です。
逆に、バイブスコーディングを行いたい場合はDockerコンテナなど安全な環境で全許可という選択肢もあるかもしれません。
基本的には、危険なコマンドには確認を入れることを推奨します。
if requiresApproval {
fmt.Printf("\n以下のコマンドを実行しますか?\n%s\n[y/n]: ", params.Command)
var answer string
fmt.Scanln(&answer)
if strings.ToLower(answer) != "y" {
return ToolResponse{
Success: false,
Message: "コマンドの実行がキャンセルされました",
}
}
}
このように、ユーザーの許可を得てからコマンドを実行することで、安全性を高めることができます。
AskQuestionの実装
AskQuestionは、ユーザーの入力をただ追加してLLMに再度推論させなおすようループを組めばOKです。
func AskQuestion(params AskQuestionParams) ToolResponse {
fmt.Printf("\n質問: %s\n回答: ", params.Question)
var answer string
fmt.Scanln(&answer)
return ToolResponse{
Success: true,
Message: fmt.Sprintf("ユーザーの回答: %s", answer),
}
}
ユーザーの回答をLLMに返すことで、LLMはその回答を考慮して次のアクションを決定できます。
拡張性を考える
基本的なコーディングエージェントができたら、次は拡張性を考えてみましょう。
たとえば
- ブラウザ操作ツール: Puppeteerなどを使ってブラウザを操作するツール
- データベース操作ツール: SQLクエリを実行するツール
- APIリクエストツール: 外部APIにリクエストを送るツール
- コード解析ツール: ASTを解析してコードの構造を理解するツール
クローリングをFirecrawlを使って行ったり、SentryのAPIを叩いてIssueベースの開発を行わせるなども可能です。
ツールを自分が得意な言語で実装してみることで、拡張ツールを作る場合や自社サービスとの連携を行うようなエージェントを開発する際の勘所がつかめるのではないでしょうか。
まとめ
今回は、ゼロからコーディングエージェントを作る方法を紹介しました。基本的な仕組みは意外とシンプル。以下の要素で構成されています。
- LLMにXML形式でツールを使わせるシステムプロンプト
- XMLをパースして実際のツールを呼び出す処理
- 各ツールの実装
- LLMとのやり取りを行うメインループ
これらを実装するだけで、基本的なコーディングエージェントが完成します。あとは、必要に応じてツールを追加したり、UIを改善したりして、自分好みのエージェントに育てていきましょう!
ぼくも実際にこの記事のコードを使って、自分専用のコーディングエージェントを作ってみましたが、自分で書いたコードから、LLMが自動でコードを完成する体験はかなり楽しいのでぜひやってみてください。
今回扱ったのはコーディングエージェントでしたが、基本的な仕組みを理解しておけば他のAIエージェントをゼロから実装することもできるはずです。
DifyやLangChain, Mastraなど、AIエージェントを作るフレームワークは非常に増えてきました。
しかし、自社ツールとの連携や利用データの利活用を行いたい場合にそれらのツールが本当にベストなのかというと疑問が湧いてきます。
ゼロから書いておく選択肢があるとビジネス上優位に立てる場合もあるのではないでしょうか。
何か質問や改善点があれば、ぜひコメントで教えてください。一緒にコーディングエージェントの可能性を広げていきましょう!🚀
おまけ
ぼくは普段、Flutter/Dartでモバイルアプリ開発をしたり、GoでバックエンドAPIを実装したりしています。最近は特にAI関連の技術に興味があり、LLMを活用したツールやアプリケーションの開発に取り組んでいます。
今回のコーディングエージェントも、そんな探求の一環です。実際に手を動かして実装してみると、理解が深まりますし、自分だけの開発ツールが手に入るのでとても楽しいですよ!
みなさんも、ぜひ自分だけのコーディングエージェントを作ってみてください。きっと新しい発見があるはずです!
Discussion
こんにちは!
本筋とは異なりますが、
ioutil
は非推奨となっていてos
かio
で置き換えられるはずですので是非調べてみて下さい。ちょうど同じように自分でコーディングエージェントを作ってみたことがあるので、面白く読ませていただきました!
ちなみにその際にはAPI標準のtool callを使ったのですが、xmlを使うメリットがあれば教えてほしいです。
Clineがxmlを使っているのは知っていますが、tool callにしたほうがxmlをパースする手間やパースのエラーが減るのでよりよいのではと思った次第です。
ご指摘ありがとうございます。
確かに tool call を使う方が実装上の手間が減るかもしれませんし、効率的ですね!
個人的には、以下の3点でXMLの利用を好んでいます。
という点です。
1についてはXMLとtool useの比較というよりは、周辺プロンプトとの連携という点でプレーンテキストで書いた方が調整が容易だと感じた次第です。
Claudeのtool useはJSONで扱うものという認識なのですが、JSONは構造化に適した形式であると同時に、複数行の文章等を扱うには比較的不向きかなと考えています。
プレーンテキストでXML形式と説明文を書くといった体験は、ツール作成時には直感的かなと。
質の高いものを作ろうとすると、もっと凝った説明文や例外処理などを書くことになるはずなので。
2については特に、Gemma3などのローカルLLMの性能向上とレガシーな企業におけるAIユースにおいては外部ホストされたLLMを今後も使えない可能性があり、ニッチなモデルではtool useがサポートされないことも多いと考えています。なるべくベーシックな機能のみ、どんなモデルでも実現できるようなアイディアを記事にしたかったのでXMLで書きました。
3については、LLMに書かせれば手間が少ないという意味です。
私はFlutterやGoをよく書くのですが、コード中に
<tool> ... </tool>
のようなマークアップ表現を挟むことが少ないからそう勘違いしているだけかもしれません。もしかしたらWeb系の開発では問題が発生するのかもです。とはいえ、tool useとXMLのトークン数の比較等も行っていないので、ちゃんと真面目に計算した方がいいかもなと思いましたw (お金は大事ですし)
色々と気付きを得ることができました。ありがとうございます!
返信ありがとうございます!
いずれの理由ももっともですね。納得しました。特に2についてはtool useでは実現できないですね。
ちなみにxmlのパースはstreamingも考慮するとパーシャルなxmlを前提とせざるを得ず、Cline本家でもなかなか工夫が見られる実装になっています。
ご参考までに
おぉ、なるほど...
Partialは見逃していました。ありがとうございます!