🤖

CodexにCommonmarkのライブラリを実装させてみた

に公開

AIを用いた開発は結構ノウハウの蓄積が必要なので、使ってみないとわからないことが多いです。

忙しかったのと他に作らせていたものは動作環境や利用データの大きさから失敗してしまったので、Commonmarkのライブラリを実装させることでその特性を見てみようと思います。

今回はCodexを使っていきます。目標としてはあまり人がコードを書かない(中身の評価も最後にちょっとだけ)にしていこうと思います。

過去欲しかったもの

昔、以下を満たすMarkdownのライブラリを欲していました。

  • 純TypeScript製
  • テキストだけではなくDOMにも変換したい
    • 自前で簡単に変換できるDOMツリーと対応したNodeがほしい

そんな時Commonmarkというものを知りました。ざっくり書けば仕様が決められたMarkdownで、仕様以外にもテストケースが用意されています。

https://spec.commonmark.org/

過去これを元にライブラリを実装しようとしたのですが、少し問題がありました。
それは、このテストケースが小さく独立したものから実装することを想定したものではないからです。結構最初の方から変なケースがでてくるので、そういうところから実装を始めるとかなりつらかったです。

ですが、明確な基準があるということは以下のようなAI自走開発ができるのではないかと考えました。

  • 手動で上記テストケースを使ったテストを実装する
  • テストが全部通るまで自動で開発させ続ける

これなら寝てる間も開発してくれそうなので、実際にやってみましょう。寝てる間に実装終わってたら嬉しいなー!!

下準備

テスト

今回作って見たものは以下です。

https://github.com/azulamb/tsmark

まずはテストを作ります。自分はDeno大好きなので今回はDenoで動作させることにします。

import * as assert from 'jsr:@std/assert';
import tests from './spec.json' with { type: 'json' };
import { convertToHTML } from '../src/tsmark.ts';

// ここからの引数から単体テストをする処理は後で追加
const testIds = Deno.args.map((arg) => {
  const id = parseInt(arg, 10);
  return isNaN(id) ? -1 : id;
}).filter((id) => {
  return 0 <= id;
});

const testCases = 0 < testIds.length
  ? tests.filter((_test, index) => {
    return testIds.includes(index);
  })
  : tests;

// テストケースを全部回す
let id = 0;
for (const test of testCases) {
  const showId = 0 < testIds.length ? testIds[id] : id;
  ++id;
  Deno.test(`test[${showId}]`, () => {
    const actual = convertToHTML(test.markdown);
    assert.assertEquals(actual, test.html, `Test failed[${test.example}]:`);
  });
}

内容は大変シンプルで、基本的には公式テストケースがJSONで提供されているのでimportで読み込んでループさせているだけです。後ほど方針変更によってIDを指定することで指定したテストのみ実行も可能なようにしました。

インターフェース

次に準備するのは自分がほしいインターフェースです。基本的にはパースすると独自のNodeツリーを返すのがメインですが、テストの関係でこれを文字列のHTMLに変換する処理も入れておきます。これによってDOMツリーに変換したい人が参考にできるというメリットもあるからです。

型に関してはノードの種類と種類に応じたノードの型を分岐させる以下のような形を初期値とします。これをベースに拡張して貰う予定です。

export type TsmarkNodeType = 'heading' | 'paragraph';
export type TsmarkNode = {
    type: 'heading';
    level: number;
    content: string;
  } | {
    type: 'paragraph';
    content: string;
  };

ソースコードはノードの配列を返すパーサーと、それを利用して文字列に変換する関数を用意し、ここに実装を追加してもらうことにします。

import type { TsmarkNode } from './types.d.ts';

export function parse(md: string): TsmarkNode[] {
  return [];
}

function nodeToHTML(node: TsmarkNode): string {
    if (node.type === 'heading') {
      return `<h${node.level}>${node.content}</h${node.level}>`;
    } else if (node.type === 'paragraph') {
      return `<p>${node.content}</p>`;
    }
    return '';
}

export function convertToHTML(md: string): string {
  const nodes = parse(md);
  return nodes.map((node) => {
    return nodeToHTML(node);
  }).join('') + '\n';
}

環境

環境は一旦以下のような構成に落ち着きました。

  • エージェントのインターネット アクセス
    • 有効: 無制限
      • でもなんか度々Denoがネットワークエラーになる。不安定で困る……。
    • メソッド: GET HEAD OPTIONS
      • 想定されるのはモジュールのDLくらいなのでこれで十分と判断。
  • セットアップ スクリプト
    • npm install -g deno
      • 別の方法だとインストールに失敗したりするのとNodeはデフォルトで入ってるのでこちらを採用
  • 環境変数
    • DENO_TLS_CA_STORE=system
      • 証明書関連でエラーが出るらしく、毎度Codex君が試行錯誤した値を入れている

ただ、度々ネットが使えなかったりしてるようなログがあったのでもう少し安定化させたいですね。

実際に動かす

実際に動かしてみるとなかなか大変でした。

方針を伝える

まず雑に投げてみました。

TypeScriptで作られたCommonmarkのパーサーを実装したいです。 
Commonmarkの仕様は以下にあります。
https://spec.commonmark.org/0.31.2/

開発環境はDenoです。またブランチはcodexという名前で作成し、そちらで作業を行ってください。

要求は以下です。

* parse()関数にマークダウンのソースを渡すと、独自のNodeの配列を返す。
* parseToHTML()関数にマークダウンのソースを渡すと、内部で上記関数を使ってNodeの配列を作り、そこからHTMLの文字列に変換する
* Commonmarkの用意したテストがすべて通る

すでに下準備をしてあります。

Denoで動作するテストが `deno task test` で実行可能なのでこれが全て正常になるまで開発してほしいです。
またparse()関数とparseToHTML()関数も用意してあるので、それらの型を変更しないでください。
独自NodeとしてTsmarkNodeを定義していますがこちらも未完成なので、適宜変更してください。

結果色々あり、命令をその都度変えていきました。

  • 既存のライブラリをimportする
    • ライブラリを実装してもらうためにライブラリを使うなと書く
  • 既存のライブラリの必要ファイルを全部持ってくる
    • mjsとかも混じってたので純粋なTypeScriptを使い、ライブラリをコピペするなと書く
  • 度々テストをいじる
    • 実行環境構築の不手際もあるが、勝手にassertを自前実装したり、import_mapを使ってテストを修正しようとする
    • import_map使うなとかお前が加工していいのは src/ のみだみたいなことを書く

一応作業はしてくれそうな雰囲気ですがそれでもいい感じに作業を進められる気がしません。

AGENTS.mdの追加

ただこれらをやっても色々面倒だったり長いタスクを投げ続けるのも面倒です。
そう言えば聞いた話でこういう時に読ませるファイルがあったなと思い出したのでちょっとググって AGENTS.md を追加することにしました。ここに重要な開発方針などを全部書いておけば最初の方に読み込んでくれるので、後は追加タスクを指示する感じにします。

# AGENTS

## 方針

- Commonmarkのパーサーを実装する
  - 仕様は https://spec.commonmark.org/0.31.2/
    にあるのでこちらを参照して実装する
  - 実行は Deno を使う
- ライブラリは使わない
- ライブラリの中身を持ってこない純粋なTypeScriptで記述する
- DOMツリーに変換可能なNodeへのパーサーを実装する
  - `parse()` に実装を追加する。
- 作業ブランチは英語にする
- テストの都合もあるので、上記パーサーを用いたHTMLソースへの変換も実装する
  - `convertToHTML()` が実装だが、 `nodeToHTML()` にNodeから変換する処理を書く
- 変更してよいのは `src/` 内に留める

## コードスタイル

deno fmt

Denoのフォーマッターを利用する。

## テスト

deno task test

テストは完成している。
import_mapを使ったり内容の書き換え等、動作に関する一切の修正を禁ずる。

リトライはしなくても良いのでエラーになった部分の修正を行う。

これで毎度長文を書かなくても開発はしてくれるので、テストが通るまで作業してみてくださいみたいな雑なタスクを投げてみました。

ただ、これでもすぐタスクが終了したり、思うように実装が進みません。理想としてはテストが通るまで延々開発をやってほしいのですが、どうもそうはいかないようです。(結局諦めて最後に追加修正のリトライしなくてもいいから今回分の修正は頑張ってと付け加えることになった。)

テストを分割する

試しに以下のようなタスクを投げてみました。

まずは `deno task test -- 1` で最初のテストが通るようにコードを修正してください。

今までは全部のテストを実行していましたが、引数で個別のテストを回せるようにテストを改造し、一つだけやらせてみることに。納得の行く作業に至るまでではないですが、ある程度作業をやってくれそうな雰囲気があります。

とは言え問題点があります。

  • 毎度人が番号指定するのはきつい
    • 公式のテストケースがこの時点で652ある
  • 前のテストが通らなくなるケースがある
    • 1つ実装して1つバグったらいつまで経っても進捗がない

これらに対応すべく、以下のようにさせてみることに。

  • まず全テストを通す
  • テストから情報を得る
    • 今の成功数
    • 失敗しているテストのID
  • 失敗しているテストを修正
  • 最後に全テスト通して成功数が増えているかチェック

実際のタスクは以下です。

まず `deno task test` でテストを実行してください。 
次に失敗したIDを使って、単体テストの `deno task test -- ID` を実行し、解消されるまで修正してください。
最後に `deno task test` でテストを実行して前回より成功が増えていることを確認してください。

ちょっと改良点はあるもののそこそこ上手く動いてくれます。
これで後はこのタスクをコピペし続ければ作業が進んでくれそうです。

それからの挙動

後はコピペでやらせていましたが以下のような感じになりました。

  • スマートフォンでタスクコピペしてGitHubでマージするだけなのでPCなくても開発が進むのは良かった
  • ただし無限ループでOOMされたり、テスト通過が悪化してもOKみたいな感じで返されるかと思ったら、逆にOKなのにエラーで終わっちゃいましたみたいな感じでタスクが終了していることがある
    • 最終結果を信じないでちゃんと自分の目でログを確認して取り込みしないといけない
    • とはいえ無限ループになっていてもそれを指摘すれば直る

そんな感じで一応出先で開発も出来たもののログを追うのが辛すぎ問題もあり、一旦以下のような感じになりました。

  • 手元で最新の状態のテストを回す
  • タスクをコピペで投げる
  • 完了したらログの最下部から見てテストを探す
  • 手元の成功件数と比較して上がっていたらプルリクを作る
  • マージ後最初に戻る

うーん、結構人の手が必要ですね。寝てる間に完成はしない……。

また、初日はちょっと外出でスマートフォンを使ってタスクを投げたりしていたのですが、途中確認が甘く無限ループ対応をマージしたにもかかわらず、半分程度のテストが通るくらいまで進みました。初めのペースはかなり良かったですが後半はこれまでの実装との兼ね合いもありなかなか進まず、失敗するテストは100/652くらいまでの進捗になりました。苦戦は予想していましたが環境エラー的なものも結構増えて終盤はちょっときつかったですね。(とはいえ土日でこの成果はすごい。)

リファクタリング

終盤失速してつらいかと思ったものの、なんとか月火の業務時間外の作業でテストケースが全て通るようになりました。

ただファイルがものすごく大きくなっていますし、ちょっとリファクタリングをさせてみましょう。テストがすべて通った今怖いものはないです。まずは雑にリファクタリングさせた後ファイルの分割を試みました。

こちらリファクタリングを行ってください。
最後に deno task test でテストを実行し、全てのテストが通ることを確認してください。

ここからまとめてファイル分割させるべくTsmarkNodeTypeの処理に応じたファイル分割の依頼をしたところ何もしてくれないので、以下のように個別にタスクを指定しました。

paragraph関連の処理をファイル分割してnodes/内に追加してください。
最後に deno task test でテストを実行し、全てのテストが通ることを確認してください。

1ファイルにほぼすべての処理が集まっていましたが、テストが正常に通る状態を維持しつつ分割してくれました。
ここでこの分離したファイルにはパーサー用の処理とHTML変換用の処理が混ざっているので、まとめて作業してくれないかな?っと思って以下のように指示を出してみました。

convertToHTMLで利用されるToHTMLが末尾につく関数をsrc/nodes/内のファイルのようにsrc/html/以下に分離してください。
最後に deno task test でテストを実行し、全てのテストが通ることを確認してください。

こちらに関してはかなりスムーズにまとめて処理をやってくれました。どうすればまとめて処理してくれるのか、もう少し試行錯誤していきたいところです。

コードチェック

一応コードのチェックもしておきます。とは言うもののガッツリ全部はやらずにまずい部分をなんとかしようと思います。

そして見つけました。テストでは文字列のマークダウンを受け取ってHTMLを出力するので、パース処理でやるべき前処理なのか?をパーサーの前でやっているようです。
ということでこちらも対応していきます。

convertToHTML内でマークダウンのソースに対して前処理が行われているので、パーサー側に実装を移してください。
最後に deno task test でテストを実行し、全てのテストが通ることを確認してください。

こちらはそこそこ上手いことやってくれたっぽいです。もっと早くコードレビューで気付いておきたかったですが、スマートフォンでやってた時間帯の成果だと思うのでちょっと厳しかったですね。

また他にもちょっと関数が大きすぎるなということで以下の指示でもう少しリファクタリングさせます。

parse関数が大きすぎるのでリファクタリングしてください。
tsmark.ts内の関数が大きいので、もう少し小さい関数になるようリファクタリングしてください。

内容を理解しない程度に雑に見て一旦問題な箇所は直ったと思うので、一旦ここで開発を終了することにしました。

まとめ

開発時間

  • 土曜日
    • 多くの時間を出先のスマートフォンで対応
    • ブラウザでタスクをコピペしてGitHubアプリでマージ
    • テストは半分くらい通った
  • 日曜日
    • そこそこ張り付いて頑張ってタスクを重ねた
    • テストは残り100くらい
  • 月曜日
    • 朝と夜に作業
    • 残りテスト10くらい
  • 火曜日
    • 朝と夜に作業
    • お昼休みにタスクを見たらテスト完走
    • 夜は最後のリファクタリング等して一旦完成

目標に対してどうだったか

今回は以下状況でCodexはいい感じに作業してくれるのか?という試みでした。

  • 仕様がすでに決まっている
  • テストケースも揃っている
    • テストの実装も完了していて修正する必要はない
  • 実装はまだない

自走するうえでこれ以上の好条件はないといっても良いでしょう。しかし寝ている間に作業をやってくれるわけではなかったです。(ちなみにログ見てる感じ仕様を見に行ってる感じはあんまりなかったので、今回はテストケースだけでよかったかも。)

  • Codexの場合は例えばテストが少し通ったらそこでタスクを終了してしまう
    • 長くても30分とか(しかも自分でドツボにハマってる時)
    • テストが全部通るまでと言ってもそれを自己判断はしていなさそう。
  • 結局タスクを細かく人間が管理しないといけない
    • 短く終わる+自分で修正すべきテストケースを見つけられる短いタスクを作れたが、コピペや確認に結構時間を取られる

途中から開発というより完全にガチャって感じになっちゃいました。(時間がかかるし確認も必要な面倒なガチャ……。)でもガチャが成功すると一気に10個くらいテストを消化してくれるのでそこはテンション上がりますね。

手法を改善するなら

今回のケースでは以下のようなタスクを管理するプログラムがあれば寝ている間も開発が進む気がします。

  • 手元でテストを回して成功件数を記録
  • コピペでタスクを投げる
  • 作業が終わったらプルリクを作ってもらう
  • 上記ブランチでテストを回して成功件数を調べる
    • 増えていればマージ、そうでなければ却下
  • 全てのテストが通るまで最初に戻る

やはり機械的にできる部分は確実に作業できるプログラムの方がいいですね。

また今回は手元ではなくクラウドで動く環境であるCodexだったので別のを使えばまた結果が異なる可能性はあります。とは言ってもこれだけの好条件でも似たような挙動になりそうな気はするので、やはりタスク管理の何かを噛ませないと寝てる間に実装終わってる!みたいなことにはならなさそうだなという感想です。

また動作環境に関してもまだまだ面倒だなという点もありました。

  • Codexはチャットするたびに環境構築から初めてそう
    • チャットを続けて修正させるより新たにタスク積んだ方が良い
  • Denoだと毎度環境構築が入ってつらい
    • 今後Dockerイメージを任意で指定できるようになりそうなUIなので、Denoのイメージを用意できればつらい部分はだいぶ解消されそう

コードの質について

パーサーってちゃんと組んでくれるかな?っと思ってやらせてみました。いい感じに汲み取ってトークナイザーとか作るかと思ったらそんな感じではなく、出来たものは結構怪しいと思っています。恐らくそういうところもちゃんと指示しないと厳しい気がしますが、あんまりレビューしないでどんな感じか?みたいな試みもちょっとあったので結構スルーしてしまいました。

テストや仕様はあってもパーサーを作るとなるとやはりそちら系の知識を持った上で誘導しないといけないのはあまり考慮してなかったので、このコードの質に関しては今回自分が雑にタスクを投げすぎたなと思います。

こちらに関してはちょっと失敗だったなと思うのでリファクタリングという名の大改造か、初めからもう一度やってみるかちょっと検討はしたいところです。(もしやるならもう少し人間の負担がない方法を確立してからにしたい。)何にせよ初めの方のレビューとかは結構大事だと思います。

今回の感想

そんなわけで、現状は自走できる理想的な状況下でも雑な投げ方で寝てる間に完成するなんてことはないし、質も期待した方向性にはなっていないので、ある程度最初のうちに細かに方向性を決めていかなければいけないというのを肌で感じた開発となりました。また何かテーマがあればやらせてみたいですね。

Discussion