背景

aichat は Rust 製の LLM CLI クライアントで、llm-functions サブモジュールを通じてシェルスクリプトベースの function calling をサポートしている。自分のフォークでは jqrgtokeidust などのシステムツールをラップするシェルスクリプトを llm-functions/tools/*.sh として登録し、LLM がこれらを呼び出せるようにしていた。

この仕組みでは、argc build によって llm-functions/bin/ 以下にツール名のシンボリックリンクが生成される。たとえば bin/jq../scripts/run-tool.sh のように、すべてのツールバイナリが共通のランナースクリプトを指す構造になっている。run-tool.sh は呼び出し時の $0 からツール名を判別し、対応する tools/*.sh を実行して結果を $LLM_OUTPUT に書き出す。

ところが function calling 経由でツールを呼ぶと、応答が永久に返ってこない問題が発生した。通常のチャット応答は正常に動くが、シェルツール呼び出しだけがハングする。具体的には [DEBUG] tool start: tokei のログは出るが [DEBUG] tool done が永遠に現れないという症状だった。

自分のフォーク環境には一つ特殊な点があった。llm-functions ディレクトリは aichat リポジトリ内のサブモジュールではなく、別の場所にあるリポジトリへのシンボリックリンクとして配置していた(/Users/ksh3/Development/aichat/llm-functions/Users/ksh3/Development/llm-functions)。この環境固有の構成が、後に問題の核心と判明する。

Rust 側の調査と空振り

最初の方針は、直前にフォーク側で行った自分の機能追加に原因がありそうだった。

src/function.rssrc/utils/command.rs の差分を調べたが、ワーキングツリーにはコミット済みの変更しかなく、明確なリグレッションは見つからなかった。git log を確認すると「mistake refactor」というコミットメッセージがあり怪しく見えたが、直接の原因ではなかった。

ビルド環境の確認として cargo run -- --infofunction_calling: true が有効であること、functions_dir が正しいパスを指していること、functions.jsontools.txt が存在することを確認した。テストコマンド ./Argcfile.sh test-function-calling を試みたが Error: Unknown role '%functions%' というエラーで失敗した。これはロール設定の問題であり、ハングとは無関係だった。

この段階で Rust 側の調査は行き止まりだった。

再現ログからの手がかり

具体的な再現ログは以下の通りだった。

  Call ctree_check {}
[DEBUG] tool start: ctree_check
[DEBUG] tool done: ctree_check exit=1
Error: Tool call exit with 1

pilot> run tokei
Call tokei {}
[DEBUG] tool start: tokei
  

ctree_check は MCP bridge 経由のツールで、これはエラーで終了するもののハングはしない。一方 tokeillm-functions/tools/tokei.sh 経由のシェルツールで、tool start の後に応答が永遠に返らなかった。

この差異が重要だった。MCP bridge 経由のツールは run-mcp-tool.sh を通り、シェルツールは run-tool.sh を通る。問題は run-tool.sh の実行パスにあることが絞り込めた。

fork bomb の発見

bin/tokei '{}' </dev/null を直接実行してみると「error: invalid JSON data」で即座に失敗する。ツール自体は動作するが、ハングの再現には至らない。

決定的だったのは ps -Ao 'pid,ppid,command' の出力だった。数百のプロセスが走っており、すべてが同じパターンだった:

  bash /Users/ksh3/Development/aichat/llm-functions/bin/jq -r def escape_shell_word:...
  

各プロセスは前のプロセスの子プロセスになっており、無限再帰のチェーンを形成していた。bin/jqrun-tool.sh へのシンボリックリンクだが、run-tool.sh は内部で JSON 引数のパースに jq コマンドを呼び出す。このとき PATH の先頭に llm-functions/bin/ があると、システムの /usr/bin/jq ではなく自分自身のラッパー bin/jq が呼ばれる。呼ばれた bin/jq はまた run-tool.sh として起動し、また jq を呼び、と無限に再帰する。これが fork bomb を引き起こし、プロセススロットを食い尽くしてツール呼び出しがハングしていた。

問題の再現条件は以下の通り:

  • llm-functions/bin/ が PATH に含まれている(aichat がツール実行時に自動的に追加する)
  • run-tool.sh 内の JSON パース処理が jq を呼ぶタイミングで、PATH クリーンアップがまだ行われていない
  • ツール名が jq のようにシステムバイナリと衝突するものに限らず、すべてのシェルツールで発生する(run-tool.sh は全ツール共通のエントリポイントであり、引数パースの jq 呼び出しは全ツールの実行前に走る)

初回修正と失敗

最初の修正は単純だった。run-tool.sh には既存の PATH クリーンアップ行 export PATH="${PATH//"$root_dir/bin:"/}" があったが、これが jq 呼び出しより後に配置されていた。この行を jq 呼び出しの前に移動し、同時に PATH クリーンアップが完全に欠落していた run-agent.sh にも同じ処理を追加した。

しかし、この修正では不十分だった。自分のフォーク環境では llm-functions がシンボリックリンクであるため、root_dir/bin は解決後の実パス /Users/ksh3/Development/llm-functions/bin になるが、PATH に入っているのはシンボリックリンク経由のパス /Users/ksh3/Development/aichat/llm-functions/bin だった。bash の文字列置換 ${PATH//"$root_dir/bin:"/} では両者がマッチしない。これが環境依存で問題が起きるメカニズムだった。通常の git submodule として llm-functions を配置していればパスは一致するため、この問題は発生しない。

sanitize_path による解消

最終的な修正として sanitize_path() 関数を作成した。この関数は:

  1. root_dir/bin のリアルパスを cd ... && pwd -P で解決する
  2. PATH の各エントリについて、リテラルパスとリアルパス(pwd -P 解決後)の両方を比較する
  3. どちらかが一致したエントリを PATH から除外する

この関数を run-tool.shrun-agent.sh の両方で、jq が最初に呼ばれるよりも前の位置に配置した。既存の文字列置換行は削除した。

検証として以下のテストを行った:

  • get_current_time '{}' — 正常動作
  • tokei '{"path":"src"}' — ハングなしで正常動作
  • jq '{"filter":".a","input":"{\"a\":1}"}' — ツール名がシステムバイナリと完全に衝突するケースでも正常動作
  • pgrep -f 'escape_shell_word' — ゾンビプロセスがゼロであることを確認

Rust 側の事後クリーンアップ

問題自体はシェルスクリプト側で解消されたが、調査時にフォーク側の Rust コードに入れた変更の整理も行った。develop ブランチとの差分を確認したところ:

  • src/function.rs のデバッグ用 eprintln! 文([DEBUG] tool start/done)— デバッグ中に追加したもので不要なため削除
  • src/utils/command.rsrun_command_no_stdin() — stdin に Stdio::null() を渡す関数で、ツールが stdin を待ってハングするのを防ぐ有効な改善。これは残した

まとめ

原因は run-tool.sh の PATH クリーンアップ処理が、シンボリックリンク経由の llm-functions 配置に対応していなかったことだった。run-tool.sh は既に bin/ を PATH から除外するロジックを持っていたが、文字列一致ベースの置換であるため、シンボリックリンクで実パスとリテラルパスが異なる環境では機能しなかった。通常の submodule 配置であればパスは一致するため、この問題は自分の環境固有の構成で顕在化したものである。

修正の要点は、jq が呼ばれるより前の段階で bin/ ディレクトリを PATH から確実に除外すること、そしてシンボリックリンク環境でもパスが正しく解決されるよう pwd -P による実パス比較を行うことだった。