Session-level preemptive_generation={"enabled": False} is ignored (dict form wraps itself; the half of #6112 #6114 did not cover)
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.
# 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
- Checkout
f966a5e,make install(or any env withlivekit-agentsimportable). - 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, henceasyncio.run.)
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' = FalseRow 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 f966a5eProposed Solution
Branch on the shape instead of wrapping unconditionally, mirroring #6114's handling:
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' = FalseAdditional 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.
Source: livekit/agents