Why aichat function calling hung with a symlinked tools directory
A PATH recursion fix for aichat shell tools, using real-path comparisons to stabilize development-tool integration with an LLM.
Background
I fixed aichat function-calling hangs with a symlinked llm-functions directory. The Rust CLI calls wrappers for jq, rg, tokei, and dust in llm-functions/tools/*.sh, connecting an LLM to development tools.
argc build creates symlinks in llm-functions/bin/, such as bin/jq → ../scripts/run-tool.sh. The shared run-tool.sh reads the tool name from $0, runs tools/*.sh, and writes to $LLM_OUTPUT.
Chat worked, but shell calls stopped after [DEBUG] tool start: tokei without [DEBUG] tool done.
My llm-functions was a symlink from /Users/ksh3/Development/aichat/llm-functions to /Users/ksh3/Development/llm-functions, rather than a submodule. That path difference caused the failure.
Rust-side checks
I first suspected a recent Rust change.
src/function.rs and src/utils/command.rs had no uncommitted changes or clear regression. The “mistake refactor” commit in git log was not the cause.
I verified the build environment with cargo run -- --info, confirming function_calling: true was enabled, functions_dir pointed to the correct path, and both functions.json and tools.txt existed. Running ./Argcfile.sh test-function-calling failed with Error: Unknown role '%functions%', which was a role configuration issue unrelated to the hang.
Clues from the Reproduction Log
The reproduction log:
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
The MCP bridge tool ctree_check exited with an error, while the shell tool llm-functions/tools/tokei.sh hung after tool start.
MCP uses run-mcp-tool.sh; shell tools use run-tool.sh. I narrowed the investigation to the latter.
Discovering the Fork Bomb
Running bin/tokei '{}' </dev/null directly produced “error: invalid JSON data” and exited immediately. The tool itself worked, but the hang could not be reproduced this way.
ps -Ao 'pid,ppid,command' showed hundreds of processes with this pattern:
bash /Users/ksh3/Development/aichat/llm-functions/bin/jq -r def escape_shell_word:...
The processes formed a recursive parent-child chain. run-tool.sh called jq for JSON parsing, but PATH selected bin/jq from llm-functions/bin/ instead of /usr/bin/jq. That symlink launched the same runner, which called jq again. The fork bomb exhausted process slots.
The reproduction conditions were:
llm-functions/bin/is in PATH (aichat adds it automatically when executing tools)- The JSON parsing in
run-tool.shcallsjqbefore PATH cleanup has run - This affects all shell tools, not just those whose names collide with system binaries (
run-tool.shis the common entry point for every tool, and thejqcall for argument parsing runs before any tool-specific code)
First Fix Attempt — Insufficient
I moved the existing export PATH="${PATH//"$root_dir/bin:"/}" cleanup before jq and added it to run-agent.sh.
That still failed. root_dir/bin was /Users/ksh3/Development/llm-functions/bin, but PATH contained /Users/ksh3/Development/aichat/llm-functions/bin. ${PATH//"$root_dir/bin:"/} could not match the symlink spelling. A normal submodule layout has no such path difference.
Resolution with sanitize_path
I added sanitize_path() to:
- Resolves the real path of
root_dir/binusingcd ... && pwd -P - Compares each PATH entry against both the literal path and the resolved real path (
pwd -P) - Excludes any entry that matches either one
This function was placed in both run-tool.sh and run-agent.sh, before the first jq invocation. The old string-substitution line was removed.
Verification tests:
get_current_time '{}'— worked correctlytokei '{"path":"src"}'— no hang, worked correctlyjq '{"filter":".a","input":"{\"a\":1}"}'— worked correctly even when the tool name collides directly with a system binarypgrep -f 'escape_shell_word'— confirmed zero zombie processes
Post-Fix Rust-Side Cleanup
I also reviewed investigation changes against develop:
src/function.rsdebugeprintln!statements ([DEBUG] tool start/done) — added during debugging, unnecessary, removedsrc/utils/command.rsrun_command_no_stdin()— a function that passesStdio::null()to stdin, preventing tools from hanging while waiting for stdin input. This was a useful improvement and was kept.
The path-cleanup fix
The fix removes bin/ before the first jq call and compares real paths with pwd -P, so alternate symlink spellings are excluded too.
