brief-dress-9950
04/20/2026, 1:30 PMasync def _wait_for_participant_disconnect(
self,
*,
ctx: JobContext,
participant_identity: str,
bound_logger: structlog.BoundLogger,
session: AgentSession,
) -> None:
"""Block until the remote participant leaves, the agent disconnects,
or end_call is invoked."""
<http://bound_logger.info|bound_logger.info>(
"Waiting for disconnect",
participant_identity=participant_identity,
room_connected=ctx.room.isconnected(),
room_name=ctx.room.name,
remote_participants=list(ctx.room.remote_participants.keys()),
)
<http://bound_logger.info|bound_logger.info>(
"session.userdata",
userdata=session.userdata,
ctxsessionuser=ctx.session.userdata,
)
while (
participant_identity in ctx.room.remote_participants
or not session.userdata.get("end_call_invoked")
):
await asyncio.sleep(0.5)
<http://bound_logger.info|bound_logger.info>(
"Disconnected",
participant_identity=participant_identity,
room_connected=ctx.room.isconnected(),
end_call_invoked=session.userdata.get("end_call_invoked", False),
)tall-airline-16756
04/20/2026, 1:30 PMtall-airline-16756
04/20/2026, 1:30 PMcontext.session.userdata["end_call_invoked"] = True inside the end_call function tool is correct and why your listener may not see it.
Short answer: The assignment is only reliable if session.userdata was initialized as a mutable session object when the AgentSession was created. If userdata is not initialized (e.g., None) or isn’t the same shared object instance, setting a key will fail or not be observed.
What to check next (quick checklist):
• Ensure userdata is initialized
Create the session with a userdata object (dataclass or dict-like) before using it:
@dataclass
class MySessionData:
end_call_invoked: bool = False
session = AgentSession[MySessionData](userdata=MySessionData(), ...)
If you use a dataclass, set the flag with dot notation: context.session.userdata.end_call_invoked = True.
• Confirm the same session instance is shared
Verify the session passed into your listener is the identical AgentSession instance used by the function tool (no copies or re-instantiations).
• Use logging immediately after setting the flag
Add a log right after the assignment to confirm it ran:
context.session.userdata.end_call_invoked = True
logger.info("end_call flag set", userdata=context.session.userdata)
• Timing / shutdown ordering
Make sure the userdata update happens before you call context.session.shutdown() (and that the listener checks state before shutdown completes). If shutdown happens immediately, listeners may not observe the change.
• Match access style to userdata type
If userdata is a dataclass use session.userdata.end_call_invoked. If it’s a dict, session.userdata["end_call_invoked"] is fine — but only if it was initialized as a dict.
If you’d like, share how you construct AgentSession (the code where userdata is passed in), and I’ll point out exactly what to change.
Sources: Agents and handoffs | LiveKit Documentation | Tasks and task groups | LiveKit Documentation | Agent speech and audio | LiveKit Documentation, with interactive elementsbrief-dress-9950
04/20/2026, 1:35 PMtall-airline-16756
04/20/2026, 1:35 PMuserdata to be a plain dict. Example:
session = AgentSession(userdata={"end_call_invoked": False}, ...)
Notes and best practices:
• If you use a dict, access and update with dict syntax: context.session.userdata["end_call_invoked"] = True.
• Many teams prefer a typed dataclass for clearer fields and dot-notation (session.userdata.end_call_invoked) and to avoid silent mistakes. Example:
from dataclasses import dataclass
@dataclass
class MySessionData:
end_call_invoked: bool = False
session = AgentSession[MySessionData](userdata=MySessionData(), ...)
Pick whichever fits your codebase; just ensure userdata is initialized and the same AgentSession instance is shared between the tool and your listener.
, with interactive elementsbrief-dress-9950
04/20/2026, 2:05 PMtall-airline-16756
04/20/2026, 2:07 PMAgentSession.userdata updated by a function tool.
How to guarantee an up-to-date session
• Create and pass a single shared AgentSession instance when you start the job/entrypoint. Don’t reinstantiate or copy the session.
session = AgentSession(userdata={"end_call_invoked": False}, ...)
start_job(session=session, ...)
• Use that same session object in both the function tool (via context.session) and your entrypoint/listener (session param). If they reference the same object, updates are visible immediately.
• Match access style to the userdata type:
◦ Dict: context.session.userdata["end_call_invoked"] = True
◦ Dataclass: context.session.userdata.end_call_invoked = True
• Log right after you set the flag to confirm it ran before shutdown:
context.session.userdata["end_call_invoked"] = True
logger.info("flag set", userdata=context.session.userdata)
Synchronization / timing
• Ensure the listener checks after the update occurs. Common patterns:
◦ Polling loop that checks the shared session.userdata (as you have), but ensure the loop condition uses OR/AND correctly so it exits when the flag is set.
◦ Use an asyncio.Event placed in shared userdata (or a separate shared object) that the function tool sets and the listener awaits.
• If you call context.session.shutdown() immediately after setting the flag, ensure the listener observes the flag before shutdown completes (log/await or set an event).
Quick note about your current loop
• Your while condition:
while (
participant_identity in ctx.room.remote_participants
or not session.userdata.get("end_call_invoked")
):
will continue while either the participant is present OR the flag is False — that’s correct if you want to wait until both the participant is gone AND the flag is True. If you intend to stop when either happens, change or to and.
If you want, paste the code where you construct/start the job (where AgentSession is created and passed) and I’ll point out any instance-sharing or timing fixes.
, with interactive elements