1021 lines
40 KiB
Markdown
1021 lines
40 KiB
Markdown
# Tool calling — Guida operativa / Operating guide / Guía de uso
|
|
|
|
Documento unico, tre lingue: [Italiano](#italiano) · [English](#english) · [Español](#español).
|
|
Riferimenti al codice: [`src/tools.rs`](src/tools.rs), [`src/llm.rs`](src/llm.rs),
|
|
[`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs),
|
|
[`tools.example.json`](tools.example.json).
|
|
|
|
---
|
|
|
|
## Italiano
|
|
|
|
### 1. Cosa fa
|
|
|
|
Il modello LLM del motore audio può **chiamare servizi HTTP esterni** prima di
|
|
rispondere. Un "tool" non è codice: è la **descrizione JSON di una richiesta
|
|
HTTP** (URL, metodo, header, schema degli argomenti). Aggiungere un tool significa
|
|
scrivere qualche riga in un file JSON — **nessuna modifica al codice Rust**.
|
|
|
|
Flusso per ogni turno utente:
|
|
|
|
1. Il motore allega l'array `tools` (formato OpenAI) a **ogni** richiesta di
|
|
completamento, con `tool_choice: "auto"`.
|
|
2. Il modello, se serve, emette la chiamata. Il server LLM di produzione può non
|
|
avere un parser di tool: la chiamata torna **come testo** nel formato nativo
|
|
Qwen (`<tool_call><function=…><parameter=…>…`), che il motore **parsa
|
|
client-side**.
|
|
3. Il motore esegue la richiesta HTTP, ne tronca il risultato e lo rimanda al
|
|
modello come messaggio di ruolo `tool`.
|
|
4. Il modello produce la risposta finale, che viene sintetizzata in voce.
|
|
|
|
Il blocco `<tool_call>` viene **sempre rimosso** dal testo parlato e mostrato in
|
|
chat: l'utente non lo sente e non lo vede mai.
|
|
|
|
### 2. Abilitazione e override runtime
|
|
|
|
| Variabile | Default | Significato |
|
|
|---|---|---|
|
|
| `LLM_TOOLS_ENABLED` | auto | Interruttore generale. Se non impostato vale `true` quando `LLM_TOOLS_FILE` è un percorso non vuoto. |
|
|
| `LLM_TOOLS_FILE` | *(vuoto)* | Percorso del file JSON con le definizioni. Vuoto o assente ⇒ tool calling **disattivato**. |
|
|
| `LLM_TOOL_MAX_ITERATIONS` | `4` | Numero massimo di round di tool per turno (chiamata → esecuzione → ri-prompt). Limita i loop infiniti. |
|
|
| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout di **una singola** richiesta HTTP del tool. |
|
|
| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | Il risultato del tool viene troncato a questo numero di caratteri prima di tornare al modello. |
|
|
|
|
```dotenv
|
|
LLM_TOOLS_ENABLED=true
|
|
LLM_TOOLS_FILE=tools.example.json
|
|
LLM_TOOL_MAX_ITERATIONS=4
|
|
LLM_TOOL_TIMEOUT_SECS=15
|
|
LLM_TOOL_RESULT_MAX_CHARS=2000
|
|
```
|
|
|
|
Attenzione: il file dei tool viene letto **una sola volta all'avvio**
|
|
(`ToolRegistry::load`). Dopo aver modificato il JSON serve un **riavvio del
|
|
servizio** perché le nuove definizioni siano attive.
|
|
|
|
#### Override a runtime — `POST /api/tools`
|
|
|
|
Per cambiare le definizioni **senza riavvio**, invia lo stesso array JSON a
|
|
`POST /api/tools`:
|
|
|
|
```bash
|
|
curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \
|
|
-d '[{"name":"get_weather","description":"Meteo di una città","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]'
|
|
```
|
|
|
|
- Le definizioni inviate **sostituiscono** quelle del file dal **turno
|
|
successivo** (nessun riavvio). Se l'endpoint **non viene mai chiamato**, il
|
|
motore continua a usare quelle di `LLM_TOOLS_FILE`: è il comportamento
|
|
predefinito, e un riavvio lo ripristina.
|
|
- L'override vive **solo in memoria**: non scrive sul file e non sopravvive al
|
|
riavvio del processo.
|
|
- `GET /api/tools` riporta le definizioni attive e la loro origine: `source` vale
|
|
`file` (default da `.env`), `request` (installate via endpoint) oppure `none`.
|
|
- Risposte: `200` con `{ok,count,source,tools}`; `400` se il body è vuoto, non
|
|
UTF-8, non è un array JSON valido o non contiene alcuna definizione; `503` se
|
|
l'LLM è disattivato.
|
|
- Il body accetta **lo stesso formato del file**: un array che funziona qui
|
|
funziona anche come `LLM_TOOLS_FILE`.
|
|
|
|
```bash
|
|
curl -s localhost:8787/api/tools
|
|
# {"ok":true,"count":2,"source":"file","tools":[ … ]}
|
|
```
|
|
|
|
Implementazione: [`src/tools_api.rs`](src/tools_api.rs). Entrambi gli endpoint
|
|
compaiono anche nella Swagger UI (`/docs`, tag `tools`).
|
|
|
|
### 3. Definire un tool
|
|
|
|
Il file è un array JSON. Ogni oggetto ha i campi seguenti:
|
|
|
|
| Campo | Obbligatorio | Descrizione |
|
|
|---|---|---|
|
|
| `name` | sì | Nome della funzione che il modello invoca. Deve combaciare con lo `name` della definizione. |
|
|
| `description` | consigliato | **È ciò che il modello legge per decidere se e quando chiamare il tool**: scrivila esplicita e orientata all'uso ("chiama questo tool quando l'utente chiede…"). |
|
|
| `parameters` | no | JSON Schema degli argomenti. Se assente vale `{"type":"object","properties":{}}`. |
|
|
| `url` | sì | URL di destinazione. Può contenere segnaposto `{argomento}`. |
|
|
| `method` | no | **Qualsiasi** verbo HTTP, case-insensitive: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, e verbi custom (es. `PURGE`). Default `POST`. |
|
|
| `headers` | no | Header inviati letteralmente (chiavi API, `Content-Type`, …). I valori possono contenere segnaposto `{argomento}`. |
|
|
|
|
#### Sostituzione dei segnaposto
|
|
|
|
In `url` **e** in `headers`, ogni occorrenza di `{nome_argomento}` viene
|
|
sostituita con il valore corrispondente della chiamata (stringhe senza virgolette,
|
|
numeri/booleani come testo).
|
|
|
|
#### Dove finiscono gli argomenti (per verbo)
|
|
|
|
Gli argomenti **già consumati** da un segnaposto `{arg}` nell'URL *non* vengono
|
|
re-inviati: sono già nel path. I rimanenti seguono il verbo:
|
|
|
|
| Verbo | Argomenti rimanenti |
|
|
|---|---|
|
|
| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **query parameters** (nessun body) |
|
|
| `POST`, `PUT`, `PATCH` e verbi custom | **body JSON** (in assenza di argomenti: `{}`) |
|
|
|
|
### 4. Esempi
|
|
|
|
**GET con segnaposto nell'URL** (il `city` finisce nel path, non nella query):
|
|
|
|
```json
|
|
{
|
|
"name": "get_weather",
|
|
"description": "Meteo e temperatura attuali di una città. Chiamalo ogni volta che l'utente chiede il meteo, la temperatura o le previsioni di un luogo specifico.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "city": { "type": "string", "description": "Nome della città, es. 'Roma'" } },
|
|
"required": ["city"]
|
|
},
|
|
"url": "https://wttr.in/{city}?format=3",
|
|
"method": "GET"
|
|
}
|
|
```
|
|
|
|
**POST con header autenticato** (segreto preso dagli argomenti):
|
|
|
|
```json
|
|
{
|
|
"name": "create_note",
|
|
"description": "Salva una nota nel gestionale interno.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"token": { "type": "string", "description": "Token di sessione dell'utente" },
|
|
"text": { "type": "string", "description": "Contenuto della nota" }
|
|
},
|
|
"required": ["token", "text"]
|
|
},
|
|
"url": "https://gestione.interno.local/api/notes",
|
|
"method": "POST",
|
|
"headers": { "Authorization": "Bearer {token}" }
|
|
}
|
|
```
|
|
|
|
**POST semplice (webhook)**:
|
|
|
|
```json
|
|
{
|
|
"name": "send_webhook",
|
|
"description": "Invia un payload JSON all'endpoint webhook configurato (notifiche / home automation).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "message": { "type": "string" } },
|
|
"required": ["message"]
|
|
},
|
|
"url": "https://httpbin.org/post",
|
|
"method": "POST",
|
|
"headers": { "X-Tool": "audio-agent-engine" }
|
|
}
|
|
```
|
|
|
|
**Superficie REST completa** — un tool per verbo sullo stesso endpoint
|
|
(`lista` → `GET`, `dettaglio` → `GET` con `{id}`, `aggiungi` → `POST`,
|
|
`aggiorna` → `PATCH`, `sostituisci` → `PUT`, `elimina` → `DELETE`,
|
|
`esiste?` → `HEAD`):
|
|
|
|
```json
|
|
[
|
|
{
|
|
"name": "list_items",
|
|
"description": "Elenca gli articoli, con filtri opzionali.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"limit": { "type": "integer" },
|
|
"search": { "type": "string" }
|
|
}
|
|
},
|
|
"url": "https://api.example.local/api/items",
|
|
"method": "GET"
|
|
},
|
|
{
|
|
"name": "get_item",
|
|
"description": "Dettaglio di un articolo per id.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "GET"
|
|
},
|
|
{
|
|
"name": "add_item",
|
|
"description": "Crea un nuovo articolo.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "name": { "type": "string" }, "qty": { "type": "integer" } },
|
|
"required": ["name"]
|
|
},
|
|
"url": "https://api.example.local/api/items",
|
|
"method": "POST"
|
|
},
|
|
{
|
|
"name": "update_item",
|
|
"description": "Aggiorna parzialmente un articolo (solo i campi inviati).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" }, "qty": { "type": "integer" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "PATCH"
|
|
},
|
|
{
|
|
"name": "delete_item",
|
|
"description": "Elimina un articolo per id.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "DELETE"
|
|
},
|
|
{
|
|
"name": "item_exists",
|
|
"description": "Verifica l'esistenza di un articolo (solo status, nessun body).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "HEAD"
|
|
}
|
|
]
|
|
```
|
|
|
|
Richieste risultanti: `GET /api/items?limit=10`, `GET /api/items/5`,
|
|
`POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`,
|
|
`DELETE /api/items/5`, `HEAD /api/items/5`.
|
|
|
|
Parti da [`tools.example.json`](tools.example.json): copialo, adattalo e punta
|
|
`LLM_TOOLS_FILE` al tuo file.
|
|
|
|
### 5. Ciclo multi-round e fallback in streaming
|
|
|
|
- Ogni round produce **un** completamento; le chiamate trovate vengono eseguite e
|
|
i risultati rinviati al modello nel round successivo (round-trip standard
|
|
OpenAI: messaggio `assistant` con la chiamata, poi un messaggio `tool` per
|
|
ciascun risultato).
|
|
- Il ciclo si ferma quando non ci sono più chiamate, oppure al raggiungimento di
|
|
`LLM_TOOL_MAX_ITERATIONS`.
|
|
- **Fallback in streaming**: alcuni server OpenAI-compatible (vLLM con
|
|
`--tool-call-parser` non allineato al modello) **mangiano** la chiamata sul
|
|
percorso SSE: il round finisce vuoto, senza errore HTTP. Se un round torna
|
|
vuoto e i tool sono configurati, il motore **rifà la richiesta una volta senza
|
|
streaming** (`chat_reply`) e recupera la chiamata da lì. Se nemmeno lì c'è una
|
|
chiamata ma c'è del testo, quel testo viene parlato. Questo fallback si attiva
|
|
**solo** sui round vuoti: nessun costo sui turni normali.
|
|
|
|
### 6. Errori e troncamento
|
|
|
|
L'esecuzione di un tool **non fallisce mai** in modo bloccante: l'errore diventa
|
|
testo che il modello può leggere e da cui può recuperare.
|
|
|
|
| Situazione | Testo consegnato al modello |
|
|
|---|---|
|
|
| Tool inesistente | `Error: unknown tool '<nome>'` |
|
|
| Verbo HTTP non valido nella definizione | `Error: invalid HTTP method '<verbo>' for tool '<nome>'` |
|
|
| Status HTTP non 2xx | `Error: HTTP <status> — <body troncato>` |
|
|
| Errore di rete/timeout | `Error: tool request failed: <dettaglio>` |
|
|
| Risultato più lungo del limite | Troncato a `LLM_TOOL_RESULT_MAX_CHARS`, con suffisso `… [truncated]` |
|
|
|
|
### 7. Nota importante sul parser lato server
|
|
|
|
Se il server LLM gira **con** un parser di tool, il flag deve combaciare con il
|
|
formato nativo del modello (`--enable-auto-tool-choice --tool-call-parser …`).
|
|
Il disallineamento è **silenzioso**: un parser `hermes` (che si aspetta JSON dentro
|
|
`<tool_call>`) contro un modello Qwen che emette XML fa fallire il parse, la
|
|
chiamata viene scartata e in streaming la risposta torna vuota (nei log vLLM:
|
|
`Error in extracting tool call from response`). Per questo modello il parser
|
|
corretto è `qwen3_coder`. Il motore è comunque robusto: gestisce **entrambi** i
|
|
formati, l'array strutturato `message.tool_calls[]` **e** il testo nativo
|
|
`<tool_call>`, più il fallback descritto al punto 5.
|
|
|
|
Sintomo tipico di disallineamento: l'agente tace, oppure "narra" la chiamata senza
|
|
eseguirla.
|
|
|
|
### 8. Verifica end-to-end
|
|
|
|
Con il servizio attivo su `ws://localhost:8787/ws`, invia un testo utente che
|
|
deve innescare il tool e verifica gli eventi:
|
|
|
|
```python
|
|
import asyncio, json, websockets
|
|
|
|
async def main():
|
|
async with websockets.connect("ws://localhost:8787/ws") as ws:
|
|
await ws.send(json.dumps({"type": "user_text", "text": "Che tempo fa a Roma?"}))
|
|
while True:
|
|
msg = json.loads(await ws.recv())
|
|
if msg.get("type") == "tool_call":
|
|
print("TOOL:", msg["name"], msg["arguments"])
|
|
if msg.get("type") == "assistant_end":
|
|
print("turno concluso")
|
|
break
|
|
|
|
asyncio.run(main())
|
|
```
|
|
|
|
Eventi attesi sul WebSocket, in ordine: `tool_call` (con `name` e `arguments`) e
|
|
poi `assistant_end`. Il blocco `<tool_call>` non deve comparire in nessun
|
|
`assistant_delta`.
|
|
|
|
### 9. Limiti e buone pratiche
|
|
|
|
- Le chiamate non vengono trasmesse in streaming al client: la risposta dell'agente
|
|
arriva **dopo** l'esecuzione del tool. Sui tool lenti alza
|
|
`LLM_TOOL_TIMEOUT_SECS` solo quanto serve o riduci il lavoro lato server.
|
|
- La qualità del `description` determina l'uso del tool: il system prompt di
|
|
default non parla dei tool, decide solo il modello leggendo le descrizioni.
|
|
- Tieni i risultati **brevi**: oltre `LLM_TOOL_RESULT_MAX_CHARS` vengono troncati e
|
|
il modello può perdere informazione utile.
|
|
- Non esporre segreti in chiaro nel `url`: usa `headers` con segnaposto, oppure un
|
|
endpoint interno.
|
|
- Il file dei tool è letto a **caldo una sola volta**: dopo una modifica riavvia il
|
|
servizio.
|
|
|
|
---
|
|
|
|
## English
|
|
|
|
### 1. What it does
|
|
|
|
The engine's LLM can **call external HTTP services** before answering. A "tool" is
|
|
not code: it is the **JSON description of an HTTP request** (URL, method, headers,
|
|
argument schema). Adding a tool means writing a few lines in a JSON file —
|
|
**no Rust changes**.
|
|
|
|
Per user turn:
|
|
|
|
1. The engine attaches the OpenAI-shaped `tools` array to **every** completion
|
|
request, with `tool_choice: "auto"`.
|
|
2. If needed, the model emits the call. The production LLM server may run without a
|
|
tool parser, so the call comes back **as text** in the Qwen native format
|
|
(`<tool_call><function=…><parameter=…>…`), which the engine **parses
|
|
client-side**.
|
|
3. The engine executes the HTTP request, truncates the result and hands it back to
|
|
the model as a `tool` role message.
|
|
4. The model produces the final answer, which is synthesised to speech.
|
|
|
|
The `<tool_call>` block is **always stripped** from both the spoken and the
|
|
displayed text: the user never hears or sees it.
|
|
|
|
### 2. Enabling it and runtime override
|
|
|
|
| Variable | Default | Meaning |
|
|
|---|---|---|
|
|
| `LLM_TOOLS_ENABLED` | auto | Master switch. When unset it is `true` if `LLM_TOOLS_FILE` is a non-empty path. |
|
|
| `LLM_TOOLS_FILE` | *(empty)* | Path to the JSON definitions file. Empty or missing ⇒ tool calling **disabled**. |
|
|
| `LLM_TOOL_MAX_ITERATIONS` | `4` | Maximum tool rounds per turn (call → execute → re-prompt). Bounds a runaway loop. |
|
|
| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout of **one** tool HTTP request. |
|
|
| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | The tool result is truncated to this many characters before going back to the model. |
|
|
|
|
```dotenv
|
|
LLM_TOOLS_ENABLED=true
|
|
LLM_TOOLS_FILE=tools.example.json
|
|
LLM_TOOL_MAX_ITERATIONS=4
|
|
LLM_TOOL_TIMEOUT_SECS=15
|
|
LLM_TOOL_RESULT_MAX_CHARS=2000
|
|
```
|
|
|
|
Note: the tools file is read **once at startup** (`ToolRegistry::load`). After
|
|
editing the JSON, **restart the service** for the new definitions to take effect.
|
|
|
|
#### Runtime override — `POST /api/tools`
|
|
|
|
To change the definitions **without a restart**, POST the same JSON array to
|
|
`POST /api/tools`:
|
|
|
|
```bash
|
|
curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \
|
|
-d '[{"name":"get_weather","description":"Current weather for a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]'
|
|
```
|
|
|
|
- The definitions you send **replace** the file ones from the **next turn** on
|
|
(no restart). If the endpoint is **never called**, the engine keeps using the
|
|
`LLM_TOOLS_FILE` definitions — that is the default, and a restart restores it.
|
|
- The override lives **in memory only**: it does not write the file and does not
|
|
survive a process restart.
|
|
- `GET /api/tools` reports the active definitions and their origin: `source` is
|
|
`file` (the `.env` default), `request` (installed through the endpoint) or
|
|
`none`.
|
|
- Responses: `200` with `{ok,count,source,tools}`; `400` when the body is empty,
|
|
is not UTF-8, is not a valid JSON array, or holds no tool definition; `503`
|
|
when the LLM is disabled.
|
|
- The body uses **the same format as the file**: an array that works here also
|
|
works as an `LLM_TOOLS_FILE`.
|
|
|
|
```bash
|
|
curl -s localhost:8787/api/tools
|
|
# {"ok":true,"count":2,"source":"file","tools":[ … ]}
|
|
```
|
|
|
|
Implementation: [`src/tools_api.rs`](src/tools_api.rs). Both endpoints also show
|
|
up in the Swagger UI (`/docs`, tag `tools`).
|
|
|
|
### 3. Defining a tool
|
|
|
|
The file is a JSON array. Each object has these fields:
|
|
|
|
| Field | Required | Description |
|
|
|---|---|---|
|
|
| `name` | yes | Name of the function the model calls. Must match the `name` in the definition. |
|
|
| `description` | recommended | **This is what the model reads to decide whether and when to call the tool**: make it explicit and usage-oriented ("call this tool when the user asks…"). |
|
|
| `parameters` | no | JSON Schema for the arguments. When absent it defaults to `{"type":"object","properties":{}}`. |
|
|
| `url` | yes | Target URL. May contain `{arg}` placeholders. |
|
|
| `method` | no | **Any** HTTP verb, case-insensitive: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE`, and custom verbs (e.g. `PURGE`). Default `POST`. |
|
|
| `headers` | no | Headers sent verbatim (API keys, `Content-Type`, …). Values may contain `{arg}` placeholders. |
|
|
|
|
#### Placeholder substitution
|
|
|
|
In `url` **and** `headers`, every `{argument_name}` is replaced with the matching
|
|
call argument (strings unquoted, numbers/booleans rendered as text).
|
|
|
|
#### Where the arguments go (by verb)
|
|
|
|
Arguments **already consumed** by a `{arg}` placeholder in the URL are *not* sent
|
|
again: they are already in the path. The remaining ones follow the verb:
|
|
|
|
| Verb | Remaining arguments |
|
|
|---|---|
|
|
| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **query parameters** (no body) |
|
|
| `POST`, `PUT`, `PATCH` and custom verbs | **JSON body** (`{}` when nothing is left) |
|
|
|
|
### 4. Examples
|
|
|
|
**GET with a URL placeholder** (`city` goes into the path, not the query):
|
|
|
|
```json
|
|
{
|
|
"name": "get_weather",
|
|
"description": "Current weather and temperature for a city. Call this whenever the user asks about the weather, the temperature or the forecast for a specific place.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "city": { "type": "string", "description": "City name, e.g. 'Paris' or 'Roma'" } },
|
|
"required": ["city"]
|
|
},
|
|
"url": "https://wttr.in/{city}?format=3",
|
|
"method": "GET"
|
|
}
|
|
```
|
|
|
|
**POST with an authenticated header** (secret taken from the arguments):
|
|
|
|
```json
|
|
{
|
|
"name": "create_note",
|
|
"description": "Save a note into the internal management system.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"token": { "type": "string", "description": "User session token" },
|
|
"text": { "type": "string", "description": "Note content" }
|
|
},
|
|
"required": ["token", "text"]
|
|
},
|
|
"url": "https://internal.example.local/api/notes",
|
|
"method": "POST",
|
|
"headers": { "Authorization": "Bearer {token}" }
|
|
}
|
|
```
|
|
|
|
**Plain POST (webhook)**:
|
|
|
|
```json
|
|
{
|
|
"name": "send_webhook",
|
|
"description": "POST a JSON payload to the configured webhook endpoint (notification / home-automation hook).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "message": { "type": "string" } },
|
|
"required": ["message"]
|
|
},
|
|
"url": "https://httpbin.org/post",
|
|
"method": "POST",
|
|
"headers": { "X-Tool": "audio-agent-engine" }
|
|
}
|
|
```
|
|
|
|
**A full REST surface** — one tool per verb on the same endpoint
|
|
(`list` → `GET`, `detail` → `GET` with `{id}`, `add` → `POST`,
|
|
`update` → `PATCH`, `replace` → `PUT`, `delete` → `DELETE`, `exists?` → `HEAD`):
|
|
|
|
```json
|
|
[
|
|
{
|
|
"name": "list_items",
|
|
"description": "List the items, with optional filters.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"limit": { "type": "integer" },
|
|
"search": { "type": "string" }
|
|
}
|
|
},
|
|
"url": "https://api.example.local/api/items",
|
|
"method": "GET"
|
|
},
|
|
{
|
|
"name": "get_item",
|
|
"description": "Details of one item by id.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "GET"
|
|
},
|
|
{
|
|
"name": "add_item",
|
|
"description": "Create a new item.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "name": { "type": "string" }, "qty": { "type": "integer" } },
|
|
"required": ["name"]
|
|
},
|
|
"url": "https://api.example.local/api/items",
|
|
"method": "POST"
|
|
},
|
|
{
|
|
"name": "update_item",
|
|
"description": "Partially update an item (only the fields sent).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" }, "qty": { "type": "integer" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "PATCH"
|
|
},
|
|
{
|
|
"name": "replace_item",
|
|
"description": "Replace an item entirely.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" }, "name": { "type": "string" } },
|
|
"required": ["id", "name"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "PUT"
|
|
},
|
|
{
|
|
"name": "delete_item",
|
|
"description": "Delete an item by id.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "DELETE"
|
|
},
|
|
{
|
|
"name": "item_exists",
|
|
"description": "Check whether an item exists (status only, no body).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "HEAD"
|
|
}
|
|
]
|
|
```
|
|
|
|
Resulting requests: `GET /api/items?limit=10`, `GET /api/items/5`,
|
|
`POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`,
|
|
`PUT /api/items/5` + body `{"name":"…"}`, `DELETE /api/items/5`,
|
|
`HEAD /api/items/5`.
|
|
|
|
Start from [`tools.example.json`](tools.example.json): copy it, adapt it and point
|
|
`LLM_TOOLS_FILE` at your file.
|
|
|
|
### 5. Multi-round loop and streaming fallback
|
|
|
|
- Each round produces **one** completion; the calls found are executed and their
|
|
results are handed back for the next round (standard OpenAI round-trip: an
|
|
`assistant` message with the call, then one `tool` message per result).
|
|
- The loop stops when there are no more calls, or at `LLM_TOOL_MAX_ITERATIONS`.
|
|
- **Streaming fallback**: some OpenAI-compatible servers (vLLM with a
|
|
`--tool-call-parser` that does not match the model) **swallow** the call on the
|
|
SSE path: the round ends empty, with no HTTP error. When a round comes back empty
|
|
and tools are configured, the engine **re-asks once without streaming**
|
|
(`chat_reply`) and takes the call from there. If even that yields no call but
|
|
holds text, that text is spoken. The fallback only triggers on empty rounds, so
|
|
it costs nothing on normal turns.
|
|
|
|
### 6. Errors and truncation
|
|
|
|
Tool execution **never fails** in a blocking way: an error becomes text the model
|
|
can read and recover from.
|
|
|
|
| Situation | Text handed to the model |
|
|
|---|---|
|
|
| Unknown tool | `Error: unknown tool '<name>'` |
|
|
| Invalid HTTP verb in the definition | `Error: invalid HTTP method '<verb>' for tool '<name>'` |
|
|
| Non-2xx HTTP status | `Error: HTTP <status> — <truncated body>` |
|
|
| Network error / timeout | `Error: tool request failed: <detail>` |
|
|
| Result longer than the limit | Truncated to `LLM_TOOL_RESULT_MAX_CHARS`, suffixed with `… [truncated]` |
|
|
|
|
### 7. Important note on the server-side parser
|
|
|
|
If the LLM server runs **with** a tool parser, the flag must match the model's
|
|
native format (`--enable-auto-tool-choice --tool-call-parser …`). A mismatch is
|
|
**silent**: an `hermes` parser (expecting JSON inside `<tool_call>`) against a Qwen
|
|
model that emits XML makes the parse throw, the call is dropped and, in streaming,
|
|
the reply comes back empty (vLLM logs: `Error in extracting tool call from
|
|
response`). The correct parser for this model is `qwen3_coder`. The engine is
|
|
robust either way: it handles **both** formats, the structured
|
|
`message.tool_calls[]` array **and** the native `<tool_call>` text, plus the
|
|
fallback described in section 5.
|
|
|
|
Typical mismatch symptom: the agent goes silent, or "narrates" the call without
|
|
executing it.
|
|
|
|
### 8. End-to-end verification
|
|
|
|
With the service running on `ws://localhost:8787/ws`, send user text that must
|
|
trigger the tool and check the events:
|
|
|
|
```python
|
|
import asyncio, json, websockets
|
|
|
|
async def main():
|
|
async with websockets.connect("ws://localhost:8787/ws") as ws:
|
|
await ws.send(json.dumps({"type": "user_text", "text": "What's the weather in Paris?"}))
|
|
while True:
|
|
msg = json.loads(await ws.recv())
|
|
if msg.get("type") == "tool_call":
|
|
print("TOOL:", msg["name"], msg["arguments"])
|
|
if msg.get("type") == "assistant_end":
|
|
print("turn finished")
|
|
break
|
|
|
|
asyncio.run(main())
|
|
```
|
|
|
|
Expected WebSocket events, in order: `tool_call` (with `name` and `arguments`) then
|
|
`assistant_end`. The `<tool_call>` block must not appear in any `assistant_delta`.
|
|
|
|
### 9. Limits and best practices
|
|
|
|
- Calls are not streamed to the client: the agent's answer arrives **after** the
|
|
tool execution. For slow tools, raise `LLM_TOOL_TIMEOUT_SECS` only as much as
|
|
needed, or reduce server-side work.
|
|
- The `description` drives tool usage: the default system prompt does not mention
|
|
tools, the model decides solely from the descriptions.
|
|
- Keep results **short**: beyond `LLM_TOOL_RESULT_MAX_CHARS` they are truncated and
|
|
the model may lose useful information.
|
|
- Do not put secrets in cleartext in the `url`: use `headers` with placeholders, or
|
|
an internal endpoint.
|
|
- The tools file is read **once**: restart the service after editing it.
|
|
|
|
---
|
|
|
|
## Español
|
|
|
|
### 1. Qué hace
|
|
|
|
El LLM del motor puede **llamar a servicios HTTP externos** antes de responder. Un
|
|
"tool" no es código: es la **descripción JSON de una petición HTTP** (URL, método,
|
|
cabeceras, esquema de argumentos). Añadir un tool significa escribir unas líneas en
|
|
un archivo JSON — **sin tocar el código Rust**.
|
|
|
|
Por cada turno del usuario:
|
|
|
|
1. El motor adjunta el array `tools` (formato OpenAI) a **cada** petición de
|
|
completion, con `tool_choice: "auto"`.
|
|
2. Si hace falta, el modelo emite la llamada. El servidor LLM de producción puede
|
|
no tener parser de tools: la llamada vuelve **como texto** en el formato nativo
|
|
de Qwen (`<tool_call><function=…><parameter=…>…`), que el motor **parsea en el
|
|
cliente**.
|
|
3. El motor ejecuta la petición HTTP, trunca el resultado y lo devuelve al modelo
|
|
como mensaje de rol `tool`.
|
|
4. El modelo produce la respuesta final, que se sintetiza a voz.
|
|
|
|
El bloque `<tool_call>` se **elimina siempre** del texto hablado y del mostrado en
|
|
el chat: el usuario nunca lo oye ni lo ve.
|
|
|
|
### 2. Activación y override en runtime
|
|
|
|
| Variable | Por defecto | Significado |
|
|
|---|---|---|
|
|
| `LLM_TOOLS_ENABLED` | auto | Interruptor general. Si no se define vale `true` cuando `LLM_TOOLS_FILE` es una ruta no vacía. |
|
|
| `LLM_TOOLS_FILE` | *(vacío)* | Ruta del archivo JSON con las definiciones. Vacío o inexistente ⇒ tool calling **desactivado**. |
|
|
| `LLM_TOOL_MAX_ITERATIONS` | `4` | Número máximo de rondas de tool por turno (llamada → ejecución → re-prompt). Limita bucles infinitos. |
|
|
| `LLM_TOOL_TIMEOUT_SECS` | `15` | Timeout de **una** petición HTTP del tool. |
|
|
| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | El resultado del tool se trunca a este número de caracteres antes de volver al modelo. |
|
|
|
|
```dotenv
|
|
LLM_TOOLS_ENABLED=true
|
|
LLM_TOOLS_FILE=tools.example.json
|
|
LLM_TOOL_MAX_ITERATIONS=4
|
|
LLM_TOOL_TIMEOUT_SECS=15
|
|
LLM_TOOL_RESULT_MAX_CHARS=2000
|
|
```
|
|
|
|
Atención: el archivo de tools se lee **una sola vez al arrancar**
|
|
(`ToolRegistry::load`). Tras modificar el JSON hay que **reiniciar el servicio**
|
|
para que las nuevas definiciones estén activas.
|
|
|
|
#### Override en runtime — `POST /api/tools`
|
|
|
|
Para cambiar las definiciones **sin reiniciar**, envía el mismo array JSON a
|
|
`POST /api/tools`:
|
|
|
|
```bash
|
|
curl -X POST localhost:8787/api/tools -H 'Content-Type: application/json' \
|
|
-d '[{"name":"get_weather","description":"Clima de una ciudad","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]},"url":"https://wttr.in/{city}?format=3","method":"GET"}]'
|
|
```
|
|
|
|
- Las definiciones enviadas **sustituyen** las del archivo desde el **siguiente
|
|
turno** (sin reinicio). Si el endpoint **nunca se llama**, el motor sigue
|
|
usando las de `LLM_TOOLS_FILE`: es el comportamiento por defecto, y un reinicio
|
|
lo restaura.
|
|
- El override vive **solo en memoria**: no escribe el archivo y no sobrevive al
|
|
reinicio del proceso.
|
|
- `GET /api/tools` informa de las definiciones activas y su origen: `source` vale
|
|
`file` (el valor por defecto de `.env`), `request` (instaladas por el endpoint)
|
|
o `none`.
|
|
- Respuestas: `200` con `{ok,count,source,tools}`; `400` si el body está vacío,
|
|
no es UTF-8, no es un array JSON válido o no contiene ninguna definición;
|
|
`503` si el LLM está desactivado.
|
|
- El body acepta **el mismo formato que el archivo**: un array que funciona aquí
|
|
también funciona como `LLM_TOOLS_FILE`.
|
|
|
|
```bash
|
|
curl -s localhost:8787/api/tools
|
|
# {"ok":true,"count":2,"source":"file","tools":[ … ]}
|
|
```
|
|
|
|
Implementación: [`src/tools_api.rs`](src/tools_api.rs). Ambos endpoints aparecen
|
|
también en la Swagger UI (`/docs`, tag `tools`).
|
|
|
|
### 3. Definir un tool
|
|
|
|
El archivo es un array JSON. Cada objeto tiene estos campos:
|
|
|
|
| Campo | Obligatorio | Descripción |
|
|
|---|---|---|
|
|
| `name` | sí | Nombre de la función que invoca el modelo. Debe coincidir con el `name` de la definición. |
|
|
| `description` | recomendado | **Es lo que el modelo lee para decidir si y cuándo llamar al tool**: escríbela explícita y orientada al uso ("llama a este tool cuando el usuario pregunte…"). |
|
|
| `parameters` | no | JSON Schema de los argumentos. Si falta vale `{"type":"object","properties":{}}`. |
|
|
| `url` | sí | URL de destino. Puede contener marcadores `{argumento}`. |
|
|
| `method` | no | **Cualquier** verbo HTTP, sin distinguir mayúsculas: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, `TRACE` y verbos personalizados (p. ej. `PURGE`). Por defecto `POST`. |
|
|
| `headers` | no | Cabeceras enviadas literalmente (claves API, `Content-Type`, …). Los valores pueden contener marcadores `{argumento}`. |
|
|
|
|
#### Sustitución de marcadores
|
|
|
|
En `url` **y** en `headers`, cada `{nombre_argumento}` se sustituye por el valor
|
|
correspondiente de la llamada (cadenas sin comillas, números/booleanos como texto).
|
|
|
|
#### Dónde van los argumentos (según el verbo)
|
|
|
|
Los argumentos **ya consumidos** por un marcador `{arg}` de la URL *no* se envían
|
|
otra vez: ya están en el path. Los restantes siguen al verbo:
|
|
|
|
| Verbo | Argumentos restantes |
|
|
|---|---|
|
|
| `GET`, `HEAD`, `OPTIONS`, `TRACE`, `DELETE` | **parámetros de query** (sin body) |
|
|
| `POST`, `PUT`, `PATCH` y verbos personalizados | **cuerpo JSON** (`{}` si no queda ninguno) |
|
|
|
|
### 4. Ejemplos
|
|
|
|
**GET con marcador en la URL** (`city` va al path, no a la query):
|
|
|
|
```json
|
|
{
|
|
"name": "get_weather",
|
|
"description": "Clima y temperatura actuales de una ciudad. Llámalo cada vez que el usuario pregunte por el clima, la temperatura o el pronóstico de un lugar concreto.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "city": { "type": "string", "description": "Nombre de la ciudad, p. ej. 'Madrid'" } },
|
|
"required": ["city"]
|
|
},
|
|
"url": "https://wttr.in/{city}?format=3",
|
|
"method": "GET"
|
|
}
|
|
```
|
|
|
|
**POST con cabecera autenticada** (secreto tomado de los argumentos):
|
|
|
|
```json
|
|
{
|
|
"name": "create_note",
|
|
"description": "Guarda una nota en el sistema interno de gestión.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"token": { "type": "string", "description": "Token de sesión del usuario" },
|
|
"text": { "type": "string", "description": "Contenido de la nota" }
|
|
},
|
|
"required": ["token", "text"]
|
|
},
|
|
"url": "https://interno.example.local/api/notes",
|
|
"method": "POST",
|
|
"headers": { "Authorization": "Bearer {token}" }
|
|
}
|
|
```
|
|
|
|
**POST simple (webhook)**:
|
|
|
|
```json
|
|
{
|
|
"name": "send_webhook",
|
|
"description": "Envía un payload JSON al endpoint webhook configurado (notificaciones / home automation).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "message": { "type": "string" } },
|
|
"required": ["message"]
|
|
},
|
|
"url": "https://httpbin.org/post",
|
|
"method": "POST",
|
|
"headers": { "X-Tool": "audio-agent-engine" }
|
|
}
|
|
```
|
|
|
|
**Superficie REST completa** — un tool por verbo sobre el mismo endpoint
|
|
(`lista` → `GET`, `detalle` → `GET` con `{id}`, `añade` → `POST`,
|
|
`actualiza` → `PATCH`, `reemplaza` → `PUT`, `elimina` → `DELETE`,
|
|
`¿existe?` → `HEAD`):
|
|
|
|
```json
|
|
[
|
|
{
|
|
"name": "list_items",
|
|
"description": "Lista los artículos, con filtros opcionales.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"limit": { "type": "integer" },
|
|
"search": { "type": "string" }
|
|
}
|
|
},
|
|
"url": "https://api.example.local/api/items",
|
|
"method": "GET"
|
|
},
|
|
{
|
|
"name": "get_item",
|
|
"description": "Detalle de un artículo por id.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "GET"
|
|
},
|
|
{
|
|
"name": "add_item",
|
|
"description": "Crea un artículo nuevo.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "name": { "type": "string" }, "qty": { "type": "integer" } },
|
|
"required": ["name"]
|
|
},
|
|
"url": "https://api.example.local/api/items",
|
|
"method": "POST"
|
|
},
|
|
{
|
|
"name": "update_item",
|
|
"description": "Actualiza parcialmente un artículo (solo los campos enviados).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" }, "qty": { "type": "integer" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "PATCH"
|
|
},
|
|
{
|
|
"name": "replace_item",
|
|
"description": "Reemplaza un artículo por completo.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" }, "name": { "type": "string" } },
|
|
"required": ["id", "name"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "PUT"
|
|
},
|
|
{
|
|
"name": "delete_item",
|
|
"description": "Elimina un artículo por id.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "DELETE"
|
|
},
|
|
{
|
|
"name": "item_exists",
|
|
"description": "Comprueba si existe un artículo (solo status, sin body).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "id": { "type": "string" } },
|
|
"required": ["id"]
|
|
},
|
|
"url": "https://api.example.local/api/items/{id}",
|
|
"method": "HEAD"
|
|
}
|
|
]
|
|
```
|
|
|
|
Peticiones resultantes: `GET /api/items?limit=10`, `GET /api/items/5`,
|
|
`POST /api/items` + body `{"name":"…"}`, `PATCH /api/items/5` + body `{"qty":2}`,
|
|
`PUT /api/items/5` + body `{"name":"…"}`, `DELETE /api/items/5`,
|
|
`HEAD /api/items/5`.
|
|
|
|
Parte de [`tools.example.json`](tools.example.json): cópialo, adáptalo y apunta
|
|
`LLM_TOOLS_FILE` a tu archivo.
|
|
|
|
### 5. Bucle multi-ronda y fallback en streaming
|
|
|
|
- Cada ronda produce **un** completion; las llamadas encontradas se ejecutan y sus
|
|
resultados se devuelven al modelo en la ronda siguiente (round-trip estándar
|
|
OpenAI: mensaje `assistant` con la llamada, luego un mensaje `tool` por cada
|
|
resultado).
|
|
- El bucle se detiene cuando no hay más llamadas, o al alcanzar
|
|
`LLM_TOOL_MAX_ITERATIONS`.
|
|
- **Fallback en streaming**: algunos servidores compatibles con OpenAI (vLLM con un
|
|
`--tool-call-parser` que no coincide con el modelo) **se tragan** la llamada en la
|
|
ruta SSE: la ronda termina vacía, sin error HTTP. Si una ronda vuelve vacía y hay
|
|
tools configurados, el motor **repite la petición una vez sin streaming**
|
|
(`chat_reply`) y recupera la llamada de ahí. Si tampoco hay llamada pero hay
|
|
texto, ese texto se habla. El fallback se activa **solo** en rondas vacías: no
|
|
cuesta nada en los turnos normales.
|
|
|
|
### 6. Errores y truncado
|
|
|
|
La ejecución de un tool **nunca falla** de forma bloqueante: el error se convierte
|
|
en texto que el modelo puede leer y del que puede recuperarse.
|
|
|
|
| Situación | Texto entregado al modelo |
|
|
|---|---|
|
|
| Tool inexistente | `Error: unknown tool '<nombre>'` |
|
|
| Verbo HTTP no válido en la definición | `Error: invalid HTTP method '<verbo>' for tool '<nombre>'` |
|
|
| Estado HTTP distinto de 2xx | `Error: HTTP <estado> — <cuerpo truncado>` |
|
|
| Error de red / timeout | `Error: tool request failed: <detalle>` |
|
|
| Resultado más largo que el límite | Truncado a `LLM_TOOL_RESULT_MAX_CHARS`, con sufijo `… [truncated]` |
|
|
|
|
### 7. Nota importante sobre el parser del servidor
|
|
|
|
Si el servidor LLM funciona **con** un parser de tools, el flag debe coincidir con
|
|
el formato nativo del modelo (`--enable-auto-tool-choice --tool-call-parser …`). El
|
|
desajuste es **silencioso**: un parser `hermes` (que espera JSON dentro de
|
|
`<tool_call>`) contra un modelo Qwen que emite XML hace fallar el parseo, la
|
|
llamada se descarta y, en streaming, la respuesta vuelve vacía (logs de vLLM:
|
|
`Error in extracting tool call from response`). El parser correcto para este modelo
|
|
es `qwen3_coder`. El motor es robusto en cualquier caso: maneja **ambos** formatos,
|
|
el array estructurado `message.tool_calls[]` **y** el texto nativo `<tool_call>`,
|
|
además del fallback descrito en la sección 5.
|
|
|
|
Síntoma típico del desajuste: el agente calla, o "narra" la llamada sin ejecutarla.
|
|
|
|
### 8. Verificación end-to-end
|
|
|
|
Con el servicio activo en `ws://localhost:8787/ws`, envía texto de usuario que deba
|
|
disparar el tool y comprueba los eventos:
|
|
|
|
```python
|
|
import asyncio, json, websockets
|
|
|
|
async def main():
|
|
async with websockets.connect("ws://localhost:8787/ws") as ws:
|
|
await ws.send(json.dumps({"type": "user_text", "text": "¿Qué tiempo hace en Madrid?"}))
|
|
while True:
|
|
msg = json.loads(await ws.recv())
|
|
if msg.get("type") == "tool_call":
|
|
print("TOOL:", msg["name"], msg["arguments"])
|
|
if msg.get("type") == "assistant_end":
|
|
print("turno terminado")
|
|
break
|
|
|
|
asyncio.run(main())
|
|
```
|
|
|
|
Eventos WebSocket esperados, en orden: `tool_call` (con `name` y `arguments`) y
|
|
después `assistant_end`. El bloque `<tool_call>` no debe aparecer en ningún
|
|
`assistant_delta`.
|
|
|
|
### 9. Límites y buenas prácticas
|
|
|
|
- Las llamadas no se transmiten en streaming al cliente: la respuesta del agente
|
|
llega **después** de la ejecución del tool. Para tools lentos, sube
|
|
`LLM_TOOL_TIMEOUT_SECS` solo lo necesario o reduce el trabajo en el servidor.
|
|
- La `description` determina el uso del tool: el system prompt por defecto no habla
|
|
de tools, decide solo el modelo leyendo las descripciones.
|
|
- Mantén los resultados **cortos**: más allá de `LLM_TOOL_RESULT_MAX_CHARS` se
|
|
truncan y el modelo puede perder información útil.
|
|
- No pongas secretos en claro en la `url`: usa `headers` con marcadores, o un
|
|
endpoint interno.
|
|
- El archivo de tools se lee **una sola vez**: reinicia el servicio tras editarlo.
|