# Tool calling — Guida operativa / Operating guide / Guía de uso Documento unico, tre lingue: [Italiano](#italiano) · [English](#english) · [Español](#español). Riferimenti al codice: [`src/tools.rs`](src/tools.rs), [`src/llm.rs`](src/llm.rs), [`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs), [`tools.example.json`](tools.example.json). --- ## Italiano ### 1. Cosa fa Il modello LLM del motore audio può **chiamare servizi HTTP esterni** prima di rispondere. Un "tool" non è codice: è la **descrizione JSON di una richiesta HTTP** (URL, metodo, header, schema degli argomenti). Aggiungere un tool significa scrivere qualche riga in un file JSON — **nessuna modifica al codice Rust**. Flusso per ogni turno utente: 1. Il motore allega l'array `tools` (formato OpenAI) a **ogni** richiesta di completamento, con `tool_choice: "auto"`. 2. Il modello, se serve, emette la chiamata. Il server LLM di produzione può non avere un parser di tool: la chiamata torna **come testo** nel formato nativo Qwen (`…`), che il motore **parsa client-side**. 3. Il motore esegue la richiesta HTTP, ne tronca il risultato e lo rimanda al modello come messaggio di ruolo `tool`. 4. Il modello produce la risposta finale, che viene sintetizzata in voce. Il blocco `` viene **sempre rimosso** dal testo parlato e mostrato in chat: l'utente non lo sente e non lo vede mai. ### 2. Abilitazione e override runtime | Variabile | Default | Significato | |---|---|---| | `LLM_TOOLS_ENABLED` | auto | Interruttore generale. Se non impostato vale `true` quando `LLM_TOOLS_FILE` è un percorso non vuoto. | | `LLM_TOOLS_FILE` | *(vuoto)* | Percorso del file JSON con le definizioni. Vuoto o assente ⇒ tool calling **disattivato**. | | `LLM_TOOL_MAX_ITERATIONS` | `4` | Numero massimo di round di tool per turno (chiamata → esecuzione → ri-prompt). Limita i loop infiniti. | | `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout di **una singola** richiesta HTTP del tool. | | `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | Il risultato del tool viene troncato a questo numero di caratteri prima di tornare al modello. | ```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 ``` Attenzione: il file dei tool viene letto **una sola volta all'avvio** (`ToolRegistry::load`). Dopo aver modificato il JSON serve un **riavvio del servizio** perché le nuove definizioni siano attive. #### Override a runtime — `POST /api/tools` Per cambiare le definizioni **senza riavvio**, invia lo stesso array JSON a `POST /api/tools`: ```bash curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \ -d '[{"name":"get_weather","description":"Meteo di una città","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]' ``` - Le definizioni inviate **sostituiscono** quelle del file dal **turno successivo** (nessun riavvio). Se l'endpoint **non viene mai chiamato**, il motore continua a usare quelle di `LLM_TOOLS_FILE`: è il comportamento predefinito, e un riavvio lo ripristina. - L'override vive **solo in memoria**: non scrive sul file e non sopravvive al riavvio del processo. - `GET /api/tools` riporta le definizioni attive e la loro origine: `source` vale `file` (default da `.env`), `request` (installate via endpoint) oppure `none`. - Risposte: `200` con `{ok,count,source,tools}`; `400` se il body è vuoto, non UTF-8, non è un array JSON valido o non contiene alcuna definizione; `503` se l'LLM è disattivato. - Il body accetta **lo stesso formato del file**: un array che funziona qui funziona anche come `LLM_TOOLS_FILE`. ```bash curl -s localhost:8787/api/tools # {"ok":true,"count":2,"source":"file","tools":[ … ]} ``` Implementazione: [`src/tools_api.rs`](src/tools_api.rs). Entrambi gli endpoint compaiono anche nella Swagger UI (`/docs`, tag `tools`). ### 3. Definire un tool Il file è un array JSON. Ogni oggetto ha i campi seguenti: | Campo | Obbligatorio | Descrizione | |---|---|---| | `name` | sì | Nome della funzione che il modello invoca. Deve combaciare con lo `name` della definizione. | | `description` | consigliato | **È ciò che il modello legge per decidere se e quando chiamare il tool**: scrivila esplicita e orientata all'uso ("chiama questo tool quando l'utente chiede…"). | | `parameters` | no | JSON Schema degli argomenti. Se assente vale `{"type":"object","properties":{}}`. | | `url` | sì | URL di destinazione. Può contenere segnaposto `{argomento}`. | | `method` | no | **Qualsiasi** verbo HTTP, case-insensitive: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, e verbi custom (es. `PURGE`). Default `POST`. | | `headers` | no | Header inviati letteralmente (chiavi API, `Content-Type`, …). I valori possono contenere segnaposto `{argomento}`. | #### Sostituzione dei segnaposto In `url` **e** in `headers`, ogni occorrenza di `{nome_argomento}` viene sostituita con il valore corrispondente della chiamata (stringhe senza virgolette, numeri/booleani come testo). #### Dove finiscono gli argomenti (per verbo) Gli argomenti **già consumati** da un segnaposto `{arg}` nell'URL *non* vengono re-inviati: sono già nel path. I rimanenti seguono il verbo: | Verbo | Argomenti rimanenti | |---|---| | `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **query parameters** (nessun body) | | `POST`, `PUT`, `PATCH` e verbi custom | **body JSON** (in assenza di argomenti: `{}`) | ### 4. Esempi **GET con segnaposto nell'URL** (il `city` finisce nel path, non nella query): ```json { "name": "get_weather", "description": "Meteo e temperatura attuali di una città. Chiamalo ogni volta che l'utente chiede il meteo, la temperatura o le previsioni di un luogo specifico.", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "Nome della città, es. 'Roma'" } }, "required": ["city"] }, "url": "https://wttr.in/{city}?format=3", "method": "GET" } ``` **POST con header autenticato** (segreto preso dagli argomenti): ```json { "name": "create_note", "description": "Salva una nota nel gestionale interno.", "parameters": { "type": "object", "properties": { "token": { "type": "string", "description": "Token di sessione dell'utente" }, "text": { "type": "string", "description": "Contenuto della nota" } }, "required": ["token", "text"] }, "url": "https://gestione.interno.local/api/notes", "method": "POST", "headers": { "Authorization": "Bearer {token}" } } ``` **POST semplice (webhook)**: ```json { "name": "send_webhook", "description": "Invia un payload JSON all'endpoint webhook configurato (notifiche / home automation).", "parameters": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] }, "url": "https://httpbin.org/post", "method": "POST", "headers": { "X-Tool": "audio-agent-engine" } } ``` **Superficie REST completa** — un tool per verbo sullo stesso endpoint (`lista` → `GET`, `dettaglio` → `GET` con `{id}`, `aggiungi` → `POST`, `aggiorna` → `PATCH`, `sostituisci` → `PUT`, `elimina` → `DELETE`, `esiste?` → `HEAD`): ```json [ { "name": "list_items", "description": "Elenca gli articoli, con filtri opzionali.", "parameters": { "type": "object", "properties": { "limit": { "type": "integer" }, "search": { "type": "string" } } }, "url": "https://api.example.local/api/items", "method": "GET" }, { "name": "get_item", "description": "Dettaglio di un articolo per id.", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "GET" }, { "name": "add_item", "description": "Crea un nuovo articolo.", "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": "Aggiorna parzialmente un articolo (solo i campi inviati).", "parameters": { "type": "object", "properties": { "id": { "type": "string" }, "qty": { "type": "integer" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "PATCH" }, { "name": "delete_item", "description": "Elimina un articolo per id.", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "DELETE" }, { "name": "item_exists", "description": "Verifica l'esistenza di un articolo (solo status, nessun body).", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "HEAD" } ] ``` Richieste risultanti: `GET /api/items?limit=10`, `GET /api/items/5`, `POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`, `DELETE /api/items/5`, `HEAD /api/items/5`. Parti da [`tools.example.json`](tools.example.json): copialo, adattalo e punta `LLM_TOOLS_FILE` al tuo file. ### 5. Ciclo multi-round e fallback in streaming - Ogni round produce **un** completamento; le chiamate trovate vengono eseguite e i risultati rinviati al modello nel round successivo (round-trip standard OpenAI: messaggio `assistant` con la chiamata, poi un messaggio `tool` per ciascun risultato). - Il ciclo si ferma quando non ci sono più chiamate, oppure al raggiungimento di `LLM_TOOL_MAX_ITERATIONS`. - **Fallback in streaming**: alcuni server OpenAI-compatible (vLLM con `--tool-call-parser` non allineato al modello) **mangiano** la chiamata sul percorso SSE: il round finisce vuoto, senza errore HTTP. Se un round torna vuoto e i tool sono configurati, il motore **rifà la richiesta una volta senza streaming** (`chat_reply`) e recupera la chiamata da lì. Se nemmeno lì c'è una chiamata ma c'è del testo, quel testo viene parlato. Questo fallback si attiva **solo** sui round vuoti: nessun costo sui turni normali. ### 6. Errori e troncamento L'esecuzione di un tool **non fallisce mai** in modo bloccante: l'errore diventa testo che il modello può leggere e da cui può recuperare. | Situazione | Testo consegnato al modello | |---|---| | Tool inesistente | `Error: unknown tool ''` | | Verbo HTTP non valido nella definizione | `Error: invalid HTTP method '' for tool ''` | | Status HTTP non 2xx | `Error: HTTP — ` | | Errore di rete/timeout | `Error: tool request failed: ` | | Risultato più lungo del limite | Troncato a `LLM_TOOL_RESULT_MAX_CHARS`, con suffisso `… [truncated]` | ### 7. Nota importante sul parser lato server Se il server LLM gira **con** un parser di tool, il flag deve combaciare con il formato nativo del modello (`--enable-auto-tool-choice --tool-call-parser …`). Il disallineamento è **silenzioso**: un parser `hermes` (che si aspetta JSON dentro ``) contro un modello Qwen che emette XML fa fallire il parse, la chiamata viene scartata e in streaming la risposta torna vuota (nei log vLLM: `Error in extracting tool call from response`). Per questo modello il parser corretto è `qwen3_coder`. Il motore è comunque robusto: gestisce **entrambi** i formati, l'array strutturato `message.tool_calls[]` **e** il testo nativo ``, più il fallback descritto al punto 5. Sintomo tipico di disallineamento: l'agente tace, oppure "narra" la chiamata senza eseguirla. ### 8. Verifica end-to-end Con il servizio attivo su `ws://localhost:8787/ws`, invia un testo utente che deve innescare il tool e verifica gli eventi: ```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": "Che tempo fa a Roma?"})) 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("turno concluso") break asyncio.run(main()) ``` Eventi attesi sul WebSocket, in ordine: `tool_call` (con `name` e `arguments`) e poi `assistant_end`. Il blocco `` non deve comparire in nessun `assistant_delta`. ### 9. Limiti e buone pratiche - Le chiamate non vengono trasmesse in streaming al client: la risposta dell'agente arriva **dopo** l'esecuzione del tool. Sui tool lenti alza `LLM_TOOL_TIMEOUT_SECS` solo quanto serve o riduci il lavoro lato server. - La qualità del `description` determina l'uso del tool: il system prompt di default non parla dei tool, decide solo il modello leggendo le descrizioni. - Tieni i risultati **brevi**: oltre `LLM_TOOL_RESULT_MAX_CHARS` vengono troncati e il modello può perdere informazione utile. - Non esporre segreti in chiaro nel `url`: usa `headers` con segnaposto, oppure un endpoint interno. - Il file dei tool è letto a **caldo una sola volta**: dopo una modifica riavvia il servizio. --- ## English ### 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. --- ## Español ### 1. Qué hace El LLM del motor puede **llamar a servicios HTTP externos** antes de responder. Un "tool" no es código: es la **descripción JSON de una petición HTTP** (URL, método, cabeceras, esquema de argumentos). Añadir un tool significa escribir unas líneas en un archivo JSON — **sin tocar el código Rust**. Por cada turno del usuario: 1. El motor adjunta el array `tools` (formato OpenAI) a **cada** petición de completion, con `tool_choice: "auto"`. 2. Si hace falta, el modelo emite la llamada. El servidor LLM de producción puede no tener parser de tools: la llamada vuelve **como texto** en el formato nativo de Qwen (`…`), que el motor **parsea en el cliente**. 3. El motor ejecuta la petición HTTP, trunca el resultado y lo devuelve al modelo como mensaje de rol `tool`. 4. El modelo produce la respuesta final, que se sintetiza a voz. El bloque `` se **elimina siempre** del texto hablado y del mostrado en el chat: el usuario nunca lo oye ni lo ve. ### 2. Activación y override en runtime | Variable | Por defecto | Significado | |---|---|---| | `LLM_TOOLS_ENABLED` | auto | Interruptor general. Si no se define vale `true` cuando `LLM_TOOLS_FILE` es una ruta no vacía. | | `LLM_TOOLS_FILE` | *(vacío)* | Ruta del archivo JSON con las definiciones. Vacío o inexistente ⇒ tool calling **desactivado**. | | `LLM_TOOL_MAX_ITERATIONS` | `4` | Número máximo de rondas de tool por turno (llamada → ejecución → re-prompt). Limita bucles infinitos. | | `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout de **una** petición HTTP del tool. | | `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | El resultado del tool se trunca a este número de caracteres antes de volver al modelo. | ```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 ``` Atención: el archivo de tools se lee **una sola vez al arrancar** (`ToolRegistry::load`). Tras modificar el JSON hay que **reiniciar el servicio** para que las nuevas definiciones estén activas. #### Override en runtime — `POST /api/tools` Para cambiar las definiciones **sin reiniciar**, envía el mismo array JSON a `POST /api/tools`: ```bash curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \ -d '[{"name":"get_weather","description":"Clima de una ciudad","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]' ``` - Las definiciones enviadas **sustituyen** las del archivo desde el **siguiente turno** (sin reinicio). Si el endpoint **nunca se llama**, el motor sigue usando las de `LLM_TOOLS_FILE`: es el comportamiento por defecto, y un reinicio lo restaura. - El override vive **solo en memoria**: no escribe el archivo y no sobrevive al reinicio del proceso. - `GET /api/tools` informa de las definiciones activas y su origen: `source` vale `file` (el valor por defecto de `.env`), `request` (instaladas por el endpoint) o `none`. - Respuestas: `200` con `{ok,count,source,tools}`; `400` si el body está vacío, no es UTF-8, no es un array JSON válido o no contiene ninguna definición; `503` si el LLM está desactivado. - El body acepta **el mismo formato que el archivo**: un array que funciona aquí también funciona como `LLM_TOOLS_FILE`. ```bash curl -s localhost:8787/api/tools # {"ok":true,"count":2,"source":"file","tools":[ … ]} ``` Implementación: [`src/tools_api.rs`](src/tools_api.rs). Ambos endpoints aparecen también en la Swagger UI (`/docs`, tag `tools`). ### 3. Definir un tool El archivo es un array JSON. Cada objeto tiene estos campos: | Campo | Obligatorio | Descripción | |---|---|---| | `name` | sí | Nombre de la función que invoca el modelo. Debe coincidir con el `name` de la definición. | | `description` | recomendado | **Es lo que el modelo lee para decidir si y cuándo llamar al tool**: escríbela explícita y orientada al uso ("llama a este tool cuando el usuario pregunte…"). | | `parameters` | no | JSON Schema de los argumentos. Si falta vale `{"type":"object","properties":{}}`. | | `url` | sí | URL de destino. Puede contener marcadores `{argumento}`. | | `method` | no | **Cualquier** verbo HTTP, sin distinguir mayúsculas: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE` y verbos personalizados (p. ej. `PURGE`). Por defecto `POST`. | | `headers` | no | Cabeceras enviadas literalmente (claves API, `Content-Type`, …). Los valores pueden contener marcadores `{argumento}`. | #### Sustitución de marcadores En `url` **y** en `headers`, cada `{nombre_argumento}` se sustituye por el valor correspondiente de la llamada (cadenas sin comillas, números/booleanos como texto). #### Dónde van los argumentos (según el verbo) Los argumentos **ya consumidos** por un marcador `{arg}` de la URL *no* se envían otra vez: ya están en el path. Los restantes siguen al verbo: | Verbo | Argumentos restantes | |---|---| | `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **parámetros de query** (sin body) | | `POST`, `PUT`, `PATCH` y verbos personalizados | **cuerpo JSON** (`{}` si no queda ninguno) | ### 4. Ejemplos **GET con marcador en la URL** (`city` va al path, no a la query): ```json { "name": "get_weather", "description": "Clima y temperatura actuales de una ciudad. Llámalo cada vez que el usuario pregunte por el clima, la temperatura o el pronóstico de un lugar concreto.", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "Nombre de la ciudad, p. ej. 'Madrid'" } }, "required": ["city"] }, "url": "https://wttr.in/{city}?format=3", "method": "GET" } ``` **POST con cabecera autenticada** (secreto tomado de los argumentos): ```json { "name": "create_note", "description": "Guarda una nota en el sistema interno de gestión.", "parameters": { "type": "object", "properties": { "token": { "type": "string", "description": "Token de sesión del usuario" }, "text": { "type": "string", "description": "Contenido de la nota" } }, "required": ["token", "text"] }, "url": "https://interno.example.local/api/notes", "method": "POST", "headers": { "Authorization": "Bearer {token}" } } ``` **POST simple (webhook)**: ```json { "name": "send_webhook", "description": "Envía un payload JSON al endpoint webhook configurado (notificaciones / home automation).", "parameters": { "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"] }, "url": "https://httpbin.org/post", "method": "POST", "headers": { "X-Tool": "audio-agent-engine" } } ``` **Superficie REST completa** — un tool por verbo sobre el mismo endpoint (`lista` → `GET`, `detalle` → `GET` con `{id}`, `añade` → `POST`, `actualiza` → `PATCH`, `reemplaza` → `PUT`, `elimina` → `DELETE`, `¿existe?` → `HEAD`): ```json [ { "name": "list_items", "description": "Lista los artículos, con filtros opcionales.", "parameters": { "type": "object", "properties": { "limit": { "type": "integer" }, "search": { "type": "string" } } }, "url": "https://api.example.local/api/items", "method": "GET" }, { "name": "get_item", "description": "Detalle de un artículo por id.", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "GET" }, { "name": "add_item", "description": "Crea un artículo nuevo.", "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": "Actualiza parcialmente un artículo (solo los campos enviados).", "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": "Reemplaza un artículo por completo.", "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": "Elimina un artículo por id.", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "DELETE" }, { "name": "item_exists", "description": "Comprueba si existe un artículo (solo status, sin body).", "parameters": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }, "url": "https://api.example.local/api/items/{id}", "method": "HEAD" } ] ``` Peticiones resultantes: `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`. Parte de [`tools.example.json`](tools.example.json): cópialo, adáptalo y apunta `LLM_TOOLS_FILE` a tu archivo. ### 5. Bucle multi-ronda y fallback en streaming - Cada ronda produce **un** completion; las llamadas encontradas se ejecutan y sus resultados se devuelven al modelo en la ronda siguiente (round-trip estándar OpenAI: mensaje `assistant` con la llamada, luego un mensaje `tool` por cada resultado). - El bucle se detiene cuando no hay más llamadas, o al alcanzar `LLM_TOOL_MAX_ITERATIONS`. - **Fallback en streaming**: algunos servidores compatibles con OpenAI (vLLM con un `--tool-call-parser` que no coincide con el modelo) **se tragan** la llamada en la ruta SSE: la ronda termina vacía, sin error HTTP. Si una ronda vuelve vacía y hay tools configurados, el motor **repite la petición una vez sin streaming** (`chat_reply`) y recupera la llamada de ahí. Si tampoco hay llamada pero hay texto, ese texto se habla. El fallback se activa **solo** en rondas vacías: no cuesta nada en los turnos normales. ### 6. Errores y truncado La ejecución de un tool **nunca falla** de forma bloqueante: el error se convierte en texto que el modelo puede leer y del que puede recuperarse. | Situación | Texto entregado al modelo | |---|---| | Tool inexistente | `Error: unknown tool ''` | | Verbo HTTP no válido en la definición | `Error: invalid HTTP method '' for tool ''` | | Estado HTTP distinto de 2xx | `Error: HTTP — ` | | Error de red / timeout | `Error: tool request failed: ` | | Resultado más largo que el límite | Truncado a `LLM_TOOL_RESULT_MAX_CHARS`, con sufijo `… [truncated]` | ### 7. Nota importante sobre el parser del servidor Si el servidor LLM funciona **con** un parser de tools, el flag debe coincidir con el formato nativo del modelo (`--enable-auto-tool-choice --tool-call-parser …`). El desajuste es **silencioso**: un parser `hermes` (que espera JSON dentro de ``) contra un modelo Qwen que emite XML hace fallar el parseo, la llamada se descarta y, en streaming, la respuesta vuelve vacía (logs de vLLM: `Error in extracting tool call from response`). El parser correcto para este modelo es `qwen3_coder`. El motor es robusto en cualquier caso: maneja **ambos** formatos, el array estructurado `message.tool_calls[]` **y** el texto nativo ``, además del fallback descrito en la sección 5. Síntoma típico del desajuste: el agente calla, o "narra" la llamada sin ejecutarla. ### 8. Verificación end-to-end Con el servicio activo en `ws://localhost:8787/ws`, envía texto de usuario que deba disparar el tool y comprueba los eventos: ```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": "¿Qué tiempo hace en Madrid?"})) 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("turno terminado") break asyncio.run(main()) ``` Eventos WebSocket esperados, en orden: `tool_call` (con `name` y `arguments`) y después `assistant_end`. El bloque `` no debe aparecer en ningún `assistant_delta`. ### 9. Límites y buenas prácticas - Las llamadas no se transmiten en streaming al cliente: la respuesta del agente llega **después** de la ejecución del tool. Para tools lentos, sube `LLM_TOOL_TIMEOUT_SECS` solo lo necesario o reduce el trabajo en el servidor. - La `description` determina el uso del tool: el system prompt por defecto no habla de tools, decide solo el modelo leyendo las descripciones. - Mantén los resultados **cortos**: más allá de `LLM_TOOL_RESULT_MAX_CHARS` se truncan y el modelo puede perder información útil. - No pongas secretos en claro en la `url`: usa `headers` con marcadores, o un endpoint interno. - El archivo de tools se lee **una sola vez**: reinicia el servicio tras editarlo.