ambitious-beach-62803
02/17/2026, 10:27 AMtall-airline-16756
02/17/2026, 10:27 AMambitious-beach-62803
02/17/2026, 10:31 AMtall-airline-16756
02/17/2026, 10:32 AMmax_tokens so the model does less work to produce the first token.
◦ Ensure the model receives only necessary state; summarize longer history.
1. Streaming & incremental output
◦ Use streaming LLM output and start piping partial tokens into downstream TTS (if supported) to reduce time-to-audible.
◦ Ensure streaming is enabled end-to-end (STT→LLM→TTS).
1. Prewarm and provision capacity
◦ Prewarm any models (and VAD) at process start to avoid cold starts.
◦ If your provider supports reserved/provisioned capacity, use it to reduce queuing and variability.
1. Measure and monitor deeper
◦ Instrument these metrics per request: DNS lookup, TCP/TLS handshake, request send time, provider queue time, model TTFT, first token receive, TTS TTFB.
◦ That will tell if the delay is network, provider queueing, or model computation.
Quick experiments to run now
1. Run the same prompt/model from the same host with curl/SDK to the LLM endpoint and measure single-request latency (isolates network vs provider).
2. Switch to a faster model (e.g., 4.1‑nano) briefly and compare TTFT.
3. Try preemptive_generation=True and measure end‑to‑end again.
4. Reduce the prompt/system content to a minimal test prompt and re-measure.
If you want I can:
• Suggest exact telemetry (span points) to add so you can tell whether the delay is network vs model queue vs compute.
• Provide a short actionable checklist and code snippet for enabling preemptive generation and streaming in your session config.
• Help pick which model to test first based on your quality/latency tradeoff.
Which would you like next: telemetry snippet, preemptive generation code, or model switching test steps?
Sources: How can I reduce latency in voice agents using STT, TTS and LLM? | LiveKit | examples/drive-thru | livekit/livekit_composite | DeepWiki | examples/frontdesk | livekit/livekit_composite | DeepWiki | examples/bank-ivr | livekit/livekit_composite | DeepWiki | Introducing LiveKit Inference: A unified model interface for voice AIambitious-beach-62803
02/17/2026, 10:32 AMtall-airline-16756
02/17/2026, 10:33 AMambitious-beach-62803
02/17/2026, 10:34 AMtall-airline-16756
02/17/2026, 10:35 AMimport asyncio
import aiohttp
import time
import uuid
import statistics
from aiohttp import web
# CONFIG
COLLECTOR_PORT = 9001
AGENT_CALLBACK_URL = f"<http://localhost:{COLLECTOR_PORT}/event>" # agent posts events here
TEST_RUNS = 20
ROOM_NAME = "test_room_123"
TEST_AUDIO_PATH = "test_utterance.wav" # short WAV file to publish as user speech
PUBLISHER_DELAY_BEFORE_START = 0.5 # seconds before starting playback to let session settle
# In-memory store for events per test_id
EVENTS = {} # test_id -> { event_name: timestamp_seconds, ... }
async def handle_event(request):
"""
Agent posts JSON: { "test_id": "<uuid>", "event": "stt_final" , "ts": 167... }
If ts not provided, collector will use now().
"""
data = await request.json()
test_id = data.get("test_id")
event = data.get("event")
ts = data.get("ts") or time.time()
if not test_id or not event:
return web.json_response({"error": "missing test_id or event"}, status=400)
EVENTS.setdefault(test_id, {})[event] = ts
return web.json_response({"status": "ok"})
async def start_collector():
app = web.Application()
app.router.add_post("/event", handle_event)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", COLLECTOR_PORT)
await site.start()
print(f"Collector listening on port {COLLECTOR_PORT}")
# Helper to publish test audio into the LiveKit room
# You must implement this for your environment. This stub should:
# - connect a publisher client to the LiveKit room
# - publish the WAV audio, and return (test_id, playback_start_ts, playback_end_ts)
async def publish_test_audio(test_id):
# Example pseudo-implementation:
# connect = await connect_to_livekit(room=ROOM_NAME)
# await sleep(PUBLISHER_DELAY_BEFORE_START)
# playback_start = time.time()
# await stream_wav_to_room(TEST_AUDIO_PATH)
# playback_end = time.time()
# disconnect publisher
# return test_id, playback_start, playback_end
await asyncio.sleep(0.1)
now = time.time()
# Simulate 800ms utterance
playback_start = now
playback_end = now + 0.8
# Simulate agent receiving events by letting agent post real events; if not instrumented, you can simulate
return test_id, playback_start, playback_end
def compute_metrics_for_run(events, playback_end_ts):
# events: dict of event_name -> ts
# required events: stt_final, llm_request, llm_first_token, tts_first_byte, playout_start (optional)
out = {}
out["playback_end_ts"] = playback_end_ts
if "stt_final" in events:
out["eou_to_stt_final_ms"] = (events["stt_final"] - playback_end_ts) * 1000
if "llm_request" in events and "llm_first_token" in events:
out["llm_ttft_ms"] = (events["llm_first_token"] - events["llm_request"]) * 1000
if "llm_first_token" in events and "tts_first_byte" in events:
out["tts_ttfb_ms"] = (events["tts_first_byte"] - events["llm_first_token"]) * 1000
if "playout_start" in events:
out["total_ms"] = (events["playout_start"] - playback_end_ts) * 1000
elif "tts_first_byte" in events:
out["total_ms"] = (events["tts_first_byte"] - playback_end_ts) * 1000
return out
def summarize(results, key):
values = [r[key] for r in results if key in r]
if not values:
return None
return {
"count": len(values),
"mean": statistics.mean(values),
"p50": statistics.median(values),
"p90": percentile(values, 90),
"p99": percentile(values, 99),
"min": min(values),
"max": max(values),
}
def percentile(data, p):
if not data:
return None
data = sorted(data)
k = (len(data)-1) * (p/100.0)
f = int(k)
c = min(f+1, len(data)-1)
if f == c:
return data[int(k)]
d0 = data[f] * (c - k)
d1 = data[c] * (k - f)
return d0 + d1
async def run_tests():
results = []
for i in range(TEST_RUNS):
test_id = str(uuid.uuid4())
# clear any prior events
EVENTS.pop(test_id, None)
print(f"Starting test {i+1}/{TEST_RUNS} id={test_id}")
# start publisher shortly after giving agent time to connect
await asyncio.sleep(0.2)
test_id, playback_start, playback_end = await publish_test_audio(test_id)
# Wait for agent events to be posted (polling with timeout)
timeout = 10.0
poll_interval = 0.05
waited = 0.0
while waited < timeout:
ev = EVENTS.get(test_id)
if ev and ("llm_first_token" in ev or "tts_first_byte" in ev or "playout_start" in ev):
# got enough to compute
metrics = compute_metrics_for_run(ev, playback_end)
metrics["test_id"] = test_id
results.append(metrics)
import asyncio
import aiohttp
import time
import uuid
import statistics
from aiohttp import web
# CONFIG
COLLECTOR_PORT = 9001
AGENT_CALLBACK_URL = f"http//localhost{COLLECTOR_PORT}/event" # agent posts events here
TEST_RUNS = 20
ROOM_NAME = "test_room_123"
TEST_AUDIO_PATH = "test_utterance.wav" # short WAV file to publish as user speech
PUBLISHER_DELAY_BEFORE_START = 0.5 # seconds before starting playback to let session settle
# In-memory store for events per test_id
EVENTS = {} # test_id -> { event_name: timestamp_seconds, ... }
async def handle_event(request):
"""
Agent posts JSON: { "test_id": "<uuid>", "event": "stt_final" , "ts": 167... }
If ts not provided, collector will use now().
"""
data = await request.json()
test_id = data.get("test_id")
event = data.get("event")
ts = data.get("ts") or time.time()
if not test_id or not event:
return web.json_response({"error": "missing test_id or event"}, status=400)
EVENTS.setdefault(test_id, {})[event] = ts
return web.json_response({"status": "ok"})
async def start_collector():
app = web.Application()
app.router.add_post("/event", handle_event)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", COLLECTOR_PORT)
await site.start()
print(f"Collector listening on port {COLLECTOR_PORT}")
# Helper to publish test audio into the LiveKit room
# You must implement this for your environment. This stub should:
# - connect a publisher client to the LiveKit room
# - publish the WAV audio, and return (test_id, playback_start_ts, playback_end_ts)
async def publish_test_audio(test_id):
# Example pseudo-implementation:
# connect = await connect_to_livekit(room=ROOM_NAME)
# await sleep(PUBLISHER_DELAY_BEFORE_START)
# playback_start = time.time()
# await stream_wav_to_room(TEST_AUDIO_PATH)
# playback_end = time.time()
# disconnect publisher
# return test_id, playback_start, playback_end
await asyncio.sleep(0.1)
now = time.time()
# Simulate 800ms utterance
playback_start = now
playback_end = now + 0.8
# Simulate agent receiving events by letting agent post real events; if not instrumented, you can simulate
return test_id, playback_start, playback_end
def compute_metrics_for_run(events, playback_end_ts):
# events: dict of event_name -> ts
# required events: stt_final, llm_request, llm_first_token, tts_first_byte, playout_start (optional)
out = {}
out["playback_end_ts"] = playback_end_ts
if "stt_final" in events:
out["eou_to_stt_final_ms"] = (events["stt_final"] - playback_end_ts) * 1000
if "llm_request" in events and "llm_first_token" in events:
out["llm_ttft_ms"] = (events["llm_first_token"] - events["llm_request"]) * 1000
if "llm_first_token" in events and "tts_first_byte" in events:
out["tts_ttfb_ms"] = (events["tts_first_byte"] - events["llm_first_token"]) * 1000
if "playout_start" in events:
out["total_ms"] = (events["playout_start"] - playback_end_ts) * 1000
elif "tts_first_byte" in events:
out["total_ms"] = (events["tts_first_byte"] - playback_end_ts) * 1000
return out
def summarize(results, key):
values = [r[key] for r in results if key in r]
if not values:
return None
return {
"count": len(values),
"mean": statistics.mean(values),
"p50": statistics.median(values),
"p90": percentile(values, 90),
"p99": percentile(values, 99),
"min": min(values),
"max": max(values),
}
def percentile(data, p):
if not data:
return None
data = sorted(data)
k = (len(data)-1) * (p/100.0)
f = int(k)
c = min(f+1, len(data)-1)
if f == c:
return data[int(k)]
d0 = data[f] * (c - k)
d1 = data[c] * (k - f)
return d0 + d1
async def run_tests():
results = []
for i in range(TEST_RUNS):
test_id = str(uuid.uuid4())
# clear any prior events
EVENTS.pop(test_id, None)
print(f"Starting test {i+1}/{TEST_RUNS} id={test_id}")
# start publisher shortly after giving agent time to connect
await asyncio.sleep(0.2)
test_id, playback_start, playback_end = await publish_test_audio(test_id)
# Wait for agent events to be posted (polling with timeout)
timeout = 10.0
poll_interval = 0.05
waited = 0.0
while waited < timeout:
ev = EVENTS.get(test_id)
if ev and ("llm_first_token" in ev or "tts_first_byte" in ev or "playout_start" in ev):
# got enough to compute
metrics = compute_metrics_for_run(ev, playback_end)
metrics["test_id"] = test_id
results.append(metrics)ambitious-beach-62803
02/17/2026, 10:37 AMtall-airline-16756
02/17/2026, 10:38 AMambitious-beach-62803
02/17/2026, 10:50 AMtall-airline-16756
02/17/2026, 10:50 AMambitious-beach-62803
02/17/2026, 10:51 AMtall-airline-16756
02/17/2026, 10:51 AMrecord=False on your AgentSession, that will disable observability for that session even if the project setting is enabled.
Action checklist to make sure you see data in Agent Insights
1. Dashboard: enable recording
◦ Go to your LiveKit Cloud project → Settings → Data & privacy (or Agent Observability) → enable “Record agent sessions” (or similar toggle).
1. Agent code: do not opt out
◦ Ensure you are not creating sessions with record=False. Either omit record or set record=True if you want to be explicit.
◦ Example (Python):
session = AgentSession(
llm=..., stt=..., tts=...,
record=True # ensure not disabled
)
1. SDK version
◦ Make sure your Agents SDK version supports observability (Agent Observability added in v1.3+). If you are running an older SDK, upgrade.
1. Run a complete session
◦ Insights often appear after a completed session (calls that include STT → LLM → TTS). Run a test call and wait for it to finish.
1. Verify ingestion / network
◦ If dashboard is enabled and code is correct but you still see no data:
▪︎ Check agent logs for telemetry emission lines (search for events like MetricsCollected, LLMMetrics, STTMetrics, or INSIGHTS).
▪︎ Confirm the agent host can reach LiveKit telemetry endpoints (no blocked egress, proxies, or auth errors).
1. Optional: add minimal logging if you use custom providers
◦ If you use custom LLM/STT/TTS plugins that don’t auto-emit LiveKit metrics, consider adding small instrumentation to emit the key metrics (TTFT, TTFB, STT latency) so Insights can show useful numbers.
Diagnostics to run now (fast)
• In the agent logs for a test call, grep for:
◦ "MetricsCollected" or "LLMMetrics"
◦ job/session start and stop messages
• If you find no metrics logs:
◦ Confirm record is not set to False
◦ Confirm project-level recording is enabled in the dashboard
◦ Confirm SDK version >= 1.3
If you want, I can:
• Provide the exact log strings to search for in your agent version.
• Give a small snippet to explicitly emit a minimal metrics event if you’re using custom plugins.
• Walk through verifying the dashboard setting step-by-step if you tell me where you look in the UI. Sources: Streamline troubleshooting with Agent Observabilityambitious-beach-62803
02/17/2026, 10:52 AMrecord=True
so in this livekit egress is used will this cost us?tall-airline-16756
02/17/2026, 10:52 AMrecord=True causes session data to be recorded, and that can incur storage and egress costs depending on your LiveKit plan and how you use the recordings.
What enabling record=True does
• It instructs the agent/session to allow observability and recording of session artifacts: transcripts, audio segments, LLM traces/metrics, and other telemetry captured by Agent Observability.
• Those recorded artifacts are stored and made available in your LiveKit project’s insights/recording backend.
Cost implications to consider
• Storage: recorded audio, transcripts, and logs are persisted and consume storage. Longer calls and more frequent recordings increase storage usage.
• Egress/Bandwidth: if you download or stream recordings out of LiveKit (for analysis, backup, or copy to other services), that may count as egress bandwidth billed by your provider.
• Processing: features that post-process recordings (transcoding, analytics, exporting to another service) can generate additional compute or transfer costs depending on your configuration.
• Retention: longer retention periods mean more storage costs over time.
How to control and minimize costs
• Enable recording only when needed:
◦ Use record=True for environments where you need insights (staging/production debugging) and record=False for short tests or dev runs.
• Use selective recording:
◦ If you can, record only certain sessions (e.g., when debug flag set or on-demand) instead of every session.
• Shorten retention:
◦ If dashboard/LiveKit settings let you configure retention, reduce retention time for non-critical sessions.
• Trim what's recorded:
◦ Avoid recording full raw audio if you only need transcripts or metrics; choose the smallest set of artifacts required.
• Batch/aggregate exports:
◦ Instead of continuously streaming recordings to another service, batch exports at intervals to reduce repeated egress.
• Monitor usage:
◦ Track storage and egress consumption so you can detect spikes and tune recording policy.
Practical steps you can take now
1. Check your LiveKit project billing/plan docs or console to see:
◦ Which items are billed (storage, egress, processing).
◦ Current usage and retention settings.
1. If cost is a concern, set record=True only for specific sessions and keep record=False for routine/dev sessions:
session = AgentSession(..., record=False) # dev/test
# or
session = AgentSession(..., record=True) # when you want insights/recordings
1. If you need consistent observability in production but want to cap cost:
◦ Record only a sample of calls (e.g., 5–10%).
◦ Or record metadata + transcripts but avoid storing raw audio long-term.
1. Ask your LiveKit account rep or consult the LiveKit Cloud billing docs for exact pricing and which operations count as billable egress/storage in your plan.
If you want, I can:
• Help you design a recording policy (when to enable recording, sampling strategy, retention) tailored to your usage pattern.
• Draft code examples to toggle record based on environment, a debug flag, or a per-call sampling rate.
• Point you to the exact billing docs or the console path to view current storage/egress usage (if you tell me which LiveKit plan you are on).ambitious-beach-62803
02/17/2026, 11:22 AMtall-airline-16756
02/17/2026, 11:22 AMrecord=False (or omit record=True).
Example:
session = AgentSession(..., record=False)
• In the LiveKit Cloud dashboard: make sure project‑level recording/observability is off (or keep it on but opt out per session with record=False).
What you lose when you disable recording
• Agent Insights: dashboard metrics, per‑session traces, transcripts, and stored audio will not be available for that session.
• Historical debugging artifacts: you won’t have stored transcripts/audio to review later.
• Automatic analytics that rely on stored data (call summaries, session replay).
What you keep / can still do
• Live, real‑time behavior still works: STT, LLM, and TTS operate normally and your agent can serve users in real time.
• Runtime state management (e.g., session.userdata) still functions for the duration of the session.
• You can still emit custom telemetry (metrics/logs) to your own monitoring stack without enabling recording.
Cost‑friendly alternatives to full recording
• Selective recording: only set record=True for a sample of sessions (e.g., 1–5%) or for sessions flagged for review.
• Transcripts only: if available in your plan, record/store transcripts and metrics instead of raw audio (lower storage).
• Short retention: enable recording but set short retention (if dashboard supports it) so artifacts are deleted quickly.
• Emit lightweight telemetry: instrument and send essential metrics (LLM TTFT, STT latency, total latency, errors) to Prometheus/Datadog—this captures performance without storing audio.
• On‑demand recording: start the session with record=False, then flip to record=True programmatically only when you need to capture a specific interaction (if your workflow supports it).
If you need observability but want to limit cost, recommended approach
1. Keep record=False for most calls.
2. Implement a sampling policy that sets record=True for a small percentage or for calls meeting criteria (errors, escalations, high value).
3. Add lightweight metrics emission for every call (TTFT, TTFB, EOU delay, errors) so you have continuous monitoring.
4. For debugging, reproduce with record=True or use the sampled recordings.
If you’d like, I can:
• Provide a small snippet to implement sampled recording (e.g., 5% of calls set record=True).
• Provide example telemetry (metric names and where to emit) so you get observability without recordings.
Which would help you next — sample recording code or a minimal telemetry plan?ambitious-beach-62803
02/17/2026, 11:24 AMtall-airline-16756
02/17/2026, 11:24 AMambitious-beach-62803
02/25/2026, 10:22 AMtall-airline-16756
02/25/2026, 10:22 AMsystem message or structured JSON block.
• Pass JSON to a tool (function tool / RawFunctionTool): I can show how to build a JSON schema for a dynamic tool and how the LLM should call it.
• Send JSON to an external API (webhook/connector): I can show how to form the request body and include call/session metadata.
• Store JSON in session state (ctx.proc.userdata): I can show safe validation and persistence patterns.
Tell me: 1) Which of the above (or another) is your goal? 2) If it’s for the LLM or tool, do you want the model to read the JSON, or to call a tool with JSON parameters? 3) If you have a sample JSON payload you want to pass, paste it (redact secrets) and I’ll give the exact code snippet for Python (or Node) using LiveKit agent patterns.
Once you confirm, I’ll provide a compact, runnable example tailored to your case.ambitious-beach-62803
02/25/2026, 10:22 AMtall-airline-16756
02/25/2026, 10:22 AMjson.loads in Python).
• If the tool expects typed params (e.g., booleans/strings), make sure the JSON fields match types exactly.
• If you built a dynamic tool schema, confirm the parameter names in the schema match the keys in your JSON.
• Check for missing required params — tools often error when required fields are absent.
If you want, paste the error + the small code snippet now and I’ll point out the exact fix (schema mismatch, type coercion, missing registration, or JSON parsing issue).ambitious-beach-62803
02/25/2026, 10:23 AMtall-airline-16756
02/25/2026, 10:23 AMjson.loads(your_payload) (Python) or an online linter to confirm it’s valid JSON.
• Schema mismatch: if the tool exposes typed parameters (boolean/string/etc.), ensure JSON keys exactly match parameter names and types. Tools often reject unknown keys or wrong types.
• Required fields: confirm you’re providing required parameters; if your schema marks fields required, missing ones will cause errors.
• Dynamic tool schema: if you build the tool schema at runtime, verify schema generation code matches the current connection config and that the tool registration happens before the LLM can call it.
• Tool registration: ensure the tool is actually registered with the Assistant/plugin set used by the running agent session (check startup logs where tools are listed).
• String vs JSON object: if the tool expects structured params but you pass a JSON string, parse it into an object first (or have the tool parse it). Conversely, if the tool expects a string, passing an object may fail.
If you want a quick example of a robust handoff tool that accepts arbitrary JSON safely, here’s a pattern to consider (adapt to your framework):
• Accept a single optional string param payload_json and parse inside the tool:
def handoff_tool(payload_json: Optional[str] = None):
if payload_json:
try:
data = json.loads(payload_json)
except Exception as e:
raise ValueError(f"Invalid JSON: {e}")
else:
data = {}
# validate and map data to expected fields, then perform handoff
...
This avoids schema mismatches when the LLM produces varying shapes.
Send me the error + code + sample JSON and I’ll point out the exact fix (schema change, parsing code, or registration timing).ambitious-beach-62803
03/02/2026, 10:36 AMtall-airline-16756
03/02/2026, 10:37 AMambitious-beach-62803
03/13/2026, 9:20 AM{
"status": "continue",
"message": "Farewell detection disabled"
}# === 3. Normal logic only runs if mode is set ===
call_ending_keywords = getattr(config, "call_ending_keywords", [])
# Resolve current language for farewell message selection
# Priority: detected user language > STT config language > default 'ar'
current_language = None
_try_:
current_language = get_current_user_language()
except Exception as e:
logger.debug(f"Could not get detected language: {e}")
# Fallback to STT config language if detected language not available
if not current_language and config.stt and "options" in config.stt:
stt_lang = config.stt["options"].get("language")
if stt_lang:
if isinstance(stt_lang, (list, tuple)):
current_language = str(stt_lang[0]).lower().split("-")[0].split("_")[0] if stt_lang else "ar"
_else_:
current_language = str(stt_lang).lower().split("-")[0].split("_")[0]
# Final fallback
if not current_language:
current_language = "en"
# Get farewell message based on language
agent_farewell_message_dict = getattr(config, "agent_farewell_message", None)
agent_farewell_message = get_farewell_message(agent_farewell_message_dict, current_language)
farewell_phrases = {
"goodbye", "bye", "see you", "take care", "farewell", "later", "catch you later","wil call you later",
"مع السلامة", "وداعا", "إلى اللقاء", "باي",
"अलविदा", "नमस्ते", "फिर मिलेंगे", "बाय",
"خدا حافظ", "اللہ حافظ", "پھر ملتے ہیں", "بائی",
"adiós", "hasta luego", "nos vemos", "cuídate", "hasta pronto",
"au revoir", "salut", "à bientôt", "prends soin de toi", "adieu",
"വിട","ബൈ ബൈ","ഗുഡ് ബൈ","ഗുഡ് ബൈ ബൈ",
}
phrases_to_check: set[str] = set()
if call_ending_keyword_mode == "default_backend":
phrases_to_check = farewell_phrases
elif call_ending_keyword_mode == "user_configured":
phrases_to_check = set(call_ending_keywords)
elif call_ending_keyword_mode == "default_and_user":
phrases_to_check = farewell_phrases.union(set(call_ending_keywords))
_else_:
logger.warning(f"Invalid mode: {call_ending_keyword_mode}, using default")
phrases_to_check = farewell_phrases
user_input_lower = _user_input_.lower()
farewell_detected = False
# === NEW: STRICT KEYWORD MATCHING WITH WORD BOUNDARIES ===
for phrase in phrases_to_check:
# re.escape handles special characters in the phrase.
# \b ensures that the phrase is matched as a whole word/phrase (word boundary).
pattern = r"\b" + re.escape(phrase.lower()) + r"\b"
if re.search(pattern, user_input_lower):
farewell_detected = True
logger.info(f"Detected strict farewell: '{phrase}' in '{_user_input_}'")
break
if farewell_detected:
session = context.session
logger.info(f"Detected farewell: '{_user_input_}'")
goodbye_audio = job_ctx.proc.userdata.get("goodbye_audio")
_try_:
await session.say(goodbye_audio or agent_farewell_message, _allow_interruptions_=True)
logger.info("Farewell message sent")
except Exception as e:
logger.error(f"Failed to say goodbye: {e}")
await asyncio.sleep(1.0)
logger.info("Ending call...")
# === EARLY VALIDATION ===
if not job_ctx.room:
logger.error("job_ctx.room is None — cannot end call")
status_manager = job_ctx.proc.userdata.get("status_manager")
if status_manager:
_try_:
await status_manager.send_status("unknown", CallStatus.REJECTED, "Room context missing")
_except_:
pass
return {"status": "error", "message": "Room context missing"}
room_id = job_ctx.room.name
status_manager = job_ctx.proc.userdata.get("status_manager")
lkapi = LiveKitAPI()
_try_:
_# === Notify WhatsApp end-call webhook (for WA_CALL_ rooms) ===_
await notify_whatsapp_end_call(room_id)
# === Remove participants ===
_try_:
participants = await lkapi.room.list_participants(ListParticipantsRequest(room=room_id))
if participants and participants.participants:
for p in participants.participants:
_try_:
await lkapi.room.remove_participant(
RoomParticipantIdentity(room=room_id, identity=p.identity)
)
logger.info(f"Removed participant: {p.identity}")
except Exception as e:
logger.warning(f"Failed to remove {p.identity}: {e}")
_else_:
logger.info("No participants to remove")
except Exception as e:
logger.warning(f"Failed to list/remove participants: {e}")
# === Send COMPLETED status ===
if status_manager:
_# Check if we already sent TRANSFERRED or TRANSFER_FAILED_
if job_ctx.proc.userdata.get("final_status_sent", False):
logger.info("end_call_tool: Skipping COMPLETED — final status already sent (TRANSFERRED or TRANSFER_FAILED)")
_else_:
await status_manager.send_status(
room_id,
CallStatus.COMPLETED,
"User said goodbye to call ended"
)
job_ctx.proc.userdata["final_status_sent"] = True
logger.info("end_call_tool: Sent COMPLETED and marked final_status_sent=True")
# === SAFE ROOM DISCONNECT (IDEMPOTENT) ===
disconnect_success = False
if hasattr(job_ctx.room, "disconnect") and callable(job_ctx.room.disconnect):
_try_:
await job_ctx.room.disconnect()
logger.info("Room disconnected successfully")
disconnect_success = True
except Exception as e:
logger.warning(f"Room disconnect failed (possibly already disconnected): {e}")
_else_:
logger.info("job_ctx.room.disconnect not available — skipping")
# === Shutdown job (if available) ===
# NOTE: Removed shutdown call - it was causing "NoneType can't be used in 'await' expression" errors
# The agent will shutdown naturally when the session ends
_# if hasattr(job_ctx, "shutdown") and callable(job_ctx.shutdown):_
# try:
_# shutdown_result = job_ctx.shutdown()_
_# if shutdown_result is not None:_
_# await shutdown_result_
# logger.info("Job shutdown successfully")
# except Exception as e:
# logger.warning(f"Job shutdown failed: {e}")
# === SUCCESS: Call ended cleanly ===
return{
"status": "call_ended",
"message": "Call ended"
}except Exception as e:
error_msg = f"End call error: {e}"
logger.error(error_msg)
# === ONLY send REJECTED on REAL failures ===
if "NoneType" in str(e) or "already disconnected" in str(e).lower():
logger.info("Call already terminated elsewhere — treating as success")
return{
"status": "call_ended",
"message": "Call ended (idempotent)"
}# Otherwise, real failure
if status_manager:
_try_:
await status_manager.send_status(room_id, CallStatus.REJECTED, f"End failed: {e}")
except Exception as send_err:
logger.error(f"Failed to send REJECTED status: {send_err}")
return {"status": "error", "message": str(e)}
_finally_:
# === Always close LiveKit API ===
_try_:
await lkapi.aclose()
_except_:
pass
logger.info(f"No farewell in: '{_user_input_}'")
return {"status": "continue", "message": "No goodbye"}
ahould i use your endcalll tool which is built in or should i use mine
FYI
mine is working perfectly fine is switching worth it ?