Posted on
Artificial Intelligence

Artificial Intelligence Agent Tool Calling

Author
  • User
    linuxbash
    Posts by this author
    Posts by this author

Teaching Your Bash Scripts to Talk: AI Agent Tool Calling on Linux

Ever caught yourself typing a long chain of commands and flags and thought, “Why can’t I just tell my machine what I want?” Imagine saying: “Find the biggest logs under /var, summarize them, and tell me which service is spamming.” That’s the promise of AI agent tool calling: a safe way to let an AI pick which Bash functions to run and with what arguments—without handing it a raw shell.

This article explains why agent tool calling matters, how it avoids the pitfalls of “LLM writes shell” approaches, and walks you through a practical, minimal Bash implementation that connects a local toolkit to an LLM via an API. You’ll finish with a working agent that can inspect disk usage, search files, read logs, and check services—using only allow‑listed commands you define.


Why tool calling beats “LLM executes shell”

  • Safety through allowlists: Instead of letting an AI emit arbitrary commands, you expose only a curated set of tools (Bash functions) with typed parameters. The model can request a tool by name and pass structured JSON arguments, but can’t run anything you didn’t approve.

  • Reliability via structure: Tool calling asks the LLM for structured intent (name + arguments), which is easier to parse and test than freeform text or brittle prompt-to-bash generation.

  • Observability and auditability: Every tool call and argument is loggable JSON with clear provenance, so you can review the exact decisions the agent made.

  • Composability: You can steadily add tools (e.g., “tail_log”, “service_status”) without changing how the agent thinks or how you call it.


What we’ll build

A single Bash script that:

  • Accepts a natural-language request (e.g., “Which directories under /var use the most space?”).

  • Calls an LLM with an allow-listed set of “tools” described as JSON.

  • Parses the model’s tool call(s), runs the associated Bash functions locally (e.g., du, grep, systemctl), returns results to the model, and prints a final answer.

We’ll use:

  • curl for HTTP

  • jq for JSON

  • The OpenAI Chat Completions API with tool-calling (you can swap to any OpenAI-compatible endpoint)


Prerequisites

Install these packages if you don’t already have them.

  • Debian/Ubuntu (apt):
sudo apt update
sudo apt install -y curl jq
  • Fedora/RHEL/CentOS Stream (dnf):
sudo dnf install -y curl jq
  • openSUSE (zypper):
sudo zypper refresh
sudo zypper install -y curl jq

Set your API key:

export OPENAI_API_KEY="sk-...your-key..."

Tip: add that line (or use a secret manager) in your shell profile if you run the agent often.


The agent script (Bash + tool calling)

Save as agent.sh and make it executable with chmod +x agent.sh.

#!/usr/bin/env bash
set -euo pipefail

# Minimal Bash agent demonstrating "tool calling".
# Requirements: curl, jq, an OpenAI-compatible API key in $OPENAI_API_KEY

API_BASE="${API_BASE:-https://api.openai.com/v1}"
MODEL="${MODEL:-gpt-4o-mini}"   # choose a tool-calling capable model
TEMPERATURE="${TEMPERATURE:-0.2}"

if [[ -z "${OPENAI_API_KEY:-}" ]]; then
  echo "ERROR: OPENAI_API_KEY is not set." >&2
  exit 1
fi

USER_PROMPT="${1:-}"
if [[ -z "$USER_PROMPT" ]]; then
  echo "Usage: $0 \"Your request in natural language\"" >&2
  exit 1
fi

# ---------- Safety helpers ----------
abs_path_or_die() {
  local p="$1"
  # Basic hardening: require absolute path, forbid parent traversal
  if [[ "$p" != /* ]] || [[ "$p" == *".."* ]]; then
    echo "Invalid path: $p (must be absolute, no ..)" >&2
    exit 2
  fi
  echo "$p"
}

# ---------- Local tools (allow-list) ----------
tool_get_disk_usage() {
  local path="$1"
  path="$(abs_path_or_die "$path")"

  # Top directories by size (one level deep)
  local top
  top="$(du -x -d1 -h --apparent-size "$path" 2>/dev/null | sort -h | tail -n 15)"

  # Filesystem free/used
  local dfout
  dfout="$(df -h "$path" 2>/dev/null | awk 'NR==1 || NR==2 {print}')"

  printf "%s\n\n%s\n" "Top entries under $path (largest last):" "$top"
  printf "\nFilesystem usage for %s:\n%s\n" "$path" "$dfout"
}

tool_search_files() {
  local path="$1" pattern="$2" max_results="$3"
  path="$(abs_path_or_die "$path")"
  max_results="${max_results:-10}"

  # Basic content search; tune excludes as needed
  LC_ALL=C grep -RIn --binary-files=without-match \
    --exclude-dir={.git,node_modules,venv,__pycache__} \
    -m "$max_results" -e "$pattern" "$path" 2>/dev/null | head -n "$max_results" || true
}

tool_tail_log() {
  local path="$1" lines="${2:-80}"
  path="$(abs_path_or_die "$path")"
  tail -n "$lines" "$path" 2>/dev/null || echo "Cannot read $path (permissions or missing file)."
}

tool_service_status() {
  local service="$1"
  local status logs
  status="$(systemctl is-active "$service" 2>/dev/null || true)"
  logs="$(journalctl -u "$service" -n 30 --no-pager 2>/dev/null || echo "No logs found or insufficient permissions.")"
  printf "Service: %s\nStatus: %s\n\nRecent logs:\n%s\n" "$service" "$status" "$logs"
}

# ---------- Tool schema for the model ----------
read -r -d '' TOOLS_JSON <<'JSON' || true
[
  {
    "type": "function",
    "function": {
      "name": "get_disk_usage",
      "description": "Show disk usage under a path and the largest subdirectories (one level).",
      "parameters": {
        "type": "object",
        "properties": {
          "path": { "type": "string", "description": "Absolute path to inspect, e.g., /var" }
        },
        "required": ["path"],
        "additionalProperties": false
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "search_files",
      "description": "Search recursively for a text pattern and return up to N matches with line numbers.",
      "parameters": {
        "type": "object",
        "properties": {
          "path": { "type": "string", "description": "Absolute root path to search" },
          "pattern": { "type": "string", "description": "Text or regex to search for" },
          "max_results": { "type": "integer", "description": "Maximum number of matches to return", "default": 10 }
        },
        "required": ["path", "pattern"],
        "additionalProperties": false
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "tail_log",
      "description": "Tail the last N lines from a log file path.",
      "parameters": {
        "type": "object",
        "properties": {
          "path": { "type": "string", "description": "Absolute path to the log file" },
          "lines": { "type": "integer", "description": "Number of lines to show", "default": 80 }
        },
        "required": ["path"],
        "additionalProperties": false
      }
    }
  },
  {
    "type": "function",
    "function": {
      "name": "service_status",
      "description": "Check a systemd service status and show recent logs.",
      "parameters": {
        "type": "object",
        "properties": {
          "service": { "type": "string", "description": "Service name, e.g., nginx" }
        },
        "required": ["service"],
        "additionalProperties": false
      }
    }
  }
]
JSON

SYSTEM_PROMPT="You are a cautious Linux SRE assistant. Use the provided tools when the user asks about files, disk usage, logs, or services. Prefer safe, read-only inspection. Never invent paths. If a path is missing or unsafe, ask for clarification."

MESSAGES_JSON="$(jq -n --arg s "$SYSTEM_PROMPT" --arg u "$USER_PROMPT" '[{role:"system",content:$s},{role:"user",content:$u}]')"

# ---------- API call helper ----------
call_api() {
  local messages_json="$1"
  local include_tools="${2:-1}"

  if [[ "$include_tools" == "1" ]]; then
    jq -n \
      --arg model "$MODEL" \
      --argjson messages "$messages_json" \
      --argjson tools "$TOOLS_JSON" \
      --argjson temp "$TEMPERATURE" \
      '{model:$model, messages:$messages, tools:$tools, temperature:0.2}' |
    curl -sS -X POST "$API_BASE/chat/completions" \
      -H "Authorization: Bearer $OPENAI_API_KEY" \
      -H "Content-Type: application/json" \
      -d @-
  else
    jq -n \
      --arg model "$MODEL" \
      --argjson messages "$messages_json" \
      --argjson temp "$TEMPERATURE" \
      '{model:$model, messages:$messages, temperature:0.2}' |
    curl -sS -X POST "$API_BASE/chat/completions" \
      -H "Authorization: Bearer $OPENAI_API_KEY" \
      -H "Content-Type: application/json" \
      -d @-
  fi
}

# ---------- First turn: ask the model ----------
FIRST_RESPONSE="$(call_api "$MESSAGES_JSON" 1)"

# If the model replies directly, print it and exit
DIRECT_CONTENT="$(jq -r '.choices[0].message.content // empty' <<<"$FIRST_RESPONSE")"
TOOL_CALLS_PRESENT="$(jq -e '.choices[0].message.tool_calls // empty' <<<"$FIRST_RESPONSE" >/dev/null && echo yes || echo no)"

if [[ "$TOOL_CALLS_PRESENT" == "no" && -n "$DIRECT_CONTENT" ]]; then
  printf "%s\n" "$DIRECT_CONTENT"
  exit 0
fi

# Otherwise, gather tool calls
ASSISTANT_MESSAGE_JSON="$(jq '.choices[0].message' <<<"$FIRST_RESPONSE")"
MESSAGES_JSON="$(jq --argjson msgs "$MESSAGES_JSON" --argjson a "$ASSISTANT_MESSAGE_JSON" '$msgs + [$a]')"

# Execute each requested tool
mapfile -t CALLS < <(jq -c '.choices[0].message.tool_calls[]' <<<"$FIRST_RESPONSE")
for call in "${CALLS[@]}"; do
  call_id="$(jq -r '.id' <<<"$call")"
  func_name="$(jq -r '.function.name' <<<"$call")"

  # "arguments" is a JSON string; parse it safely with jq
  args_json="$(jq -r '.function.arguments' <<<"$call")"
  get_arg() { jq -r --arg k "$1" '.[$k] // empty' <<<"$args_json"; }

  tool_output=""
  case "$func_name" in
    get_disk_usage)
      path="$(get_arg path)"
      tool_output="$(tool_get_disk_usage "$path" 2>&1 || true)"
      ;;
    search_files)
      path="$(get_arg path)"
      pattern="$(get_arg pattern)"
      max_results="$(get_arg max_results)"
      tool_output="$(tool_search_files "$path" "$pattern" "$max_results" 2>&1 || true)"
      ;;
    tail_log)
      path="$(get_arg path)"
      lines="$(get_arg lines)"
      tool_output="$(tool_tail_log "$path" "$lines" 2>&1 || true)"
      ;;
    service_status)
      service="$(get_arg service)"
      tool_output="$(tool_service_status "$service" 2>&1 || true)"
      ;;
    *)
      tool_output="Tool $func_name is not implemented."
      ;;
  esac

  # Append the tool result to messages
  TOOL_MESSAGE="$(jq -n --arg id "$call_id" --arg content "$tool_output" '{role:"tool", tool_call_id:$id, content:$content}')"
  MESSAGES_JSON="$(jq --argjson msgs "$MESSAGES_JSON" --argjson t "$TOOL_MESSAGE" '$msgs + [$t]')"
done

# Second turn: send tool results back to the model to get a final answer
FINAL_RESPONSE="$(call_api "$MESSAGES_JSON" 0)"
FINAL_TEXT="$(jq -r '.choices[0].message.content // "No response"' <<<"$FINAL_RESPONSE")"
printf "%s\n" "$FINAL_TEXT"

Notes

  • The script is intentionally conservative. It rejects relative paths and “..” to reduce risk.

  • All actions are read-only (analysis, search, tail, status). Add write/destructive tools only with confirmation gates.

  • You can swap MODEL or API_BASE via env vars (e.g., OpenAI-compatible gateways).


Try these real-world prompts

  • Disk triage:
./agent.sh "Under /var, which subdirectories are using the most space? Summarize the top offenders and suggest cleanup ideas."
  • Log search:
./agent.sh "Search /var/log for 'error 500' and show 5 representative hits with filenames."
  • Service health:
./agent.sh "Is nginx running? If it's not active, summarize the last 30 log lines."
  • Investigate a noisy app:
./agent.sh "Tail the last 100 lines of /var/log/syslog and tell me if a single service is spamming."

4 tips to productionize your agent

1) Tighten the sandbox

  • Run as a dedicated low-privilege user.

  • Use timeouts and ulimits on tools: timeout 10s ..., limit grep depth, etc.

  • Consider wrappers like firejail or containers for isolation.

2) Confirm before danger

  • Keep your toolkit read-only at first. If you add “cleanup” or “restart” tools, require explicit confirmation from the user or use a dry-run mode.

3) Log everything

  • Save the full request/response JSON for audits (redact secrets).

  • Emit trace IDs in both your Bash logs and your LLM messages.

4) Expand your toolkit gradually

  • Add tools like list_large_files, top_cpu_processes, or check_ports.

  • Each new tool is just a function + a JSON schema entry.


What you gained

  • A practical understanding of AI agent tool calling: safe, structured, allow-listed command execution driven by natural language.

  • A working Bash agent you can extend with your own tools.

  • A pathway to bring AI superpowers to your terminal without giving it the keys to your system.

Call to action

  • Clone this pattern into your dotfiles or an internal repo. Add the 1–2 tools that would save you the most time this week.

  • Share your best prompt-tool combos with your team, and set a policy for adding new tools safely.

  • If you want a deeper dive (multi-turn memory, retries, streaming), build a tiny service around this script or port it to Python/Go while keeping the same tool API.

Happy automating—safely.