Skip to content

Commit 07761a9

Browse files
authored
simulation: --simulation flag disables the worker load limit; text sims skip STT/TTS (#6036)
1 parent 852bcc7 commit 07761a9

11 files changed

Lines changed: 87 additions & 10 deletions

File tree

livekit-agents/livekit/agents/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def _dispatch(server: AgentServer, args: argparse.Namespace) -> None:
7272
reload_addr=args.reload_addr,
7373
log_format=args.log_format,
7474
dev=args.dev,
75+
simulation=args.simulation,
7576
),
7677
)
7778

@@ -111,6 +112,9 @@ def main(argv: list[str] | None = None) -> int:
111112
start_p.add_argument("--api-secret")
112113
start_p.add_argument("--dev", action="store_true", default=False)
113114
start_p.add_argument("--reload-addr")
115+
# set by `lk simulate`: disables the worker load limit so simulation runs
116+
# can saturate the agent
117+
start_p.add_argument("--simulation", action="store_true", default=False)
114118

115119
console_p = sub.add_parser("console")
116120
console_p.add_argument("entrypoint", nargs="?")

livekit-agents/livekit/agents/cli/_legacy.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,14 @@ def start(
17091709
help="Time in seconds to wait for jobs to finish before shutting down.",
17101710
),
17111711
] = None,
1712+
simulation: Annotated[
1713+
bool,
1714+
typer.Option(
1715+
hidden=True,
1716+
help="Run under an agent simulation: the worker load limit is disabled "
1717+
"so runs can saturate the agent. Set by `lk simulate`.",
1718+
),
1719+
] = False,
17121720
) -> None:
17131721
if drain_timeout is not None:
17141722
server.update_options(drain_timeout=drain_timeout)
@@ -1720,6 +1728,7 @@ def start(
17201728
url=url,
17211729
api_key=api_key,
17221730
api_secret=api_secret,
1731+
simulation=simulation,
17231732
),
17241733
)
17251734

livekit-agents/livekit/agents/cli/cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@ def _run_worker(server: AgentServer, args: proto.CliArgs) -> None:
285285
if kwargs:
286286
server.update_options(**kwargs)
287287

288+
if args.simulation:
289+
server._simulation = True
290+
288291
if args.reload_addr and not args.dev:
289292
raise ValueError("--reload-addr requires --dev")
290293

livekit-agents/livekit/agents/cli/proto.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ class CliArgs:
1717
reload_addr: str | None = None
1818
log_format: str = "json"
1919
dev: bool = False
20+
# set by `lk simulate` when launching the agent under test; disables the
21+
# worker load limit so simulation runs can saturate the agent
22+
simulation: bool = False
2023

2124

2225
def running_job_to_proto(info: RunningJobInfo) -> agent_dev.RunningAgentJobInfo:

livekit-agents/livekit/agents/job.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,10 @@ def simulation_context(self) -> SimulationContext | None:
467467
from .simulation import SimulationContext
468468

469469
try:
470-
dispatch = json_format.Parse(metadata, sim_pb.SimulationDispatch())
470+
# ignore unknown fields so dispatches from newer servers still parse
471+
dispatch = json_format.Parse(
472+
metadata, sim_pb.SimulationDispatch(), ignore_unknown_fields=True
473+
)
471474
except json_format.ParseError:
472475
return None
473476

livekit-agents/livekit/agents/simulation.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
ScenarioGroup = proto.ScenarioGroup
1515
SimulationRun = proto.SimulationRun
1616
SimulationDispatch = proto.SimulationDispatch
17+
SimulationMode = proto.SimulationMode
1718

1819
# Decoded form of a Scenario's `userdata` (arbitrary JSON). On the wire it is a
1920
# JSON-encoded string; in a scenarios.yaml it is written as a nested mapping.
@@ -24,6 +25,7 @@
2425
"ScenarioGroup",
2526
"SimulationRun",
2627
"SimulationDispatch",
28+
"SimulationMode",
2729
"ScenarioUserdata",
2830
"SimulationVerdict",
2931
"SimulationContext",
@@ -66,11 +68,20 @@ def scenario(self) -> proto.Scenario:
6668
return self._scenario
6769

6870
@property
69-
def run(self) -> proto.SimulationRun | None:
71+
def simulation_mode(self) -> int:
72+
"""How the simulated user interacts with the agent (text chat or audio).
73+
Unspecified is treated as text, since simulations predating the field
74+
were all text-only."""
75+
if self._dispatch.mode == proto.SimulationMode.SIMULATION_MODE_UNSPECIFIED:
76+
return proto.SimulationMode.SIMULATION_MODE_TEXT
77+
return self._dispatch.mode
78+
79+
@property
80+
def simulation_run(self) -> proto.SimulationRun | None:
7081
return self._run
7182

7283
@property
73-
def job(self) -> proto.SimulationRun.Job | None:
84+
def simulation_job(self) -> proto.SimulationRun.Job | None:
7485
return self._job
7586

7687
@property

livekit-agents/livekit/agents/voice/agent_activity.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3893,8 +3893,15 @@ def _init_metrics_from_end_of_turn(self, info: _EndOfTurnInfo) -> llm.MetricsRep
38933893
return metrics_report
38943894

38953895
# move them to the end to avoid shadowing the same named modules for mypy
3896+
@property
3897+
def _text_only(self) -> bool:
3898+
# text simulations run without audio: no STT/TTS/VAD
3899+
return self._session._text_only
3900+
38963901
@property
38973902
def vad(self) -> vad.VAD | None:
3903+
if self._text_only:
3904+
return None
38983905
return self._agent.vad if is_given(self._agent.vad) else self._session.vad
38993906

39003907
def _resolve_interruption_detection(self) -> inference.AdaptiveInterruptionDetector | None:
@@ -3951,6 +3958,8 @@ def _resolve_interruption_detection(self) -> inference.AdaptiveInterruptionDetec
39513958

39523959
@property
39533960
def stt(self) -> stt.STT | None:
3961+
if self._text_only:
3962+
return None
39543963
return self._agent.stt if is_given(self._agent.stt) else self._session.stt
39553964

39563965
@property
@@ -3959,4 +3968,6 @@ def llm(self) -> llm.LLM | llm.RealtimeModel | None:
39593968

39603969
@property
39613970
def tts(self) -> tts.TTS | None:
3971+
if self._text_only:
3972+
return None
39623973
return self._agent.tts if is_given(self._agent.tts) else self._session.tts

livekit-agents/livekit/agents/voice/agent_session.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,12 @@ async def start(
688688

689689
job_ctx.init_recording(self._recording_options)
690690

691+
# Under a text simulation the simulated user interacts over text
692+
# streams only: disable audio I/O here, and STT/TTS/VAD via
693+
# AgentActivity (both consult _text_only).
694+
if self._text_only:
695+
logger.info("text simulation: disabling STT/TTS/VAD and audio I/O")
696+
691697
self._session_span = current_span = tracer.start_span("agent_session")
692698
# we detach here to avoid context issues since tokens need to be detached
693699
# in the same context as it was created
@@ -738,6 +744,10 @@ async def start(
738744
)
739745
room_options = copy.copy(room_options) # shadow copy is enough
740746

747+
if self._text_only:
748+
room_options.audio_input = False
749+
room_options.audio_output = False
750+
741751
if self.input.audio is not None:
742752
if room_options.audio_input:
743753
logger.warning(
@@ -1722,6 +1732,20 @@ def _config_update_added(self, item: llm.AgentConfigUpdate) -> None:
17221732
self._chat_ctx.insert(item)
17231733

17241734
# move them to the end to avoid shadowing the same named modules for mypy
1735+
@property
1736+
def _text_only(self) -> bool:
1737+
"""True when running under a text simulation: the session uses no audio
1738+
I/O and no audio models (STT/TTS/VAD)."""
1739+
from ..job import get_job_context
1740+
1741+
job_ctx = get_job_context(required=False)
1742+
if job_ctx is None or (sim_ctx := job_ctx.simulation_context()) is None:
1743+
return False
1744+
1745+
from ..simulation import SimulationMode
1746+
1747+
return sim_ctx.simulation_mode == SimulationMode.SIMULATION_MODE_TEXT
1748+
17251749
@property
17261750
def stt(self) -> stt.STT | None:
17271751
return self._stt

livekit-agents/livekit/agents/worker.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,9 @@ def __init__(
359359

360360
self._http_proxy = http_proxy
361361
self._log_level = _validate_and_normalize_log_level(log_level)
362+
# Set by the CLI (--simulation) when the worker runs under an agent
363+
# simulation: load shedding is disabled so runs can saturate the agent.
364+
self._simulation = False
362365
self._agent_name = ""
363366
self._server_type = ServerType.ROOM
364367
self._id = "unregistered"
@@ -584,6 +587,9 @@ async def run(self, *, devmode: bool = False, unregistered: bool = False) -> Non
584587
)
585588
self._load_threshold = _default_load_threshold
586589

590+
if self._simulation:
591+
logger.info("simulation mode enabled: worker load limit disabled")
592+
587593
self._loop = asyncio.get_event_loop()
588594
self._devmode = devmode
589595
self._job_lifecycle_tasks = set[asyncio.Task[Any]]()
@@ -1017,7 +1023,7 @@ async def aclose(self) -> None:
10171023
await self._prometheus_server.aclose()
10181024

10191025
if self._api is not None:
1020-
await self._api.aclose() # type: ignore[no-untyped-call]
1026+
await self._api.aclose() # type: ignore[no-untyped-call, unused-ignore]
10211027

10221028
# await asyncio.sleep(0.25) # see https://github.com/aio-libs/aiohttp/issues/1925
10231029
self._msg_chan.close()
@@ -1296,6 +1302,9 @@ def _is_available(self) -> bool:
12961302
if self._draining:
12971303
return False
12981304

1305+
if self._simulation:
1306+
return True
1307+
12991308
load_threshold = ServerEnvOption.getvalue(self._load_threshold, self._devmode)
13001309
if math.isinf(load_threshold):
13011310
return True
@@ -1455,7 +1464,7 @@ async def _update_worker_status(self) -> None:
14551464

14561465
load_threshold = ServerEnvOption.getvalue(self._load_threshold, self._devmode)
14571466
effective_load = self._get_effective_load()
1458-
is_full = effective_load >= load_threshold
1467+
is_full = not self._simulation and effective_load >= load_threshold
14591468
currently_available = not is_full and not self._draining
14601469

14611470
status = (

livekit-agents/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dependencies = [
2929
"certifi>=2025.6.15",
3030
"livekit==1.1.8",
3131
"livekit-api>=1.0.7,<2",
32-
"livekit-protocol>=1.1.14,<2",
32+
"livekit-protocol>=1.1.15,<2",
3333
"livekit-blingfire~=1.1,<2",
3434
"protobuf>=3",
3535
"pyjwt>=2.0",

0 commit comments

Comments
 (0)