client sdk

This commit is contained in:
valenti
2026-10-21 17:56:04 +01:00
parent 1cafe3dd3c
commit d6bc2437c9
237 changed files with 5227 additions and 1019 deletions
+15 -694
View File
@@ -1,348 +1,12 @@
# Tool calling — Guida operativa / Operating guide / Guía de uso
# Tool calling — Operating guide
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),
Code references: [`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).
[`src/tools_api.rs`](src/tools_api.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
## 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,
@@ -364,7 +28,7 @@ Per user turn:
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
## 2. Enabling it and runtime override
| Variable | Default | Meaning |
|---|---|---|
@@ -385,7 +49,7 @@ 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`
### Runtime override — `POST /api/tools`
To change the definitions **without a restart**, POST the same JSON array to
`POST /api/tools`:
@@ -417,7 +81,7 @@ curl -s localhost:8787/api/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
## 3. Defining a tool
The file is a JSON array. Each object has these fields:
@@ -430,12 +94,12 @@ The file is a JSON array. Each object has these fields:
| `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
### 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)
### 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:
@@ -445,7 +109,7 @@ again: they are already in the path. The remaining ones follow the verb:
| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **query parameters** (no body) |
| `POST`, `PUT`, `PATCH` and custom verbs | **JSON body** (`{}` when nothing is left) |
### 4. Examples
## 4. Examples
**GET with a URL placeholder** (`city` goes into the path, not the query):
@@ -596,7 +260,7 @@ Resulting requests: `GET /api/items?limit=10`, `GET /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
## 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
@@ -610,7 +274,7 @@ Start from [`tools.example.json`](tools.example.json): copy it, adapt it and poi
holds text, that text is spoken. The fallback only triggers on empty rounds, so
it costs nothing on normal turns.
### 6. Errors and truncation
## 6. Errors and truncation
Tool execution **never fails** in a blocking way: an error becomes text the model
can read and recover from.
@@ -623,7 +287,7 @@ can read and recover from.
| 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
## 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
@@ -638,7 +302,7 @@ fallback described in section 5.
Typical mismatch symptom: the agent goes silent, or "narrates" the call without
executing it.
### 8. End-to-end verification
## 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:
@@ -663,7 +327,7 @@ 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
## 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
@@ -675,346 +339,3 @@ Expected WebSocket events, in order: `tool_call` (with `name` and `arguments`) t
- 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.