C#でオリジナルコーディングエージェントをつくってみよう

この記事は 面白法人グループ Advent Calendar 2025 の5日目の記事です。

はじめに

こんにちは!技術部の村上です。
普段はUnityを用いてハイパーカジュアルゲームを作っています。

近年の爆発的な進歩により、AIはもはや日々の開発に欠かせない存在になってきました。中でも今年に入ってからのコーディングエージェントの発展には目を見張るものがあります。
カヤックのハイパーカジュアルゲームチームでは、全員がAIを積極的に利用できるよう制度が整備され、Claude Codeを始めとしたコーディングエージェントを活用して日々開発が行われています。

ところで、コーディングエージェントって実のところ何をやっていて、どう動いて、どう作られているんでしょう?
私は、動いているモノを見るとそういうのが気になっちゃうのがエンジニアって生き物の生態だと思っています。
そこで今回は、簡易なオリジナルコーディングエージェントを自分の手で実際につくってみて、その構造を掴んでいくことにしましょう。

そもそもLLMエージェントはなにをやっているのか

Claude Codeなどのコーディングエージェントを使ってみると、だいたい大まかに以下のような動作をするものを「LLMエージェント」と呼ぶようです。

while (true)
{
    ユーザーがLLMに指示する();
    while (LLMが満足するまで)
    {
        LLMがToolを呼ぶ();
        呼ばれたToolに応じてコンテキストを編集する();
    }
}

こうしてみると、やっていることはかなりシンプルなことがわかります。
世の各プロダクトを見ると、実際には

  • 起動時に各種設定やら初期共通指示やらを読み込む
  • 安全のためにサンドボックス機構を用意する
  • コンテキスト節約のためにサブエージェント機構をつける

などの工夫が各プロダクトでなされていますが、単体のエージェントの動作としては上記のフローが基本と考えてよいでしょう。

プログラムからLLMを呼び出すには?

人間が普通にLLMを利用するときには、ChatGPTのようなWeb UIを使用します。
一方、プログラムから利用する場合はWeb APIを叩くのが一般的です。
現在、多くのLLMサービスがWeb APIを提供しており、中でもOpenAIのChat Completions APIの形式がデファクトスタンダードになっています。
具体的には、以下のようなJSONデータをHTTPリクエストとして送信してやり取りすることになります。

{
  "model": "gpt-4o",
  "messages": [
    {"role": "system", "content": "あなたは優秀なエンジニアです。"},
    {"role": "user", "content": "C#でHello Worldを書いて"}
  ]
}

また、OpenAIはこのリクエストの組み立てやパース、通信をラップするライブラリを各言語向けに提供しており、C#向けのものも公開されています。
今回はOpenAIパッケージ (Nuget)を使用することにします。

LLMは自律的にToolを呼べるのか?

ここで1つ疑問が湧きます。
「LLMはテキストを生成するだけの機構なのに、どうやってファイルを保存したりコマンドを実行したりするんだろう?」

結論から言うと、LLM単体ではツールを実行できません。LLMができるのは、「ツールを実行したい」という意思表示だけです。 これを Tool Use (Function Calling) と呼びます。
流れとしてはこのような感じになります。

  1. プログラム側: 「君はファイル書き込みのために Write(path, content) という関数を使えるよ」と定義をプロンプトに含めて渡す。
  2. LLM: ユーザーの依頼を達成するためにファイル書き込みが必要だと判断すると、返答として「 Writepath="hoge.cs", content="..." で呼んでくれ」というJSONデータを返す。
  3. プログラム側: そのJSONを受け取り、実際にファイル書き込み処理を実行する。
  4. プログラム側: 「書き込みに成功したよ」という結果をLLMに伝える。

つまり、LLMエージェントとは「LLMが『実行したつもり』で出力した関数呼び出しの要求を、裏方であるプログラムが必死に実行してあげる」という二人三脚で成り立っているのです。

コーディングエージェントに必要なToolの整理

LLMエージェントが、特にコーディングエージェントとして振る舞うために必要なToolを考えてみると、最低限以下の3つがあれば成立しそうです。

  • Read: 指定したファイルの内容を読み込む
  • Write: 指定したファイルに、指定した内容を書き込む
  • List: 指定したディレクトリのファイル一覧を取得する

そのほか、 Exec として指定したコマンドを実行するToolを加えることで、ビルドやテストを自律的に行えるようになり、一気に可動域が広がります。
昨今のコーディングエージェント大ブレイクの主要因は、このToolを用意したことにあると言ってもよいかもしれません。

ただし、C#で外部コマンドを呼び出して万全のハンドリングを行うのは大変面倒です。 System.Diagnostics.Process のAPIを利用することになりますが、プロセス制御やプロセス間通信の細々したことをゴリゴリとやっていくことになり、実装が複雑になりすぎてしまうので、今回は割愛します。

コンテキストとはなんなのか

APIを用いて呼び出すLLMは、基本的にステートレスです。前回の会話の内容を覚えていてはくれません。
そこで、これまでのやりとりの履歴(自分が何を言ったか、相手が何を言ったか、ツールの実行結果はどうだったか)を全てリストにして、リクエストのたびに毎回全量送信します。
このリストが「コンテキスト」です。

var context = new List<ChatMessage>
{
    new ChatMessage(Role.System, "あなたはコーディングエージェントです。"),
    new ChatMessage(Role.User, "Hogeクラスを作成して"),
    new ChatMessage(Role.Assistant, "ToolUse: Write(path: 'Hoge.cs', contents: ...)"),
    new ChatMessage(Role.Tool, "Success"),
    new ChatMessage(Role.Assistant, "作成が完了しました。"),
};

これはLLMエージェントの作業記憶そのものと言えます。
Toolの実行結果もこのリストに追加することでLLMに見せることになります。

作業が進むにつれてこの履歴は肥大化していくため、古い履歴を要約したり、重要なファイルの中身だけを再注入したりと、各プロダクトで様々な工夫が見られます。

LLMにとっての「満足」とは?

ところで、冒頭のフローにおける内側のループの終了条件「LLMが満足するまで」について考えてみましょう。
LLMが「満足」したかどうかを一体どうやって判断したらいいのでしょうか?

今回はシンプルに、「LLMがTool Useを行わずにテキストのみを返答した」ときを「満足した」とみなすことにしてみます。なにか作業を行う必要があるとLLMが判断したならTool Useのリクエストが行われるはずであり、ユーザーへ作業が終了したことを報告したり、作業指示が不十分で質問したりするときにテキストの返答を行うはずだからです。
ただしこの場合、「ちょくちょく報告をしながら長時間稼働して一つのプロダクトをまるまる作り切る」みたいな使い方はできなくなります。世のプロダクトではもっと素敵な手法が使われているのでしょう。

実装していこう

ここまでで、コーディングエージェントの全体像と、それを構成するそれぞれの要素技術について見てきました。
ここからは実際に動くものを作っていくことにします。
上記の通り、ごくシンプルな作りのコーディングエージェントなので、1つのファイルで完結する程度の実装量です。

LM Studio

APIの使用料金や情報流出等を気にせず実験するために、今回はローカルLLMを利用することにします。
LM Studioは、GUIで簡単にローカルLLMを動作させることができるツールです。Windowsでもmacでも動きます。
OpenAI互換のAPIサーバーとして動作できるので、LLMプロダクトの試作に便利です。

LM Studio - Local AI on your computer

今回は、LLMモデルとして openai/gpt-oss-20b をロードし、ローカルサーバが http://localhost:1234/v1 で動作していることを前提とします。
利用するモデルは別のものでもよいと思いますが、Tool Useが行えるように訓練されたモデルを利用する必要があります。

APIサーバーへの接続準備

LLMが動作しているAPIサーバーに接続し、リクエストのやり取りを行うために必要なオブジェクトの準備を行います。

var credential = new System.ClientModel.ApiKeyCredential("lm-studio");
var clientOptions = new OpenAIClientOptions { Endpoint = new Uri("http://localhost:1234/v1") };
var client = new OpenAIClient(credential, clientOptions);
var chat = client.GetChatClient("openai/gpt-oss-20b");

今回は接続先を決め打ちしていますが、コマンドライン引数や環境変数から読み出す作りにしてもよいでしょう。

Tool定義

利用できるツールの名前と使い方、およびそのツールが対応する引数の説明を定義しておきます。
引数の説明に関しては、jsonをバイナリ化して渡す必要があります。
また、OpenAIのAPI形式ではjsonのキーの名前が CamelCase (というかPascalCase) である必要があります。

var camelCaseOption = new System.Text.Json.JsonSerializerOptions
{
    PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
};

var readTool = ChatTool.CreateFunctionTool(
    "Read",
    "Read contents of specified file.",
    BinaryData.FromObjectAsJson(
        new
        {
            Type = "object",
            Properties = new
            {
                Path = new
                {
                    Type = "string",
                    Description = "relative filepath, e.g. ./src/main.c"
                }
            }
        },
        camelCaseOption));

var writeTool = ChatTool.CreateFunctionTool(
    "Write",
    "Writes the content to the file. If the file does not exist, it will be newly created.",
    BinaryData.FromObjectAsJson(
        new
        {
            Type = "object",
            Properties = new
            {
                Path = new
                {
                    Type = "string",
                    Description = "relative filepath, e.g. ./src/main.c",
                },
                Content = new
                {
                    Type = "string",
                    Description = "The content to write to the file. Any existing content will be discarded and overwritten with this content."
                }
            }
        },
        camelCaseOption));

var listTool = ChatTool.CreateFunctionTool(
    "List",
    "Get a list of files and directories in the specified directory.",
    BinaryData.FromObjectAsJson(
        new
        {
            Type = "object",
            Properties = new
            {
                Path = new
                {
                    Type = "string",
                    Description = "relative directory path, e.g. ./src/internal"
                }
            }
        },
        camelCaseOption));

var completionOptions = new ChatCompletionOptions
{
    AllowParallelToolCalls = false,
    ToolChoice = ChatToolChoice.CreateAutoChoice(),
    Tools =
    {
        readTool,
        writeTool,
        listTool
    }
};

また、合わせて下記の構造体を作っておきましょう。

record ReadArguments(string Path);
record WriteArguments(string Path, string Content);
record ListArguments(string Path);

コアループ

前準備が終わったので、いよいよエージェントとしての心臓部となるメインのループを実装します。
冒頭の説明では2重ループとして示しましたが、ここでは「必要ならユーザに入力を求める」フローに解釈し直すことで1重のループとして表現しています。

なお、ツール実行部分に関しては少し長くなるので、ここではいったん記述を省いています。

var context = new List<ChatMessage>();
context.Add(ChatMessage.CreateSystemMessage("You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user."));

var skipUserInput = false;
while (true)
{
    if (!skipUserInput)
    {
        Console.Write("\n> ");
        var prompt = Console.ReadLine();
        if (prompt?.StartsWith("/exit") ?? true)
        {
            break;
        }
        context.Add(ChatMessage.CreateUserMessage(prompt));
    }
    skipUserInput = false;

    var result = chat.CompleteChat(context, completionOptions).Value; // LLMへのリクエストを実行
    if (result.FinishReason == ChatFinishReason.Stop)
    {
        var msg = string.Join('\n', result.Content.Where(p => p.Kind == ChatMessageContentPartKind.Text).Select(p => p.Text.TrimEnd()));
        context.Add(ChatMessage.CreateAssistantMessage(msg));
        Console.WriteLine(msg);
    }
    else if (result.FinishReason == ChatFinishReason.ToolCalls)
    {
        skipUserInput = true;
        context.Add(ChatMessage.CreateAssistantMessage(result));
        foreach (var toolCall in result.ToolCalls)
        {
            string toolResult;
            try
            {
                // Toolに応じて実行する (後述)
            }
            catch (Exception e)
            {
                toolResult = e.Message;
            }

            context.Add(ChatMessage.CreateToolMessage(toolCall.Id, toolResult));
            Console.ForegroundColor = ConsoleColor.DarkGray;
            Console.WriteLine(toolResult.TrimEnd());
            Console.ResetColor();
        }
    }
    else
    {
        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine(result.FinishReason);
        Console.ResetColor();
    }
}

メインの出力は通常色、ツール実行に伴うログ表示は暗い色、エラーを赤色で表示すると、何が起こっているのかを人間が把握しやすくなります。
API的にサポートされている機能のうち一部だけに対応するような実装ですが、LLMはそんなことを考慮して動いてくれるわけではないので、非対応のレスポンスはまとめてエラーにしています。
また、ツール実行の中で例外を吐いた場合には、起こったエラーをLLMにフィードバックするようにすると、自動で修正して実行し直してくれることが期待できます。
ただし、修正できないままにひたすらツールを呼ぼうとする無限ループに陥ってしまう場合もあるので、「最大呼び出し回数を決めて強制的に打ち切る」などの安全策をつけておいたほうが無難です。今回は、LMStudio側から強制停止が可能なので実装を省いています。

Toolに応じて実行

呼ばれたToolの名前に応じて、対応する動作を実装していきます。
ここは泥臭く1つ1つやるしかありません。
引数がおかしかったりなどのエラーは、前述した例外フィードバック機構により修正が期待できるので、そこまで気を使って実装しなくても大丈夫かと思います。

if (toolCall.FunctionName == "Read")
{
    var arg = System.Text.Json.JsonSerializer.Deserialize<ReadArguments>(toolCall.FunctionArguments, camelCaseOption);
    Console.WriteLine($"Read({arg?.Path ?? "null"})");
    toolResult = File.ReadAllText(arg?.Path ?? "");
}
else if (toolCall.FunctionName == "Write")
{
    var arg = System.Text.Json.JsonSerializer.Deserialize<WriteArguments>(toolCall.FunctionArguments, camelCaseOption);
    Console.WriteLine($"Write({arg?.Path ?? "null"})");
    Console.Write("allow? [y/N]: ");
    var allow = Console.ReadLine()?.ToLower().StartsWith('y') ?? false;
    toolResult = allow ? "complete" : "denied by user";
    if (allow)
    {
        File.WriteAllText(arg?.Path ?? "", arg?.Content ?? "");
    }
}
else if (toolCall.FunctionName == "List")
{
    var arg = System.Text.Json.JsonSerializer.Deserialize<ListArguments>(toolCall.FunctionArguments, camelCaseOption);
    var path = string.IsNullOrEmpty(arg?.Path) ? "." : arg.Path;
    Console.WriteLine($"List({path})");

    var dirs = Directory.GetDirectories(path).Select(d => d + Path.DirectorySeparatorChar);
    var files = Directory.GetFiles(path);
    toolResult = string.Join('\n', string.Join("\n", dirs), string.Join('\n', files));
}
else
{
    toolResult = "error: undefined tool";
}

完成!

無事完成です!
動かして、このコード自身のコードレビューを依頼してみましょうか。

実行結果 (クリックで開きます)

$ dotnet run agent.cs

> Analyze the implementation of this project and review the code. Output the results to review.md
List(.)

./agent.cs
Read(agent.cs)
※ 今回実装したファイルのコードがログとしてまるまる出力される。実装全文については後述するためここでは省略。
Write(review.md)
allow? [y/N]: y
complete
The review has been written to `review.md`.

> /exit

$ ls
agent.cs  review.md

指示したとおり、 review.md にレビューを書き込んだようです。 中身を見てみましょう。

review.md (クリックで開きます)

# Project Review – Agent CLI Tool

## 1. Overview
The repository contains a single C# console application (`agent.cs`) that implements an **interactive command‑line interface** for software‑engineering assistance powered by OpenAI’s function calling API. The tool exposes three built‑in *function calls*:

| Function | Purpose |
|----------|---------|
| `Read`   | Read the contents of a file specified by a relative path |
| `Write`  | Write (or overwrite) a file with supplied content |
| `List`   | Enumerate files and directories in a given directory |

The user interacts via standard input; the application forwards the prompt to an OpenAI chat model (`openai/gpt‑oss‑20b`) running locally at `http://localhost:1234/v1`. The model decides whether to respond directly or invoke one of the functions. The program then performs the file operation, feeds the result back into the conversation, and displays the updated assistant message.

> **Key take‑away** – The project demonstrates a minimal but functional example of function calling with .NET, showcasing how to wire up tools, handle responses, and keep an interactive loop.

## 2. Architecture & Flow
1. **Client setup** – Instantiates `OpenAIClient` with hard‑coded endpoint and API key (`lm-studio`).
2. **Tool definitions** – Uses `ChatTool.CreateFunctionTool` to register three function schemas (JSON objects describing argument structure). The schemas are built with `System.Text.Json` using a camelCase naming policy.
3. **Conversation context** – Maintains a list of `ChatMessage`s representing the dialogue state. Starts with a system prompt that explains the role.
4. **Main loop** –
   * Reads user input (unless skipping due to a pending tool call).
   * Calls `chat.CompleteChat` with the current context and options.
   * Handles three finish reasons:
     - **Stop**: normal assistant reply → prints it.
     - **ToolCalls**: iterates over calls, executes the corresponding .NET logic, feeds back results via `CreateToolMessage`, then continues the loop.
     - Other reasons are printed as errors.
5. **File operation logic** – Simple synchronous I/O wrapped in try/catch; user confirmation for writes.

## 3. Code Quality Assessment
| Aspect | Strengths | Weaknesses |
|--------|-----------|------------|
| **Readability** | Clear variable names, inline comments, and a single‑file structure make the flow easy to follow. | Mixing UI logic (console I/O) with business logic (tool execution) in one file reduces modularity.
| **Type safety** | Uses `record` types for argument deserialization – concise and immutable. |
| **Error handling** | Wraps each tool call in a try/catch, returning the exception message as tool output. |
| **Resource management** | No explicit disposal of `OpenAIClient`. The SDK may implement `IDisposable`, but it is not used.
| **Coding conventions** | Follows .NET naming conventions for classes/records; however, JSON property names are camel‑cased while record fields are PascalCase – handled correctly by the serializer.
| **Hard‑coded values** | Endpoint, model ID, and API key are embedded in source code. |
| **Security** | No validation or sandboxing of file paths – arbitrary read/write is possible from any directory relative to the process root. |
| **Performance** | Synchronous I/O blocks the UI thread; could become a bottleneck for large files or slow storage.

## 4. Functionality
* The application works as intended in a controlled environment: it can read, write, and list files.
* The tool invocation logic correctly handles single and multiple calls (though parallel calls are disabled).
* User confirmation before writing mitigates accidental data loss but is minimal.
* The `List` function concatenates directories and files into a newline‑separated string; however, the double `string.Join` call can produce an unintended blank line between the two lists.

## 5. Security Considerations
| Risk | Impact | Mitigation |
|------|--------|------------|
| **Path traversal** – Users could supply `../secret.txt` to read or delete files outside the intended workspace. | High – potential data leakage or corruption. | Resolve paths against a fixed root directory, reject absolute or parent‑directory components.
| **Unrestricted writes** – Any file can be overwritten. | Medium – accidental overwrite of critical configuration. | Prompt with full path and confirm; optionally enforce write permissions or a whitelist.
| **Hard‑coded API key** – `lm-studio` is visible in source code. | Low to medium (depends on environment). | Move credentials to environment variables or secure vaults.
| **No input sanitization** – User prompts are forwarded directly to the model. | Potential for prompt injection if the model misbehaves. | Sanitize user input or restrict allowed commands.

## 6. Performance & Scalability
* The single‑threaded, blocking I/O design is acceptable for a CLI but will not scale for high throughput or large files.
* Using async file APIs (`ReadAllTextAsync`, `WriteAllTextAsync`) would free the thread during disk operations.
* The chat loop could be improved by streaming partial responses (via `chat.CompleteChatStreaming`), giving a more responsive UX.

## 7. Extensibility & Maintainability
| Feature | Current State | Suggested Improvement |
|---------|---------------|------------------------|
| Adding new tools | Hard‑coded in the main file; each tool requires manual JSON schema construction and handling logic. | Extract a `Tool` abstraction (interface) that knows how to build its schema and execute itself.
| Configuration | Embedded constants. | Use a configuration file (`appsettings.json`) or command‑line arguments for endpoint, model ID, and root directory.
| Logging | Console writes only. | Integrate `Microsoft.Extensions.Logging` for structured logs.
| Unit testing | None. | Separate tool execution logic into classes/methods that can be unit‑tested without needing the OpenAI client.

## 8. Recommendations
### Short‑Term (quick wins)
1. **Add path sanitization** – Reject or normalize paths containing `..` or absolute components.
2. **Fix List output formatting** – Use a single `string.Join('
', dirs.Concat(files))`.
3. **Move credentials to environment variables** – `DOTNET_LM_STUDIO_API_KEY`, `OPENAI_ENDPOINT`, etc.
4. **Add basic logging** – Log tool calls and results to a file for audit.
5. **Wrap OpenAIClient in using statement** – Ensure proper disposal.

### Medium‑Term (refactor)
1. **Introduce a Tool registry** – Create an abstract `ITool` with methods: `CreateSchema()`, `ExecuteAsync(JsonElement args)`. Register each tool in a dictionary.
2. **Make the main loop async** – Use `await chat.CompleteChatAsync(...)` and async file I/O.
3. **Implement configuration abstraction** – Support multiple environments (dev, prod).
4. **Add unit tests** – Mock the OpenAI client; test argument deserialization and tool execution.
5. **Improve user prompts** – Provide help commands (`/help`) to list available tools and syntax.

### Long‑Term (feature expansion)
1. **Sandboxed file system** – Run all operations under a dedicated working directory with enforced permissions.
2. **Multiple models & endpoints** – Allow dynamic selection of model or remote OpenAI services.
3. **Plugin architecture** – Enable third‑party developers to add custom tools via DLLs.
4. **Graphical UI** – Wrap the CLI logic into a WPF/Blazor app for richer interaction.

## 9. Testing Strategy
* **Unit tests** for each tool: mock file system operations using libraries like `System.IO.Abstractions`.
* **Integration test** with a local OpenAI server stub that returns predetermined tool calls.
* **Security tests**: attempt path traversal inputs and assert the tool rejects them.
* **Performance benchmark**: measure latency of large file reads/writes under async vs sync.

## 10. Conclusion
The project is a concise, functional proof‑of‑concept for integrating OpenAI function calling into a .NET CLI. It demonstrates the core mechanics but would benefit from several enhancements to make it production‑ready:
* **Security hardening** – path validation and sandboxing.
* **Configurability** – externalize settings.
* **Modularity** – extract tool logic into separate classes for testability.
* **Asynchronous I/O** – improve responsiveness.

With these improvements, the tool could serve as a solid foundation for more complex AI‑powered development assistants.

パストラバーサルへの防御や入出力の非同期化、ツール定義の抽象化などを提案されています。わりとまっとうなレビューが行われているように思えますね。

今回のようにローカルLLMで動かしている場合、実行途中でコンテキスト長の上限に達してエラーになってしまうことが多々あるかもしれません。 LM Studioのモデル設定でコンテキスト長制限を広げることができるので、すぐに上限に達してしまう場合には調整してみてください。 同様に、とんちんかんな動作をしてしまう場合には Reasoning Effort (いわゆる思考の深さ) や Temperature (応答の一貫性) の設定値を調整してみるとよいです。
ただ、今回使用した gpt-oss-20b のような小さなモデルは、そもそも扱えるコンテキスト長があまり大きくなかったり、深く思考してもそれほど精度がよくなかったりします。 ポンコツ具合を楽しむ、くらいの気持ちで接してあげてください。

一応、下記の折りたたみに実装全文を貼り付けておきます。
.NET10でサポートされた、file-based appsに対応する形式です。

実装全文 (クリックで開きます)

#!/usr/bin/env dotnet
#:property PublishAot=false
#:package OpenAI@2.7.0

using OpenAI;
using OpenAI.Chat;

class Program
{
    static void Main(string[] args)
    {
        var credential = new System.ClientModel.ApiKeyCredential("lm-studio");
        var clientOptions = new OpenAIClientOptions { Endpoint = new Uri("http://localhost:1234/v1") };
        var client = new OpenAIClient(credential, clientOptions);
        var chat = client.GetChatClient("openai/gpt-oss-20b");

        var camelCaseOption = new System.Text.Json.JsonSerializerOptions
        {
            PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
        };

        var readTool = ChatTool.CreateFunctionTool(
            "Read",
            "Read contents of specified file.",
            BinaryData.FromObjectAsJson(
                new
                {
                    Type = "object",
                    Properties = new
                    {
                        Path = new
                        {
                            Type = "string",
                            Description = "relative filepath, e.g. ./src/main.c"
                        }
                    }
                },
                camelCaseOption));

        var writeTool = ChatTool.CreateFunctionTool(
            "Write",
            "Writes the content to the file. If the file does not exist, it will be newly created.",
            BinaryData.FromObjectAsJson(
                new
                {
                    Type = "object",
                    Properties = new
                    {
                        Path = new
                        {
                            Type = "string",
                            Description = "relative filepath, e.g. ./src/main.c",
                        },
                        Content = new
                        {
                            Type = "string",
                            Description = "The content to write to the file. Any existing content will be discarded and overwritten with this content."
                        }
                    }
                },
                camelCaseOption));

        var listTool = ChatTool.CreateFunctionTool(
            "List",
            "Get a list of files and directories in the specified directory.",
            BinaryData.FromObjectAsJson(
                new
                {
                    Type = "object",
                    Properties = new
                    {
                        Path = new
                        {
                            Type = "string",
                            Description = "relative directory path, e.g. ./src/internal"
                        }
                    }
                },
                camelCaseOption));

        var completionOptions = new ChatCompletionOptions
        {
            AllowParallelToolCalls = false,
            ToolChoice = ChatToolChoice.CreateAutoChoice(),
            Tools =
            {
                readTool,
                writeTool,
                listTool
            }
        };

        var context = new List<ChatMessage>();
        context.Add(ChatMessage.CreateSystemMessage("You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user."));

        var skipUserInput = false;
        while (true)
        {
            if (!skipUserInput)
            {
                Console.Write("\n> ");
                var prompt = Console.ReadLine();
                if (prompt?.StartsWith("/exit") ?? true)
                {
                    break;
                }
                context.Add(ChatMessage.CreateUserMessage(prompt));
            }
            skipUserInput = false;

            var result = chat.CompleteChat(context, completionOptions).Value;
            if (result.FinishReason == ChatFinishReason.Stop)
            {
                var msg = string.Join('\n', result.Content.Where(p => p.Kind == ChatMessageContentPartKind.Text).Select(p => p.Text.TrimEnd()));
                context.Add(ChatMessage.CreateAssistantMessage(msg));
                Console.WriteLine(msg);
            }
            else if (result.FinishReason == ChatFinishReason.ToolCalls)
            {
                skipUserInput = true;
                context.Add(ChatMessage.CreateAssistantMessage(result));
                foreach (var toolCall in result.ToolCalls)
                {
                    string toolResult;
                    try
                    {
                        if (toolCall.FunctionName == "Read")
                        {
                            var arg = System.Text.Json.JsonSerializer.Deserialize<ReadArguments>(toolCall.FunctionArguments, camelCaseOption);
                            Console.WriteLine($"Read({arg?.Path ?? "null"})");
                            toolResult = File.ReadAllText(arg?.Path ?? "");
                        }
                        else if (toolCall.FunctionName == "Write")
                        {
                            var arg = System.Text.Json.JsonSerializer.Deserialize<WriteArguments>(toolCall.FunctionArguments, camelCaseOption);
                            Console.WriteLine($"Write({arg?.Path ?? "null"})");
                            Console.Write("allow? [y/N]: ");
                            var allow = Console.ReadLine()?.ToLower().StartsWith('y') ?? false;
                            toolResult = allow ? "complete" : "denied by user";
                            if (allow)
                            {
                                File.WriteAllText(arg?.Path ?? "", arg?.Content ?? "");
                            }
                        }
                        else if (toolCall.FunctionName == "List")
                        {
                            var arg = System.Text.Json.JsonSerializer.Deserialize<ListArguments>(toolCall.FunctionArguments, camelCaseOption);
                            var path = string.IsNullOrEmpty(arg?.Path) ? "." : arg.Path;
                            Console.WriteLine($"List({path})");

                            var dirs = Directory.GetDirectories(path).Select(d => d + Path.DirectorySeparatorChar);
                            var files = Directory.GetFiles(path);
                            toolResult = string.Join('\n', string.Join("\n", dirs), string.Join('\n', files));
                        }
                        else
                        {
                            toolResult = "error: undefined tool";
                        }
                    }
                    catch (Exception e)
                    {
                        toolResult = e.Message;
                    }

                    context.Add(ChatMessage.CreateToolMessage(toolCall.Id, toolResult));
                    Console.ForegroundColor = ConsoleColor.DarkGray;
                    Console.WriteLine(toolResult.TrimEnd());
                    Console.ResetColor();
                }
            }
            else
            {
                Console.ForegroundColor = ConsoleColor.Red;
                Console.WriteLine(result.FinishReason);
                Console.ResetColor();
            }
        }
    }

    record ReadArguments(string Path);
    record WriteArguments(string Path, string Content);
    record ListArguments(string Path);
}

おわりに

こうやって自分で作ったコーディングエージェントを動かしていると、なんだかとてもかわいく思えてきてしまいますね。
2022年に、Googleのエンジニアが初期の対話型AIと話していて「AIに意識が芽生えた」と主張し解雇された騒動がありましたが、彼もこのような気持ちだったのかなぁと思えてきます。
自分で作ったものには愛着が芽生えるものではありますが、LLMのように予測できない挙動をするプログラムは、なおさら生き物のように感じられますよね。

既存のツールはもちろんバシバシ使い倒すべきですが、たまにはこうして「車輪の再発明」をしてみると、ブラックボックスだった中身がクリアになり、AIとの付き合い方も少し変わってくるかもしれません。

カヤックでは、ブラックボックスを恐れず開けていくエンジニアも募集しています!

明日は id:chibapse さんによる記事です。お楽しみに!

参考

OpenAI の Chat Completions API Reference

Building Effective AI Agents \ Anthropic