[object Object]

← back to Butlr

AI-gather fixes: hermes3:8b model (0.3s warm vs 6.9s qwen3:14b), speechTimeout=3 (no more mid-sentence clipping), 4s LLM AbortController timeout (no more 24s call-killers), warm keep_alive=15m

4d03f06d1d5d4e70a37f01d9ec06b75f8f8aad2e · 2026-05-12 15:56:29 -0700 · Steve

Files touched

Diff

commit 4d03f06d1d5d4e70a37f01d9ec06b75f8f8aad2e
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 12 15:56:29 2026 -0700

    AI-gather fixes: hermes3:8b model (0.3s warm vs 6.9s qwen3:14b), speechTimeout=3 (no more mid-sentence clipping), 4s LLM AbortController timeout (no more 24s call-killers), warm keep_alive=15m
---
 routes/twilio-webhooks.js | 43 ++++++++++++++++++++++++++++---------------
 1 file changed, 28 insertions(+), 15 deletions(-)

diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index 2ed1c21..0baf406 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -103,7 +103,7 @@ async function twimlHandler(req, res) {
   <Gather input="speech dtmf"
           action="${pub}/twilio/gather/${callId}"
           method="POST"
-          speechTimeout="auto"
+          speechTimeout="3"
           timeout="20"
           actionOnEmptyResult="true"
           speechModel="phone_call"
@@ -152,31 +152,44 @@ You are speaking on a phone call. Be brief — 1-2 short sentences max per turn.
 
 You will hear what ${call.business_name} just said. Respond appropriately. Keep responses SHORT (under 25 words).`;
 
+  // ── LLM call with hard 4s timeout ────────────────────────────────────
+  // Model: hermes3:8b — warm response ~0.3s on M2 Max vs qwen3:14b ~6.9s.
+  // Phone-call latency budget is sub-3s; anything slower and the human
+  // hears silence and hangs up.
+  // OLLAMA_HOST override supported for non-default endpoints.
   let aiText = '';
   let aiAction = null;
+  const OLLAMA_URL = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
+  const MODEL = process.env.BUTLR_LLM_MODEL || 'hermes3:8b';
+  const ac = new AbortController();
+  const timeoutMs = parseInt(process.env.BUTLR_LLM_TIMEOUT_MS || '4000', 10);
+  const timeoutId = setTimeout(() => ac.abort(), timeoutMs);
   try {
     const messages = [{ role: 'system', content: systemPrompt }, ...turns.slice(-12)];
-    const r = await fetch('http://host.docker.internal:11434/api/chat', {
+    const r = await fetch(`${OLLAMA_URL}/api/chat`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ model: 'qwen3:14b', stream: false, messages, options: { temperature: 0.3, num_predict: 200 }}),
-    }).catch(async () => {
-      // Fallback to localhost if not running in container
-      return fetch('http://127.0.0.1:11434/api/chat', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ model: 'qwen3:14b', stream: false, messages, options: { temperature: 0.3, num_predict: 200 }}),
-      });
+      body: JSON.stringify({ model: MODEL, stream: false, messages, options: { temperature: 0.3, num_predict: 120 }, keep_alive: '15m' }),
+      signal: ac.signal,
     });
     if (r && r.ok) {
       const j = await r.json();
       aiText = (j.message && j.message.content || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim();
-      // Try to parse as JSON action
-      const m = aiText.match(/\{[^{}]*"action"[^{}]*\}/);
-      if (m) try { aiAction = JSON.parse(m[0]); } catch {}
+      // Try to parse as JSON action (be forgiving with whitespace, single quotes)
+      const m = aiText.match(/\{\s*"?action"?\s*:\s*"[^"]+"[\s\S]*?\}/);
+      if (m) try { aiAction = JSON.parse(m[0].replace(/'/g, '"')); } catch {}
+    } else {
+      console.error('[ai-agent] LLM http error:', r && r.status);
     }
   } catch (e) {
-    console.error('[ai-agent] LLM error:', e.message);
+    if (e.name === 'AbortError') {
+      console.error(`[ai-agent] LLM timed out after ${timeoutMs}ms — falling back`);
+      aiText = "One moment please, I'm checking on that.";
+    } else {
+      console.error('[ai-agent] LLM error:', e.message);
+    }
+  } finally {
+    clearTimeout(timeoutId);
   }
 
   if (!aiText) aiText = 'Sorry, could you repeat that?';
@@ -207,7 +220,7 @@ You will hear what ${call.business_name} just said. Respond appropriately. Keep
     : `<Gather input="speech dtmf"
               action="${pub}/twilio/gather/${callId}"
               method="POST"
-              speechTimeout="auto"
+              speechTimeout="3"
               timeout="20"
               actionOnEmptyResult="true"
               speechModel="phone_call">

← 391b859 feat(listen): live AI-agent transcript fallback when Twilio  ·  back to Butlr  ·  security(P0): kill ?reveal=1 leak + owner-auth + robots noin 5ef12b5 →