37 KiB
Reasoning & Context Injection — Architettura Completa
Questo documento descrive come funziona il "reasoning" (il layer di intelligenza che decide cosa rispondere) nell'app Vocero/S2S e in quali punti puoi iniettare contesto (identità dell'agente, istruzioni, strumenti MCP, profilo chiamante, dati extra, ecc.).
Esistono due modalità di reasoning distinte:
| Modalità | Variabile env | Chi fa il "pensiero" | Flusso |
|---|---|---|---|
| PROXY (default) | S2S_REASONING_TYPE=PROXY |
Il server realtime upstream (ai-server, Vocero, o qualunque backend dietro S2S_PROXY_TARGET) |
Browser → WebSocket /v1/realtime → upstream (prompt + tools inviati in session.update) |
| OPENAI (reasoning esterno) | S2S_REASONING_TYPE=OPENAI |
Una chiamata HTTP separata a OpenAI Chat Completions (es. gpt-4.1-mini) |
Browser → POST /api/reasoning → OpenAI → risposta reiniettata nel realtime come messaggio utente forzato |
In entrambe le modalità le informazioni di contesto vengono assemblate lato client in un'unica stringa "istruzioni" (effectiveReasoningInstructions) e passate poi in modo diverso.
1. Panoramica del flusso completo (uno sguardo dall'alto)
┌─────────────────────────────────────────────────────────────────────────┐
│ /home/duxiter/s2s/.env │
│ S2S_PRIMARY_PROMPT S2S_AGENT_PROFILE S2S_REASONING_TYPE │
│ S2S_TOOL_EXECUTION_MCP_URL S2S_USER_PHONE_AUTH_URL │
│ OPENAI_API_KEY / BASE_URL / REASONING_MODEL / ORG / PROJECT │
│ S2S_PROXY_TARGET S2S_SEARCH_MCP etc. │
└───────────────────────────┬─────────────────────────────────────────────┘
│ caricato da start_ui.sh
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ demo/server.py (backend Python FastAPI) │
│ │
│ fetchConfig() GET /api/config ←─────── GET /api/user/phone │
│ │ │ (se URL param phone) │
│ ├─ primaryPrompt (priorità 1: Aquachile /agents/profile │
│ │ priorità 2: S2S_PRIMARY_PROMPT env) │
│ ├─ agentProfile (URL di dove abbiamo preso il prompt) │
│ ├─ agentTools[] (catalogo MCP Aquachile, enabled=True) │
│ ├─ reasoningType = "PROXY" oppure "OPENAI" │
│ └─ searchMcp, s2sUrl, lb, auth, search, ecc. │
│ │
│ POST /api/reasoning (solo se reasoningType=OPENAI) │
│ body → httpx POST {OPENAI_BASE_URL}/chat/completions │
│ │
│ POST /api/mcp/tool │
│ body → httpx POST {S2S_TOOL_EXECUTION_MCP_URL} + chiavi API │
│ │
│ WS /v1/realtime (proxy a S2S_PROXY_TARGET) │
│ client ↔ proxy ↔ upstream: logga [INPUT UTENTE]/[OUTPUT AGENTE] │
└───────────────────────────┬─────────────────────────────────────────────┘
│ fetch su caricamento pagina
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ demo/main.js (frontend browser) │
│ │
│ pinnedPrompt = primaryPrompt (da /api/config) — read-only se valoriz.│
│ agentToolDefs[] = agentTools (da /api/config) │
│ reasoningType = "PROXY" | "OPENAI" │
│ voceroUserPhoneNumber = new URL(location).searchParams.phoneNumber │
│ │ │
│ └─► async fetchVoceroUserProfile() │
│ GET api/user/phone?phoneNumber=… → voceroUserProfile{} │
│ │
│ Quando l'utente clicca l'orb → doStart(): │
│ startInstructions = effectiveInstructions() │
│ startTools = activeToolDefs() │
│ │ │
│ └─► new S2sWsRealtimeClient({instructions, tools, …}) │
│ │ │
│ ├─► session.update al realtime upstream │
│ │ (se reasoningType=PROXY → qui pensa) │
│ │ │
│ └─► event "transcript" (user, partial=false) │
│ │ │
│ └─► maybeRunOpenAiReasoning() │
│ (solo reasoningType=OPENAI) │
│ → POST /api/reasoning → answer │
│ → sendUserText(renderPrompt) │
│ → requestResponse() │
└─────────────────────────────────────────────────────────────────────────┘
2. Assemblaggio del prompt: effectiveReasoningInstructions()
La funzione centrale che costruisce il blocco di testo che verrà dato al modello è in main.js:516-533:
function effectiveReasoningInstructions() {
const base = pinnedPrompt || settings.instructions;
let instructions = base;
if (activeToolDefs().length) instructions += TOOL_USE_HINT;
if (searchMcpEnabled && agentToolDefs.length) {
instructions += MCP_SEARCH_HINT;
instructions += ` Available MCP tools: ${buildMcpToolCatalogSummary(agentToolDefs)}`;
}
const suggested = topSuggestedAgentTools(3);
if (searchMcpEnabled && suggested.length) {
instructions += ` Current user request: "${lastUserQuery}". Client-side ranking hint only: …`;
}
const userContext = voceroUserContextText();
if (userContext) instructions += userContext;
return instructions;
}
Ordine di concatenazione (dalla priorità più alta alla più bassa):
| # | Fonte | Dove viene da | Note |
|---|---|---|---|
| 1 | pinnedPrompt |
S2S_AGENT_PROFILE (instructions campo Aquachile) → fallback S2S_PRIMARY_PROMPT env → fallback settings.instructions (localStorage) → fallback DEFAULT_INSTRUCTIONS |
Identità e personalità principale dell'agente. Quando pinnedPrompt è presente, il campo instructions di Settings è disabilitato in UI (read-only). |
| 2 | TOOL_USE_HINT |
Costante in linea main.js:32-36 | Aggiunto se c'è almeno 1 tool attivo: "When the user's request calls for one of your tools, do not describe your capabilities or say you can do it and wait for another turn. Instead, say a brief acknowledgement… and call the tool right away in the same response." |
| 3 | MCP_SEARCH_HINT + catalogo completo tools |
Condizionato a S2S_SEARCH_MCP=true in env |
Istruisce il modello a leggere l'intero catalogo MCP e scegliere lo strumento migliore. Poi appende come testo TUTTI i tool MCP (buildMcpToolCatalogSummary) con nome, descrizione e parametri obbligatori. |
| 4 | Suggerimento tool client-side (top 3) | topSuggestedAgentTools(3) basato su lastUserQuery |
Messaggio "Client-side ranking hint only: 1. get_day_report: … 2. … 3. …". Usato solo come hint secondario, il modello deve sempre controllare il catalogo completo prima. |
| 5 | voceroUserContextText() |
Profile restituito da /api/user/phone?phoneNumber=… |
Stringa: " Caller profile resolved from the page phoneNumber: {JSON profilo}. Use this as trusted caller context when relevant." (troncato a 1600 char) |
Poi la funzione effectiveInstructions() (main.js:537-539) prende il risultato e:
- Se
reasoningType=OPENAI→ appendeOPENAI_RENDER_HINT: istruisce il realtime a rispondere solo ai messaggi che iniziano con il marker[VOCERO_RENDER_REPLY], in modalità "leggi e basta" (niente tool, niente ragionamento). Questo è il segreto che permette di scrivere la frase pensata da OpenAI e farla ripetere fedelmente in TTS. - Se
reasoningType=PROXY→ ritorna le istruzioni tal quali.
Quando viene ricalcolato e reinviato al modello
- Alla connessione in
doStart():startInstructions = effectiveInstructions()→new S2sWsRealtimeClient({instructions: startInstructions, …}). - Durante la chiamata, in risposta a:
- ogni trascrizione utente parziale o finale (linee 1122-1128):
pushLiveToolPolicy()+pushLiveInstructions(effectiveInstructions())→client.updateSession({ instructions }) - quando arriva il profilo chiamante da phone auth (2388-2390): stesso
pushLiveInstructions - quando l'utente modifica le impostazioni nel form e salva.
- ogni trascrizione utente parziale o finale (linee 1122-1128):
3. I due motori di reasoning, nel dettaglio
3.1 Modalità PROXY (default — consigliata con Aquachile)
S2S_REASONING_TYPE=PROXY (o qualsiasi valore diverso da "OPENAI")
S2S_PROXY_TARGET=ws://ai-server:12345/v1/realtime # o qualunque endpoint WS realtime
In questa modalità, l'upstream realtime è l'unico a "pensare". Tutta l'intelligenza risiede nel backend puntato da S2S_PROXY_TARGET (es. Vocero, un wrapper realtime custom, un server che parla OpenAI Realtime API).
Flusso testuale di quello che succede a ogni avvio:
doStart()calcolastartInstructions = effectiveInstructions()(tutto il blocco §2).- Costruisce
startTools = activeToolDefs()= tool browser (web_search, camera_snapshot) +agentToolDefs(catalogo MCP Aquachile). - Passa
instructionsetoolsal costruttore diS2sWsRealtimeClient. - Quando il WS si connette e arriva
session.created, il client mandasession.updatecon:{ "type": "realtime", "instructions": "<tutte le istruzioni assemblate>", "tools": [ { "type":"function", "name":"…", "description":"…", "parameters":{…} }, … ], "tool_choice": "auto", "audio": { "output": { "voice": "Aiden" } } } - TUTTO IL RESTO è SULL'UPSTREAM: L'upstream decide quando fare tool call, quando parlare, quando ascoltare. Il browser è solo uno "schermo+microfono+telecomando".
- Quando l'upstream fa
conversation.item.input_audio_transcription.completedil log server stampa[INPUT UTENTE] …. - Quando l'upstream fa
response.*_transcript.done/response.content_part.doneecc. il log server stampa[OUTPUT AGENTE] ….
Vantaggi:
- Un solo round trip per tutto (latenza più bassa per voce→voce).
- L'upstream può usare qualsiasi modello/rete interna (non per forza OpenAI).
- Le MCP tool call sono orchestrate dallo stesso server che fa STT/TTS → meno desincronizzazioni.
Svantaggi:
- Tutto il "pensiero" è black-box sull'upstream; non puoi vedere i token del thinking né usare un modello diverso senza cambiare upstream.
3.2 Modalità OPENAI (reasoning HTTP separato)
S2S_REASONING_TYPE=OPENAI
OPENAI_API_KEY=sk-… # obbligatorio
OPENAI_BASE_URL=https://api.openai.com/v1 # default
OPENAI_REASONING_MODEL=gpt-4.1-mini # default
OPENAI_ORGANIZATION=… # opzionale
OPENAI_PROJECT=… # opzionale
In questa modalità il ragionamento vero e proprio non viene fatto dall'upstream realtime. Invece:
- L'upstream fa solo STT (converte voce in testo) e TTS (converte testo in voce). Agisce come un "dumb" audio I/O.
- Non appena arriva una trascrizione utente COMPLETA (evento
transcript,role=="user",partial==false),maybeRunOpenAiReasoning(transcript, itemId)viene triggerato. - Questo chiama
fetchOpenAiReasoningAnswer(transcript)(main.js:563-586) che fa:POST /api/reasoning body = { transcript: "quanto è il report giornaliero?", instructions: effectiveReasoningInstructions(), // stesse di PROXY language: "es-CL" o vuoto, user_context: voceroUserContextText(), // duplicato per sicurezza } - Il server (endpoint in server.py:635-710) a sua volta fa:
POST {OPENAI_BASE_URL}/chat/completions body = { model: OPENAI_REASONING_MODEL, messages: [ { role:"system", content: "{instructions}\n\n{user_context}\nReply only with final answer. Language=…" }, { role:"user", content: transcript } ] } - Riceve
answer(stringa semplice, pensata per essere "parlata"), logga[Vocero REASONING] transcript=… answer=…e la restituisce al client. - Il client costruisce
renderPrompt = "[VOCERO_RENDER_REPLY]\n" + answere lo manda come se fosse un testo scritto dall'utente al realtime:client.sendUserText(renderPrompt); client.requestResponse(); - L'upstream realtime, a causa di
OPENAI_RENDER_HINTnel prompt, vede il marker[VOCERO_RENDER_REPLY]e quindi:- NON usa tool, NON fa ragionamento proprio, NON modifica la frase.
- Si limita a leggere la parte dopo il marker in TTS.
- Risultato: l'utente sente "la voce dell'agente" (TTS dell'upstream) ma il testo è stato scritto da OpenAI in
/chat/completions.
Log server corrispondenti:
[EXT][OPENAI] reasoning POST https://api.openai.com/v1/chat/completions model=gpt-4.1-mini systemLen=… transcriptLen=…
[EXT][OPENAI] reasoning RESP status=200 len=…
[EXT][OPENAI] reasoning USAGE prompt=1234 completion=89 total=1323
INFO [s2s] [Vocero REASONING] transcript=… answer=…
Vantaggi:
- Scegli tu qualsiasi modello compatibile Chat Completions (anche Azure, Together, self-hosted se l'endpoint parla lo stesso protocollo).
- Hai usage e log del reasoning separati da quelli dell'audio.
- Puoi aggiungere facilmente post-processing, cache o fallback.
Svantaggi:
- Due round trip invece di uno → un po' più di latenza totale (~200-800 ms).
- Più costoso in token:
instructions+ contesto utente vengono mandati due volte (una a OpenAI, una al realtime). - Le tool call che il modello OpenAI vorrebbe fare non possono essere usate direttamente — in OPENAI mode
chat/completionsNON ha accesso atools(al momento l'endpointPOST /api/reasoningnon inoltra il catalogo MCP a OpenAI). Quindi in OPENAI mode, le MCP Aquachile vengono ancora passate al realtime (che in questa modalità non "pensa" ma solo reinderizza il testo), quindi non sono usabili.
⚠️ Importante: se usi Aquachile con il suo ecosistema MCP, la modalità PROXY è quella corretta da usare. OPENAI mode è pensata per scenari "solo testo + voce" senza tool complessi.
4. TUTTI i modi per iniettare contesto all'agente, dal più semplice al più avanzato
I punti di injection sono ordinati per priorità di applicazione (chi "vince" in caso di conflitto) e per facilità di modifica.
🅿️ Punto 1 — .env (variabili di ambiente, configurazione statica)
File: .env
Caricato in memoria da start_ui.sh (source "$ROOT_DIR/.env") prima di avviare uvicorn.
| Variabile | Tipo | Cosa fa all'agente |
|---|---|---|
S2S_PRIMARY_PROMPT |
testo lungo | Prompt backup quando Aquachile è irraggiungibile o S2S_AGENT_PROFILE non è impostato. Sarà il valore di primaryPrompt in /api/config se _extract_agent_prompt() torna stringa vuota. Supporta \n → convertiti in newline. |
S2S_AGENT_PROFILE |
URL | Endpoint GET (es. Aquachile: https://aquachile.azurianlab.com/api/v1/agents/profile). Deve ritornare JSON { agent: { instructions: "...", tools: [ {name, description, parameters, enabled}, ... ] } }. Ha priorità su S2S_PRIMARY_PROMPT — vedi primary_prompt = agent_prompt or S2S_PRIMARY_PROMPT in server.py:517. |
S2S_TOOL_EXECUTION_MCP_URL |
URL | Endpoint POST per eseguire le tool call MCP (stesso backend Aquachile). Riceve {server, name, arguments} e viene chiamata ogni volta che il modello invoca uno strumento. |
S2S_TOOL_EXECUTION_MCP_API_KEY / S2S_TOOL_EXECUTION_MCP_API_SECRET |
stringhe segrete | Intestazioni X-API-KEY e X-API-SECRET aggiunte a ogni chiamata MCP. |
S2S_USER_PHONE_AUTH_URL |
URL | Endpoint GET (con ?phoneNumber=…) che restituisce il profilo del chiamante. Se l'URL della UI contiene ?phoneNumber=+56…, questo endpoint viene chiamato e il JSON risultante viene appeso al prompt di sistema (vedi Punto 4). |
S2S_SEARCH_MCP=true|false |
bool | Quando true, appende MCP_SEARCH_HINT + il catalogo COMPLETO dei tool MCP dentro le instructions. Fondamentale se vuoi che il modello sappia che esistono 17 strumenti e possa scegliere tra quelli. |
S2S_REASONING_TYPE |
PROXY | OPENAI |
Motore di reasoning (§3). Normalizzato con _normalize_reasoning_type() (case-insensitive). |
OPENAI_API_KEY / BASE_URL / REASONING_MODEL |
stringhe | Solo per mode OPENAI. BASE_URL serve anche per compatibilità Azure/Together (basta che parli /chat/completions). |
S2S_PROXY_TARGET |
URL WS | Upstream realtime che fa STT/TTS + (in mode PROXY) il reasoning vero e proprio. Di solito ws://ai-server:12345/v1/realtime. |
S2S_PRELOAD_DATA_ENDPOINT, SERPER_API_KEY, LOAD_BALANCER_URL, SPEECH_TO_SPEECH_URL |
varie | Non iniettano testo nel prompt direttamente, ma abilitano altre funzionalità (PowerBI preload, web search Serper, queue & limiter, direct URL fissa). |
🅿️ Punto 2 — Aquachile /api/v1/agents/profile (contesto Aziendale + Tools)
URL impostato da S2S_AGENT_PROFILE. Fetchato lato server in _agent_profile_data() con:
- cache 60 secondi globale (
_agent_profile_cached), lock asincrona - timeout 10 s
- chiamata GET senza header speciali (aggiungibili se serve a mano nel codice)
Formato JSON atteso (si adatta in modo tollerante):
{
"agent": {
// ↓ usato come prompt principale (priority #1)
"instructions": "Eres el asistente de voz oficial de Aquachile. Te llamas Aquachile Assistant…",
// ↓ fallback se instructions non esiste
"description": "…",
"tools": [
{
"name": "get_day_report",
"description": "Obtiene el reporte de producción diario de una planta…",
"parameters": {
"type": "object",
"properties": {
"center": {"type": "string", "description": "Nombre de la planta"}
},
"required": []
},
"enabled": true // ↓ se missing o true → incluso; se false → ignorato
},
// … altri 16 strumenti
]
}
}
Parser corrispondente:
- _extract_agent_prompt() — cerca in ordine:
agent.instructions→agent.description→instructionsradice →descriptionradice. - _extract_agent_tools() — cerca
agent.tools[], filtra quelli conenabled!=false.
Tutto il resto del payload viene ignorato. Se vuoi estrarre altri campi (es. agent.name, agent.greeting, agent.language) puoi aggiungerne l'estensione in _extract_agent_prompt per concatenarli, oppure creare un nuovo campo nel dizionario di ritorno e poi usarlo in /api/config.
Quando modificare questo endpoint piuttosto che altre parti:
- Quando il contenuto cambia spesso (ogni giorno: nuovi report, nuove tipologie di domanda).
- Quando vuoi che tutte le installazioni dell'UI aggiornino all'istante senza rebuild/restart del server.
- Quando vuoi rilasciare un nuovo tool MCP: basta aggiungerlo all'array
tools[]e comparirà ovunque entro 60 s (prossima invalidazione della cache).
🅿️ Punto 3 — URL parameters della pagina (contesto per-chiamata)
Analizzati all'avvio di main.js (riga 410):
const pageParams = new URLSearchParams(window.location.search);
const voceroUserPhoneNumber = (pageParams.get("phoneNumber") || "").trim();
Attualmente è gestito un solo param:
| Parametro URL | Tipo | Effetto |
|---|---|---|
?phoneNumber=+56912345678 |
stringa E.164 | Triggera fetchVoceroUserProfile() → GET /api/user/phone?phoneNumber=… → endpoint Aquachile S2S_USER_PHONE_AUTH_URL → profilo JSON appenduto al prompt (vedi Punto 4). |
È molto semplice estendere la lista: aggiungi una riga tipo:
const plantId = (pageParams.get("plant") || "").trim();
e poi inietta il valore in modo statico (in voceroUserContextText() o in un pezzo di testo a parte in effectiveReasoningInstructions) o fai una fetch al tuo endpoint.
🅿️ Punto 4 — Profilo chiamante /api/user/phone (contesto per-caller)
End-to-end:
URL: https://<app>/?phoneNumber=+56912345678
│
▼ fetchVoceroUserProfile() [main.js:2370-2398]
GET api/user/phone?phoneNumber=%2B56912345678
│
▼ user_by_phone() [server.py:539-573]
GET {S2S_USER_PHONE_AUTH_URL}?phoneNumber=%2B56912345678
│
▼
Risposta Aquachile (es.)
{
"id": 987,
"name": "Don Manuel",
"role": "Jefe de Planta",
"center": "Puerto Montt",
"email": "manuel@aquachile.cl",
"authorizations": ["report:daily", "report:weekly"]
}
│
▼ { "result": <payload> } al browser
│
▼ voceroUserProfile = <payload> ; voceroUserProfileLoaded = true
│
▼ voceroUserContextText() [main.js:454-458]
" Caller profile resolved from the page phoneNumber: {name:\"Don Manuel\",…}. Use this as trusted caller context when relevant."
│
└─► concatenato ALLA FINE di effectiveReasoningInstructions
Una volta che il profilo è arrivato, viene chiamato pushLiveInstructions(effectiveInstructions()) → l'istruzione sul realtime viene aggiornata in volo anche se la conversazione è già iniziata. Quindi anche se il profilo impiega 2 secondi ad arrivare, le battute successive alla ricezione beneficeranno del contesto.
Quando usarlo: passare dati per-caller (ruolo, permessi, centro, nome, lingua preferita, reporting line, storico chiamate recenti ecc.). Tieni conto che il JSON viene serializzato in chiaro nel prompt (max 1600 caratteri), quindi non passare PII sensibili in chiaro.
🅿️ Punto 5 — Catalogo MCP Aquachile (contesto come "abilità" dell'agente)
Fonte: agent.tools[] dal profilo Aquachile (Punto 2), poi in agentToolDefs lato client.
Entrano nel prompt in due modi:
- Come
tools(dichiarazione formale OpenAI Realtime function-call) :S2sWsRealtimeClient({tools: activeToolDefs()})→ arrivano all'upstream comesession.tools[]con schema JSON Schema. Questo permette all'upstream di decidere di chiamarli tramiteresponse.create_response + function_call_arguments.done. - Come testo nel prompt vero e proprio :
Available MCP tools: get_day_report - Obtiene el reporte… Required parameters: []; get_metrics_summary - ….(solo seS2S_SEARCH_MCP=true).
Quando poi il modello effettivamente chiama un tool MCP, il client:
- emette l'evento
toolcall - il server chiama
POST /api/mcp/tool(server.py):POST {S2S_TOOL_EXECUTION_MCP_URL} headers: X-API-KEY, X-API-SECRET, Content-Type: application/json body: {"server": "mcp", "name": "get_day_report", "arguments": "{\"center\":\"Puerto Montt\"}"} - Il risultato torna al client e viene rinviato all'upstream come
conversation.itemdi tipofunction_call_output. Il modello legge l'output e prosegue il turn.
🅿️ Punto 6 — Settings utente (localStorage) (solo se prompt non pinned)
Se pinnedPrompt è stringa vuota (né da Aquachile, né da env), allora in effectiveReasoningInstructions() la base è settings.instructions che viene da:
// main.js loadSettings: riga 201
instructions: localStorage.getItem("s2s.ws.instructions") || DEFAULT_INSTRUCTIONS
Nel form Settings (pannellino in alto a destra) l'utente può editare il testo "System instructions" e salvarlo → va in localStorage.
⚠️ Attenzione: se è presente un prompt "pinned" (da Aquachile o env), il campo nel Settings diventa read-only (non è modificabile). Puoi sempre verificare lo stato aprendo il pannello.
🅿️ Punto 7 — Aggiungere contesto custom via codice (patch diretta)
Se vuoi iniettare dati specifici (es. l'ID di una campagna, il turno di lavoro corrente, la stazione meteorologica più vicina, ecc.) senza creare un nuovo endpoint esterno, hai 3 punti comodi dove patchare con poche righe:
7a. Aggiungere un pezzo di testo statico alle instructions
Modifica direttamente effectiveReasoningInstructions() in main.js:516-533, aggiungendo una sezione. Esempio:
function effectiveReasoningInstructions() {
const base = pinnedPrompt || settings.instructions;
let instructions = base;
if (activeToolDefs().length) instructions += TOOL_USE_HINT;
if (searchMcpEnabled && agentToolDefs.length) { … }
const suggested = topSuggestedAgentTools(3);
if (searchMcpEnabled && suggested.length) { … }
const userContext = voceroUserContextText();
if (userContext) instructions += userContext;
// ↓ aggiungi contesto custom QUI
instructions += `\n\nTurno actual: ${TURNO_CORRIENTE}. Campaña: ${CAMPAIGN_ID}. Planta: ${PLANTA_ID}.`;
return instructions;
}
Funziona in entrambe le modalità (PROXY e OPENAI).
7b. Inviare un messaggio pre-filla a inizio conversazione
Dopo che il client è connesso e pronto (evento ready o status=="connected"), chiama:
client.sendUserText(
"Contexto adicional de esta llamada (no responder a esto directamente): " +
"El usuario está llamando desde la planta Puerto Montt. Su último reporte " +
"fue el día 2026-08-17 y mostró un 95% de cumplimiento. El turno actual " +
"es el turno noche. Usa esta información cuando respondas."
);
// (opzionale) poi client.requestResponse() se vuoi che l'agente saluti subito
Il messaggio entra nella history del modello (coda conversation.items[]) ed è visibile per il resto della conversazione.
7c. Patchare /api/config lato server per ritornare un campo custom e usarlo dal client
Aggiungi in server.py ~riga 518-531:
out = {
...,
"myCustomContext": {"turno": current_shift(), "planta": os.environ.get("PLANTA_ID", "")},
}
Poi lato client in fetchConfig() (circa riga 2408):
myCustomContext = json.myCustomContext || null;
e usa il valore come in 7a. Utile quando il contesto lo conosci solo lato server (es. viene da DB aziendale o da variabili env del deploy).
7d. Aggiungere un parametro HTTP user_context custom alla chiamata /api/reasoning (solo OPENAI mode)
L'endpoint già accetta il campo user_context nel body della request — è già popolato da voceroUserContextText(), ma puoi aggiungere ulteriore testo concatenandolo nel client o nel server.
Oppure estendi ReasoningRequest in server.py:346-350 con un nuovo campo BaseModel (es. turno: str = "", planta: str = "") e poi usalo in system_parts.append(...) dentro def reasoning().
5. Verifiche e Debug — come controllare "cosa vede veramente l'agente"
5a. Log del server (cosa arriva all'esterno)
Su docker logs -f s2s_ui (o sull'output di start_ui.sh) puoi monitorare TUTTE le chiamate esterne. Filtri utili:
# Solo contesto caricato all'avvio (profilo Aquachile)
grep "\[EXT\]\[AQUACHILE\]" server.log
# Solo ragionamento OpenAI (se mode OPENAI)
grep "\[EXT\]\[OPENAI\]" server.log
# Dialogo parlato (TESTO della chat)
grep -E "INPUT UTENTE|OUTPUT AGENTE" server.log
# Tutte le chiamate MCP
grep "\[EXT\]\[AQUACHILE\].*mcp.*tools/call" server.log
# /api/config (quello che arriva al browser come initial setup)
grep "/api/config returned" server.log
# Autenticazione chiamante
grep "Vocero USER AUTH" server.log
5b. Debug lato client (cosa viene assemblato)
Apri la console del browser (F12) e, dopo che la pagina ha caricato e ti sei connesso, puoi eseguire:
// Controlla il tipo di reasoning attivo
> reasoningType
"PROXY" // oppure "OPENAI"
// Stringa effettiva di instruzioni (copiala/incolala per ispezione manuale)
> copy(effectiveInstructions())
// Testo che è stato passato all'ultimo session.update
> liveInstructionsSnapshot.length
32456
// Elenco dei tool attivi che l'agente "vede"
> activeToolDefs().map(t => t.name)
(19) ['web_search', 'camera_snapshot', 'get_day_report', 'get_weekly_report', 'get_monthly_report', 'get_report_detail', … ]
// Profilo chiamante caricato
> voceroUserProfileLoaded, voceroUserProfile
true, { id: 987, name: "Don Manuel", … }
// Top 3 suggeriti per l'ultima domanda
> topSuggestedAgentTools(3).map(x => `${x.tool.name}:${x.score}`)
["get_day_report:38", "get_metrics_summary:22", "get_report_detail:15"]
5c. Cosa viene mandato a OpenAI in modalità OPENAI
Basta guardare il log [EXT][OPENAI] reasoning POST … systemLen=… transcriptLen=… e saprai già il numero di token di sistema. Se vuoi vedere l'esatto system message, puoi aggiungere temporaneamente:
# dentro def reasoning() dopo la costruzione di body
logger.debug("Reasoning system message PREVIEW:\n%s",
body["messages"][0]["content"][:3000])
6. Priorità effettiva di tutte le sorgenti (chi vince se c'è conflitto)
Tabella riassuntiva che risponde a: "Se due punti dicono cose diverse, quale vince?"
| Cosa | Vincitore (priorità max → min) |
|---|---|
| Identità / sistema prompt | 1. agent.instructions da Aquachile S2S_AGENT_PROFILE → 2. S2S_PRIMARY_PROMPT env → 3. settings.instructions (localStorage utente) → 4. DEFAULT_INSTRUCTIONS hardcoded |
| Catalogo MCP | Aquachile agent.tools[] (unico fornitore). Gli unici tool aggiuntivi sono web_search e camera_snapshot (browser-native). |
| Hint su come usare i tool | Sempre TOOL_USE_HINT fisso se tools>0; se searchMcpEnabled si aggiunge MCP_SEARCH_HINT + catalogo testuale completo. |
| Hint tool ranking (top 3) | Calcolato lato client per ogni battuta; prompt dice "secondary signal, full catalog wins" |
| Profilo chiamante | S2S_USER_PHONE_AUTH_URL + URL param phoneNumber (nessun altro percorso attuale). |
| Modalità di reasoning | 1. S2S_REASONING_TYPE env normalizzato (OPENAI o default PROXY) |
| URL endpoint upstream | 1. SPEECH_TO_SPEECH_URL pinned direct → 2. LOAD_BALANCER_URL LB + queue → 3. URL inserito manualmente in Settings (solo allowDirect) |
7. Troubleshooting: "L'agente non usa il contesto che mi aspetto"
Checklist da fare in ordine:
-
/api/configcosa restituisce?curl -s http://localhost:8000/api/config | python -m json.toolVerifica:
primaryPromptè valorizzato e lungo?agentToolsquanti elementi ha?reasoningTypeè quello che ti aspetti? -
Aquachile profile risponde? Guarda
[EXT][AQUACHILE] agent_profilenel log. Status 200? Cache HIT o MISS?PARSED promptLen=… toolCount=…ha i numeri attesi? -
Il browser ha
phoneNumbernella URL? Apri DevTools → Network e cercauser/phone. C'è una richiesta? Torna 200? Che JSON ritorna? Ilresultviene inserito invoceroUserProfile(check console). -
Hai cambiato qualcosa nel prompt in Aquachile ma non l'aggiornamento non si vede? Il profilo ha cache 60 secondi. Aspetta o riavvia il server (la cache è in memoria).
-
In modalità PROXY i tool MCP non vengono chiamati:
- Verifica che il tool catalogo testuale sia nel prompt (controlla
copy(effectiveInstructions())e cerca la stringaAvailable MCP tools). - Verifica che
S2S_SEARCH_MCP=true. - Guarda nella console del browser
[Vocero MCP MATCH]e[Vocero MCP RANK]— se nessuno score è sopra la soglia (default 20), l'auto-invocazione lato client non scatta; il modello potrebbe comunque chiamarlo via function-call standard.
- Verifica che il tool catalogo testuale sia nel prompt (controlla
-
In modalità OPENAI non sente le MCP: Corretto — attualmente
/api/reasoningNON manda tools a OpenAI. Vedi §3.2. Se vuoi chiamare tool in OPENAI mode devi spezzare la catena: OpenAI deve dire quale tool chiamare, tu lo chiami via/api/mcp/tool, e poi reinietti il risultato in un nuovo reasoning turn. O, più semplicemente, usa la modalità PROXY quando servono i tool Aquachile. -
Output lungo o ripetitivo: Inserisci in coda al prompt un blocco tipo:
Limita la risposta a 2 frasi e parla in modo naturale. Non ripetere la domanda dell'utente.— funziona in entrambe le modalità.
8. Riferimenti rapidi (file e linee)
| Concetto | File e righe |
|---|---|
.env caricato |
start_ui.sh |
| Variabili env parsate lato server | server.py |
ReasoningRequest body schema |
server.py:346-350 |
| Fetch profilo Aquachile + cache 60 s | _agent_profile_data |
| Parse istruzioni + tools dal JSON Aquachile | _extract_agent_prompt, _extract_agent_tools |
/api/config (bootstrap verso client) |
server.py:509-536 |
/api/user/phone (profilo chiamante) |
server.py:539-573 |
| Esecuzione tool MCP (inoltro ad Aquachile) | server.py /api/mcp/tool |
/api/reasoning (OpenAI chat completions proxy) |
server.py:635-710 |
| Proxy WS realtime, INPUT/OUTPUT text log | _client_to_upstream, _upstream_to_client |
fetchConfig() lato client |
main.js:2401-2439 |
fetchVoceroUserProfile() phone auth |
main.js:2370-2398 |
effectiveReasoningInstructions() assemblaggio prompt |
main.js:516-533 |
effectiveInstructions() (prompt per realtime, + OPENAI marker hint) |
main.js:537-539 |
| Tool hint + MCP search hint costanti | main.js:32-42 |
voceroUserContextText() caller profile injection |
main.js:454-458 |
fetchOpenAiReasoningAnswer() POST /api/reasoning client |
main.js:563-586 |
maybeRunOpenAiReasoning() trigger da transcript + inject [VOCERO_RENDER_REPLY] |
main.js:592-625 |
pushLiveInstructions() / pushLiveToolPolicy() update sessione live |
main.js:628-649 |
doStart() avvio sessione + invio istruzioni/tools iniziali |
main.js:2739-2788 |
| Gestore transcript utente → trigger reasoning + ranking MCP | main.js:2814-2821 |