client sdk
This commit is contained in:
+15
-694
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user