Files
piperust/TOOL_CALLING.md
2026-10-21 17:56:04 +01:00

342 lines
13 KiB
Markdown

# Tool calling — Operating guide
Code references: [`src/tools.rs`](src/tools.rs), [`src/llm.rs`](src/llm.rs),
[`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs),
[`src/tools_api.rs`](src/tools_api.rs), [`tools.example.json`](tools.example.json).
---
## 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.