# Tool calling — Operating guide Code references: [`src/tools.rs`](src/tools.rs), [`src/llm.rs`](src/llm.rs), [`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs), [`src/tools_api.rs`](src/tools_api.rs), [`tools.example.json`](tools.example.json). --- ## 1. What it does The engine's LLM can **call external HTTP services** before answering. A "tool" is not code: it is the **JSON description of an HTTP request** (URL, method, headers, argument schema). Adding a tool means writing a few lines in a JSON file — **no Rust changes**. Per user turn: 1. The engine attaches the OpenAI-shaped `tools` array to **every** completion request, with `tool_choice: "auto"`. 2. If needed, the model emits the call. The production LLM server may run without a tool parser, so the call comes back **as text** in the Qwen native format (`…`), which the engine **parses client-side**. 3. The engine executes the HTTP request, truncates the result and hands it back to the model as a `tool` role message. 4. The model produces the final answer, which is synthesised to speech. The `` block is **always stripped** from both the spoken and the displayed text: the user never hears or sees it. ## 2. Enabling it and runtime override | Variable | Default | Meaning | |---|---|---| | `LLM_TOOLS_ENABLED` | auto | Master switch. When unset it is `true` if `LLM_TOOLS_FILE` is a non-empty path. | | `LLM_TOOLS_FILE` | *(empty)* | Path to the JSON definitions file. Empty or missing ⇒ tool calling **disabled**. | | `LLM_TOOL_MAX_ITERATIONS` | `4` | Maximum tool rounds per turn (call → execute → re-prompt). Bounds a runaway loop. | | `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout of **one** tool HTTP request. | | `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | The tool result is truncated to this many characters before going back to the model. | ```dotenv LLM_TOOLS_ENABLED=true LLM_TOOLS_FILE=tools.example.json LLM_TOOL_MAX_ITERATIONS=4 LLM_TOOL_TIMEOUT_SECS=15 LLM_TOOL_RESULT_MAX_CHARS=2000 ``` Note: the tools file is read **once at startup** (`ToolRegistry::load`). After editing the JSON, **restart the service** for the new definitions to take effect. ### Runtime override — `POST /api/tools` To change the definitions **without a restart**, POST the same JSON array to `POST /api/tools`: ```bash curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \ -d '[{"name":"get_weather","description":"Current weather for a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]' ``` - The definitions you send **replace** the file ones from the **next turn** on (no restart). If the endpoint is **never called**, the engine keeps using the `LLM_TOOLS_FILE` definitions — that is the default, and a restart restores it. - The override lives **in memory only**: it does not write the file and does not survive a process restart. - `GET /api/tools` reports the active definitions and their origin: `source` is `file` (the `.env` default), `request` (installed through the endpoint) or `none`. - Responses: `200` with `{ok,count,source,tools}`; `400` when the body is empty, is not UTF-8, is not a valid JSON array, or holds no tool definition; `503` when the LLM is disabled. - The body uses **the same format as the file**: an array that works here also works as an `LLM_TOOLS_FILE`. ```bash curl -s localhost:8787/api/tools # {"ok":true,"count":2,"source":"file","tools":[ … ]} ``` Implementation: [`src/tools_api.rs`](src/tools_api.rs). Both endpoints also show up in the Swagger UI (`/docs`, tag `tools`). ## 3. Defining a tool The file is a JSON array. Each object has these fields: | Field | Required | Description | |---|---|---| | `name` | yes | Name of the function the model calls. Must match the `name` in the definition. | | `description` | recommended | **This is what the model reads to decide whether and when to call the tool**: make it explicit and usage-oriented ("call this tool when the user asks…"). | | `parameters` | no | JSON Schema for the arguments. When absent it defaults to `{"type":"object","properties":{}}`. | | `url` | yes | Target URL. May contain `{arg}` placeholders. | | `method` | no | **Any** HTTP verb, case-insensitive: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, and custom verbs (e.g. `PURGE`). Default `POST`. | | `headers` | no | Headers sent verbatim (API keys, `Content-Type`, …). Values may contain `{arg}` placeholders. | ### Placeholder substitution In `url` **and** `headers`, every `{argument_name}` is replaced with the matching call argument (strings unquoted, numbers/booleans rendered as text). ### Where the arguments go (by verb) Arguments **already consumed** by a `{arg}` placeholder in the URL are *not* sent again: they are already in the path. The remaining ones follow the verb: | Verb | Remaining arguments | |---|---| | `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **query parameters** (no body) | | `POST`, `PUT`, `PATCH` and custom verbs | **JSON body** (`{}` when nothing is left) | ## 4. Examples **GET with a URL placeholder** (`city` goes into the path, not the query): ```json { "name": "get_weather", "description": "Current weather and temperature for a city. Call this whenever the user asks about the weather, the temperature or the forecast for a specific place.", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "City name, e.g. 'Paris' or 'Roma'" } }, "required": ["city"] }, "url": "https://wttr.in/{city}?format=3", "method": "GET" } ``` **POST with an authenticated header** (secret taken from the arguments): ```json { "name": "create_note", "description": "Save a note into the internal management system.", "parameters": { "type": "object", "properties": { "token": { "type": "string", "description": "User session token" }, "text": { "type": "string", "description": "Note content" } }, "required": ["token", "text"] }, "url": "https://internal.example.local/api/notes", "method": "POST", "headers": { "Authorization": "Bearer {token}" } } ``` **Plain POST (webhook)**: ```json { "name": "send_webhook", "description": "POST a JSON payload to the configured webhook endpoint (notification / home-automation hook).", "parameters": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] }, "url": "https://httpbin.org/post", "method": "POST", "headers": { "X-Tool": "audio-agent-engine" } } ``` **A full REST surface** — one tool per verb on the same endpoint (`list` → `GET`, `detail` → `GET` with `{id}`, `add` → `POST`, `update` → `PATCH`, `replace` → `PUT`, `delete` → `DELETE`, `exists?` → `HEAD`): ```json [ { "name": "list_items", "description": "List the items, with optional filters.", "parameters": { "type": "object", "properties": { "limit": { "type": "integer" }, "search": { "type": "string" } } }, "url": "https://api.example.local/api/items", "method": "GET" }, { "name": "get_item", "description": "Details of one item by id.", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "GET" }, { "name": "add_item", "description": "Create a new item.", "parameters": { "type": "object", "properties": { "name": { "type": "string" }, "qty": { "type": "integer" } }, "required": ["name"] }, "url": "https://api.example.local/api/items", "method": "POST" }, { "name": "update_item", "description": "Partially update an item (only the fields sent).", "parameters": { "type": "object", "properties": { "id": { "type": "string" }, "qty": { "type": "integer" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "PATCH" }, { "name": "replace_item", "description": "Replace an item entirely.", "parameters": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" } }, "required": ["id", "name"] }, "url": "https://api.example.local/api/items/{id}", "method": "PUT" }, { "name": "delete_item", "description": "Delete an item by id.", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "DELETE" }, { "name": "item_exists", "description": "Check whether an item exists (status only, no body).", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "HEAD" } ] ``` Resulting requests: `GET /api/items?limit=10`, `GET /api/items/5`, `POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`, `PUT /api/items/5` + body `{"name":"…"}`, `DELETE /api/items/5`, `HEAD /api/items/5`. Start from [`tools.example.json`](tools.example.json): copy it, adapt it and point `LLM_TOOLS_FILE` at your file. ## 5. Multi-round loop and streaming fallback - Each round produces **one** completion; the calls found are executed and their results are handed back for the next round (standard OpenAI round-trip: an `assistant` message with the call, then one `tool` message per result). - The loop stops when there are no more calls, or at `LLM_TOOL_MAX_ITERATIONS`. - **Streaming fallback**: some OpenAI-compatible servers (vLLM with a `--tool-call-parser` that does not match the model) **swallow** the call on the SSE path: the round ends empty, with no HTTP error. When a round comes back empty and tools are configured, the engine **re-asks once without streaming** (`chat_reply`) and takes the call from there. If even that yields no call but holds text, that text is spoken. The fallback only triggers on empty rounds, so it costs nothing on normal turns. ## 6. Errors and truncation Tool execution **never fails** in a blocking way: an error becomes text the model can read and recover from. | Situation | Text handed to the model | |---|---| | Unknown tool | `Error: unknown tool ''` | | Invalid HTTP verb in the definition | `Error: invalid HTTP method '' for tool ''` | | Non-2xx HTTP status | `Error: HTTP — ` | | Network error / timeout | `Error: tool request failed: ` | | Result longer than the limit | Truncated to `LLM_TOOL_RESULT_MAX_CHARS`, suffixed with `… [truncated]` | ## 7. Important note on the server-side parser If the LLM server runs **with** a tool parser, the flag must match the model's native format (`--enable-auto-tool-choice --tool-call-parser …`). A mismatch is **silent**: an `hermes` parser (expecting JSON inside ``) against a Qwen model that emits XML makes the parse throw, the call is dropped and, in streaming, the reply comes back empty (vLLM logs: `Error in extracting tool call from response`). The correct parser for this model is `qwen3_coder`. The engine is robust either way: it handles **both** formats, the structured `message.tool_calls[]` array **and** the native `` text, plus the fallback described in section 5. Typical mismatch symptom: the agent goes silent, or "narrates" the call without executing it. ## 8. End-to-end verification With the service running on `ws://localhost:8787/ws`, send user text that must trigger the tool and check the events: ```python import asyncio, json, websockets async def main(): async with websockets.connect("ws://localhost:8787/ws") as ws: await ws.send(json.dumps({"type": "user_text", "text": "What's the weather in Paris?"})) while True: msg = json.loads(await ws.recv()) if msg.get("type") == "tool_call": print("TOOL:", msg["name"], msg["arguments"]) if msg.get("type") == "assistant_end": print("turn finished") break asyncio.run(main()) ``` Expected WebSocket events, in order: `tool_call` (with `name` and `arguments`) then `assistant_end`. The `` block must not appear in any `assistant_delta`. ## 9. Limits and best practices - Calls are not streamed to the client: the agent's answer arrives **after** the tool execution. For slow tools, raise `LLM_TOOL_TIMEOUT_SECS` only as much as needed, or reduce server-side work. - The `description` drives tool usage: the default system prompt does not mention tools, the model decides solely from the descriptions. - Keep results **short**: beyond `LLM_TOOL_RESULT_MAX_CHARS` they are truncated and the model may lose useful information. - Do not put secrets in cleartext in the `url`: use `headers` with placeholders, or an internal endpoint. - The tools file is read **once**: restart the service after editing it.