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

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:

  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.
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/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.
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 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:

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.
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_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.
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 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:

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.
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/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.
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 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:

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.