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.sh calls jq before PATH cleanup has run
  • This affects all shell tools, not just those whose names collide with system binaries (run-tool.sh is the common entry point for every tool, and the jq call 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:

  1. Resolves the real path of root_dir/bin using cd ... && pwd -P
  2. Compares each PATH entry against both the literal path and the resolved real path (pwd -P)
  3. 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 correctly
  • tokei '{"path":"src"}' — no hang, worked correctly
  • jq '{"filter":".a","input":"{\"a\":1}"}' — worked correctly even when the tool name collides directly with a system binary
  • pgrep -f 'escape_shell_word' — confirmed zero zombie processes

Post-Fix Rust-Side Cleanup

I also reviewed investigation changes against develop:

  • src/function.rs debug eprintln! statements ([DEBUG] tool start/done) — added during debugging, unnecessary, removed
  • src/utils/command.rs run_command_no_stdin() — a function that passes Stdio::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.