Zenn
🐁

600行から始める自作Coding Agent

2025/03/28に公開
1

はじめに

弊社でも今年1月からDevinを使い始め、ライブラリ更新にともなうコードの書き換えなど、作業に近いアウトプットが決まり切った仕事をお任せできるようになってきました。一方、Devinに任せることが難しいタスクもまだまだ多く、ClineやCursorのAgent modeなども利用して生産性を上げることができないかと試行錯誤しています。

このような状況のなか、既成のAgentを使うのではなく自分自身で作ることで次の利点があると考えて、プライベートの時間を使ってCoding Agentを作成しています。

  • 仕組みを理解して極限までフル活用できるようになる
  • 今後、プロダクトの機能にLLMを組み込むためのスキルを身につける
  • (サブミッション) 普段Neovimを使うのでターミナルから使えるAgentが欲しい ※ Claude Code もあるが、OpenAIのモデルも使いたい
  • (サブミッション) vimの設定を育てるように自分の開発スタイルに合わせてAgentを育てる、というのも楽しみたい

この記事では、600行程度の最低限のサンプル実装をベースに、作り方やその後の改善ポイントについて紹介します。

OpenAIやAnthropicなどLLMのプロバイダがPythonやNode.jsのクライアントを提供しているため、世の中にはそれらを使った実装例が多いですが、他の言語に移植しやすいようにNode.jsの標準ライブラリのみを使って実装しています。なお、サンプル実装ではAnthropicのAPIを利用しています。同じようなものを作ろうとしている方にとって、少しでも参考になれば幸いです。

Coding Agentの仕組み

Agentとのやり取りの例です。指示を出すとToolを実行してファイルを読み書きしてくれます。

ファイルの編編集箇所を特定するためにcatコマンドでファイルを読むところ


patch_fileを使ってファイルを編集するところ

大まかに、以下のような流れで動作します。※ Toolの定義や形式は説明のための擬似的なものです

(1) SystemからModelに対して利用可能なToolとその使い方を教える

System:

あなたはユーザーのコーディングタスクをサポートするアシスタントです。

以下のToolが利用可能です。

- exec_command
  - input:
    - command: string
    - args: string[]
- write_file
  - input:
    - path: string
    - content: string
...

(2) UserがModelに対して指示を出す

User:

agent.mjsのOpenAI版を書いて

(3) ModelがToolの利用をリクエストする

Model:

まず、現在の `agent.mjs` ファイルを確認して、それをOpenAI APIを使用するバージョンに変更したいと理解しました。

{
  "type": "tool_use",
  "name": "exec_command",
  "input": {
    "command": "cat",
    "args": ["agent.mjs"]
  }
}

(4) Toolを実行して結果をModelに渡す

User:

{
  "type": "tool_result",
  "content": "<agent.mjsの内容>"
}

つまり、Coding Agentを作るには以下を実装する必要があります。

  • ファイルの読み書きなどの操作を行うTool群
  • ModelからのToolの利用リクエストを受け取り、実行して結果を渡す処理

サンプル実装の解説

実装した機能と重要なポイントについて説明します。

Claude APIとの通信

  • Modelとのやり取りが長くなると、レスポンスが遅くコストも掛かるため Prompt Caching を有効化しています。ありがたいことに 3/14 のアップデート でより簡単にCacheを有効化できるようになりました。
    const cacheEnabledMessage = /** @type {AnthropicMessage[]} */
    
  • 参考: OpenAIのAPIではPrompt Cachingが自動で有効になります。

コーディングに必要なToolの実装

  • ClaudeのTool useを利用:
    Clineの場合はTool useをサポートしていないModelでもToolが利用できるようにXML形式でやりとりするようになっていますが、OpenAI、Anthropicなど私が利用したいプロバイダはTool useをサポートしているため、ここに対する依存は許容と判断しました。

  • Toolの種類は exec_commandwrite_file, patch_file の3つのみです。可能な限り実装を少なくするために exec_command で自由にコマンドを実行させ、例外的に単純なコマンド実行では実現が難しい場合に別ツールとして切り出す、という方針を取りました。例えば、ファイルの内容を読む場合は exec_commandcat を実行すれば良い、ということです。

    /** @type {Tool[]} */
    const tools = [
      createExecCommandTool(),
      createWriteFileTool(),
      createPatchFileTool(),
    ];
    
  • ファイルの書き込み: exec_command で実現する場合、 echo "content" > path/to/file というコマンドを実行することになりますが、content内の特殊文字のエスケープに失敗するケースが多かったのと、シェルのパイプ | やリダイレクト > を許容すると、今後ツール実行の自動承認を実装する際に安全性のチェックが難しくなるため write_file として切り出しました。

  • ファイルの編集: 当初、write_filePatch file を書かせて、exec_commandpatch を実行する方針でしたが、Patch fileの生成がうまくできず、patch_file として切り出しました。形式は aiderのEdit formats のdiffを参考にしました。Clineも同様のフォーマットを利用しています。

Tool useの承認、実行

今回は最低限の実装として、すべてのTool利用を確認する実装にしました。

// There are pending tool uses
if (pendingToolUses.length > 0) {
  if (trimmedInput.match(/^(yes|y)$/)) {
    // Tool use approved
    const toolResultContents = await Promise.all(
      pendingToolUses.map((toolUse) => executeTool(toolUse, toolByName)),
    );
    messages.push({
      role: "user",
      content: toolResultContents,
    });
  } else {
    // Tool use rejected
    /** @type {AnthropicMessageContentToolResult[]} */
    const toolUseRejectContents = pendingToolUses.map((toolUse) => ({
      type: "tool_result",
      tool_use_id: toolUse.id,
      content: [{ type: "text", text: "Tool use rejected" }],
    }));

    messages.push({
      role: "user",
      content: [...toolUseRejectContents, { type: "text", text: trimmedInput }],
    });
  }

改善ポイント

今回紹介したサンプル実装は最低限のものなので、実際に使ってみるとさまざまな改善ポイントが見つかります。以下、私が実際に使っていく中で改善したものをいくつか紹介します。

Toolの使用例の提示

特に exec_command のような自由度の高いToolの場合、どのようなコマンドがどのように実行できるかを提示することで、Modelがより正しくToolを利用できるようになります。

  • find . -name "*.js" | xargs wc -l のようにパイプを使ったコマンドを実行する場合はshellによる解釈が必要なため、{ command: "bash", args: ["-c", "find . -name '*.js' | xargs wc -l"] } のように実行する必要がありますが、使用例をToolのdescriptionに追加しないと正しく扱うことができませんでした。
  • node_modules など .gitignore で指定されているファイルは多くのケースで不要なので、findgrep よりもデフォルトでそれらを無視する fdripgrep を使うように促すと良いです。(prompt.mjs)
    File and directory command examples:
    - Find files: fd ["<regex>", "path/to/directory"]
    - Search for a string in files: rg ["-n", "<regex>", "./"]
    

Tool useの自動承認

破壊的な変更以外は自動で実行して、タスクを先に進めて欲しいので、以下のように許可するパターンをもとに自動承認する機能を実装しました。また、同じ失敗を繰り返して無駄にTokenを消費することを防ぐため、自動承認の回数に上限を設定しています。
(tool.mjs)

[
  // Web search
  { toolName: tavilySearchTool.def.name, input: { query: /./ } },

  // Exec command
  {
    toolName: execCommandTool.def.name,
    input: { command: /^(ls|wc|cat|head|tail|fd|rg|find|grep|date)$/ },
  },
  {
    toolName: execCommandTool.def.name,
    input: { command: "sed", args: ["-n", /^.+p$/] },
  },
]

巨大なファイル / コマンド出力の読み込み防止

絶対に避けたいのが、巨大なファイルやコマンドの出力結果を無駄に読み込んでTokenを消費することです。コストもかかりますし、タスクの途中でプロンプトの最大長に到達する可能性が高くなります。

この問題に対しては以下の対策をしました。

  • exec_command の実行結果に最大長を設定し、それを超えた場合は部分的にModelに渡すように制御。(execCommand.mjs)

  • rg を使って、ファイルのアウトラインを抽出する方法を提示。(prompt.mjs)

    - Extract the outline of a file, including line numbers for headings, function definitions, etc.: rg ["-n", "<patterns according to file type>", "file.txt"]
      - markdown: rg ["-n", "^#+", "file.md"]
      - typescript: rg ["-n", "^(export|const|function|class|interface|type|enum)", "file.ts"]
    
  • sed を使って、数百行単位で読み込む例を提示。(prompt.mjs)

    - Read lines from a file:
      - Use rg to either extract the outline or get the line numbers of lines containing a specific pattern.
      - Get the specific lines: sed ["-n", "<start>,<end>p", "file.txt"]
        - It is recommended to read 200 lines at a time.
        - 1st to 200th lines: sed ["-n", "1,200p", "file.txt"]
        - 301st to 400th lines: sed ["-n", "201,400p", "file.txt"]
        - Read more lines if needed.
    

Daemon / Interactive なアプリケーションの操作

実際にアプリケーションの開発をする際は、HTTPサーバーなどのDaemonを起動したり、対話型のアプリケーションを操作することもあるかと思います。真面目に実装すると大変そうなので、 tmux を操作してもらうことで実現しました。
(tmuxCommand.mjs)

  • tmuxexec_command で実行できなくはないのですが、send-keys での特殊文字のエスケープが難しかったので、専用Toolとして切り出しました。
  • Node.jsのインタプリタを通してのPlaywrightでのブラウザ操作も試してみましたが、指定したURLを開くまでで、クリックしたりフォームを入力するなどの操作はまだできていません。(agent.ts - お試しで作ったLangGraphバージョン)

その他細かいTips

Modelの切り替え

  • AnthropicとOpenAIのModelが利用できるように、一般化した形式に変換して扱うようにしています。(anthropic.mjsopenai.mjs)
  • コマンドオプションで切り替えるのは面倒なので、agent-<model_name> という形式で、モデルごとに実行ファイルを用意しています。

Streaming

  • ChatGPTやClaudeのWeb画面のように、Modelの応答すべてが生成されるのを待たずに部分的に表示する機能です。これがないと体感でかなり待たされる印象です。
  • Anthropic、OpenAIともにstreamingに対応しているため、この機能を使ってModelの応答を部分的に表示するようにしています。(anthropic.mjsopenai.mjs)

Modelへの入力方法

  • Node.jsのreadlineを使っていますが、複数行にまたがる入力はできないので、ファイル名を指定したらファイルの内容をModelに渡すようにしています。普段はNeovimでやってほしいことをテキストファイルに書いて読み込ませています。(cli.mjs)

タスクの中断・再開

  • "bye" もしくは "save memory" と入力したら現在のタスクの状況をテキストファイルに保存、"resume work" と入力したらそのファイルを読み込んで再開できるようにしています。
    (prompt.mjs)
  • Promptの中ではMemory Bankと呼んでいますが、本家のMemory Bank とは別物で、あくまでもタスクレベルの内容を記録するものです。
  • Modelとのやり取りそのものを保存しても良いかもしれませんが、必要な情報だけ保存することで再開時のToken消費量を抑えるたいのでこのような形にしました。

おわりに

最後まで読んでいただきありがとうございます。少しでも参考になれば幸いです。

記事で紹介した実装のリンクです。


最後にちょっとだけ宣伝です🙏

令和トラベルではDevinやCursor、Clineなどのエージェントを活用した開発スタイルを模索しながら、旅行アプリ『NEWT(ニュート)』にもLLMを活用して驚くほど便利な旅行体験を実現しようと取り組んでいます。興味がある方はぜひお話させてください!

https://www.reiwatravel.co.jp/engineers

1
令和トラベル Tech Blog

Discussion

ログインするとコメントできます