#7343·agents

Session-level preemptive_generation={"enabled": False} is ignored (dict form wraps itself; the half of #6112 #6114 did not cover)

Author: feiiiiii5Created Sep 18, 2026Updated Sep 18, 2026

Bug Description

AgentSession(preemptive_generation={"enabled": False}) does not turn preemptive generation off, and the other keys of the dict are dropped, because the session-level migration wraps whatever it is given into {"enabled": <value>} without checking whether it already is an options mapping.

python
# livekit-agents/livekit/agents/voice/turn.py:384-385  (at f966a5e)
if is_given(preemptive_generation):
    result["preemptive_generation"] = {"enabled": preemptive_generation}

The declared type is NotGivenOr[bool | PreemptiveGenerationOptions] (voice/agent_session.py:520), so the dict form is a documented input. After the wrap, enabled holds a dict, and the two consumers read it directly as a flag: voice/agent_activity.py:2569-2570 (if not preemptive_opts["enabled"] … return) and voice/agent_activity.py:3598 (preemptive_opts["enabled"] and preemptive_opts["preemptive_tts"]). A non-empty dict is truthy, so {"enabled": False} reads as "on", and the caller's preemptive_tts / max_speech_duration / max_retries never reach the level where the code looks for them.

This is the session-level half of #6112. #6113 (which patched this same kwarg) was closed by @longcw with "closing in favor of #6114", and #6114 changed voice/agent_activity.py and tests/test_agent_session.py only — so the per-agent path honours the dict form, while AgentSession(...) still folds it into itself.

Expected Behavior

preemptive_generation=False and preemptive_generation={"enabled": False} should both disable it, and {"enabled": True, "preemptive_tts": True} should enable preemptive TTS — the same contract #6114 established for per-agent options.

Reproduction Steps

  1. Checkout f966a5e, make install (or any env with livekit-agents importable).
  2. Run the snippet below with PYTHONPATH=livekit-agents. No server, no credentials, no network — it only builds the session options. (AgentSession() needs a running loop, hence asyncio.run.)
python
import asyncio
from livekit.agents import AgentSession

async def main():
    for value in (False, {"enabled": False}, {"enabled": True, "preemptive_tts": True}):
        opts = AgentSession(preemptive_generation=value).options.preemptive_generation
        print(value, "->", opts, "| gate 'not enabled' =", not opts["enabled"])

asyncio.run(main())

Observed on Python 3.11.15:

False                                     -> {'enabled': False, 'preemptive_tts': False, 'max_speech_duration': 10.0, 'max_retries': 3} | gate 'not enabled' = True
{'enabled': False}                        -> {'enabled': {'enabled': False}, 'preemptive_tts': False, 'max_speech_duration': 10.0, 'max_retries': 3} | gate 'not enabled' = False
{'enabled': True, 'preemptive_tts': True}  -> {'enabled': {'enabled': True, 'preemptive_tts': True}, 'preemptive_tts': False, 'max_speech_duration': 10.0, 'max_retries': 3} | gate 'not enabled' = False

Row 1 is the working bool form; rows 2 and 3 are the documented dict form, where the "off" request becomes "on" and the caller's preemptive_tts=True is ignored.

Operating System

macOS 27.0, arm64

Models Used

None — the reproduction constructs AgentSession only and makes no model, worker or network calls.

Package Versions

livekit==1.1.18
livekit-agents==1.8.2
livekit-api==1.2.1
python==3.11.15
commit f966a5e

Proposed Solution

Branch on the shape instead of wrapping unconditionally, mirroring #6114's handling:

python
if is_given(preemptive_generation):
    if isinstance(preemptive_generation, dict):
        result["preemptive_generation"] = dict(preemptive_generation)
    else:
        result["preemptive_generation"] = {"enabled": preemptive_generation}

I ran that patch against the same reproduction, so the expected column is measured rather than guessed:

False                                     -> enabled=False  preemptive_tts=False  gate 'not enabled' = True
{'enabled': False}                        -> enabled=False  preemptive_tts=False  gate 'not enabled' = True
{'enabled': True, 'preemptive_tts': True}  -> enabled=True   preemptive_tts=True   gate 'not enabled' = False

Additional Context

preemptive_generation is deprecated in favour of turn_handling=TurnHandlingOptions(...) ("will be removed in v2.0", agent_session.py:520), so it is entirely reasonable to leave the deprecated kwarg as-is rather than spend review on it — I have not opened a PR for that reason. If you would like one, I will add the branch above plus a --unit case in tests/test_agent_session.py asserting all three forms.