Skip to content

fix(voice): keep the session host for the job so finalize_simulation outlives the session - #7986

Draft
u9g wants to merge 2 commits into
feat/session-closed-eventfrom
fix/simulation-finalize-after-end-call
Draft

u9g wants to merge 2 commits into
feat/session-closed-eventfrom
fix/simulation-finalize-after-end-call

Conversation

@u9g

@u9g u9g commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #8099.

Problem

When an agent ends the call itself during a simulation, its on_simulation_end verdict is silently dropped.

The simulator asks for the agent's verdict with finalize_simulation only after the run is over and it has judged the transcript, which takes a few seconds of LLM calls. When the agent hangs up (for example with EndCallTool), AgentSession closes its SessionHost along with itself, and EndCallTool's default delete_room=True then deletes the room. By the time the simulator sends finalize_simulation, nothing is listening. The simulator logs finalize_simulation failed; no agent verdict and reports the run using only its own verdict.

In production over the last 24h before this PR was opened, this hit 2,945 of ~21.8k simulation runs.

Fix

The SessionHost belongs to the job rather than the session.

  • JobContext owns one host. The primary AgentSession attaches to it when it starts and detaches when it closes. Outside a job, the session still owns and closes its own host.

  • Explicit attach and detach. SessionHost has attach(), detach() and a session property. At most one session is attached at a time.

  • No attached session. Session requests get an explicit no active session error instead of no reply. finalize_simulation needs only the job, so it is answered either way.

  • Job shutdown. Under a simulation, _shutdown_job keeps the host up until one of these happens, then closes it before ShuttingDown, room.disconnect and the shutdown callbacks (where EndCallTool deletes the room):

    • the simulator finalizes,
    • the simulator leaves the room, or
    • session_end_timeout passes.

    The supervisor waits for ShuttingDown with no time limit, so this wait doesn't count against close_timeout.

  • No wire protocol change in this PR. The agent now stays in the room after it hangs up, so the simulator learns the call ended from session_closed (feat(voice): send session_closed to the remote session when the session closes #8099). The simulator side is livekit/agents-private#756, and it has to be deployed before this PR is released.

The first commit is a pure refactor: register_session becomes attach, and detach() is extracted. The second commit is the fix.

Verification

  • tests/test_session_host.py: a request with no attached session is refused, finalize is answered after detach, and detach stops forwarding events.
  • tests/test_simulation_finalize.py: the job owns the host and sessions share it, and the session owns the host outside a job. The shutdown wait ends on finalize, ends when the simulator leaves, skips the wait when the room is gone, and gives up at the timeout.
  • ruff and mypy are clean.
  • An end-to-end run against LiveKit Cloud is pending the protocol release. The demo video in the comments shows the earlier version of this PR.

Not covered

RoomIO with delete_room_on_close=True (the default is False) still deletes the room when the session closes, which takes the host down before finalize.

Parity: livekit/agents-js#2793

@u9g

u9g commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

A before/after text simulation on production LiveKit Cloud with an agent that hangs up via EndCallTool and vetoes every run from on_simulation_end. On main, on_simulation_end never runs and the run passes. On this branch, it runs after end_call and its veto fails the run.

simulation-finalize-after-end-call.mp4

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +1333 to +1337
if reason == CloseReason.USER_INITIATED:
# only an agent ending its own session leaves the simulator yet
# to finalize; a job shutdown has to stay within the supervisor's
# shutdown_process_timeout
await self._session_host.hold_for_simulation_finalize()

@devin-ai-integration devin-ai-integration Bot Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Room deletion preempts simulation finalization

When delete_room_on_close is enabled, close triggers RoomIO._on_agent_session_close before the hold starts. Room deletion disconnects the transport, so the simulator cannot deliver finalize_simulation or receive the agent verdict.

Learn more

RoomIO can schedule room deletion from the session's close event when delete_room_on_close is enabled. The close event is emitted before the new simulation hold begins, so holding the host cannot keep the room transport connected. The remote simulator sends its finalize request over that same room transport.

Example: A simulated agent enables delete_room_on_close, ends its session, and RoomIO schedules deletion during close. The hold then waits for a finalize request over a deleted room and eventually times out without an agent verdict.

Recommended fix: Defer RoomIO._on_agent_session_close's room deletion for simulated agent-initiated closes until after hold_for_simulation_finalize returns. Preserve immediate deletion for other close reasons.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +400 to +403
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(
self._simulation_released.wait(), timeout=_SIMULATION_FINALIZE_TIMEOUT
)

@devin-ai-integration devin-ai-integration Bot Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Replacement sessions lose forwarded events

When attach() replaces an attached session, _events_registered skips subscribing to the new session. The old session keeps forwarding events, while the new session's events never reach the remote client.

Learn more

SessionHost owns event subscriptions for its currently attached AgentSession. Calling attach() again before detach() replaces _session without removing subscriptions from the first session; _events_registered prevents subscriptions on the second. detach() then attempts to remove handlers from the second session and loses its reference to the first. The new shared-host test exercises this exact sequence but checks only host.session, not event delivery.

Example: Attach session A, then attach B. A's user_state_changed reaches the client; B's does not. Calling detach() still leaves A's callback subscribed.

Recommended fix: Either detach the existing session before replacing it in attach(), or explicitly reject an attachment until the previous session is detached. Test event subscriptions when attaching two sessions.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

# only an agent ending its own session leaves the simulator yet
# to finalize; a job shutdown has to stay within the supervisor's
# shutdown_process_timeout
await self._session_host.hold_for_simulation_finalize()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's the right fix, should we just "Drain"?

Like make sure the entire queue is consumed then we just await the main task

u9g added 2 commits October 23, 2026 17:52
Rename register_session to attach, split event unhooking out of aclose
into detach, and expose the attached session. Request handling captures
the session once per request and finalize_simulation moves into its own
method. No behavior change.
…outlives the session

The SessionHost is now owned by the job rather than the AgentSession: the
primary session attaches on start and detaches on close, and the job
closes the host during shutdown. Under a simulation, shutdown keeps the
host serving until the simulator finalizes, leaves, or session_end_timeout
passes, so on_simulation_end still runs when the agent ends the call.

While no session is attached, session requests fail with
"no active session" instead of being dropped; finalize_simulation needs
only the job and is answered either way.
@u9g
u9g force-pushed the fix/simulation-finalize-after-end-call branch from 62bbafa to fc50c34 Compare October 23, 2026 21:53
@u9g u9g changed the title fix(voice): keep the session host open for finalize_simulation after the agent ends the call fix(voice): keep the session host for the job so finalize_simulation outlives the session Oct 23, 2026
@u9g
u9g changed the base branch from main to feat/session-closed-event October 23, 2026 21:55
@u9g
u9g marked this pull request as draft October 23, 2026 21:55

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +527 to +529
def _check_simulator_left(*_: Any) -> None:
if not room.isconnected() or not room.remote_participants:
simulator_left.set()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Simulator departure delays job shutdown

When the simulator leaves but another participant remains, _check_simulator_left waits for a finalize request that cannot arrive. The job stays open until session_end_timeout, which defaults to five minutes.

Learn more

The job already identifies the simulator by ATTRIBUTE_SIMULATOR in the disconnect callback. This wait instead tests whether every remote participant has left. A SIP leg or avatar can remain after the simulator disconnects, so the wait runs for the entire 300-second default even though no simulator can send finalize. The job retains its room and delays its shutdown callbacks during that time.

Example: A room contains a simulator and a SIP participant. The simulator disconnects after judging its run. The SIP participant stays; remote_participants is nonempty, so shutdown waits five minutes rather than finishing when the simulator leaves.

Recommended fix: Track the participant with ATTRIBUTE_SIMULATOR and finish the wait when that participant disconnects or is absent, regardless of other participants. Handle room disconnect and finalization as separate completion conditions.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants