aloof-forest-57428
12/23/2025, 7:11 AMpython
<@U07SJDE11GF>.rtc_session(
agent_name="emi-reminder-agent-test2",
type=WorkerType.ROOM,
on_session_end=on_session_end, # ← This is the "webhook"
)
***When Session Ends → on_session_end() is called:***
1. ***Extracts transcript*** from ctx.make_session_report()
2. ***Formats transcript*** from LiveKit's session report format
3. ***Saves to database*** via CallRepository.queue_call_record()
---
## ⚠️ The Problem You Identified
### Issue #1: on_session_end Only Fires When Session Completes Successfully
## ⚠️ The Problem You Identified
### Issue #1: on_session_end Only Fires When Session Completes Successfully
***Evidence from logs:***
:white_check_mark: SUCCESS: ":floppy_disk: [SessionReport] Transcript persisted for call 7c530579... (10 items)"
:white_check_mark: SUCCESS: ":floppy_disk: [SessionReport] Transcript persisted for call 53e71e23... (17 items)"
:x: FAILURE: ":x: Unable to build session report: Cannot prepare report, no AgentSession was found"
***Why it fails:***
- If agent crashes ***before*** session.start() → No AgentSession exists
- If agent disconnects abruptly → on_session_end may not fire
- If SIP call drops → Session might not end gracefully
### Issue #2: Small Transcript Counts
Looking at successful saves:
:white_check_mark: "Transcript persisted... (2 items)" ← Very short
:white_check_mark: "Transcript persisted... (10 items)" ← Reasonable
:white_check_mark: "Transcript persisted... (17 items)" ← Good
***Many calls have only 2 items***, which suggests:
- Calls are ending very early (probably just greeting)
- Or the session is terminated before full conversation
---
## 🔍 Root Cause Analysis
### Why Transcripts Are Missing:
| Scenario | on_session_end Called? | Transcript Saved? |
|----------|-------------------------|-------------------|
| ***Normal completion*** | ✅ Yes | ✅ Yes (2-17 items) |
| ***Agent crash before session.start()*** | ❌ No | ❌ No - "no AgentSession found" |
| ***Abrupt SIP disconnect*** | ❌ Maybe not | ❌ No |
| ***Agent dispatch fails*** | ❌ No | ❌ No |
| ***Network issue during call*** | ❌ Maybe not | ❌ No |
### The Core Problem:tall-airline-16756
12/23/2025, 7:11 AMtall-airline-16756
12/23/2025, 7:12 AMon_session_end only runs when the agent process survives to a graceful end, so crashes/abrupt disconnects or sessions that never start lead to missing reports.
Recommended fixes (ordered from easiest/fastest to most robust):
1) Persist transcript chunks in real time (best immediate improvement)
• Subscribe to the transcription/conversation events (e.g., user_input_transcribed / conversation_item_added) and immediately append final chunks to your DB or a queue.
• This preserves partial transcripts if the agent dies mid-call.
2) Add a shutdown callback to attempt final save
• Use ctx.add_shutdown_callback() to run a last-attempt save on agent shutdown.
• Note: shutdown callbacks have a limited timeout and won’t run if the process is killed instantly, but they catch many graceful/controlled shutdowns.
3) Start sessions with recording enabled
• Call session.start(..., record=True) so LiveKit stores the session data independently of your agent.
• You can retrieve session reports from LiveKit after the fact even if the agent crashed.
4) Combine with server-side webhooks for room lifecycle
• Configure LiveKit webhooks (e.g., room_finished) to notify your backend. From that webhook you can fetch recordings or trigger a retry of transcript collection independent of the agent process.
5) Add diagnostics to distinguish short transcripts vs early terminations
• Log participant events and agent_state_changed and save an exit reason (crash, SIP drop, dispatched-failed) with each call record to decide whether a short transcript is legitimate or due to an error.
Immediate next step I can help with
• Which of the above do you want to implement first? If you pick one, tell me which language/stack your agent uses (Python or Node) and I’ll give a minimal code snippet and exact event names to wire up. Sources: Agent session | LiveKit docs | Job lifecycle | LiveKit docs | Transcriber | LiveKit docs | Agents (Node.js) | livekit/livekit_composite | DeepWiki | https://blog.livekit.io/streamline-troubleshooting-with-agent-observability/#/portal|aloof-forest-57428
12/23/2025, 7:13 AMtall-airline-16756
12/23/2025, 7:13 AMrecord=True) when you start the session, and stream transcript chunks as they arrive as a backup. That combination is the simplest and most reliable.
Why this is best:
• record=True ensures LiveKit stores the full session independently of your agent process, so you can always retrieve the session report after crashes or abrupt disconnects.
• Real-time chunk persistence (subscribe to transcription / conversation events and save final chunks immediately) guarantees you keep partial transcripts if recording/report retrieval is delayed or the recording missed anything.
Recommended minimal setup
1. Start session with recording:
await session.start(agent=agent, room=ctx.room, record=True)
1. Persist transcript chunks as they arrive:
• Subscribe to user_input_transcribed or conversation_item_added.
• On final segments (is_final), append to DB or enqueue for processing.
Optional reliability additions
• Add ctx.add_shutdown_callback() to attempt a last save on graceful shutdown.
• Configure a LiveKit webhook (e.g., room_finished) to trigger post-call retrieval of the session report if your agent is unavailable.
Which do you want first — a short code snippet for streaming transcripts into your DB, or instructions for fetching the recorded session report after a crash?aloof-forest-57428
12/23/2025, 8:21 AM.on(). Use asyncio.create_task within your synchronous callback instead.
ERRORemi agent worker❌ EMI agent error: Cannot register an async callback with .on(). Use asyncio.create_task within your synchronous callback instead.
{"level": "ERROR", "name": "emi_agent_worker", "message": "❌ EMI agent error: Cannot register an async callback with .on(). Use asyncio.create_task within your synchronous callback instead.", "pid": 31204, "job_id": "AJ_ttDoUpbK8HX8", "room_id": "RM_e8zk2pouyW38", "timestamp": "2025-12-23T081418.621860+00:00"}
Traceback (most recent call last):
File "/Users/aryansharma/Desktop/Voice Ai Agent Indiap2p/livekit-voice-agent/Indiap2pVoiceAiAgents/src/agents/emi_agent_worker.py", line 966, in entrypoint
@session.on("user_input_transcribed")
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Caskroom/miniconda/base/lib/python3.13/site-packages/livekit/rtc/event_emitter.py", line 172, in decorator
self.on(event, callback)
~~~~~~~^^^^^^^^^^^^^^^^^
File "/opt/homebrew/Caskroom/miniconda/base/lib/python3.13/site-packages/livekit/rtc/event_emitter.py", line 161, in on
raise ValueError(
"Cannot register an async callback with .on(). Use asyncio.create_task within your synchronous callback instead."
)
ValueError: Cannot register an async callback with .on(). Use asyncio.create_task within your synchronous callback instead.
WARNINGemi agent worker⚠️ [FINALLY] No transcript to save (empty)
INFOemi agent worker✅ [CLEANUP] No pending writes
WARNINGemi agent worker⚠️ [FINALLY] No transcript to save (empty)
{"level": "WARNING", "name": "emi_agent_worker", "message": "⚠️ [FINALLY] No transcript to save (empty)", "pid": 31204, "job_id": "AJ_ttDoUpbK8HX8", "room_id": "RM_e8zk2pouyW38", "timestamp": "2025-12-23T081418.622920+00:00"}
INFOemi agent worker✅ [CLEANUP] No pending writes
{"level": "INFO", "name": "emi_agent_worker", "message": "✅ [CLEANUP] No pending writes", "pid": 31204, "job_id": "AJ_ttDoUpbK8HX8", "room_id": "RM_e8zk2pouyW38", "timestamp": "2025-12-23T081418.622959+00:00"}
DEBUGpymongo.topology{"message": "Server heartbeat succeeded", "topologyId": {"$oid": "694a4f57df074610f8e96577"}, "driverConnectionId": 1, "serverConnectionId": 1499, "serverHost": "localhost", "serverPort": 27017, "awaited": true, "durationMS": 10074.618582963012, "reply": "{\"isWritablePrimary\": true, \"topologyVersion\": {\"processId\": {\"$oid\": \"69412ba80daa675be1de9642\"}}, \"maxBsonObjectSize\": 16777216, \"maxMessageSizeBytes\": 48000000, \"maxWriteBatchSize\": 100000, \"localTime\": {\"$date\": \"2025-12-23T081425.940Z\"}, \"logicalSessionTimeoutMinutes\": 30, \"connectionId\": 1499, \"maxWireVersion\": 27, \"ok\": 1.0}"}
DEBUGpymongo.topology{"message": "Server heartbeat started", "topologyId": {"$oid": "694a4f57df074610f8e96577"}, "driverConnectionId": 1, "serverConnectionId": 1499, "serverHost": "localhost", "serverPort": 27017, "awaited": true}
DEBUGpymongo.topology{"message": "Server heartbeat succeeded", "topologyId": {"$oid": "694a4f57df074610f8e96577"}, "driverConnectionId": 1, "serverConnectionId": 1499, "serverHost": "localhost", "serverPort": 27017, "awaited": true, "durationMS": 10069.249459018465, "reply": "{\"isWritablePrimary\": true, \"topologyVersion\": {\"processId\": {\"$oid\": \"69412ba80daa675be1de9642\"}}, \"maxBsonObjectSize\": 16777216, \"maxMessageSizeBytes\": 48000000, \"maxWriteBatchSize\": 100000, \"localTime\": {\"$date\": \"2025-12-23T081436.014Z\"}, \"logicalSessionTimeoutMinutes\": 30, \"connectionId\": 1499, \"maxWireVersion\": 27, \"ok\": 1.0}"}
DEBUGpymongo.topology{"message": "Server heartbeat started", "topologyId": {"$oid": "694a4f57df074610f8e96577"}, "driverConnectionId": 1, "serverConnectionId": 1499, "serverHost": "localhost", "serverPort": 27017, "awaited": true}
DEBUGpymongo.topology{"message": "Server heartbeat succeeded", "topologyId": {"$oid": "694a4f57df074610f8e96577"}, "driverConnectionId": 1, "serverConnectionId": 1499, "serverHost": "localhost", "serverPort": 27017, "awaited": true, "durationMS": 10071.388457959983, "reply": "{\"isWritablePrimary\": true, \"topologyVersion\": {\"processId\": {\"$oid\": \"69412ba80daa675be1de9642\"}}, \"maxBsonObjectSize\": 16777216, \"maxMessageSizeBytes\": 48000000, \"maxWriteBatchSize\": 100000, \"localTime\": {\"$date\": \"2025-12-23T081446.089Z\"}, \"logicalSessionTimeoutMinutes\": 30, \"connectionId\": 1499, \"maxWireVersion\": 27, \"ok\": 1.0}"}
DEBUGpymongo.topology{"message": "Server heartbeat started", "topologyId": {"$oid": "694a4f57df074610f8e96577"}, "driverConnectionId": 1, "serverConnectionId": 1499, "serverHost": "localhost", "serverPort": 27017, "awaited": true}
DEBUGpymongo.topology{"message": "Server heartbeat succeeded", "topologyId": {"$oid": "694a4f57df074610f8e96577"}, "driverConnectionId": 1, "serverConnectionId": 1499, "serverHost": "localhost", "serverPort": 27017, "awaited": true, "durationMS": 10042.52662503859, "reply": "{\"isWritablePrimary\": true, \"topologyVersion\": {\"processId\": {\"$oid\": \"69412ba80daa675be1de9642\"}}, \"maxBsonObjectSize\": 16777216, \"maxMessageSizeBytes\": 48000000, \"maxWriteBatchSize\": 100000, \"localTime\": {\"$date\": \"2025-12-23T081456.148Z\"}, \"logicalSessionTimeoutMinutes\": 30, \"connectionId\": 1499, \"maxWireVersion\": 27, \"ok\": 1.0}"}
DEBUGpymongo.topology{"message": "Server heartbeat started", "topologyId": {"$oid": "694a4f57df074610f8e96577"}, "driverConnectionId": 1, "serverConnectionId": 1499, "serverHost": "localhost", "serverPort": 27017, "awaited": true}
DEBUGlivekittungstenite:protocol666tungstenite:protocol - Received close frame: Some(CloseFrame { code: Normal, reason: "" })
DEBUGlivekittungstenite:protocol683tungstenite:protocol - Replying to close with Frame { header: FrameHeader { is_final: true, rsv1: false, rsv2: false, rsv3: fa
INFOlivekitlivekit:room1400livekit:room - disconnected from room with reason: RoomClosed
ERRORemi agent worker❌ Unable to build session report: Cannot prepare report, no AgentSession was found logstall-airline-16756
12/23/2025, 8:22 AM.on() requires a synchronous callback. Your async handlers raised ValueError: Cannot register an async callback with .on().
What to do next (concise, actionable):
1) Wrap async work inside a synchronous handler and spawn a task:
def _sync_user_transcribed(event):
asyncio.create_task(_handle_user_transcribed(event))
async def _handle_user_transcribed(event):
try:
if getattr(event, "is_final", False):
entry = {
"speaker": "user",
"text": getattr(event, "text", str(event)),
"timestamp": datetime.utcnow().isoformat(),
"relative_time": (datetime.utcnow() - agent.call_start_time).total_seconds(),
}
transcript_buffer.append(entry)
emi_data.full_transcript.append(entry)
if len(transcript_buffer) >= 5:
record = CallRecordModel(callId=call_id, fullTranscript=transcript_buffer.copy(), updatedAt=datetime.utcnow())
await CallRepository.queue_call_record(record)
<http://logger.info|logger.info>(f"💾 [REAL-TIME] Saved transcript chunk ({len(transcript_buffer)} entries)")
transcript_buffer.clear()
except Exception as e:
logger.error(f"❌ Error saving user transcript: {e}")
session.on("user_input_transcribed", _sync_user_transcribed)
2) Do the same for agent_speech_committed (sync wrapper -> asyncio.create_task -> async handler).
3) Keep your shutdown callback as-is but ensure it’s async and added via ctx.add_shutdown_callback(save_final_transcript). That will run on graceful shutdown; it’s a good last-attempt saver.
4) Continue using record=True on session.start(...) — this gives you the most reliable fallback (LiveKit-stored session report retrievable after crashes).
Quick checks
• Ensure agent.call_start_time exists before first event.
• Protect shared lists (transcript_buffer, emi_data.full_transcript) if accessed from multiple tasks (use an asyncio.Lock()).
• Log exceptions inside the task so failures don’t silently vanish.
If you want, I can:
• Provide the wrapped handler for agent_speech_committed and a sample save_final_transcript that merges buffered and persisted chunks safely.
• Show how to fetch the recorded session report after a crash. Which would you like next?aloof-forest-57428
12/24/2025, 8:30 AMtall-airline-16756
12/24/2025, 8:30 AMagent_speech_committed and check timestamps vs disconnect.
• Verify event.text exists on the agent events you expect. Log the raw event when you see only user transcripts.
• Check whether the disconnect is abrupt (process killed / network) — shutdown callbacks won’t run if the process is killed immediately.
Concrete fixes (apply in this order)
1) Flush agent speech immediately (don’t wait for buffer size)
• Agent speech may be committed only at the end; when it appears, persist it immediately instead of waiting for 5 items.
Example change inside `_handle_agent_speech`:
# after appending entry
await CallRepository.queue_call_record(CallRecordModel(callId=call_id, fullTranscript=[entry], updatedAt=datetime.utcnow()))
This ensures the agent chunk is persisted even if the process dies right after.
2) Force agent finalization on disconnect events
• Register listeners for participant/room lifecycle events (e.g., participant left, connection state changed, room closed) and trigger an immediate save task.
• Use synchronous wrapper + asyncio.create_task same as you did:
def _sync_participant_left(event):
asyncio.create_task(_handle_disconnect_event(event))
session.on("participant_disconnected", _sync_participant_left)
In _handle_disconnect_event call your final save logic (with lock) to persist any buffered data.
3) Ensure text output for agent speech (so event.text exists)
• If your agent synthesizes speech without generating text, enable the engine/path that produces transcription text or ensure the agent commit step populates text. If agent-side TTS bypasses text, produce the text before committing.
4) Shorten shutdown dependency and wait for pending writes
• You already wait for pending DB writes; make sure db_manager.wait_for_writes(timeout=...) is sufficient and avoid very long blocking during shutdown.
• Add a small synchronous flush on critical events (disconnect/room closed) to reduce reliance on shutdown callbacks.
5) Keep record=True as a final fallback
• Session recording stores the full session independent of your process and is the most reliable recovery if your agent crashes before persisting final agent chunks.
• After a crash, fetch the session report to recover missing agent speech.
Thread-safety and robustness tips
• You have transcript_lock — good. Also protect any in-memory flags like agent.call_start_time.
• When creating tasks, wrap with try/except and log exceptions to avoid silent failures.
• Consider batching writes by time (e.g., every 5–10s) in addition to item count, so low-traffic calls still flush regularly.
If you want, I can:
• Provide the wrapped disconnect handler snippet that triggers an immediate flush (with lock + safe DB write).
• Provide a minimal example to persist every agent chunk immediately while still keeping your 5-item buffer for user chunks. Which would you prefer?