AIコード編集ツールの開発備忘録

senpai.nvimというNeovimのAIプラグインを開発しています。その過程で発生した「AIによるコード編集ツール」の開発備忘録を残しておきます。

senpai.nvimの紹介については別の記事Neovimの履歴管理ができるAIプラグインsenpai.nvimの使い方に書いています。

コード編集として何を出力させるか問題

AIにコード編集させるということは、その「編集対象」と「編集後のテキスト」を出力してもらうことになります。

これにはいくつかパターンがあります。

ファイル全体を対象にする

ファイル全体を対象にするのが開発当初は一番楽かもしれません。
AIに出力してもらうのは「ファイルパス」「編集後のファイル全体のテキスト」です。

出力のイメージ
{
"filepath": "./piyo.ts",
"text": "import { foo } from \"awesome-foo\";\n\nconsole.log(`foo is ${foo}`);"
}

ただしファイルが大きいとそれだけ時間もトークンもかかります。

行指定にする

置換する行の行番号を出力してもらうパターンはほとんどうまくいかなかったです。

出力のイメージ
{
"filepath": "./piyo.ts",
"start": 3,
"end": 3,
"text": "console.log(`foo is ${foo}`);"
}

どうやらAIは行を数えるのが苦手のようです。人間もテキストだけ渡されて行番号隠されたら「面倒くせぇ!」って思いますよね。
ちなみに「行数を数えて」「行番号は正確に」のような指示をしても駄目でした。

あと行番号だと「同じファイルで複数箇所を変更させる時」に行番号がずれるケースもあります。

部分的に置換する

「検索対象のテキスト」「置き換え後のテキスト」を出力してもらうパターンです。筆者はこれを採用しました。
というのも、人間がやっていることに一番近いと考えたからです。人間がコードを編集するときには「3行目だから書き換えよう」ではなく「〇〇の処理はおかしいからこの部分を書き換えよう」のように「内容」に着目するだろう、という考えです。

出力のイメージ
{
"filepath": "./piyo.ts",
"search": "console.log(`bar is ${bar}`);",
"replace": "console.log(`foo is ${foo}`);"
}

この場合、「検索対象のテキストの出力」には次のような指示が必要です。

  • スペルミスや余計な空白もそのまま出力する
  • 同じ内容の行が存在する可能性を考慮して、一意に特定できるようにする

後者について、ざっくりとこんな感じです。たとえば、次のようなコードでconsole.log(a + b);が検索対象だとどちらのことを言っているのか分かりません。

a = 1;
console.log(a + b);
// ...
a = 2;
console.log(a + b);

この場合はa = 2;\nconsole.log(a + b);のように一意に特定できる範囲を出力してもらいます。

a = 1;
console.log(a + b);
// ...
a = 2;
console.log(a + b);

試してないやつ

まだ試してないやつも書いておきます。

  • AIにコードを渡すときに5: print("foo")のように行番号をくっつけて行を指定するパターン
  • diffを出力させるパターン

コード編集の差分を何を使って出力させるか問題

前述まではコード編集の差分をJSONで表現しましたが、実際はいろいろな表現があります。

たとえばあらかじめZodなどでスキーマを指定して出力させたり、普通のテキストに混ぜるパターンがあります。

スキーマ指定のパターン

スキーマを指定して出力できれば、パースはJSONとして扱うだけなので楽です。

ただしツールとして呼び出すことになるため、ユーザーからすると「パラパラとテキストが表示されない待ち時間」が発生します。

<!-- ↓パラパラと表示される部分 -->
`bar``foo`に変えたいのですね。編集ツールを呼び出します。
<!-- ここでStructured Outputsの結果を待つ -->
<!-- ↓Structured Outputsの結果をパースして表示-->
編集ファイル: `./foo.ts`
編集内容
```diff typescript
- console.log(`bar is ${bar}`);
+ console.log(`foo is ${foo}`);
```
<!-- ↓パラパラと表示される部分 -->
編集が完了しました。`bar`の部分を……

そもそも対応していないモデルもあります。これを考慮しないのであればこっちのほうが実装は楽です。

普通のテキストと混ぜるパターン

「あらかじめ指示しておいたフォーマット」を普通のテキストに混ぜて出力するパターンです。これならスキーマ指定に対応していないモデルでもできます。

たとえばClineの実装をみると次のようにXMLを出力させているようです。

<replace_in_file>
<path>File path here</path>
<diff>
<<<<<<< SEARCH
return a - b;
=======
return a + b;
>>>>>>> REPLACE
</diff>
</replace_in_file>

筆者はこれを参考にしつつ、パースしやすい形を採用しました。

<replace_file>
<path>src/main.js</path>
<search>
return a - b;
</search>
<replace>
return a + b;
</replace>
</replace_file>

コードを読ませる方法のバリエーション

AIにコードを読んでもらう方法もいくつかあります。

ツールを使って読ませる

「ファイル読み込みツール」を呼んでもらうことでファイルを読んでもらうパターンです。

ツールのイメージ
{
"toolName": "ファイル読み込みツール",
"args": {
"filename": {
"type": "string",
"description": "読み込みたいファイル名"
}
}
// ...
}

この方法は実装が楽です。
ただ、ツールとして呼び出すということは「すでにプロンプトを渡してAIが出力している最中」です。ということは「ファイルに応じて適用するルール(プロンプト)を変更する」といった処理ができなくなります。

プロンプトと一緒にファイルを添付

前述のツールのパターンは「プロンプトに書いた文字列」の中からAIにファイル名を検出させています。
一方、プロンプトと一緒にファイルの内容を添付するパターンでは実装側でファイル名を判断しようという方針です。

プロンプトの例
@/foo/bar.ts の内容を教えて

前述のとおり「ファイルに応じて適用するルール(プロンプト)を変更する」といった柔軟な処理ができます。

単にfoo/bar.tsだとファイル名の検出が大変です。補完などを用意しつつ@/のようなメンション形式にすると実装が楽になります。

添付自体は2パターンあります。

ガチのファイルとして添付する

ファイルを実装側で読み込み、type: "file"のようにファイルとしてメッセージに添えるパターンです。

イメージ
{
"role": "user",
"content": [
{
"type": "text",
"text": "@/foo/bar.ts の内容を教えて"
},
{
"type": "file",
"data": "base64のデータ",
"mimetype": "text/plain"
}
]
}

この方法は実装が楽ですが、コードファイルというよりかは「画像やPDFのようなファイル」向けかなと思います。これで実装してもAIは認識しくれますが、「base64のデータをデコードしますね!」みたいことを言ってくるので手間です。

プロンプトのテキストに追加する

続いて、プロンプトにテキストを追加するパターンです。

たとえばこんな感じ。

ユーザーが入力したプロンプト
@/foo/bar.ts の内容を教えて

次のように追記してやります。

実際に送るプロンプト
@/foo/bar.ts の内容を教えて
---
Reference
```typescript title="foo/bar.ts"
// ここにコード
```

ファイルの内容は「コードブロックとして書く」「XMLとして書く」などいろいろな実装がありそうです。

チャットとして表現するときは、その添付部分(上記だと---以降)は表示しないほうがいいです。
ユーザーからしたら「なんかオレの書いたプロンプト長くて見づらくなっている!」って思われそうなので。

ファイルパスの表示どうするの問題

ファイル名をメンション形式などで表示するのも、実装の幅がいろいろあります。

  • @/foo/bar.ts: 全部表示
  • @/bar.ts: ファイル名だけ。ファイル補完時に内部でデータを保持
  • [bar.ts](./foo/bar.ts): リンク形式

ファイルパスを全部表示

@/foo/bar.ts の内容を教えて

@/foo/bar.tsのように全部表示すると、実装は楽です。ただ、ファイルパスが長いと圧迫して見づらくなります。現在行以外は@bar.tsのように短縮して表示するのが吉です。

ファイル名だけ表示

そもそも最初から短縮表示をして、ファイルのフルパスは内部データで保持し、プロンプト送信時にやりくりする方法もあります。

@bar.ts の内容を教えて

ただ、この方法だとプロンプトをコピーしたときに値を保持できなくなってしまいます。

リンクとして表示

あとはリンクとして表示する方法です。Markdownとしてはこれが一番自然ではないでしょうか。

[bar.ts](./foo/bar.ts) の内容を教えて

この方式だと若干入力が面倒になります。筆者はこの方法を採用しつつ、/fileというスラッシュコマンド入力時に別途補完ウィンドウをプレビュー付きで表示できるようにしました。

RAGは自動にする?メンション形式にする?

RAG(Retrieval Augmented Generation)の呼び出しをAIに判断してもらうか、@ragのようなメンション形式にするかも議論の余地がありそうです。

筆者が実装してみた結果、メンション形式の方が良さそうでした。「今このタイミングで検索しなくてもいいのに……」というのが多発したためです。

まぁ人間でも「Wiki・Slack・このリポジトリのどこかに書いてあるから後はよろしく!」って投げられるよりも、「今回の内容はWikiに書いてあるよ」って指示されたほうがいいですよね。

今後、それが気にならないくらいAIの応答速度や精度が上がるなら自動の方が良くなるかもしれません。


以上、現時点でAIコード編集ツールの開発でたまった備忘録でした。

こういう方法でうまくいったよ、みたいな知見があれば@eetann092かvim-jpのSlackの#times-eetannで伝えていただけるとうれしいです。