Spaces:
Running
Auto-reconnect Gemini Live on 1011 / handshake / transient drops
Browse filesWithout 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>
|
@@ -232,50 +232,151 @@ class GeminiLiveHandler(AsyncStreamHandler):
|
|
| 232 |
if gemini_tools:
|
| 233 |
config_obj["tools"] = gemini_tools
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
try:
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 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 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
except Exception as e:
|
| 277 |
-
logger.
|
| 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
|