Files
piperust/TOOL_CALLING.md
T
2026-10-21 10:44:49 +01:00

1021 lines
40 KiB
Markdown

# 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 (`<tool_call><function=…><parameter=…>…`), 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 `<tool_call>` 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 '<nome>'` |
| Verbo HTTP non valido nella definizione | `Error: invalid HTTP method '<verbo>' for tool '<nome>'` |
| Status HTTP non 2xx | `Error: HTTP <status> — <body troncato>` |
| Errore di rete/timeout | `Error: tool request failed: <dettaglio>` |
| 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
`<tool_call>`) 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
`<tool_call>`, 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 `<tool_call>` 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
(`<tool_call><function=…><parameter=…>…`), 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 `<tool_call>` 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 '<name>'` |
| Invalid HTTP verb in the definition | `Error: invalid HTTP method '<verb>' for tool '<name>'` |
| Non-2xx HTTP status | `Error: HTTP <status> — <truncated body>` |
| Network error / timeout | `Error: tool request failed: <detail>` |
| 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 `<tool_call>`) 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 `<tool_call>` 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 `<tool_call>` 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 (`<tool_call><function=…><parameter=…>…`), 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 `<tool_call>` 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 '<nombre>'` |
| Verbo HTTP no válido en la definición | `Error: invalid HTTP method '<verbo>' for tool '<nombre>'` |
| Estado HTTP distinto de 2xx | `Error: HTTP <estado> — <cuerpo truncado>` |
| Error de red / timeout | `Error: tool request failed: <detalle>` |
| 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
`<tool_call>`) 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 `<tool_call>`,
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 `<tool_call>` 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.