--- name: local-llama-tool-calling-debug description: Diagnose and fix local llama.cpp models that narrate tool calls instead of executing them through the Hermes agent. Root cause analysis for the Hermes4 + llama-server + OpenAI-compatible API tool chain. version: 1.0.0 author: Ezra license: MIT metadata: hermes: tags: [llama.cpp, tool-calling, local-model, hermes, debugging, sovereignty] related_skills: [systematic-debugging, wizard-house-remote-triage] --- # Local llama.cpp Tool Calling Debug ## When to Use - Local Hermes/llama-server model narrates tool calls as text instead of executing them - Model outputs `{"name": "read_file", ...}` as plain text - Agent treats tool-call text as a normal response and does nothing - Issue #93 in timmy-config: "Hard local Timmy proof test" ## Root Cause (discovered 2026-03-30) The Hermes agent expects tool calls via the OpenAI API's structured `tool_calls` field in the JSON response: ```json {"choices": [{"message": {"tool_calls": [{"id": "...", "function": {"name": "...", "arguments": "..."}}]}}]} ``` Hermes-format models (like Hermes-4-14B) output tool calls as XML-tagged text: ``` {"name": "read_file", "arguments": {"path": "..."}} ``` If llama-server doesn't convert those text tags into structured `tool_calls`, the agent sees `message.tool_calls = None` and treats the response as plain text. ## The Tool Chain ``` Hermes Agent (run_agent.py) → api_mode: "chat_completions" → sends tools as OpenAI function-calling JSON → calls client.chat.completions.create(**api_kwargs) → expects response.choices[0].message.tool_calls llama-server (with --jinja) → applies model's chat template → converts tags → OpenAI tool_calls in response → WITHOUT --jinja: returns raw text, no structured tool_calls ``` ## Fix: Start llama-server with --jinja The `--jinja` flag tells llama-server to use the model's built-in chat template, which includes Hermes-format tool call parsing. ```bash llama-server \ -m /path/to/NousResearch_Hermes-4-14B-Q4_K_M.gguf \ --port 8081 \ --jinja \ -ngl 99 \ -c 8192 \ -np 1 ``` Key flags: - `--jinja` — REQUIRED for tool call formatting - `-ngl 99` — offload all layers to GPU (Apple Silicon) - `-c 8192` — context length (keep small for speed; 65536 is valid but slow) - `-np 1` — CRITICAL: single parallel slot. Without this, llama-server defaults to `-np 4` (auto), splitting context evenly across slots. With `-c 8192 -np 4`, each slot only gets 2048 tokens — not enough for tool schemas + system prompt. The server accepts the request but `n_decoded` stays at 0 forever. Use `-np 1` to give the full context to one slot. ## Provider Configuration in Hermes "local-llama.cpp" is NOT in the PROVIDER_REGISTRY. It resolves through custom_providers in config.yaml: ```yaml model: default: hermes4:14b provider: custom base_url: http://localhost:8081/v1 custom_providers: - name: Local llama.cpp base_url: http://localhost:8081/v1 api_key: none model: hermes4:14b ``` This gives it `api_mode: "chat_completions"` which is correct for llama-server's OpenAI-compatible endpoint. ## Existing Parser (not wired into main loop) The codebase has `environments/tool_call_parsers/hermes_parser.py` (HermesToolCallParser) that CAN parse `` tags from text. But it's only used for Atropos RL training, NOT in the main agent loop (`run_agent.py`). A future code fix could wire this parser as a fallback in run_agent.py around line 7352 when `message.tool_calls` is None but the content contains `` tags. ## Performance: System Prompt Bloat The default Timmy config injects SOUL.md (~4K tokens) + full skills list (~6K tokens) + memory (~2K tokens) into the system prompt. On a 14B model with 65K context, this causes: - Very slow prompt processing (minutes, not seconds) - Timeouts on 180s SSH commands - Session appears stuck Fix for overnight loops: - Use `-c 8192` instead of `-c 65536` - Use `system_prompt_suffix: ""` or a minimal prompt - Use `-t file` to restrict toolsets (fewer tool schemas in context) - Keep task prompts short and specific ## Verification 1. Health check: `curl -s http://localhost:8081/health` 2. Slot status: `curl -s http://localhost:8081/slots | python3 -m json.tool` 3. Quick tool test: ```bash hermes chat -Q -m hermes4:14b -t file \ -q "Use read_file to read /tmp/test.txt" ``` 4. Check session JSON for `"tool_calls"` in assistant messages ## Proof That --jinja Works Session `20260329_211125_cb03eb` (cron heartbeat) shows the model successfully called `session_search` via the tool API. The `--jinja` flag enables tool calling. ## Pitfalls 1. **Without --jinja, tools silently fail.** The model outputs tool-call text, agent ignores it, returns the text as the response. No error thrown. 2. **Large context = slow.** 65K context on 14B Q4 with full system prompt can take 3+ minutes per turn. Use 8192 for tight loops. 3. **-np 4 silently starves slots.** llama-server defaults to `n_parallel = auto` (usually 4). With `-c 8192`, each slot gets only 2048 tokens. Tool schemas + system prompt exceed that. The request hangs with `n_decoded: 0` and no error. Always use `-np 1` for tool-calling workloads unless you've verified per-slot context is sufficient. 4. **Custom provider CLI.** `--provider custom` is not a valid CLI arg. Use env vars: `OPENAI_BASE_URL=http://localhost:8081/v1 OPENAI_API_KEY=none hermes chat ...` 5. **System python on macOS is 3.9.** Use the venv python (`~/.hermes/hermes-agent/venv/bin/python3`) for scripts that use 3.10+ syntax like `X | None`. 6. **Cron jobs may inherit cloud config.** If `model=null` in a cron job, it inherits the default. After cutting to local, verify all cron jobs have explicit local model/provider. 7. **Gemini fallback may still be active.** Check `fallback_model` in config.yaml — it may silently route to cloud when local times out. 8. **OpenClaw model routing is separate.** Even if Hermes config points local, OpenClaw may still route Telegram DMs to cloud. Check with `openclaw models status --json`.