adorable-bear-14321
04/24/2026, 10:16 AMtall-airline-16756
04/24/2026, 10:16 AMtall-airline-16756
04/24/2026, 10:16 AMrole = event.item.role and the runtime fails with AttributeError: 'AgentHandoff' object has no attribute 'role'. That means the conversation item added is an AgentHandoff (which has handoff metadata like `old_agent_id`/`new_agent_id`) and not a message, so role does not exist on that item.
What you can do now
• Guard access to role by checking the item type first. For example:
if event.item.type == "message":
# safe to read event.item.role
role = event.item.role
text = event.item.text_content
else:
# handle non-message items (e.g., AgentHandoff)
# inspect event.item.old_agent_id / event.item.new_agent_id if needed
• Update any observer/transcription logic that assumed every conversation_item_added is a message to skip or specially-handle non-message types.
Quick diagnostic questions
• Is this handler in your code (line 1267 in /livekit-agent/agents/agent.py) or third‑party code you can edit?
• Do you want to treat AgentHandoff items specially (e.g., log handoffs) or ignore them?
If you share the small snippet around your on_conversation_item_added handler, I’ll suggest the exact fix to insert.
Sources: Chat context | LiveKit Documentation | Agent session | LiveKit Documentation | How to Build a Background Observer for Voice AI Guardrails | LiveKit | How to capture real-time agent transcriptions during conversations | Agents and handoffs | LiveKit Documentation, with interactive elementsadorable-bear-14321
04/24/2026, 10:18 AM@session.on("conversation_item_added")
def on_conversation_item_added(event: ConversationItemAddedEvent):
global agent_avg_e2e_latency, agent_first_e2e_latency, agent_latency_count_e2e
role = event.item.role
if role == "assistant":
role = "agent"
transcription = TranscriptionLog(
role=role, transcription=event.item.text_content,
time=datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
)
transcriptions.append(transcription)
if event.item.role is not None and event.item.role == 'assistant':
try:
item_metrics = getattr(event.item, "metrics", None)
e2e_value = None
if item_metrics is not None:
if isinstance(item_metrics, dict):
e2e_value = item_metrics.get("e2e_latency")
else:
e2e_value = getattr(item_metrics, "e2e_latency", None)
if e2e_value is not None and e2e_value >= 0:
<http://logger.info|logger.info>("E2E latency: %.3fs", e2e_value, extra={"e2e_latency_val": round(e2e_value, 3)})
if agent_first_e2e_latency == -1:
agent_first_e2e_latency = round(e2e_value, 3)
if agent_avg_e2e_latency == -1:
agent_avg_e2e_latency = round(e2e_value, 3)
agent_latency_count_e2e = 1
else:
agent_latency_count_e2e += 1
agent_avg_e2e_latency = round(
agent_avg_e2e_latency
+ (e2e_value - agent_avg_e2e_latency)
/ agent_latency_count_e2e,
3,
)
except Exception as e:
logger.error(f"Failed to update e2e latency metrics: {e}")
is_hangup = check_hangup(event.item.text_content)
if is_hangup:
asyncio.create_task(hangup_with_reason(ctx, participant, "Agent Speech Hangup"))
else:
is_voicemail = check_voicemail(event.item.text_content)
is_customer_hold = check_on_hold(event.item.text_content)
if is_voicemail:
asyncio.create_task(hangup_with_reason(ctx, participant, "Voicemail detected"))
elif is_customer_hold:
asyncio.create_task(hangup_with_reason(ctx, participant, "Customer Hold detected"))tall-airline-16756
04/24/2026, 10:18 AMevent.item always has `role`; AgentHandoff items don’t. You should check the item type before accessing role. Below is a minimal, safe fix that preserves your metrics/transcription logic and handles non-message items:
@session.on("conversation_item_added")
def on_conversation_item_added(event: ConversationItemAddedEvent):
global agent_avg_e2e_latency, agent_first_e2e_latency, agent_latency_count_e2e
# Only treat message items as having a role/text_content
if getattr(event.item, "type", None) == "message":
role = event.item.role
if role == "assistant":
role = "agent"
transcription = TranscriptionLog(
role=role,
transcription=event.item.text_content,
time=datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
)
transcriptions.append(transcription)
if role == "assistant":
try:
item_metrics = getattr(event.item, "metrics", None)
e2e_value = None
if item_metrics is not None:
if isinstance(item_metrics, dict):
e2e_value = item_metrics.get("e2e_latency")
else:
e2e_value = getattr(item_metrics, "e2e_latency", None)
if e2e_value is not None and e2e_value >= 0:
logger.info("E2E latency: %.3fs", e2e_value, extra={"e2e_latency_val": round(e2e_value, 3)})
if agent_first_e2e_latency == -1:
agent_first_e2e_latency = round(e2e_value, 3)
if agent_avg_e2e_latency == -1:
agent_avg_e2e_latency = round(e2e_value, 3)
agent_latency_count_e2e = 1
else:
agent_latency_count_e2e += 1
agent_avg_e2e_latency = round(
agent_avg_e2e_latency + (e2e_value - agent_avg_e2e_latency) / agent_latency_count_e2e,
3,
)
except Exception as e:
logger.error(f"Failed to update e2e latency metrics: {e}")
is_hangup = check_hangup(event.item.text_content)
if is_hangup:
asyncio.create_task(hangup_with_reason(ctx, participant, "Agent Speech Hangup"))
else:
is_voicemail = check_voicemail(event.item.text_content)
is_customer_hold = check_on_hold(event.item.text_content)
if is_voicemail:
asyncio.create_task(hangup_with_reason(ctx, participant, "Voicemail detected"))
elif is_customer_hold:
asyncio.create_task(hangup_with_reason(ctx, participant, "Customer Hold detected"))
else:
# Non-message items (e.g., AgentHandoff) — handle or ignore explicitly
# Example: log handoff metadata if useful
try:
old_agent = getattr(event.item, "old_agent_id", None)
new_agent = getattr(event.item, "new_agent_id", None)
logger.info("Non-message conversation item: type=%s old_agent=%s new_agent=%s", getattr(event.item, "type", None), old_agent, new_agent)
except Exception:
pass
Would you like me to:
• Suggest a smaller patch that only adds a single guard line, or
• Add explicit handling for AgentHandoff (e.g., emit a specific log/event)?, with interactive elementsadorable-bear-14321
04/24/2026, 10:47 AMtall-airline-16756
04/24/2026, 10:47 AMif event.item.type == "message": check only skips the message-specific logic for non-message items (like AgentHandoff). All original message processing (transcription, metrics, hangup/voicemail checks) runs unchanged when the item is a message. Non-message items are now explicitly ignored or logged, preventing the AttributeError.
Line-by-line explanation of the important parts
• if getattr(event.item, "type", None) == "message":
◦ Safely checks the item type (uses getattr in case type is missing) and only proceeds when the item is a message. This prevents accessing role on types that don't have it.
• role = event.item.role
◦ Reads the role (e.g., "user", "assistant") for message items. Safe because we already confirmed the item is a message.
• if role == "assistant": role = "agent"
◦ Normalizes the assistant role to your internal name "agent" for logging/transcription.
• TranscriptionLog(...) and transcriptions.append(transcription)
◦ Creates and stores a transcription record from the message text and role.
• if role == "assistant": (or your original if event.item.role == 'assistant':)
◦ Runs assistant-only metrics and hangup detection. In the patched code this uses the already-read role, avoiding repeated attribute access.
• Metrics block (getattr(event.item, "metrics", None) ... e2e_value ...)
◦ Safely reads an optional metrics field, supports either a dict or an object with e2e_latency, and updates running averages if a valid e2e_latency is present. The try/except prevents metric parsing errors from crashing the handler.
• is_hangup = check_hangup(event.item.text_content) and subsequent asyncio.create_task(hangup_with_reason(...))
◦ Detects hangup phrases in the assistant speech and triggers an async hangup task. Creating a task avoids blocking the event loop.
• Else branch (voicemail / customer hold)
◦ Runs only for non-assistant messages and triggers hangup tasks when those conditions match.
• Else (non-message) logging:
◦ The else at the end logs or ignores non-message items (e.g., AgentHandoff), preventing the AttributeError while optionally capturing handoff metadata (old_agent_id, new_agent_id).
If you want, I can:
• Provide a minimal one-line guard to place where your original code is (if you prefer the smallest change), or
• Add explicit handling for AgentHandoff (e.g., emit a handoff transcription or separate metric). Which would you prefer?, with interactive elements