mukul-chauhan-methdai Claude Opus 4.7 commited on
Commit
bcc66c2
·
1 Parent(s): 2747b30

Auto-reconnect Gemini Live on 1011 / handshake / transient drops

Browse files

Without this, when Gemini Live's websocket dies mid-session (1011
Internal error from Google, or transient network blip), the bot
went silent for the rest of the app's lifetime — operator had to
SSH in and restart. Manual register from the dashboard also
appeared to be a "no greeting" bug, but it was actually working
correctly: the session-event push fired with a SPEAK_NOW hint, the
push just had nowhere to land because the socket was dead.

Wraps the connect/run block in a reconnect loop with exponential
backoff (1s -> 30s cap), resetting the backoff on every successful
connection. First connect keeps the visitor-greeting kick-off
prompt; reconnects skip it and call _push_reconnect_context to
hand the LLM a one-line recap of where the conversation was
(state, visitor name, employee, appointment, email status). For
SPEAK_NOW states like RECOGNIZED / APPOINTMENT_MATCHED, the recap
also includes "SPEAK NOW: <hint>" so the bot picks up speaking
immediately instead of waiting silently.

Shutdown path still works — _shutdown_requested breaks the loop
before the next backoff sleep, and CancelledError on the sleep
also exits cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

src/reachy_mini_receptionist/gemini_live.py CHANGED
@@ -232,50 +232,151 @@ class GeminiLiveHandler(AsyncStreamHandler):
232
  if gemini_tools:
233
  config_obj["tools"] = gemini_tools
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  try:
236
- async with self._client.aio.live.connect(model=model_id, config=config_obj) as session:
237
- self.session = session
238
- logger.info("Gemini Live connected.")
239
- # Start background tool manager (same callback signature)
240
- self.tool_manager.start_up(tool_callbacks=[self._handle_tool_result])
241
-
242
- # Kick off conversation with a one-shot greeting prompt.
243
- # With the tighter VAD config (400ms silence) we could
244
- # in theory wait for visitor speech, but a proactive
245
- # greeting keeps the demo natural — the bot says hi
246
- # the moment a face is detected. Server VAD handles
247
- # all subsequent visitor turns with low latency.
248
- try:
249
- await session.send_client_content(
250
- turns=[{
251
- "role": "user",
252
- "parts": [{
253
- "text": (
254
- "(Visitor just walked up. Greet them "
255
- "very briefly in ONE short friendly "
256
- "sentence and ask their name or who "
257
- "they're here to see. Keep it under "
258
- "8 words.)"
259
- ),
260
- }],
261
- }],
262
- turn_complete=True,
263
- )
264
- logger.info("Gemini Live: sent kick-off greeting prompt")
265
- except Exception as e:
266
- logger.warning("Gemini Live: kick-off send failed: %s", e)
267
 
268
- try:
269
- await self._run_event_loop()
270
- except Exception as inner:
271
- logger.exception("Gemini Live event loop crashed: %s", inner)
272
- finally:
273
- await self.tool_manager.shutdown()
274
- self.session = None
275
- logger.info("Gemini Live: session ended cleanly")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  except Exception as e:
277
- logger.exception("Gemini Live session failed: %s", e)
278
- self.session = None
279
 
280
  async def shutdown(self) -> None:
281
  self._shutdown_requested = True
 
232
  if gemini_tools:
233
  config_obj["tools"] = gemini_tools
234
 
235
+ # Reconnect loop. Gemini Live websockets die periodically with
236
+ # 1011 "Internal error" — observed in production after long
237
+ # idle periods, after certain tool error responses, and after
238
+ # transient Google-side hiccups. Without auto-reconnect the
239
+ # bot goes silent for the rest of the app's lifetime, requiring
240
+ # an operator restart. This loop reconnects with exponential
241
+ # backoff (1s → 30s cap) and resets backoff on every successful
242
+ # connection. First connect sends the visitor-greeting prompt;
243
+ # subsequent reconnects skip it and push a state-restoration
244
+ # context message instead, so the LLM picks up wherever the
245
+ # conversation was when the socket dropped.
246
+ backoff_seconds = 1.0
247
+ max_backoff_seconds = 30.0
248
+ is_first_connect = True
249
+
250
+ while not self._shutdown_requested:
251
+ try:
252
+ async with self._client.aio.live.connect(model=model_id, config=config_obj) as session:
253
+ self.session = session
254
+ if is_first_connect:
255
+ logger.info("Gemini Live connected.")
256
+ else:
257
+ logger.warning("Gemini Live reconnected after disconnect.")
258
+ backoff_seconds = 1.0 # reset on every successful connect
259
+ self.tool_manager.start_up(tool_callbacks=[self._handle_tool_result])
260
+
261
+ try:
262
+ if is_first_connect:
263
+ # Visitor just walked up — proactive greeting.
264
+ await session.send_client_content(
265
+ turns=[{
266
+ "role": "user",
267
+ "parts": [{
268
+ "text": (
269
+ "(Visitor just walked up. Greet them "
270
+ "very briefly in ONE short friendly "
271
+ "sentence and ask their name or who "
272
+ "they're here to see. Keep it under "
273
+ "8 words.)"
274
+ ),
275
+ }],
276
+ }],
277
+ turn_complete=True,
278
+ )
279
+ logger.info("Gemini Live: sent kick-off greeting prompt")
280
+ else:
281
+ # Reconnect — restore session context so the
282
+ # LLM knows current state (visitor name,
283
+ # employee, appointment) and continues
284
+ # naturally instead of asking the visitor to
285
+ # repeat themselves.
286
+ await self._push_reconnect_context(session)
287
+ except Exception as e:
288
+ logger.warning("Gemini Live: post-connect send failed: %s", e)
289
+
290
+ is_first_connect = False
291
+
292
+ try:
293
+ await self._run_event_loop()
294
+ except Exception as inner:
295
+ logger.exception("Gemini Live event loop crashed: %s", inner)
296
+ finally:
297
+ await self.tool_manager.shutdown()
298
+ self.session = None
299
+ logger.info("Gemini Live: session ended.")
300
+ except Exception as e:
301
+ logger.exception("Gemini Live session failed: %s", e)
302
+ self.session = None
303
+
304
+ if self._shutdown_requested:
305
+ break
306
+
307
+ logger.warning(
308
+ "Gemini Live disconnected — reconnecting in %.1fs",
309
+ backoff_seconds,
310
+ )
311
+ try:
312
+ await asyncio.sleep(backoff_seconds)
313
+ except asyncio.CancelledError:
314
+ break
315
+ backoff_seconds = min(backoff_seconds * 2.0, max_backoff_seconds)
316
+
317
+ async def _push_reconnect_context(self, session: Any) -> None:
318
+ """After auto-reconnect, tell the LLM where we left off.
319
+
320
+ Pulls the current session snapshot (state, visitor, employee,
321
+ appointment) and sends it as a context message so the model
322
+ resumes the flow instead of cold-starting. If a visitor is
323
+ mid-flow in a SPEAK-NOW state, prompts the model to speak
324
+ immediately — this is what makes manual register or face
325
+ recognition work right after a websocket recovery.
326
+ """
327
+ sm = self._session_manager
328
+ if sm is None:
329
+ return
330
  try:
331
+ snap = sm.session
332
+ snap_dict = snap.to_dict() if hasattr(snap, "to_dict") else {}
333
+ current_state = snap.current_state
334
+ state_value = getattr(current_state, "value", str(current_state))
335
+ except Exception as e:
336
+ logger.debug("reconnect-context: snapshot failed: %s", e)
337
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
+ if state_value == "idle":
340
+ return # No active visitor; nothing to restore.
341
+
342
+ hint = ""
343
+ speak_now = False
344
+ try:
345
+ from reachy_mini_receptionist.conversation_controller import (
346
+ next_action_hint, should_speak_immediately,
347
+ )
348
+ hint = next_action_hint(current_state)
349
+ speak_now = should_speak_immediately(current_state)
350
+ except Exception:
351
+ pass
352
+
353
+ base = (
354
+ f"[Backend reconnect {self.format_timestamp()}] "
355
+ f"Resuming mid-session. Current state: {state_value}; "
356
+ f"visitor={snap_dict.get('visitor_name')}; "
357
+ f"employee={snap_dict.get('employee_name')}; "
358
+ f"appointment={(snap_dict.get('matched_appointment') or {}).get('time')}; "
359
+ f"email_sent_to={snap_dict.get('email_sent_to')}."
360
+ )
361
+ if hint and speak_now:
362
+ msg = f"{base} SPEAK NOW: {hint}"
363
+ elif hint:
364
+ msg = f"{base} Next: {hint}"
365
+ else:
366
+ msg = f"{base} Continue the conversation naturally."
367
+
368
+ try:
369
+ if speak_now:
370
+ await session.send_client_content(
371
+ turns=[{"role": "user", "parts": [{"text": msg}]}],
372
+ turn_complete=True,
373
+ )
374
+ else:
375
+ await session.send_realtime_input(text=msg)
376
+ logger.info("Gemini Live: pushed reconnect context (state=%s, speak_now=%s)",
377
+ state_value, speak_now)
378
  except Exception as e:
379
+ logger.warning("reconnect-context send failed: %s", e)
 
380
 
381
  async def shutdown(self) -> None:
382
  self._shutdown_requested = True