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
+9
View File
@@ -343,3 +343,12 @@ LLM_TOOL_RESULT_MAX_CHARS=2000
# Then open https://localhost:8787/ and accept the certificate warning.
TLS_CERT=certs/cert.pem
TLS_KEY=certs/key.pem
# --- CORS (optional) ---
# Comma-separated browser origins allowed to call the REST surface cross-origin
# (single `*` = any origin). Empty = no CORS layer (default behaviour: the
# engine answers cross-origin fetch requests without CORS headers). Needed when
# the JavaScript client SDK (see sdk/) is served from another origin. It does
# NOT affect /ws: a WebSocket handshake is not a CORS request, so the realtime
# session works cross-origin regardless.
# CORS_ALLOW_ORIGIN=https://your-app.example,http://localhost:3000
Generated
+1
View File
@@ -73,6 +73,7 @@ dependencies = [
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"utoipa",
+3
View File
@@ -44,6 +44,9 @@ zip = { version = ">=2.2.0, <2.3.0", default-features = false, features = ["defl
hyper = { version = "1", features = ["server", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
tower = "0.5"
# Opt-in CORS for browser clients hosted on another origin (`CORS_ALLOW_ORIGIN`).
# Already a transitive dependency; the `cors` feature pulls in no new crates.
tower-http = { version = "0.6", default-features = false, features = ["cors"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-pki-types = { version = "1", features = ["std"] }
+195 -16
View File
@@ -30,6 +30,9 @@ Browser mic ──► WebSocket PCM ──► STT (Parakeet/ONNX) + VAD/EOU
## Features
- **Full-duplex WebSocket audio** — 44.1 kHz / 16-bit mono PCM, 20 ms frames.
- **JavaScript client SDK** — a dependency-free, single-file ESM module (browser
+ Node) wrapping the WebSocket session and the REST surface, so the client can
live on another server. See [Client SDK (JavaScript)](#client-sdk-javascript).
- **Pluggable TTS engines** — switch engines (Fish Audio, Kokoro, …) via `.env`
without changing orchestration code. See [TTS Plugin System](#tts-plugin-system).
- **Latency filler** — a preloaded filler is played on end-of-utterance *only if
@@ -95,6 +98,11 @@ Browser mic ──► WebSocket PCM ──► STT (Parakeet/ONNX) + VAD/EOU
format (a round that streams back empty is re-asked without streaming),
executed over HTTP and fed back to the model; the tool-call block is never
spoken. See [Tool calling](#tool-calling).
- **Per-session system prompt** — each WebSocket session gets a server-assigned
id (delivered to the client in a `{"type":"session","id":"…"}` message) and the
LLM system prompt can be read and overridden **for that session** — or
process-wide — at runtime through `GET`/`POST /api/prompt`, without a restart.
See [Runtime system prompt](#runtime-system-prompt-get--post-apiprompt).
- **Embedded playground** — a single-page HTML client served at `/`.
- **Mivocero speech-to-speech client** — an example agent client at `/mivocero`
with a microphone, a live chat box (user transcript + streaming assistant
@@ -223,7 +231,7 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `VAD_SEGMENT_STT` | `true` | Feed the STT engine with VAD-segmented utterances instead of the worker's RMS segmentation. |
| `VAD_BARGE_IN` | `true` | Interrupt the agent when the user starts speaking over it. |
| `VAD_DEBUG` | `false` | Log the speech probability of every analysis window. |
| `FILLERS_DIR` | `assets/fillers` | Directory of `*.raw` filler PCM files (16-bit LE mono, 44.1 kHz), played round-robin, at most once per turn. Clips live in a per-language subdirectory `<dir>/<lang>` (e.g. `assets/fillers/es/`). |
| `FILLERS_DIR` | `assets/fillers` | Directory of `*.raw` filler PCM files (16-bit LE mono, 44.1 kHz), played round-robin, at most once per turn. Clips live in a per-language, per-voice-profile subdirectory `<dir>/<lang>/<voice_profile>` (e.g. `assets/fillers/es/maricel/`). |
| `FILLER_ENABLED` | `true` | Master switch for the end-of-turn filler. `false` => no filler is ever queued. |
| `FILLER_DELAY_MS` | `2500` | How long (ms) to wait for the response audio to start before playing the filler. If the reply starts earlier, the filler is dropped instead of talking over the beginning of the answer. |
| `FILLER_GAIN_IDLE` | `1.0` | Filler gain when TTS is idle. |
@@ -231,11 +239,12 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `START_FILLER_ENABLED` | `true` | Master switch for the **start filler** — a turn-start acknowledgment queued on its own mixer channel when the reply audio is late, independently of the delayed filler above. `false` => no cue is ever queued. |
| `START_FILLER_GO_DELAY` | `1800` | How long (ms) the agent waits for the **first audio chunk of the reply** to reach the mixer before queueing the start filler. If audio arrives within this window the cue is dropped instead of talking over the beginning of the answer. The gate is latched on chunk *arrival* (`AudioMixer::tts_audio_arrived`), not on playback start, so it does not wait for the `TTS_PREBUFFER_MS` jitter buffer. Same idea as `FILLER_DELAY_MS`, but shorter — the start filler only covers the gap before the reply begins, not the whole generation. If the cue does fire and the reply then starts, it is faded out (80 ms) and cut so it does not overlap the answer. |
| `START_FILLER_TEXTS` | `[calm] Un momento...` | Phrases spoken by the start filler, **one per line** (a `\|` separator also works). One clip is synthesized per phrase and a **random** one is played when the reply is late, so the acknowledgment varies. Editing the list re-synthesizes the whole bank at the next restart (no manual deletion); an unchanged list is skipped to keep startup fast, and a failed synthesis keeps the previous clip. Drama-3 style tags (`[calm]`, `[sigh]`, …) are passed through to the TTS. |
| `START_FILLER_DIR` | `assets/start_filler` | Directory holding the start-filler clips (`sf01.raw`, `sf02.raw`, …, plus a `texts.txt` list index). Clips live in a per-language subdirectory `<dir>/<lang>` (e.g. `assets/start_filler/es/`). Kept out of `FILLERS_DIR` so the clips are not part of the round-robin filler bank. |
| `START_FILLER_DIR` | `assets/start_filler` | Directory holding the start-filler clips (`sf01.raw`, `sf02.raw`, …, plus a `texts.txt` list index). Clips live in a per-language, per-voice-profile subdirectory `<dir>/<lang>/<voice_profile>` (e.g. `assets/start_filler/es/maricel/`). Kept out of `FILLERS_DIR` so the clips are not part of the round-robin filler bank. |
| `ASSET_VOICE_PROFILE` | *(auto)* | Optional explicit cue-asset profile name. When unset, the engine derives it from the active voice setup (prefer `TTS_VOICE` when it is not `default`, otherwise fall back to the reference-audio file stem, then to `default`). |
| `TTS_PREBUFFER_MS` | `550` | Synthesized audio (ms) buffered on the TTS channel before the reply starts playing. Lower = the agent speaks sooner; higher = fewer drop-outs on uneven TTS chunk arrival. With the deployment's vocoder windows (first `185.8` ms, then `371.6` ms), any value in `(186, 557]` ms primes on the *same* second window, so `550` takes the whole cushion at no extra TTFA (~`0.73 s`). Below the first window (~`186` ms) playback starts on that window (TTFA ≈ server TTFB, ~`0.38 s`) but leaves almost no margin and exposes chunk seams. Only awaited at the start of a reply; mid-reply underruns resume as soon as any audio is queued again. |
| `TTS_PIPELINE_DEPTH` | `3` | How many TTS requests may be in flight at once. Chunks are synthesized ahead of playback and replayed in order, so each request's start-up latency hides behind the previous chunk's audio. `1` restores strictly serial synthesis. |
| `BACKCHANNEL_ENABLED` | `true` | Master switch for during-utterance acknowledgments. |
| `BACKCHANNEL_DIR` | `assets/backchannel` | Directory of the generated `*.raw` backchannel PCM clips (16-bit LE mono, 44.1 kHz; `bc01.raw`, `bc02.raw`, …, plus a `texts.txt` list index). Clips live in a per-language subdirectory `<dir>/<lang>` (e.g. `assets/backchannel/es/`). |
| `BACKCHANNEL_DIR` | `assets/backchannel` | Directory of the generated `*.raw` backchannel PCM clips (16-bit LE mono, 44.1 kHz; `bc01.raw`, `bc02.raw`, …, plus a `texts.txt` list index). Clips live in a per-language, per-voice-profile subdirectory `<dir>/<lang>/<voice_profile>` (e.g. `assets/backchannel/es/maricel/`). |
| `BACKCHANNEL_TEXTS` | `ack::[calm] Mm-hmm...` … | Phrases spoken by the backchannel, **one per line** (a `\|` separator also works). One clip is synthesized per phrase and one is played per cue. Editing the list re-synthesizes the whole bank at the next restart (no manual deletion); an unchanged list is skipped to keep startup fast, and a failed synthesis keeps the previous clip. Drama-3 style tags (`[calm]`, …) are passed through to the TTS. A line may carry an optional **`category::`** prefix (e.g. `agree::[agreeing] Claro...`) used to progress through the turn; a line without it belongs to the default `ack` category. |
| `BACKCHANNEL_GAIN` | `0.35` | Mix gain applied to the backchannel channel (0..1). |
| `BACKCHANNEL_MIN_SPEECH_MS` | `1500` | Confirmed speech required before the first cue may be considered. |
@@ -246,7 +255,7 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `BACKCHANNEL_PROB` | `0.65` | Probability (0..1) of cueing on a qualifying micro-pause — keeps the agent from acknowledging every pause. |
| `BACKCHANNEL_VOLUME_JITTER` | `0.2` | Random per-cue volume scaling (± fraction), so cues don't sit at a fixed level. |
| `BACKCHANNEL_MAX_PER_TURN` | `3` | Maximum backchannels emitted per user turn. |
| `LANG_DEFAULT` | `es` | Base language of the **filler, start-filler and backchannel** banks — its clips live in the `<dir>/<lang>` subdirectory (e.g. `assets/backchannel/es/`). Other languages are generated on demand into their own per-language subdirectories. See [Multilingual clip banks](#multilingual-clip-banks). |
| `LANG_DEFAULT` | `es` | Base language of the **filler, start-filler and backchannel** banks — its clips live in the `<dir>/<lang>/<voice_profile>` subdirectory (e.g. `assets/backchannel/es/maricel/`). Other languages are generated on demand into their own language/voice-profile subdirectories. See [Multilingual clip banks](#multilingual-clip-banks). |
| `LANG_DETECT_ENABLED` | `true` | Master switch for LLM-based language detection. Requires the LLM (`LLM_ENABLED=true`). |
| `LANG_MIN_CHARS` | `12` | Minimum transcript length (chars) before a sample is sent to the detector. |
| `LANG_REDETECT_EVERY` | `5` | The first user turn is always checked; afterwards the language is re-checked once every N turns. |
@@ -268,7 +277,7 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `LLM_ENABLE_THINKING` | *(unset)* | `false` sends `chat_template_kwargs: {"enable_thinking": false}` — turns Qwen3-style thinking off on vLLM/SGLang so the model answers directly. Unset omits the vendor-specific field entirely (safe for strict OpenAI-compatible servers). |
| `LLM_STRIP_REASONING` | `true` | Drop the chain-of-thought from the reply before it reaches the chat box / TTS: ignores `reasoning_content` deltas and removes inline think blocks. `false` passes the raw output through. |
| `LLM_DIRECT_ANSWERS` | `true` | Append an "answer directly, never narrate your reasoning" directive to the system prompt. |
| `LLM_SYSTEM_PROMPT` | *(built-in)* | Overrides the system prompt. The default asks for short, spoken-friendly replies (no markdown / bullet points). |
| `LLM_SYSTEM_PROMPT` | *(built-in)* | Overrides the system prompt. The default asks for short, spoken-friendly replies (no markdown / bullet points). Can also be overridden at runtime per session via `GET`/`POST /api/prompt` ([Runtime system prompt](#runtime-system-prompt-get--post-apiprompt)). |
| `LLM_TOOLS_ENABLED` | *(auto)* | Master switch for tool calling. Default: enabled when `LLM_TOOLS_FILE` is a non-empty path. |
| `LLM_TOOLS_FILE` | *(empty)* | JSON file with the tool definitions (`name`, `description`, `parameters` JSON schema, `url`, `method` — any HTTP verb, `headers`). See [Tool calling](#tool-calling) and [`tools.example.json`](tools.example.json). |
| `LLM_TOOL_MAX_ITERATIONS` | `4` | Maximum tool-call rounds per turn (call → execute → re-prompt). Bounds runaway loops. |
@@ -276,6 +285,9 @@ See [`.env.example`](.env.example) for a copy-paste template.
| `LLM_TOOL_RESULT_MAX_CHARS` | `2000` | Tool responses are truncated to this many characters before being handed back to the model. |
| `TLS_CERT` | *(empty)* | PEM certificate chain. Set together with `TLS_KEY` to serve HTTPS/WSS. |
| `TLS_KEY` | *(empty)* | PEM private key matching `TLS_CERT`. |
| `CORS_ALLOW_ORIGIN` | *(empty)* | Comma-separated browser origins allowed to call the REST surface cross-origin (single `*` = any). Empty adds no CORS layer (default behaviour). Needed when the [client SDK](#client-sdk-javascript) is served from another origin. Does not affect `/ws` (a WebSocket handshake is not a CORS request). |
| `AUTH_API_BASE` | `https://aquachile.azurianlab.com` | Base URL of the auth API proxied by `POST /api/auth/phone-login` ([Phone login](#phone-login-post-apiauthphone-login)). |
| `AUTH_PHONE_LOGIN_PATH` | `/api/v1/auth/phone-login` | Login path on `AUTH_API_BASE`. |
| `SESSION_AUDIT_ENABLED` | `true` | Write a structured JSONL audit file per WebSocket session (config snapshot, inbound control messages, every outbound event, cues, language switches, barge-ins, end-of-session summary). Best-effort — a write failure never affects the session. See [Session audit log](#session-audit-log). |
| `SESSION_AUDIT_DIR` | `logs/sessions` | Directory holding the per-session audit files (`<id>.jsonl`). Relative to the process working directory; git-ignored. |
@@ -882,20 +894,26 @@ of any other language.
### Layout
Every language — **including the base `LANG_DEFAULT`** — lives in its own
subdirectory named after its ISO 639-1 code under `FILLERS_DIR` /
`START_FILLER_DIR` / `BACKCHANNEL_DIR`:
Every language — **including the base `LANG_DEFAULT`** — keeps a separate bank
for each cue-asset voice profile under `FILLERS_DIR` / `START_FILLER_DIR` /
`BACKCHANNEL_DIR`:
```text
assets/backchannel/es/ <- base language (LANG_DEFAULT), pre-synthesized
assets/fillers/es/
assets/start_filler/es/
assets/backchannel/en/ <- English, generated on demand
assets/backchannel/it/ <- Italian, generated on demand
assets/fillers/en/
assets/start_filler/en/
assets/backchannel/es/maricel/ <- base language (LANG_DEFAULT), profile maricel
assets/fillers/es/maricel/
assets/start_filler/es/maricel/
assets/backchannel/es/solis/ <- same language, different voice profile
assets/backchannel/en/maricel/ <- English, generated on demand
assets/fillers/en/maricel/
assets/start_filler/en/maricel/
```
On startup, if a legacy single-voice bank still exists directly under
`<dir>/<lang>/` (for example `assets/backchannel/es/bc01.raw`), the engine
copies those flat files into the active `<dir>/<lang>/<voice_profile>/`
directory when the target files are missing. The legacy files are left in
place, so the migration is non-destructive.
Because the refresh pass (`remove_stale_clips`) only prunes `*.raw` files inside
the directory it is given, scoping each language — base included — to its own
subdirectory is what guarantees a regeneration never deletes another language's
@@ -997,7 +1015,9 @@ chat box ──────────────┘
overlaps each request's start-up latency with the previous chunk's playback,
so raising the pre-buffer is no longer needed to avoid inter-chunk gaps.
- **History** — a rolling window of the last `LLM_HISTORY_MESSAGES` user/assistant
messages is kept per connection and prepended with `LLM_SYSTEM_PROMPT`.
messages is kept per connection and prepended with the effective system prompt
(the session override if any, else `LLM_SYSTEM_PROMPT`, else the built-in
default — see [Runtime system prompt](#runtime-system-prompt-get--post-apiprompt)).
- **Barge-in** — an interrupt (client JSON, VAD onset, or disconnect) cancels
both the in-flight HTTP request ([`CancellationToken`] in the `reqwest`
`tokio::select!`, including while waiting for the next SSE chunk) and the TTS
@@ -1007,6 +1027,51 @@ chat box ──────────────┘
- **Failure handling** — request errors are reported to the client as
`{"type":"error","text":…}` and the turn ends cleanly.
### Runtime system prompt (`GET` / `POST /api/prompt`)
The system prompt is normally the process-wide default (the built-in one, or
`LLM_SYSTEM_PROMPT`). Because it is injected identically into every session, two
clients cannot run with different instructions. `GET`/`POST /api/prompt` adds a
**runtime, in-memory** override — per session or process-wide.
Each WebSocket session is assigned a server id (e.g. `s-1729170000000-0001`, the
same id used for its audit log) and the id is pushed to the client right after
connect, before the `cues` message:
```json
{"type":"session","id":"s-1729170000000-0001"}
```
That id is the key of the REST surface:
```bash
# Effective prompt for a session (+ where it comes from)
curl 'localhost:8787/api/prompt?session_id=s-1729170000000-0001'
# Override it for that session
curl -X POST localhost:8787/api/prompt -H 'Content-Type: application/json' \
-d '{"session_id":"s-1729170000000-0001","prompt":"You are a terse assistant. One sentence max."}'
# Clear the override (falls back to the configured default)
curl -X POST localhost:8787/api/prompt -H 'Content-Type: application/json' \
-d '{"session_id":"s-1729170000000-0001","prompt":null}'
```
- **Scope** — with a `session_id` the override applies to that session only; omit
it (`null`) to set a process-wide fallback used by every session without its
own override.
- **Precedence** — session override → process-wide override → `LLM_SYSTEM_PROMPT`
→ built-in default. The response's `source` field (`session` / `global` /
`env` / `default` / `none`) reports which one won, and `has_override` whether a
runtime override is in effect.
- **In-memory only** — a restart restores the configured default, and a session
override is dropped when that session disconnects. An unknown/disconnected
`session_id` returns `404`.
- `prompt: null` (or `""`) clears the override.
- The response also carries `direct_answers_applied` (whether the
`LLM_DIRECT_ANSWERS` directive is currently appended).
- See [`src/prompt.rs`](src/prompt.rs) and [`src/prompt_api.rs`](src/prompt_api.rs).
---
## Tool calling
@@ -1419,6 +1484,7 @@ Connect to `ws://<host>:8787/ws`.
| **Text — `{"type":"audio_source","state":"…","url":"…"}`** | External audio channel state change: `loading` (decoding is being set up / YouTube URL resolving), `playing` (PCM is streaming), `stopped`, or `error` (with a `text` message). |
| **Text — `{"type":"cue","kind":"…","name":"…","category":"…"}`** | A latency-mask / backchannel cue was actually emitted into the mixer: `kind` is `filler` (end-of-turn), `start_filler` (turn start) or `backchannel` (during user speech); `name` is the clip name and `category` (backchannel only) its category. Fired only when the cue really plays — a skipped cue sends nothing. Drives the demo's cue indicators. |
| **Text — `{"type":"cues","lang":"…","config":{…}}`** | Sent once at session start: the effective policy of the three cues for the active language — the filler parameters (`delay_ms`, gains, clip count), the start-filler `go_delay_ms` and the full backchannel configuration (gain, min-speech, interval, jitter, pause window, probability, volume jitter, per-turn cap, categories). Purely informational; drives the demo's **Cue methods** panel so it shows the live values instead of hard-coded ones. |
| **Text — `{"type":"session","id":"…"}`** | Sent once right after connect: the server-assigned session id (also the audit-log file stem). It is the key used by `GET`/`POST /api/prompt` to read/override the system prompt of this session ([Runtime system prompt](#runtime-system-prompt-get--post-apiprompt)). |
| **Text — `{"type":"barge_in","source":"vad","prob":…}`** | The agent was interrupted. Sent for the server-side VAD barge-in (the browser has no other way to know, since the onset is detected in the session task); the client-side `interrupt` does not echo one. Drives the demo's barge-in indicator. |
---
@@ -1491,6 +1557,7 @@ audio-agent-engine/
├── certs/ # self-signed TLS cert+key (generated)
├── UPDATES.md # changelog (English, newest-first)
├── tools.example.json # tool definitions for LLM tool calling (copy/edit)
├── sdk/ # dependency-free JavaScript client SDK (+ .d.ts, served at /sdk/)
├── assets/fillers/<lang>/ # *.raw filler PCM (16-bit LE mono, 44.1 kHz; round-robin), one dir per language
├── assets/start_filler/<lang>/ # sf01.raw … turn-start start-filler clips + texts.txt index
├── assets/backchannel/<lang>/ # bc01.raw … backchannel clips + texts.txt index
@@ -1511,6 +1578,9 @@ audio-agent-engine/
├── llm.rs # OpenAI-compatible chat-completions client (streaming + think stripping)
├── tools.rs # configurable HTTP tool calling (native <tool_call> parser + executor)
├── tools_api.rs # GET/POST /api/tools — runtime tool definitions (in-memory override)
├── prompt.rs # per-session system-prompt registry (overrides + resolution)
├── prompt_api.rs # GET/POST /api/prompt — read/override the LLM system prompt per session
├── auth_api.rs # POST /api/auth/phone-login — server-side proxy to the AquaChile auth API
├── media.rs # external audio source (URL / radio / YouTube) via ffmpeg + yt-dlp
├── customvoice.rs # custom-voice builder endpoints (record → WAV reference → set_reference)
├── transcribe.rs # POST /api/transcribe — Parakeet transcription of an uploaded audio file
@@ -1534,7 +1604,12 @@ audio-agent-engine/
| `POST /api/transcribe` | Upload a raw audio file (any ffmpeg-readable container); returns the Parakeet transcript of the whole file (`text`, `duration_ms`, `tokens`). |
| `GET /api/tools` | The active LLM tool definitions and their origin (`source`: `file` / `request` / `none`). |
| `POST /api/tools` | Install LLM tool definitions at runtime (same JSON array as `LLM_TOOLS_FILE`) — in-memory, no restart ([Tool calling](#tool-calling)). |
| `GET /api/prompt` | The effective LLM system prompt, with its origin (`source`: `session` / `global` / `env` / `default` / `none`). Optional `?session_id=…`; `404` for an unknown session ([Runtime system prompt](#runtime-system-prompt-get--post-apiprompt)). |
| `POST /api/prompt` | Override the LLM system prompt at runtime — for one session (`{"session_id":"…","prompt":"…"}`) or process-wide (`prompt` only); `prompt: null` clears it. In-memory, no restart. |
| `POST /api/auth/phone-login` | Authenticate a phone number (`{"phoneNumber":"+44…","session_id":"s-…"}`) through a **server-side proxy** to the AquaChile auth API (`AUTH_API_BASE` + `AUTH_PHONE_LOGIN_PATH`) and return its payload (`sessionToken` + `user`). When `session_id` is provided, the engine stores a sanitized user profile as hidden LLM context for that live session ([Phone login](#phone-login-post-apiauthphone-login)). |
| `GET /health` | Liveness probe (`ok`). |
| `GET /sdk/audio-agent-client.js` | The dependency-free JavaScript client SDK ([Client SDK (JavaScript)](#client-sdk-javascript), source in [`sdk/`](sdk)). |
| `GET /sdk/demo.html` | Demo client built on the SDK (source in [`sdk/demo.html`](sdk/demo.html)). |
| `GET /ws` | WebSocket upgrade → per-session audio loop. |
| `GET /docs` | Swagger UI for the REST surface ([API documentation](#api-documentation-swagger-ui)). |
| `GET /api-docs/openapi.json` | The raw OpenAPI 3 document the Swagger UI renders. |
@@ -1586,6 +1661,110 @@ curl -s -X POST http://localhost:8787/api/transcribe \
---
## Phone login (`POST /api/auth/phone-login`)
The SDK demo can authenticate the user by phone number, read from the page URL
(`?phoneNumber=+44…`), at the start of the session. The call is made **through
the engine**, not directly from the browser:
```bash
curl -X POST localhost:8787/api/auth/phone-login \
-H 'Content-Type: application/json' \
-d '{"phoneNumber":"+447432477433"}'
```
The engine forwards the same JSON to the upstream AquaChile auth API
(`AUTH_API_BASE` + `AUTH_PHONE_LOGIN_PATH`) and returns its status and body
unchanged — on success a payload like
`{"sessionToken":"…","user":{"id":…,"name":…,"email":…,"permissions":[…]}}`.
If the request also carries a live WebSocket `session_id`, the engine extracts a
sanitized user profile (`name`, `email`, `businessRoles`, `permissionSet`,
`centers`, `permissions`) and injects it into that session's LLM context as a
hidden system block. The SDK demo does this automatically right after connect.
The proxy exists because the upstream API rejects browser calls from the client
origin: its CORS preflight answers `400 Disallowed CORS origin` and its response
carries no `Access-Control-Allow-Origin`, so a direct `fetch` from the demo is
blocked. Routing the call through the engine makes it same-origin for the
browser. Errors: `400` for an empty/invalid body or a missing `phoneNumber`,
`502` when the upstream is unreachable. See [`src/auth_api.rs`](src/auth_api.rs).
---
## Client SDK (JavaScript)
[`sdk/audio-agent-client.js`](sdk/audio-agent-client.js) is a **dependency-free,
single-file ESM module** (with [`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts))
for writing clients against the engine. It is deliberately decoupled from the
server: copy the file to any host, no build step, no npm install. The engine also
serves it at `GET /sdk/audio-agent-client.js`.
A ready-made **[demo client](sdk/demo.html)** built on the SDK is served at
`GET /sdk/demo.html` — open `http://localhost:8787/sdk/demo.html` and press
*Connect*.
It covers:
- **Realtime session** — `AudioAgentClient`: connects to `/ws`, sends PCM
(`sendAudio`) and typed controls (`sendUserText`, `interrupt`,
`playAudioSource`, `setReference`, `speak`, …), and emits one event per server
message (`assistant_delta`, `cue`, `barge_in`, `tool_call`, `audio_source`,
`error`, …) plus a local `audio` event carrying the decoded PCM frames.
- **REST** — `AudioAgentRest`: `health`, `getTools` / `setTools`,
`getCustomVoicePhrases` / `setCustomVoice` / `clearCustomVoice`, `transcribe`,
`openapi`; failures throw an error carrying `status` and the parsed `body`.
- **Browser audio helpers** — `MicCapture` (mic → 882-sample `Int16Array` frames,
via an inline AudioWorklet), `PcmPlayer` (jitter-buffered playback,
`flush()` for barge-in) and `AudioSession`, which wires the three together.
- **PCM utilities** — `int16ArrayFromBytes`, `float32ToInt16`, `int16ToFloat32`,
`toInt16`, plus the `AUDIO` constants (44.1 kHz, 20 ms = 882 samples).
### Browser
```html
<script type="module">
import { AudioSession } from 'http://localhost:8787/sdk/audio-agent-client.js';
const session = new AudioSession({ baseUrl: 'http://localhost:8787' });
session.on('user', (e) => console.log('you:', e.text));
session.on('assistant_delta', (e) => (log.textContent += e.text));
session.on('error', (e) => console.error(e));
await session.start(); // connect + microphone + playback
session.sendUserText('hello'); // text turn
session.interrupt(); // barge-in
</script>
```
Serve the page from a **secure context** (`https://` or `localhost`) or the
browser will refuse microphone access. Cross-origin pages must have CORS enabled
on the engine (`CORS_ALLOW_ORIGIN=https://your.app`); `/ws` needs no CORS.
### Node
The core (client + REST + PCM helpers) is browser-agnostic. Node 22+ has a global
`WebSocket`; on older versions pass one in (e.g. the `ws` package). The audio
helpers (`MicCapture`, `PcmPlayer`, `AudioSession`) require the Web Audio API and
are browser-only.
```js
import { AudioAgentClient, AudioAgentRest, toInt16 } from './sdk/audio-agent-client.js';
import WebSocket from 'ws';
const client = new AudioAgentClient({ baseUrl: 'http://localhost:8787', WebSocket });
client.on('assistant_delta', (e) => process.stdout.write(e.text));
await client.connect();
client.sendUserText("what's the weather in Rome?");
client.sendAudio(toInt16(new Float32Array(882))); // PCM 16-bit LE, 44.1 kHz
const rest = new AudioAgentRest('http://localhost:8787');
console.log(await rest.getTools());
```
See [`sdk/README.md`](sdk/README.md) for the full reference.
---
## Development
```bash
+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.
+261
View File
@@ -5,6 +5,267 @@ Entries are written in English and ordered newest-first.
---
## 2026-10-21 — Feat: demo routes audio tool results to the external source
### Changed
- **Cue asset layout** — filler, start-filler and backchannel banks now live
under `<base>/<lang>/<voice_profile>/` instead of `<base>/<lang>/`, so
multiple TTS voice configurations can coexist on disk without overwriting one
another.
- **[`src/config.rs`](src/config.rs)** — added a resolved cue-asset voice
profile (`Config::cue_voice_profile()`), with optional explicit override via
`ASSET_VOICE_PROFILE`. When unset, the engine derives the profile from the
active voice setup (prefer `TTS_VOICE` when it is not `default`, otherwise
fall back to the reference-audio file stem, then to `default`).
- **[`src/assets.rs`](src/assets.rs)** / **[`src/main.rs`](src/main.rs)** /
**[`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs)** /
**[`src/ws/session.rs`](src/ws/session.rs)** — cue-bank bootstrap, runtime
language switching and cue metadata now all resolve the same
`<lang>/<voice_profile>` directory layout.
- **Legacy cue-bank migration** — when old single-voice banks still exist
directly under `<base>/<lang>/`, startup now copies their flat files
(`*.raw`, `texts.txt`, `.voice_signature`) into the active
`<base>/<lang>/<voice_profile>/` directory if the target files are missing.
The legacy files are kept in place.
- **[`run/refresh_voice_assets.sh`](run/refresh_voice_assets.sh)** — the voice
asset refresh script now targets the current cue voice profile and refreshes
directories under `<base>/<lang>/<voice_profile>/`, keeping other voice
profiles intact.
- **[`README.md`](README.md)** — documented the new cue-bank directory layout
and the optional `ASSET_VOICE_PROFILE` override.
- **[`sdk/demo.html`](sdk/demo.html)** — a tool definition may now carry an
extra `audio: true` flag (ignored by the backend, which only reads
`name`/`description`/`parameters`/`url`/`method`/`headers`). When the model
calls such a tool, the demo rebuilds the call's effective URL (arguments of
read-style verbs are appended as query parameters, mirroring the backend's
argument placement), writes it into the external audio source field and
starts playback on the engine's dedicated channel. `get_today_podcast_mp3` is
flagged this way, so a podcast request is played back instead of only being
returned to the model. The manual Play button now shares the same
`playExternalSource()` helper.
- **[`sdk/demo.html`](sdk/demo.html)** — the default session prompt and the
built-in tool descriptions now explicitly tell the model to call
`get_today_podcast_mp3` on the first relevant podcast/audio request and
`get_public_day_report` on the first relevant report/alerts request, to
reduce cases where the user has to repeat the same intent before tool calling
is triggered.
- **Markdown document mode** — when the user explicitly asks for a Markdown
document, the demo prompt now instructs the model to keep a very short spoken
preface outside the block and place the full document inside
`<chat_markdown>...</chat_markdown>`.
- **[`sdk/demo.html`](sdk/demo.html)** — audio tool results are queued on
`tool_call` and autoplay after a fixed post-`assistant_end` delay, rather
than waiting for the SDK player to become idle. This avoids false timeouts in
deployments where the engine keeps sending mixed 20 ms PCM frames
continuously, even during silence, while keeping the post-reply timing
deterministic.
- **[`src/tools.rs`](src/tools.rs)** / **[`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs)** —
the engine now strips `<chat_markdown>...</chat_markdown>` blocks out of the
spoken stream, emits them separately as `assistant_markdown` events, and
keeps the document text in the assistant history for follow-up turns.
- **[`sdk/demo.html`](sdk/demo.html)** — the SDK demo renders
`assistant_markdown` payloads as formatted Markdown bubbles in chat instead
of showing raw source text.
- **[`sdk/audio-agent-client.js`](sdk/audio-agent-client.js)** /
**[`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts)** — the SDK now
exposes the `assistant_markdown` event type for clients that want chat-only,
non-TTS Markdown documents.
- **[`sdk/audio-agent-client.js`](sdk/audio-agent-client.js)** /
**[`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts)** — `PcmPlayer`
now exposes `isIdle()` and emits a local `idle` event when its scheduled
audio has fully drained, giving browser clients a reliable post-playback
signal.
---
## 2026-10-21 — Chore: voice-asset refresh helper script
### Added
- **[`run/refresh_voice_assets.sh`](run/refresh_voice_assets.sh)** — bash helper
that reads the current `.env`, analyzes the active TTS voice / reference /
sampling / cue-text settings, computes a voice signature, inspects every cue
bank (`fillers`, `start_filler`, `backchannel`) across the discovered
languages, and decides which generated assets are stale.
- The script stores a per-directory `.voice_signature`, supports `--dry-run`,
`--force`, `--lang <code>`, `--base-lang-only` and `--no-restart`, deletes the
generated `*.raw` clips (plus `texts.txt` for text-driven banks) when a
refresh is needed, restarts `pm2-piperust`, and writes the updated signature
markers back to the asset directories.
---
## 2026-10-21 — Chore: SDK demo installs a default day-report tool
### Changed
- **[`sdk/demo.html`](sdk/demo.html)** — the demo now installs a default
runtime tool set through `POST /api/tools` right after connect:
`get_public_day_report`, mapped to
`GET https://aquachile.azurianlab.com/api/v1/day-report` with query
parameters `limit` and `date`, plus `get_today_podcast_mp3`, mapped to
`GET https://aquachile.azurianlab.com/api/v1/podcast/today/mp3` with query
parameter `download`. This lets the demo exercise real tool calling without
relying on an external `LLM_TOOLS_FILE`.
---
## 2026-10-21 — Feat: phone-login proxy (`POST /api/auth/phone-login`)
### Added
- **`POST /api/auth/phone-login`** ([`src/auth_api.rs`](src/auth_api.rs)) — a
server-side proxy that forwards `{ "phoneNumber": "…" }` to the AquaChile auth
API (`AUTH_API_BASE` + `AUTH_PHONE_LOGIN_PATH`) and returns its status and body
unchanged (on success: `sessionToken` + `user`). It exists because that API
rejects browser calls from the client origin (CORS preflight `400 Disallowed
CORS origin`, no `Access-Control-Allow-Origin`), so a direct `fetch` from the
demo is blocked; going through the engine makes the call same-origin. When the
request also carries a live `session_id`, the engine stores a sanitized user
profile (`name`, `email`, `businessRoles`, `permissionSet`, `centers`,
`permissions`) as hidden context for that session's LLM. Errors: `400` for an
empty/invalid body or missing `phoneNumber`, `404` for an unknown
`session_id`, `502` when the upstream is unreachable.
- Config `AUTH_API_BASE` (default `https://aquachile.azurianlab.com`) and
`AUTH_PHONE_LOGIN_PATH` (default `/api/v1/auth/phone-login`).
- Swagger UI (`/docs`) documents the endpoint (new `auth` tag,
`PhoneLoginRequest` schema).
### Changed
- **[JavaScript SDK](sdk/audio-agent-client.js)** — added
`phoneLogin(phoneNumber, sessionId?)` to `AudioAgentRest`; types updated in
[`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts) and the reference
in [`sdk/README.md`](sdk/README.md).
- **[`sdk/demo.html`](sdk/demo.html)** — at session start it reads `phoneNumber`
from the page URL (`?phoneNumber=+44…`) and authenticates through the engine
proxy, showing the resolved user in the header, a chat chip and the event log.
(Replaces the earlier direct cross-origin `fetch`, which the browser blocked.)
- **Hidden LLM user context** — the session user block now instructs the model
to treat `permissions`, `permissionSet`, `businessRoles` and `centers` as
access/domain scope, avoid fabricating unavailable access, constrain KPI /
report / alert interpretation to the verified user scope, and suggest next
steps aligned with the user's role.
---
## 2026-10-21 — Feat: per-session system prompt (`GET` / `POST /api/prompt`)
### Added
- **Server-assigned session id** — every WebSocket session is given an id
(`s-<epoch_ms>-<seq>`, the same stem used for its audit log) by a new
per-session prompt registry ([`src/prompt.rs`](src/prompt.rs)) and the id is
pushed to the client right after connect, before `cues`, as
`{"type":"session","id":"…"}` ([`src/ws/session.rs`](src/ws/session.rs)).
`SessionAudit::new` now receives that id instead of generating its own, so the
audit file and the live session share one identifier.
- **`GET` / `POST /api/prompt`** ([`src/prompt_api.rs`](src/prompt_api.rs)) — read
or override the LLM system prompt at runtime, **per session** (with a
`session_id`) or process-wide (without one). `GET` returns the effective prompt
plus metadata (`source`: `session` / `global` / `env` / `default` / `none`,
`has_override`, `direct_answers_applied`); `POST` installs an override
(`prompt: null` / `""` clears it). Overrides live **in memory only** — a
restart restores the configured default and a session override is dropped on
disconnect; an unknown session id returns `404`.
- The LLM turn now resolves the system prompt through the registry
([`src/orchestrator/turn_manager.rs`](src/orchestrator/turn_manager.rs)):
session override → process-wide override → `LLM_SYSTEM_PROMPT` → built-in
default. With `/api/prompt` never called the behaviour is unchanged.
- `Config::llm_system_prompt_from_env` records whether the configured prompt came
from `LLM_SYSTEM_PROMPT` (reported as `source: "env"`).
- Swagger UI (`/docs`) documents the new endpoint (new `prompt` tag, schemas
`PromptResponse` / `SetPromptRequest`).
### Changed
- **[JavaScript SDK](sdk/audio-agent-client.js)** — added the `session` event to
`CLIENT_EVENTS` and `getPrompt(sessionId?)` / `setPrompt(prompt, sessionId?)`
to `AudioAgentRest`; types updated in
[`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts) and the reference
in [`sdk/README.md`](sdk/README.md).
- **[`sdk/demo.html`](sdk/demo.html)** — captures the session id from the
`session` event and adds a **System prompt** panel to reload / override / clear
the prompt of the current session over REST. It also applies a built-in
`SESSION_PROMPT` (the Vocero / AquaChile assistant instructions) automatically
at the start of every session, right after the `session` event.
---
## 2026-10-21 — Chore: English-only code and documentation convention
### Changed
- **All code is now English-only.** Every comment, doc-comment, error/log string
and test description across `src/` and `sdk/` was reviewed and the remaining
Italian was translated — e.g. [`src/stt/tokenizer.rs`](src/stt/tokenizer.rs).
Per-language *content* is intentionally left as-is: the voice-cloning reading
phrases in [`src/customvoice.rs`](src/customvoice.rs) and the clip phrase
tables in [`src/lang.rs`](src/lang.rs) must stay in their own language.
- **Documentation is now English-only.** [`TOOL_CALLING.md`](TOOL_CALLING.md) and
[`sdk/README.md`](sdk/README.md) dropped their `Italiano` / `Español`
sections and [`README.md`](README.md) examples switched to English sample text.
This supersedes the previous IT/EN/ES documentation convention.
---
## 2026-10-21 — Feat: JavaScript client SDK + opt-in CORS
### Added
- **`sdk/audio-agent-client.js`** — a dependency-free, single-file ESM client
SDK (browser + Node) with types in
[`sdk/audio-agent-client.d.ts`](sdk/audio-agent-client.d.ts) and a
reference in [`sdk/README.md`](sdk/README.md). It exists so the
client can be **decoupled** from the engine and hosted on another server:
- `AudioAgentClient` — the realtime `/ws` session: `sendAudio()` (accepts
`Int16Array` / `Float32Array` / `ArrayBuffer` / view / `number[]`, normalized
to 16-bit LE mono 44.1 kHz), typed controls (`sendUserText`, `interrupt`,
`playAudioSource`, `stopAudioSource`, `setAudioSourceVolume`,
`setReference`, `clearReference`, `speak`), optional auto-reconnect with
exponential backoff, and one event per server message (`cues`, `user`,
`assistant_delta`, `assistant_end`, `cue`, `barge_in`, `tool_call`,
`audio_source`, `error`) plus a local `audio` event with the decoded frames.
- `AudioAgentRest` — typed REST wrapper: `health`, `getTools` / `setTools`,
`getCustomVoicePhrases` / `setCustomVoice` / `clearCustomVoice`,
`transcribe`, `openapi`; failures throw an error carrying `status`/`body`.
- Browser audio helpers `MicCapture` (mic → 882-sample `Int16Array` frames via
an inline AudioWorklet), `PcmPlayer` (jitter-buffered playback with
`flush()` for barge-in) and `AudioSession` (wires client + mic + player).
- PCM utilities `int16ArrayFromBytes`, `float32ToInt16`, `int16ToFloat32`,
`toInt16` and the `AUDIO` constants.
- **`GET /sdk/audio-agent-client.js`** ([`src/main.rs`](src/main.rs),
[`src/web/mod.rs`](src/web/mod.rs)) — serves the SDK (embedded at compile
time, like the HTML clients) so a client hosted elsewhere can fetch it.
- **`sdk/demo.html`** + **`GET /sdk/demo.html`** — a complete demo client built
on the SDK (English UI, like all the code in this project): connect/disconnect,
live session state, streaming
agent replies with word-level *karaoke* highlighting, user/agent chat bubbles,
inline chips for `cue` / `tool_call` / `audio_source` events, a barge-in
counter, a microphone toggle with a live level meter, a text-turn input, an
external audio source (play / stop / volume) and a raw event log. It imports
`./audio-agent-client.js`, so it works both served by the engine and copied to
another origin; it warns when opened outside a secure context (no mic on
`file://`).
- **Opt-in CORS** — `CORS_ALLOW_ORIGIN` (comma-separated origins, or a single
`*`) adds a [`tower-http`](https://docs.rs/tower-http) CORS layer
([`src/main.rs`](src/main.rs), [`src/config.rs`](src/config.rs)) for browser
clients on another origin. Empty (the default) adds **no** layer, so the
engine behaves exactly as before. The WebSocket `/ws` is unaffected (a
handshake is not a CORS request).
### Notes
- No build step for the SDK: copy the file, or import it from the engine origin.
- `tower-http` was already a transitive dependency; the `cors` feature adds no
new crates.
- Docs updated: [README.md](README.md#client-sdk-javascript) (new section, route
table, env table, feature bullet), [`.env.example`](.env.example), and the new
[`sdk/README.md`](sdk/README.md) (IT/EN/ES).
---
## 2026-10-21 — Feat: every HTTP verb for tools (list / get / add / update / delete / …)
### Changed
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
Sí...
D'acord...
Entesos...
Clar...
Correcte...
Perfecte...
Comprenc...
Genial...
+1
View File
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
[calm] Mm-hmm...
[attentive] Ajá...
[agreeing] Claro...
[listening] Ya veo...
[attentive] Sí, sí...
[calm] Entiendo...
[relaxed] Vale...
[thoughtful] Ah, ya...
+1
View File
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
[calm] Mmm...
[attentive] Eh già...
[agreeing] Certo...
[listening] Capisco...
[attentive] Sì, sì...
[calm] Ho capito...
[relaxed] Va bene...
[thoughtful] Ah, ecco...
+1
View File
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
+1
View File
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
Sawa...
Ndiyo...
Uh-huh...
Sijui...
Vizuri...
Sawa...
Hapo...
Ee...
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
umm...
bé...
a veure...
doncs...
hmm...
és a dir...
donem...
+1
View File
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
264d587d6f6486de813458cb9800a4c557ab440ee8195b92635601f95ddb1864
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More