Ai logo

JetBrains AI

Supercharge your tools with AI-powered features inside many JetBrains products

Tutorials

AI エージェントを Kotlin で構築する - 第 1 回:ミニマルなコーディングエージェントの実装 

Read this post in other languages:

エージェントのビルドは一風変わっています。開発者は何かを実行するコードを書きません。開発者は何かを実行する機能を LLM に与えるコードを書き、LLM が何を実行すべきかを決定します。

この変化に慣れるまでには少し時間がかかります。エージェントにファイルを読み取る機能を与えた場合、どのファイルをいつ読み取るのかはエージェントが決定します。エージェントはメインのファイルから読み取ると思うかもしれませんが、 まずは 3 つのテストファイルを読み取ってパターンを理解しようとします。そのような動作を指示していなくても、 そのように動作するのです。

では、あなたならどんな機能を与えますか? 与えすぎれば、適切に機能を選択してくれません。少なすぎれば、ジョブを完了できないでしょう。このバランスを取るには、試行錯誤を繰り返して失敗に注目し、調整する必要があります。

このブログ連載記事では、実際のコーディングエージェントを一緒にビルドしながら AI エージェントを詳しく見ていきます。まずは基本的な 3 つのツールをビルドし、連載を進めながらそのエージェントを完全なエージェントへと変換していきましょう。エージェントの実際の動作、その動作を確認してデバッグする方法、さらには重要なアーキテクチャ上の決定について学ぶことになります。この連載が終了するまでには、このエージェントだけでなく、独自のエージェントをビルドする方法を理解できるようになるはずです。すべて Kotlin で書いていきます。

まずはコードベース内を移動して狙い通りの変更を行うエージェントのビルドから簡単に始めましょう。Koog を使用します。これは JetBrains のオープンソースフレームワークであり、プロンプトの送信、ツール呼び出しの解析、呼び出しの実行からなる実行ループを完了するまで繰り返し、処理するものです。実行ループは複雑になる場合がありますが、まずは基本的なものから始めた後、「実際にはどんな機能をエージェントに与えるべきか?」というより気になる問いに着目していきます。

エージェントを段階的にビルドする

何かを書く前に、実際の要件について考えてみましょう。具体的に何を実行できるエージェントを求めているのでしょうか?

バグの修正でしょうか? 機能の作成でしょうか? 古いコードのリファクタリングでしょうか?

私たちはこれらすべてを実行したがるものですが、 それは実際にはどういう意味なのでしょうか? これが意味するのは、コードベース内を移動して狙い通りの変更を適用できるエージェントを求めているということです。

エージェントはプロジェクト内にどんなファイルがあるのかを確認し、見つかったファイルを読み取り、それを編集するか、新しいファイルを作成できる必要があります。ここでは 3 つのツールを使用することで、これら 3 つの機能をエージェントに提供していきます。

これらの 3 つのツールはあらかじめビルドされており、Koog にバンドルされています。ここでは各ツールの動作を確認した後、すべてを GPT-5-Codex に接続します。そうすることで、コードベース内を探索し、ファイルを読み取り、変更を適用できる完全なエージェントを得ることができます。

確認することを教える

まずはこのツールを追加し、エージェントに探索手段を提供します。

tool(ListDirectoryTool(JVMFileSystemProvider.ReadOnly))

これでエージェントがどんなファイルが存在するかを確認できるようになりました。ただし、エージェントにファイルパスのリストを提供するだけではあまり役に立ちません。エージェントは構造を理解する必要があります。

構造を分かりやすくするため、このツールは出力に高度な整形を施します。JVM プロジェクトのパッケージは深い階層構造をしています。一般的には、src/main/kotlin/com/example/service/impl/UserServiceImpl.kt のようなパスになっています。このツールがすべての中間ディレクトリを表示した場合、何の追加情報も提供しない単一の子ディレクトリで出力が埋め尽くされてしまうでしょう。

単一ディレクトリの連鎖が長くなっていることを検出されると、連鎖しているディレクトリが次のように折りたたまれます。

/project/src/main/kotlin/Main.kt (<0.1 KiB, 20 lines)

同じ階層に複数のファイルがある場合は、ツリーが表示されます。

/project/src/main/kotlin/
  Main.kt (<0.1 KiB, 20 lines)
  Utils.kt (<0.1 KiB, 10 lines)

ファイルサイズと行数が表示されているのが分かります。なぜ表示されているのでしょうか?

この表示がなければ、私たちが経験した問題に行き当たることになるでしょう。  私たちがこのエージェントをビルドした当初はファイルサイズを含めていませんでした。エージェントはディレクトリを探索してファイル名を確認してはいたものの、サイズに関する情報をまったく把握していなかったのです。そのため、エージェントはファイルを理解する必要が生じた場合、すべての内容を読み取るしかなかったのです。

その後、大きな問題に遭遇しました。エージェントが 7,700 行以上もの Python テストファイルを読み取ろうとしたのです。LLM のコンテキストウィンドウがその 1 個のファイルで埋め尽くされました。作業を続行しようとしたエージェントがすでに読み取っていた他のファイル、実行するはずの内容、適用する必要のあった変更を見失ってしまったのです。1 個の巨大なファイルのせいで他のすべての情報がコンテキストから除外されてしまったのです。

そこで、行数とファイルサイズをディレクトリのリストに含めることにしました。これにより、エージェントがコードベースを探索する際、test_dataset.py に 7,700 行が含まれていることを確認してからファイルの読み取りを試みるようになり、 ファイル全体の代わりに特定の部分のみを読み取るか、ファイルがタスクに関連しない場合に読み取りをスキップするかを判断できるようになりました。

コードを読み取る

次に、エージェントはファイルを読み取る必要があります。

tool(ReadFileTool(JVMFileSystemProvider.ReadOnly))

JVMFileSystemProvider.ReadOnly とあることに注目してください。これがあるのは、このツールが java.nio.file API を直接呼び出すわけではないためです。これは Koog の FileSystemProvider インターフェースに対してビルドされています。

なぜかというと、 ツールは一度作成すればどこででも使用できるからです。ReadFileTool はプロバイダーのメソッドの呼び出ししか行いません。そのプロバイダーが開発者のローカルディスクから読み取るのか、SSH 経由でファイルから読み取るのか、クラウドストレージから読み取るのかは関知しません。それを別のプロバイダーに渡しても、ツールは同じように動作します。独自のファイルツールをビルドしている場合は、ストレージバックエンドに FileSystemProvider を実装し、Koog のファイルツールに渡せば機能します。

ここでは JVMFileSystemProvider.ReadOnly を使用しています。ReadOnly なのはなぜでしょうか? 読み取り権限と書き込み権限をプロバイダーレベルで分離することにより、権限レベルの異なるエージェントを作成することができます。コードの探索と解析のみを行うエージェントが必要ですか? エージェントに ReadOnly プロバイダーを使用するツールを与えると、エージェントが何も変更できないことが確実に分かります。このエージェントについては、変更機能を次に追加することにします。その場合、EditFileToolJVMFileSystemProvider.ReadWrite を代わりに使用します。明確に分けることで、個々のエージェントが実行可能なものとそうでないものを正確に制御できます。

このツールでは行の範囲もサポートされています。

readFile("Main.kt", startLine = 45, endLine = 72)

ListDirectoryTool の行数を覚えていますか? その機能を実現するための範囲サポートです。エージェントはディレクトリ内で大量の行を含むファイルを検出した場合、その読み取り方法についてより戦略的な判断を下すことができます。エージェントはファイルをある程度のまとまった分量で読み取ることも、何度も読み取り直さないようにすることも、特定の部分を抽出する場合もあります。これにより、7,700 行の Python テストファイルの例のような大規模なファイルによってコンテキストが埋め尽くされるのを防ぐことができます。

コードを変更する

では、エージェントに実際に何かを変更できる機能を提供しましょう。

tool(EditFileTool(JVMFileSystemProvider.ReadWrite))

EditFileTool は検索と置換を実行します。ファイルパス、検索するテキスト、置換後のテキストを指定する 単純な機能です。

ここで興味深いのは、これらの 3 つのパラメーターが何を渡すかによって異なる動作をすることです。

テキストを置換したい 場合は、元のテキストと置換後のテキストを指定します。

edit_file(
    path = "Main.kt", 
    original = "Hello World!", 
    replacement = "Hello Koog!"
)

何かを削除したい 場合は、置換後のテキストを空のままにします。

edit_file(
    path = "Main.kt", 
    original = "Hello World!", 
    replacement = ""
)

新しいファイルを作成したい 場合は、元のテキストを空のままにします。

edit_file(
    path = "Main.kt", 
    original = "", 
    replacement = "fun main() { println("Hello World!") }"
)

1 つのツールで 3 つの異なる処理が行われます。

失敗から学ぶ

ツールは動作するものの、実際のエージェントでテストする際にあることに気付きました。ファイルを何度か編集すると、別物になっていたのです。エージェントがさらに編集を加えようとしたところ、エージェントが探していたテキストが消えていました。先行する編集でテキストが変更されてしまったためです。

ツールは「編集失敗」を返していました。エージェントはファイルを読み取り直し、再試行するものだと思っていました。代わりに何が起きたのか分かりますか?

エージェントは「編集失敗」を確認し、「このファイルに問題がある。権限が与えられていないのか? 自分の変更内容で新しいファイルを作成してしまおう!」と考えていたのです。いきなり新しいファイルがあちこちに作成されてしまいました。編集されるはずだったコードが新しいファイルとして作成されたのです。 

修正は簡単で、 エージェントに実際に何が問題なのかを伝えるだけで済みました。エラーメッセージを「置換対象の元のテキストがファイルの内容に見つかりませんでした。ファイルを読み取り直し、最後に読み取った時点から元のテキストが変更されていないかどうかを確認してください」に変更したのです。この変更が功を奏し、 エージェントが「テキストが見つからない場合は読み取り直して再試行すべき」ことを学習してくれました。

機能を組み合わせる

3 つのツールがそろったので、 それらを GPT-5-Codex に接続しましょう。

これをフレームワークなしでゼロからビルドした場合、「プロンプトを GPT-5-Codex に送る、回答を解析する、ツールを呼び出す必要性を確認する、そのツールを実行する、結果を送り返す、これらを繰り返す」という実行ループを書くことになります。API リクエスト、エラーケース、トークン制限、ツール呼び出しプロトコルを処理する必要性が生じます。セットアップも手間も膨大です。

ここでは、Koog の AIAgent がそのループを処理します。このエージェントに LLM、ツール、指示を与えます。

val executor = simpleOpenAIExecutor(System.getenv("OPENAI_API_KEY"))
val agent = AIAgent(
    promptExecutor = executor,
    llmModel = OpenAIModels.Chat.GPT5Codex,

これにより、エージェントが GPT-5-Codex に接続されます。Koog は promptExecutor によって LLM の対話を処理します。API キーとモデルを指定すれば、残りの処理を任せることができます。

次に、ツールに関する情報を指定しましょう。

    toolRegistry = ToolRegistry {
        tool(ListDirectoryTool(JVMFileSystemProvider.ReadOnly))
        tool(ReadFileTool(JVMFileSystemProvider.ReadOnly))
        tool(EditFileTool(JVMFileSystemProvider.ReadWrite))
    },

ToolRegistry を使用してツールを列挙します。Koog は各ツールを取得して GPT-5-Codex が必要とする JSON 形式に変換し、その呼び出しを GPT-5-Codex がツールをリクエストする際に処理します。

次の指示を与えます。

    systemPrompt = """
        You are a highly skilled programmer tasked with updating the provided codebase according to the given task.
        Your goal is to deliver production-ready code changes that integrate seamlessly with the existing codebase 
        and solve given task.
    """.trimIndent(),

上記はエージェントにジョブを指示するシステムプロンプトです。「updating the provided codebase」(提供されたコードベースを更新する)の部分に注目してください。この指示により、エージェントは単に変更すべき内容を説明するのではなく、実際にファイルを変更するようになります。

実行方法を指示します。

    strategy = singleRunStrategy(),
    maxIterations = 100

戦略はエージェントのループです。singleRunStrategy() は Koog 組み込みの基本的なループです。エージェントは、タスクが完了したと判断するまでツールを呼び出します。複雑な独自の戦略の描き方については今後の記事で詳しく見ていきますが、ほとんどのタスクではこの戦略を使用できます。

しかし、無限ループになった場合はどうなるでしょうか? ある時はエージェントが 7,000 行のファイルを読み取り、トークン制限に達し、コンテキストが失われ、同じファイルを何度も読み取ってしまうことがありました。幸いにも maxIterations = 200 により、API の予算が完全に消費される前に停止しました。200 ステップ後にエージェントが処理を止めたのです。各ツールの呼び出しは 2 ステップ(呼び出しと応答)で行われるため、呼び出しは最大で 100 件となります。

場合によってはエージェントが少しだけ実行され、動作しているのか止まっているのか分からないことがあります。エージェントの動作を明確化し、処理内容を確認できるようにしましょう。Koog で実行されている内容を確認できるようにするため、handleEvents ブロックを指定します。

{
    handleEvents {
        onToolCallStarting { ctx -> println("Tool '${ctx.tool.name}' called with args:" +
                " ${ctx.toolArgs.toString().take(100)}")
        }
    }
}

Koog には onToolCallStartingonToolCallFinishedonAgentFinished など、フックできる各種のイベントがあります。ここでは発生するイベントをログに記録できるようにするため、各ツールの実行直前に実行される onToolCallStarting を使用しています。 

ただし、ログの記録内容は調整することをお勧めします。私たちが最初に実装した際にはすべてを出力していたため、 EditFileTool では何百行もスクロールしなければなりませんでした。その後、ツール名のみを試しました。EditFileTool は 3 回実行されましたが、何が編集されたのか分かりませんでした。今では 100 文字を出力しています。大量のテキストを見なくても、何が起きているのかを把握することができます。

エージェントを実行する

エージェントをビルドしたら、 今度は実際にそれを使用する方法が必要です。

suspend fun main(args: Array) {
    if (args.size < 2) {
        println("Error: Please provide the project absolute path and a task as arguments")
        println("Usage:  ")
        return
    }

    val (path, task) = args
    val input = "Project absolute path: $pathnn## Taskn$task"
    try {
        val result = agent.run(input)
        println(result)
    } finally {
        executor.close()
    }
}

エージェントにはプロジェクトが存在する場所と実行内容の 2 つの情報が必要です。このコードブロックでは、両方の情報をコマンドラインの引数から取得しています。情報がない場合はエラーを表示しますが、 そうでない場合は 2 つの情報を 1 つの入力文字列にまとめて agent.run() を呼び出しています。

そこから先はエージェントが処理します。プロジェクトを探索してファイルを読み取り、変更を適用し、完了するまでループします。ループが終了したら、結果を出力します。

以下は実際の動作です。

検証

エージェントを実行できたので、これら 3 つのツールが実際に何を達成できるのかを確認することにしました。この検証には、GitHub の課題にある 500 件のコーディングタスクのベンチマークとして提供されている SWE-bench Verified を使用しました。 

それらのコーディングタスクの約 50% を完了しました。50 行のコードにしては上出来です。

ただし、エージェントは何も検証しません。コンパイルをすることも、 テストを実行することもできません。コードを読み取り、その内容に応じて変更を適用しますが、その変更が実際に機能するかどうかを知るすべを持ちません。

また、ここではこれら 3 つのツールをビルドする方法を示すことなく使用しました。これらのツールが何をするかは分かっていても、その動作や独自ツールの作成方法はまだ分からないと思います。

この連載の次の記事では、私の同僚の Bruno がそれを説明します。 Bruno はシェル実行ツールをゼロからビルドし、エージェントがコマンドを実行し、出力を確認して失敗から学べるようにします。また、Koog ツールの仕組みを詳しく説明します。50 行のコードを理解してこそ、特定の問題に対応するツールを使ってエージェントを拡張できるようになるためです。

このコードは GitHub にあります。ぜひお試しになり、ご感想をお聞かせください。

オリジナル(英語)ブログ投稿記事の作者:

Fatimazahra El Akkary

Fatimazahra El Akkary

Machine Learning Engineer

image description