Files
vocero-client/reasoning.md
T
2026-08-26 17:42:00 -04:00

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 → appende OPENAI_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.

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:

  1. doStart() calcola startInstructions = effectiveInstructions() (tutto il blocco §2).
  2. Costruisce startTools = activeToolDefs() = tool browser (web_search, camera_snapshot) + agentToolDefs (catalogo MCP Aquachile).
  3. Passa instructions e tools al costruttore di S2sWsRealtimeClient.
  4. Quando il WS si connette e arriva session.created, il client manda session.update con:
    {
      "type": "realtime",
      "instructions": "<tutte le istruzioni assemblate>",
      "tools": [ { "type":"function", "name":"…", "description":"…", "parameters":{…} }, … ],
      "tool_choice": "auto",
      "audio": { "output": { "voice": "Aiden" } }
    }
    
  5. TUTTO IL RESTO è SULL'UPSTREAM: L'upstream decide quando fare tool call, quando parlare, quando ascoltare. Il browser è solo uno "schermo+microfono+telecomando".
  6. Quando l'upstream fa conversation.item.input_audio_transcription.completed il log server stampa [INPUT UTENTE] ….
  7. Quando l'upstream fa response.*_transcript.done / response.content_part.done ecc. 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:

  1. L'upstream fa solo STT (converte voce in testo) e TTS (converte testo in voce). Agisce come un "dumb" audio I/O.
  2. Non appena arriva una trascrizione utente COMPLETA (evento transcript, role=="user", partial==false), maybeRunOpenAiReasoning(transcript, itemId) viene triggerato.
  3. 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
    }
    
  4. 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 }
      ]
    }
    
  5. Riceve answer (stringa semplice, pensata per essere "parlata"), logga [Vocero REASONING] transcript=… answer=… e la restituisce al client.
  6. Il client costruisce renderPrompt = "[VOCERO_RENDER_REPLY]\n" + answer e lo manda come se fosse un testo scritto dall'utente al realtime:
    client.sendUserText(renderPrompt);
    client.requestResponse();
    
  7. L'upstream realtime, a causa di OPENAI_RENDER_HINT nel 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.
  8. 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/completions NON ha accesso a tools (al momento l'endpoint POST /api/reasoning non 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:

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:

  1. Come tools (dichiarazione formale OpenAI Realtime function-call) : S2sWsRealtimeClient({tools: activeToolDefs()}) → arrivano all'upstream come session.tools[] con schema JSON Schema. Questo permette all'upstream di decidere di chiamarli tramite response.create_response + function_call_arguments.done.
  2. Come testo nel prompt vero e proprio : Available MCP tools: get_day_report - Obtiene el reporte… Required parameters: []; get_metrics_summary - …. (solo se S2S_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.item di tipo function_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:

  1. /api/config cosa restituisce?

    curl -s http://localhost:8000/api/config | python -m json.tool
    

    Verifica: primaryPrompt è valorizzato e lungo? agentTools quanti elementi ha? reasoningType è quello che ti aspetti?

  2. Aquachile profile risponde? Guarda [EXT][AQUACHILE] agent_profile nel log. Status 200? Cache HIT o MISS? PARSED promptLen=… toolCount=… ha i numeri attesi?

  3. Il browser ha phoneNumber nella URL? Apri DevTools → Network e cerca user/phone. C'è una richiesta? Torna 200? Che JSON ritorna? Il result viene inserito in voceroUserProfile (check console).

  4. 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).

  5. In modalità PROXY i tool MCP non vengono chiamati:

    • Verifica che il tool catalogo testuale sia nel prompt (controlla copy(effectiveInstructions()) e cerca la stringa Available 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.
  6. In modalità OPENAI non sente le MCP: Corretto — attualmente /api/reasoning NON 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.

  7. 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