40 KiB
Tool calling — Guida operativa / Operating guide / Guía de uso
Documento unico, tre lingue: Italiano · English · Español.
Riferimenti al codice: src/tools.rs, src/llm.rs,
src/orchestrator/turn_manager.rs,
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:
- Il motore allega l'array
tools(formato OpenAI) a ogni richiesta di completamento, contool_choice: "auto". - 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. - Il motore esegue la richiesta HTTP, ne tronca il risultato e lo rimanda al
modello come messaggio di ruolo
tool. - 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. |
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:
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/toolsriporta le definizioni attive e la loro origine:sourcevalefile(default da.env),request(installate via endpoint) oppurenone.- Risposte:
200con{ok,count,source,tools};400se il body è vuoto, non UTF-8, non è un array JSON valido o non contiene alcuna definizione;503se l'LLM è disattivato. - Il body accetta lo stesso formato del file: un array che funziona qui
funziona anche come
LLM_TOOLS_FILE.
curl -s localhost:8787/api/tools
# {"ok":true,"count":2,"source":"file","tools":[ … ]}
Implementazione: 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):
{
"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):
{
"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):
{
"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):
[
{
"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: 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
assistantcon la chiamata, poi un messaggiotoolper 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-parsernon 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:
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_SECSsolo quanto serve o riduci il lavoro lato server. - La qualità del
descriptiondetermina 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_CHARSvengono troncati e il modello può perdere informazione utile. - Non esporre segreti in chiaro nel
url: usaheaderscon 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:
- The engine attaches the OpenAI-shaped
toolsarray to every completion request, withtool_choice: "auto". - 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. - The engine executes the HTTP request, truncates the result and hands it back to
the model as a
toolrole message. - 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. |
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:
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_FILEdefinitions — 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/toolsreports the active definitions and their origin:sourceisfile(the.envdefault),request(installed through the endpoint) ornone.- Responses:
200with{ok,count,source,tools};400when the body is empty, is not UTF-8, is not a valid JSON array, or holds no tool definition;503when 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.
curl -s localhost:8787/api/tools
# {"ok":true,"count":2,"source":"file","tools":[ … ]}
Implementation: 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):
{
"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):
{
"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):
{
"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):
[
{
"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: 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
assistantmessage with the call, then onetoolmessage 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-parserthat 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:
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_SECSonly as much as needed, or reduce server-side work. - The
descriptiondrives 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_CHARSthey are truncated and the model may lose useful information. - Do not put secrets in cleartext in the
url: useheaderswith 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:
- El motor adjunta el array
tools(formato OpenAI) a cada petición de completion, contool_choice: "auto". - 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. - El motor ejecuta la petición HTTP, trunca el resultado y lo devuelve al modelo
como mensaje de rol
tool. - 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. |
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:
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/toolsinforma de las definiciones activas y su origen:sourcevalefile(el valor por defecto de.env),request(instaladas por el endpoint) onone.- Respuestas:
200con{ok,count,source,tools};400si el body está vacío, no es UTF-8, no es un array JSON válido o no contiene ninguna definición;503si 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.
curl -s localhost:8787/api/tools
# {"ok":true,"count":2,"source":"file","tools":[ … ]}
Implementación: 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):
{
"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):
{
"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):
{
"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):
[
{
"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: 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
assistantcon la llamada, luego un mensajetoolpor 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-parserque 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:
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_SECSsolo lo necesario o reduce el trabajo en el servidor. - La
descriptiondetermina 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_CHARSse truncan y el modelo puede perder información útil. - No pongas secretos en claro en la
url: usaheaderscon marcadores, o un endpoint interno. - El archivo de tools se lee una sola vez: reinicia el servicio tras editarlo.