Feb 17 09:21:54 ip-172-31-23-35.ec2.internal pytho...
# ask-ai
w
Feb 17 092154 ip-172-31-23-35.ec2.internal python3[1641054]: {"message": "exception occurred while executing tool\nTraceback (most recent call last):\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/agents/voice/generation.py\", line 597, in _traceable_fnc_tool\n val = await function_callable()\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/agent.py\", line 990, in end_call\n logger.info(f\"Ending the call for {self.participant.identity}\")\n ^^^^^^^^^^^^^^^^^^^^^^^^^\nAttributeError: 'NoneType' object has no attribute 'identity'", "level": "ERROR", "name": "livekit.agents", "exc_info": "Traceback (most recent call last):\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/agents/voice/generation.py\", line 597, in _traceable_fnc_tool\n val = await function_callable()\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/agent.py\", line 990, in end_call\n logger.info(f\"Ending the call for {self.participant.identity}\")\n ^^^^^^^^^^^^^^^^^^^^^^^^^\nAttributeError: 'NoneType' object has no attribute 'identity'", "function": "end_call", "speech_id": "speech_29551ef87ef6", "pid": 1641067, "job_id": "AJ_EjbDy5B8ryXq", "room_id": "RM_uoenXfQbowKh", "timestamp": "2026-02-17T092154.274698+00:00"}
t
Thanks for reaching out, Sujata. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I see the error you pasted: the
end_call
method is trying to log
self.participant.identity
, but
self.participant
is None, which triggers the AttributeError. I’ll help you fix it. Summary of the problem • The crash happens because
self.participant
was never set (or was cleared) before
end_call
uses
self.participant.identity
. • Any code path that assumes
self.participant
exists will raise this error when it is None. What you can do (recommended fixes) 1. Ensure the participant is assigned during initialization ◦ When you construct the agent, store the participant (or a safe reference) on the instance so tool methods can use it:
Copy code
class MyAgent(Agent):
       def __init__(self, job_context: JobContext):
           super().__init__(instructions="...")
           self.job_context = job_context
           self.participant = job_context.room.local_participant
• Make sure the place that creates the agent passes the JobContext/room so
local_participant
is available. 1. Add a defensive null-check in
end_call
◦ Always verify the participant exists before accessing `identity`:
Copy code
@function_tool
   async def end_call(self, context: RunContext):
       if self.participant is None:
           logger.warning("No participant assigned to agent; skipping end_call")
           return "Cannot end call: no participant"
       <http://logger.info|logger.info>(f"Ending the call for {self.participant.identity}")
       # continue with normal shutdown logic
• This prevents the exception and makes failures explicit in logs. 1. If the participant may be set later (dynamic assignment) ◦ Either delay calling
end_call
until after assignment, or have
end_call
accept the participant as a parameter rather than relying on an instance attribute:
Copy code
async def end_call(self, participant):
       if participant is None:
           logger.warning("end_call called without participant")
           return
       <http://logger.info|logger.info>(f"Ending the call for {participant.identity}")
1. Verify initialization path in your entrypoint ◦ Confirm your entrypoint or session startup actually sets up the agent with the job context / room:
Copy code
@server.rtc_session()
   async def entrypoint(ctx: JobContext):
       agent = MyAgent(job_context=ctx)
       session = AgentSession(...)
       await session.start(agent=agent, room=ctx.room)
Quick checklist to debug now • Where is the agent instantiated? Confirm that code receives and stores
job_context.room.local_participant
. • Search for any code that might clear or overwrite
self.participant
later. • Add logging at agent init and just before
end_call
to print whether
self.participant
is None and, if set, its identity. If you’d like, paste the agent’s init and the code that calls/starts the agent (entrypoint/session.start) and I’ll point out exactly where to set the participant or where to add the null-check. Sources: How to set a fixed participant for agent audio processing | Company directory phone assistant | LiveKit Documentation | SIP Lifecycle Management Agent | LiveKit Documentation
w
try: metadata = json.loads(ctx.job.metadata) useCase = metadata.get("useCase") chatvoice = metadata.get("voice") language = metadata.get("language") except Exception as e: logging.warning(f"Metadata parsing failed: {e}") voice = "Aoede" useCase = "Real_Estate_Property_Dealing" # === Load Workflow Dynamically === # try: # workflow_data = load_workflow(useCase) # except Exception as e: # logging.error(f"Failed to load workflow for use case '{useCase}': {e}") # return # logging.info(f"Workflow loaded: {useCase}") # # Initialize FlowEngine (expects nodes + edges) # flow_engine = FlowEngine( # workflow_data.get("nodes", []), # workflow_data.get("edges", []) # ) # # Get initial step # step_result = flow_engine.step() # initial_prompt = step_result.get("text", "") # instructions = ( f"{initial_prompt}\n\n" "You are the voice-based AI assistant of CallerBot.io. Your task is to guide the user through a predefined conversation flow in a polite, professional, and controlled manner." "You MUST speak only in english throughout this conversation if user want change language then talk in users preffered language. You must only ask the questions and follow the instructions provided in this flow. " "Do NOT answer any user questions or go off-script, even if asked repeatedly. If the user asks something unrelated, politely redirect them back to the current question or step." ) agent = OutboundCaller( name="Dear", appointment_time="3pm", dial_info=metadata, ) session = AgentSession( llm=google.beta.realtime.RealtimeModel(model="gemini-2.5-flash-native-audio-preview-09-2025",voice=chatvoice,language=language), # vad=silero.VAD.load(), # stt=LocalWhisperSTT(model_size="tiny"), # stt=deepgram.STT(), # tts=LocalKokoroTTS(voice="af_heart", lang_code="a"), # tts=openai.TTS( # model="gpt-4o-mini-tts", # voice="ash", # ), # llm=google.LLM(), ) # Start Agent with your workflow instructions # agent = Agent(instructions=instructions) await session.start(room=ctx.room, agent=agent, room_options=room_io.RoomOptions( audio_input=room_io.AudioInputOptions( noise_cancellation=noise_cancellation.BVC(), ), ),) @session.on("conversation_item_added") def on_conversation_item_added(event: ConversationItemAddedEvent): if not event.item.text_content: return entry = { "role": event.item.role, "text": event.item.text_content, "timestamp": datetime.now().isoformat(), } # # Add sentiment only for user messages # if event.item.role == "user": # raw_sentiment = analyze_sentiment(event.item.text_content) # entry["sentiment"] = format_sentiment(raw_sentiment) # # Attach emotion if available # if entry["role"] == "user" and entry["role"] in last_user_emotion: # raw_emotion, raw_score = last_user_emotion[entry["role"]] # entry["emotion"] = map_emotion_label(raw_emotion) # entry["emotion_score"] = round(raw_score * 100, 2) # 3️⃣ Store in agent context for FlowEngine # agent.context["last_user_emotion"] = emotion conversation_log.append(entry) # ✅ Reuse save_transcript from outbound (slight differences for inbound) async def save_transcript(): try: if pending_emotion_tasks: logger.info("⏳ Waiting for pending emotion analyses...") await asyncio.gather(*pending_emotion_tasks, return_exceptions=True) room_id = ctx.room.name timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") file_name = f"demo_{room_id}_{timestamp}.json" os.makedirs("transcripts", exist_ok=True) # Add sentiment analysis to transcript if available if hasattr(agent, 'sentiment_history') and agent.sentiment_history: conversation_log.append({ "role": "system", "text": "[User Sentiment History]", "timestamp": datetime.now().isoformat(), "sentiment_data": agent.sentiment_history }) # Send transcript to API server (only filename and conversation_log) api_url = f"{API_BASE_URL}/save-transcript" logger.info(f"📤 Sending transcript to API: {api_url}") logger.info(f"📤 language: {language}") # Run synchronous requests in a separate thread def send_api_request(): # 1️⃣ Send to your main API (unchanged) main_response = requests.post( api_url, json={ "filename": file_name, "conversation_log": conversation_log }, timeout=10, verify=False ) # 2️⃣ Send to Laravel (added) try: laravel_url = "https://callerbot.io/api/incoming/calldata" laravel_response = requests.get( laravel_url, json={ "filename": file_name, "conversation_log": conversation_log }, timeout=10, verify=False ) if laravel_response.status_code == 200: logger.info("📩 Laravel received inbound call data") workflow_id = metadata.get("workflowId") if workflow_id: logger.info(f"workflow id sent to API: {workflow_id}") try: response = requests.post( "https://callerbot.io/api/process-chat-webhook", json={ "workflow_id": workflow_id, "transcript": conversation_log }, timeout=10, verify=False ) response.raise_for_status() print(f" Laravel callback response: {response.json()}") except Exception as e: print(f" Error calling Laravel API: {e}") else: logger.warning(f"⚠️ Laravel responded with {laravel_response.status_code}") except Exception as e: logger.error(f"❌ Error sending inbound transcript to Laravel: {e}") # IMPORTANT: Keep returning your main API response return main_response response = await asyncio.to_thread(send_api_request) if response.status_code == 200: logger.info(f"✅ Transcript successfully sent to API: {file_name}") except Exception as e: logger.error(f"Failed to save inbound transcript: {e}") ctx.add_shutdown_callback(save_transcript) this is my code
t
Thanks — this is helpful. I reviewed your code and the earlier traceback (AttributeError: 'NoneType' object has no attribute 'identity'). That error happens when
end_call
tries to reference
self.participant.identity
but
self.participant
is None. What to check and how to fix it • Ensure the agent stores the participant when the session starts ◦ Where you call
await session.start(room=ctx.room, agent=agent, ...)
the session has access to
ctx.room
. Your agent must save that participant reference (for example,
room.local_participant
) into
self.participant
during initialization or soon after session start. ◦ If your agent class is
OutboundCaller
(the code you pass to
session.start
), add something like this in its initializer or in a session-start handler:
Copy code
class OutboundCaller(Agent):
        def __init__(self, name, appointment_time, dial_info, job_context=None):
            super().__init__(instructions="...")
            self.name = name
            self.appointment_time = appointment_time
            self.dial_info = dial_info
            self.job_context = job_context
            self.participant = None  # default

        async def on_session_start(self, room):
            # Called when session starts; make sure you set participant here
            self.participant = getattr(room, "local_participant", None)
• If your framework doesn't call
on_session_start
, set
agent.participant = ctx.room.local_participant
immediately before/after calling
session.start
. • Add a defensive check in
end_call
so the method doesn't crash if the participant isn't set:
Copy code
@function_tool
  async def end_call(self, context: RunContext):
      if self.participant is None:
          logger.warning("end_call invoked but no participant is set")
          return "Cannot end call: no participant"
      <http://logger.info|logger.info>(f"Ending the call for {self.participant.identity}")
      # remaining end_call logic...
• If participant is assigned dynamically, pass it explicitly to the method instead of relying on an instance attribute:
Copy code
await agent.end_call(participant=ctx.room.local_participant)
Quick debugging steps you can add now • Add logging right after session.start to confirm participant is set:
Copy code
<http://logger.info|logger.info>(f"Room: {ctx.room.name}, local_participant: {getattr(ctx.room, 'local_participant', None)}")
  <http://logger.info|logger.info>(f"Agent.participant after start: {agent.participant}")
• Search your code for any place that might overwrite
self.participant = None
or reassign
agent
unexpectedly. • Reproduce the error and capture a short trace showing where
end_call
is invoked from and the agent's state at that moment. One targeted question to narrow this down • Is
OutboundCaller
your agent class, and do you have a place where the agent receives `ctx`/`room` so you can set
self.participant
? If so, paste the
__init__
of
OutboundCaller
and the code that constructs/starts the session and I’ll point exactly where to set the participant and add the safest checks. If you share those small snippets (agent class init and session start), I’ll give the precise patch you can apply.
w
if is_outbound: # Extract phone number and room info phone_number = dial_info.get("phone_number") participant_identity = phone_number room_name = ctx.room.name # Ensure only one dial per room using a lock async with DIAL_LOCK: if room_name in DIALED_ROOMS: logging.warning(f"🚫 Already dialed for room {room_name}, skipping") return DIALED_ROOMS.add(room_name) logging.info(f"✅ Locked room {room_name} for outbound dial") await ctx.connect() dial_info = json.loads(ctx.job.metadata) phone_number = dial_info["phone_number"] participant_identity = phone_number agent = OutboundCaller( name="nikhil", appointment_time="3pm", dial_info=dial_info, ) metadata = json.loads(ctx.job.metadata) voice_name = metadata.get("voice_name") language_code = metadata.get("language") customerplan = metadata.get("customerplan") logger.info(f"Voice name {voice_name} and language code {language_code}") # Set the llm depending on customer plan enterprise_allowed = { "en-US","hi-IN","fr-FR","de-DE","pt-BR","id-ID","ja-JP", "vi-VN","nl-NL","ru-RU","es-US","it-IT","tr-TR","ko-KR","pl-PL","th-TH" } if customerplan == "Enterprise": if language_code in enterprise_allowed: # Allowed → use enterprise model llm = google.beta.realtime.RealtimeModel( model="gemini-2.5-flash-native-audio-preview-09-2025", voice=voice_name, language=language_code, ) else: # Not allowed → fallback llm = google.beta.realtime.RealtimeModel( model="gemini-2.5-flash-native-audio-preview-09-2025", voice=voice_name, language=language_code ) else: # Non-enterprise → basic model llm = google.beta.realtime.RealtimeModel( model="gemini-2.5-flash-native-audio-preview-09-2025", voice=voice_name, language=language_code ) session = AgentSession( # vad=silero.VAD.load( # min_speech_duration=0.05, # keep default # min_silence_duration=0.25, # 🔥 MUCH faster turn end # prefix_padding_duration=0.3, # activation_threshold=0.5, # ), llm=llm, discard_audio_if_uninterruptible=False, min_interruption_duration=0.15, min_interruption_words=0, min_endpointing_delay=0.2, max_endpointing_delay=1.5, # preemptive_generation=True, ) session.min_consecutive_speech_delay = 0.0 session_started = asyncio.create_task( session.start( agent=agent, room=ctx.room, room_input_options=RoomInputOptions( noise_cancellation=noise_cancellation.BVCTelephony(), close_on_disconnect=False ), ) ) await session_started conversation_log = [] @session.on("conversation_item_added") def on_conversation_item_added(event: ConversationItemAddedEvent): if not event.item.text_content: return entry = { "role": event.item.role, "text": event.item.text_content, "timestamp": datetime.now().isoformat(), } conversation_log.append(entry) try: async with DIAL_LOCK: # Double-check: prevent duplicate SIP calls if multiple entrypoints run if room_name in DIALED_ROOMS: logging.info("✅ Already dialed inside lock, skipping SIP dial") else: DIALED_ROOMS.add(room_name) sip_trunk_id = metadata.get("sip_trunk_id") logging.info(f"📞 Initiating SIP call to {phone_number}") await ctx.api.sip.create_sip_participant( api.CreateSIPParticipantRequest( room_name=ctx.room.name, sip_trunk_id=sip_trunk_id, sip_call_to=phone_number, participant_identity=participant_identity, participant_name="Outbound Call", play_dialtone=True, wait_until_answered=True ) ) # await session.generate_reply( # instructions="You must begin the conversation immediately and deliver the full first line of the script. Do not wait for the user to speak." # ) participant = await ctx.wait_for_participant(identity=participant_identity) logger.info(f"Participant joined: {participant.identity}") agent.set_participant(participant) call_sid = participant.attributes.get("sip.twilio.callSid") logger.info(f"📞 Twilio Call SID: {call_sid}") # Save actual call start time call_start_time = datetime.now().strftime("%Y%m%d_%H%M%S") logger.info(f"📞 Call started at: {call_start_time}") agent.call_start_time = call_start_time agent.call_sid = call_sid ctx.add_shutdown_callback(save_transcript) except api.TwirpError as e: logger.error( f"Error creating SIP participant: {e.message}, " f"SIP status: {e.metadata.get('sip_status_code')} " f"{e.metadata.get('sip_status')}" ) ctx.shutdown() this is for outbound call this works but if is_chat: try: metadata = json.loads(ctx.job.metadata) useCase = metadata.get("useCase") chatvoice = metadata.get("voice") language = metadata.get("language") except Exception as e: logging.warning(f"Metadata parsing failed: {e}") voice = "Aoede" useCase = "Real_Estate_Property_Dealing" agent = OutboundCaller( name="Dear", appointment_time="3pm", dial_info=metadata, ) session = AgentSession( llm=google.beta.realtime.RealtimeModel(model="gemini-2.5-flash-native-audio-preview-09-2025",voice=chatvoice,language=language), ) await session.start(room=ctx.room, agent=agent, room_options=room_io.RoomOptions( audio_input=room_io.AudioInputOptions( noise_cancellation=noise_cancellation.BVC(), ), ),) @session.on("conversation_item_added") def on_conversation_item_added(event: ConversationItemAddedEvent): if not event.item.text_content: return entry = { "role": event.item.role, "text": event.item.text_content, "timestamp": datetime.now().isoformat(), } this is normal chat code in this tool is not work
t
Thanks — this is helpful context. You have a working outbound flow where you create the SIP participant, wait for it to join, then call
agent.set_participant(participant)
and everything is fine. The chat flow doesn't create or attach a participant, so methods that expect
self.participant
(like
end_call
) can fail because
self.participant
remains None. What I'm responding to • You said the outbound flow works (you set participant after
ctx.wait_for_participant(...)
) but the chat flow “is not work” — I interpret that as the agent in chat never receives a participant reference and later raises AttributeError when
end_call
or other tools try to use
self.participant.identity
. What to change (two safe options) • Option A — Attach a participant for chat sessions (recommended if your agent logic expects a participant) a. If a chat session should use a room participant (for example, the bot’s local participant), set it from
ctx.room
right after session start:
Copy code
await session.start(room=ctx.room, agent=agent, ...)
     # Immediately set participant for chat sessions:
     agent.set_participant(getattr(ctx.room, "local_participant", None))
1. Or inside your agent class, implement a session-start handler that sets
self.participant
from the room passed to the session:
Copy code
class OutboundCaller(Agent):
         async def on_session_start(self, room):
             self.participant = getattr(room, "local_participant", None)
1. Add logging to confirm:
Copy code
<http://logger.info|logger.info>(f"Agent participant after start: {agent.participant}")
• Option B — Make agent methods defensive / accept participant explicitly (safer if chat sessions don’t have SIP participants) a. Add a guard in
end_call
and similar tools:
Copy code
@function_tool
     async def end_call(self, context: RunContext):
         if self.participant is None:
             logger.warning("end_call called but no participant is set")
             return "Cannot end call: no participant"
         <http://logger.info|logger.info>(f"Ending the call for {self.participant.identity}")
         # ...
1. Or change methods to accept a participant argument so the caller supplies it when available:
Copy code
await agent.end_call(participant=getattr(ctx.room, "local_participant", None))
Why this addresses the difference between outbound and chat • Outbound flow explicitly creates and waits for a SIP participant; you then set that participant on the agent. • Chat flow never creates/waits for a participant, so nothing is assigned. Either assign a participant for chat (if appropriate), or change your code to not rely on
self.participant
for chat-only interactions. Quick debugging checklist • After
await session.start(...)
, add: ◦ logger.info(f"ctx.room: {ctx.room.name}, local_participant: {getattr(ctx.room, 'local_participant', None)}") ◦ logger.info(f"agent.participant: {agent.participant}") • Re-run chat flow and paste the logs for those two lines if
agent.participant
is still None. • If you get an AttributeError, paste the full traceback (file and line number where
end_call
is called) so I can point to the exact call site to guard. One targeted question to confirm next steps • For chat sessions, should the agent act as a room participant (i.e.,
local_participant
), or are chat sessions purely LLM-driven without any participant object? If it should act as a participant, I’ll give the exact one-line patch to set
agent.participant
after session start. If you paste the OutboundCaller class (its init, set_participant method, and
end_call
) or the logs after adding the two info lines, I’ll provide the exact code patch to fix the crash.