背景

aichat のfunction callingが、symlink経由の llm-functions で止まる問題を直しました。Rust製CLIから llm-functions/tools/*.sh の jq、rg、tokei、dust を呼ぶ構成です。LLMを開発ツールへ接続する部分の修正です。

argc build は llm-functions/bin/ にsymlinkを作ります。bin/jq → ../scripts/run-tool.sh のように共通runnerへ向け、run-tool.sh が $0 から名前を判定して tools/*.sh を実行し、$LLM_OUTPUT へ書きます。

通常のチャットは動きますが、shell toolは [DEBUG] tool start: tokei の後に止まり、[DEBUG] tool done が出ませんでした。

この環境の llm-functions はsubmoduleではなく、/Users/ksh3/Development/aichat/llm-functions → /Users/ksh3/Development/llm-functions のsymlinkです。このパスの違いが原因でした。

Rust側で確認したこと

最初は、直前に追加したRust側の機能を疑いました。

src/function.rs と src/utils/command.rs に未コミットの差分はなく、明確な回帰も見つかりませんでした。git log の「mistake refactor」も直接の原因ではありません。

cargo run -- --info で function_calling: true、functions_dir、functions.json、tools.txt を確認しました。./Argcfile.sh test-function-calling は Error: Unknown role '%functions%' で失敗しましたが、これは別のロール設定の問題です。

再現ログからの手がかり

再現ログです。

  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
  

MCP bridgeの ctree_check はエラー終了しましたが、止まりません。shellの llm-functions/tools/tokei.sh は tool start の後に返ってきませんでした。

MCPは run-mcp-tool.sh、shellは 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:...
  

親子プロセスが無限再帰していました。run-tool.sh がJSON解析に jq を呼ぶと、PATH先頭の llm-functions/bin/ にある bin/jq が選ばれます。これは同じrunnerへのsymlinkなので、再び jq を呼びます。システムの /usr/bin/jq へ到達せず、fork bombでプロセス枠を使い切っていました。

再現条件です。

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

初回修正と失敗

既存の export PATH="${PATH//"$root_dir/bin:"/}" を jq より前へ移し、未処理だった run-agent.sh にも加えました。

それでも直りませんでした。root_dir/bin は実パス /Users/ksh3/Development/llm-functions/bin、PATHはsymlink経由の /Users/ksh3/Development/aichat/llm-functions/bin です。${PATH//"$root_dir/bin:"/} では一致しません。通常のsubmodule配置では、このパス差は生じません。

sanitize_path による解消

sanitize_path() を追加しました。

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

run-tool.sh と run-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.rs の run_command_no_stdin() — stdin に Stdio::null() を渡す関数で、ツールが stdin を待ってハングするのを防ぐ有効な改善。これは残した

パス除外の修正点

修正は、jq の前に bin/ をPATHから除き、pwd -P で実パスも比較することです。symlinkの表記が異なっても同じディレクトリを除外できます。