From 69a57d8872004a3e7ace25b6dcc48f9460a84d92 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 14 Jul 2026 12:46:17 -0600 Subject: [PATCH] docs: Added common pitfalls docs for custom bash scripts --- Custom-Bash-Tools.md | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/Custom-Bash-Tools.md b/Custom-Bash-Tools.md index f68f663..2f51979 100644 --- a/Custom-Bash-Tools.md +++ b/Custom-Bash-Tools.md @@ -336,6 +336,117 @@ argc_some_field="$(jq -r '.some_field' <<< "$LLM_AGENT_RAW_JSON")" --- +# Output Handling and Common Pitfalls + +Coyote captures your tool's result from the `$LLM_OUTPUT` file (not from stdout), and it applies a few rules that are +easy to trip over. The bundled `fs_*` tools follow the patterns below; your own tools should too. + +## An empty result is shown to the model as `"DONE"` + +When a tool exits `0` but writes **nothing** to `$LLM_OUTPUT`, Coyote has no content to return, so the model receives +the bare string `"DONE"`. The model cannot distinguish that from a broken tool. It has no idea whether the file was +empty, the search had no hits, or the script silently failed. + +Always write *something* for every successful outcome, including the "nothing found" cases: + +```bash +# BAD: a search that finds nothing writes nothing, so the model just sees "DONE" +main() { + grep -rn "$argc_pattern" . >> "$LLM_OUTPUT" +} + +# GOOD: empty results get an explicit, informative message +main() { + local results + results=$(grep -rn "$argc_pattern" . 2>/dev/null) || true + if [[ -z "$results" ]]; then + echo "No matches found for: $argc_pattern" >> "$LLM_OUTPUT" + return 0 + fi + echo "$results" >> "$LLM_OUTPUT" +} +``` + +The same applies to reading an empty file, listing an empty directory, or an offset/range that lands past the end of +the data. If a "no result" is a normal outcome, say so explicitly. + +## Errors written to `$LLM_OUTPUT` are discarded on a non-zero exit + +Coyote reads `$LLM_OUTPUT` **only when the tool exits `0`**. On a non-zero exit it instead returns +`Tool call '' exited with code N` plus whatever the tool printed to **stdout/stderr**. The `$LLM_OUTPUT` file is +ignored. So the following hides your helpful message; the model only sees `exited with code 1`: + +```bash +# BAD: the message lands in $LLM_OUTPUT, then the non-zero exit makes Coyote ignore it +main() { + if [[ ! -d "$argc_path" ]]; then + echo "Error: directory not found: $argc_path" >> "$LLM_OUTPUT" + return 1 + fi +} +``` + +Use one of these instead: + +```bash +# OPTION A (real errors): print to stderr, then exit non-zero. +# Coyote captures stderr and attaches it to the error the model sees. +main() { + if [[ ! -d "$argc_path" ]]; then + echo "Error: directory not found: $argc_path" >&2 + exit 1 + fi +} + +# OPTION B (expected "empty"/"not found" outcomes that are not real failures): +# write the note to $LLM_OUTPUT and exit 0 so Coyote returns it as normal content. +main() { + if [[ ! -d "$argc_path" ]]; then + echo "No such directory: $argc_path" >> "$LLM_OUTPUT" + return 0 + fi +} +``` + +## Preserve a file's final line when it has no trailing newline + +Two common idioms silently drop the last line of a file that does not end in `\n`: + +- `while IFS= read -r line; do ...; done` stops before the final unterminated line, because `read` returns non-zero on it. +- `wc -l` counts *newlines*, so it under-reports the line count by one for such files. + +```bash +# BAD: drops the final line when the file ends without a newline; total is off by one +total=$(wc -l < "$file") +while IFS= read -r line; do + printf '%s\n' "$line" +done < "$file" + +# GOOD: `|| [[ -n "$line" ]]` processes the final unterminated line; awk counts it correctly +total=$(awk 'END { print NR }' "$file") +while IFS= read -r line || [[ -n "$line" ]]; do + printf '%s\n' "$line" +done < "$file" +``` + +## Type numeric options with `` / `` + +An `@option` with no value notation compiles to a **string** in the JSON schema, so the model tends to send `"359"` +instead of `359`. Add an `` (integer) or `` (floating point) notation so the schema advertises the right type: + +```bash +# BAD: schema type is "string"; the model sends "359" +# @option --offset The line to start from + +# GOOD: schema type is "integer"; the model sends 359 +# @option --offset The line to start from +``` + +Your script still receives the value as text in `argc_offset` either way (Bash has no typed variables), but the schema +now guides the model and lets strict providers validate the call. + +--- + # Prompt Helpers It's often useful to create interactive prompts for our bash tools so that our tools can get input from users.