Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
1c7b33e
wip
longcw Dec 19, 2025
8eba4e9
add logs
longcw Dec 19, 2025
de63ced
add sms-console
longcw Dec 22, 2025
fedadd3
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Dec 22, 2025
e0f387b
fix types
longcw Dec 22, 2025
4e2b715
fix agent handoff
longcw Dec 22, 2025
d8bf768
fix wrapped entrypoint
longcw Dec 22, 2025
5d05117
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Jan 5, 2026
a092a42
save session after the text handler
longcw Jan 5, 2026
d6c7065
add multiple responses
longcw Jan 6, 2026
4f22115
add e2e encryption
longcw Jan 8, 2026
30f9711
add get_init_kwargs
longcw Jan 9, 2026
6084764
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Jan 13, 2026
fcc3b92
serialize old_agent for AgentTask
longcw Jan 13, 2026
03ac592
add Agent.configure
longcw Jan 16, 2026
7bdae50
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Jan 19, 2026
1eff439
hand text message request from lk server (#4553)
longcw Jan 20, 2026
12c6b88
durable scheduler WIP
theomonnom Jan 21, 2026
685e7b8
contextvars already handled
theomonnom Jan 21, 2026
8495cea
export session state as db and compute delta for sync (#4604)
longcw Jan 28, 2026
52f1eb2
durable functions integration (#4647)
longcw Jan 30, 2026
4db5018
update sms cli
longcw Jan 30, 2026
cb731e4
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Feb 2, 2026
214f5f7
clean and update session store versions
longcw Feb 2, 2026
09a2026
fix types
longcw Feb 2, 2026
29f13e4
save init kwargs as blob
longcw Feb 2, 2026
363d168
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Feb 3, 2026
c763a96
store chat ctx as text
longcw Feb 3, 2026
128ceb8
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Feb 4, 2026
acad0a7
ignore config update
longcw Feb 4, 2026
9a1e67e
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Feb 6, 2026
54fc75a
add http server for text mode (#4718)
longcw Feb 8, 2026
b4f9246
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Feb 8, 2026
2a18d4e
fix cli
longcw Feb 8, 2026
c854820
fix cli
longcw Feb 8, 2026
ba69121
fix drain messages in buffer before close
longcw Feb 9, 2026
970976c
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Feb 9, 2026
2511a47
fix SpeechHandle creation
longcw Feb 9, 2026
9c10af2
update endpoint and fix http server close
longcw Feb 9, 2026
b7782f5
update session store and cache
longcw Feb 9, 2026
5ca2442
rename durable and http sever modules
longcw Feb 10, 2026
0bfbd27
add error code to types
longcw Feb 10, 2026
c45f6f6
update protocol for http api
longcw Feb 10, 2026
207b6f2
fix import
longcw Feb 10, 2026
5f7dca2
update to response proto
longcw Feb 11, 2026
e46bd74
handle durable unpickle error
longcw Feb 11, 2026
fbc978f
fix typo
longcw Feb 11, 2026
b3590c9
handle durable function failure
longcw Feb 12, 2026
c5c7f70
fix durable function resume
longcw Feb 12, 2026
9d9179f
remove session started event
longcw Feb 12, 2026
93ab71b
fix _resume_durable_function
longcw Feb 12, 2026
e4683b2
fix types
longcw Feb 13, 2026
19d781a
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Feb 23, 2026
6d3979d
fix durable scheduler shutdown and text mode session cleanup
longcw Feb 24, 2026
e346db6
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Feb 25, 2026
fcbeda8
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Feb 26, 2026
3d7ebea
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Mar 10, 2026
58ee25a
use Instructions in example
longcw Mar 10, 2026
8f90a86
add setup_fnc for Agent
longcw Mar 10, 2026
73c22ef
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Apr 7, 2026
f93f61f
fix import
longcw Apr 7, 2026
8e85966
update livekit-protocol version and remove encryption
longcw Apr 8, 2026
227b906
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Apr 9, 2026
3e962cf
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Apr 13, 2026
c853dbf
Merge remote-tracking branch 'origin/main' into longc/text-mode
longcw Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions examples/voice_agents/basic_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
JobProcess,
MetricsCollectedEvent,
RunContext,
TextMessageContext,
cli,
metrics,
room_io,
Expand Down Expand Up @@ -40,6 +41,7 @@ async def on_enter(self):
# when the agent is added to the session, it'll generate a reply
# according to its instructions
# Keep it uninterruptible so the client has time to calibrate AEC (Acoustic Echo Cancellation).
logger.debug("on_enter called")
self.session.generate_reply(allow_interruptions=False)

# all functions annotated with @function_tool will be passed to the LLM when this
Expand Down Expand Up @@ -74,6 +76,15 @@ def prewarm(proc: JobProcess):
server.setup_fnc = prewarm


@server.sms_handler()
async def sms_handler(ctx: TextMessageContext):
logger.info(f"SMS received: {ctx.text}")

session = await ctx.rehydrated_session()
result = await session.run(user_input=ctx.text)
await ctx.send_result(result)


@server.rtc_session()
async def entrypoint(ctx: JobContext):
# each log entry will include these fields
Expand Down
2 changes: 2 additions & 0 deletions livekit-agents/livekit/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
JobExecutorType,
JobProcess,
JobRequest,
TextMessageContext,
get_job_context,
)
from .llm.chat_context import (
Expand Down Expand Up @@ -121,6 +122,7 @@ def __getattr__(name: str) -> typing.Any:
"JobProcess",
"JobContext",
"JobRequest",
"TextMessageContext",
"get_job_context",
"JobExecutorType",
"AutoSubscribe",
Expand Down
70 changes: 63 additions & 7 deletions livekit-agents/livekit/agents/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@
import logging
import os
import pathlib
import pickle
import re
import signal
import sys
import tempfile
import textwrap
import threading
import time
import traceback
from collections.abc import Iterator
from collections.abc import Awaitable, Iterator
from contextlib import contextmanager
from types import FrameType
from typing import TYPE_CHECKING, Annotated, Any, Callable, Literal, Optional, Union
Expand All @@ -37,7 +39,7 @@
from livekit import rtc

from .._exceptions import CLIError
from ..job import JobExecutorType
from ..job import JobExecutorType, TextMessageContext
from ..log import logger
from ..plugin import Plugin
from ..utils import aio
Expand Down Expand Up @@ -985,7 +987,12 @@
yield update


def _text_mode(c: AgentsConsole) -> None:
def _text_mode(
c: AgentsConsole,
*,
sms_handler: Callable[[TextMessageContext], Awaitable[None]] | None,
sess_dump_file: str | None,
) -> None:
def _key_read(ch: str) -> None:
if ch == key.CTRL_T:
raise _ToggleMode()
Expand All @@ -1006,8 +1013,32 @@

def _generate_with_context(text: str, result_fut: asyncio.Future[list[RunEvent]]) -> None:
async def _generate(text: str) -> list[RunEvent]:
sess = await c.io_session.run(user_input=text) # type: ignore
return sess.events.copy()
if sms_handler is not None:
assert sess_dump_file

sess_data: bytes | None = None
if os.path.exists(sess_dump_file):
with open(sess_dump_file, "rb") as f:
sess_data = f.read()
text_context = TextMessageContext(
text=text, session=c.io_session, sess_data=sess_data
)

await sms_handler(text_context)
result = text_context.result
if result is None:
logger.warning("result is not set from the sms handler")
return []

# serialize the state of the session
state = c.io_session.get_state()
with open(sess_dump_file, "wb") as f:
f.write(pickle.dumps(state))
logger.debug("session state serialized")
else:
result = await c.io_session.run(user_input=text) # type: ignore

return result.events.copy()

def _done_callback(task: asyncio.Task[list[RunEvent]]) -> None:
if exception := task.exception():
Expand Down Expand Up @@ -1176,6 +1207,8 @@
output_device: str | None,
mode: ConsoleMode,
record: bool,
simulate_sms: bool,
sess_dump_file: str | None,
) -> None:
c = AgentsConsole.get_instance()
c.console_mode = mode
Expand Down Expand Up @@ -1228,14 +1261,26 @@
console_worker = _ConsoleWorker(server=server, shutdown_cb=_on_worker_shutdown)
console_worker.start()

temp_file: str | None = None
sms_handler: Callable[[TextMessageContext], Awaitable[None]] | None = None
if simulate_sms:
assert server._sms_handler_fnc is not None, (
"sms_handler is required when simulating SMS"
)
sms_handler = server._sms_handler_fnc
if not sess_dump_file:
sess_dump_file = temp_file = tempfile.mktemp(
suffix=".pkl", prefix="lk-agents-sess_"
)
Comment thread Fixed

# TODO: wait for a session request the agents console context before showing any of the mode
try:
c.wait_for_io_acquisition()

while True:
try:
if c.console_mode == "text":
_text_mode(c)
_text_mode(c, sms_handler=sms_handler, sess_dump_file=sess_dump_file)
elif c.console_mode == "audio":
_audio_mode(c, input_device=input_device, output_device=output_device)

Expand All @@ -1247,6 +1292,8 @@
finally:
console_worker.shutdown()
console_worker.join()
if temp_file and pathlib.Path(temp_file).exists():
pathlib.Path(temp_file).unlink()

except CLIError as e:
c.print(" ")
Expand Down Expand Up @@ -1370,6 +1417,13 @@
bool,
typer.Option(help="Whether to start the console in text mode"),
] = False,
sms: Annotated[
bool, typer.Option(help="Whether to simulate an SMS received event")
] = False,
sess_dump_file: Annotated[
Optional[str], # noqa: UP007
typer.Option(help="Path to the serialized AgentSession data file"),
] = None,
record: Annotated[bool, typer.Option(help="Whether to record the AgentSession")] = False,
) -> None:
"""
Expand All @@ -1389,8 +1443,10 @@
server=server,
input_device=input_device,
output_device=output_device,
mode="text" if text else "audio",
mode="text" if text or sms else "audio",
record=record,
simulate_sms=sms,
sess_dump_file=sess_dump_file,
)

@app.command()
Expand Down
31 changes: 31 additions & 0 deletions livekit-agents/livekit/agents/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import json
import logging
import multiprocessing as mp
import pickle
import tempfile
from collections.abc import Coroutine
from dataclasses import dataclass
Expand Down Expand Up @@ -51,6 +52,7 @@
from .ipc.inference_executor import InferenceExecutor
from .voice.agent_session import AgentSession
from .voice.report import SessionReport
from .voice.run_result import RunResult


def get_job_context() -> JobContext:
Expand Down Expand Up @@ -129,6 +131,35 @@ def filter(self, record: logging.LogRecord) -> bool:
return True


class TextMessageContext:
def __init__(self, *, text: str, session: AgentSession, sess_data: bytes | None) -> None:
self._text = text
self._session = session
self._sess_data = sess_data
self._result: RunResult | None = None

async def send_result(self, result: RunResult) -> None:
self._result = result

async def rehydrated_session(self) -> AgentSession:
# TODO: it should be AgentSession(...).rehydrate(context)
if not self._sess_data:
return self._session

state = pickle.loads(self._sess_data)
await self._session.rehydrate(state)
logger.debug("session rehydrated")
return self._session

@property
def text(self) -> str:
return self._text

@property
def result(self) -> RunResult | None:
return self._result


class JobContext:
_PARTICIPANT_ENTRYPOINT_CALLBACK = Callable[
["JobContext", rtc.RemoteParticipant], Coroutine[None, None, None]
Expand Down
30 changes: 30 additions & 0 deletions livekit-agents/livekit/agents/voice/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def __init__(

self._mcp_servers = mcp_servers
self._activity: AgentActivity | None = None
self._rehydrated = False

@property
def id(self) -> str:
Expand Down Expand Up @@ -359,6 +360,35 @@ def _get_activity_or_raise(self) -> AgentActivity:

return self._activity

def __getstate__(self) -> dict[str, Any]:

@theomonnom theomonnom Dec 23, 2025

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.

How should it looks like from the user perspective when they add their own states, should they override this function and call super().__getstate__?

tool_ctx = llm.ToolContext(self.tools)
return {
"tools": list(tool_ctx.function_tools.keys()),
"chat_ctx": self.chat_ctx.to_dict(),
}

def __setstate__(self, state: dict[str, Any]) -> None:
try:
self.__init__()
except TypeError as e:
logger.error("Agent rehydration requires a zero-argument constructor")
raise e

tool_ctx = llm.ToolContext(self.tools)
valid_tools: list[llm.FunctionTool | llm.RawFunctionTool] = []
for name in state["tools"]:
if name in tool_ctx.function_tools:
valid_tools.append(tool_ctx.function_tools[name])
else:
logger.warning("tool not found when unpickling", extra={"missing_tool": name})

# TODO: serialize the ongoing speech tasks?
# TODO: support AgentTask

self._tools = valid_tools
self._chat_ctx = llm.ChatContext.from_dict(state["chat_ctx"])
self._rehydrated = True # skip on_enter when rehydrating

class default:
@staticmethod
async def stt_node(
Expand Down
9 changes: 5 additions & 4 deletions livekit-agents/livekit/agents/voice/agent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,10 +474,11 @@ async def _traceable_on_enter() -> None:
finally:
_OnEnterContextVar.reset(tk)

self._on_enter_task = task = self._create_speech_task(
_traceable_on_enter(), name="AgentTask_on_enter"
)
_set_activity_task_info(task, inline_task=True)
if not self._agent._rehydrated:
self._on_enter_task = task = self._create_speech_task(
_traceable_on_enter(), name="AgentTask_on_enter"
)
_set_activity_task_info(task, inline_task=True)
finally:
start_span.end()

Expand Down
30 changes: 28 additions & 2 deletions livekit-agents/livekit/agents/voice/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from types import TracebackType
from typing import (
TYPE_CHECKING,
Any,
Generic,
Literal,
Optional,
Expand Down Expand Up @@ -838,6 +839,32 @@ async def _aclose_impl(
async def aclose(self) -> None:
await self._aclose_impl(reason=CloseReason.USER_INITIATED)

def get_state(self) -> dict[str, Any]:

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.

Should we standardize this with __setstate__ and __getstate__ functions?

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.

Fwiw, for the AgentSession, I think this method should stay private

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I didn't use __setstate__ and __getstate__ because we cannot load the AgentSession using pickle.loads(data) directly if it's required to create the AgentSession in user's code.

tool_ctx = llm.ToolContext(self.tools)
return {
"tools": list(tool_ctx.function_tools.keys()),
"chat_ctx": self._chat_ctx.to_dict(),
"agent": self._agent,
}

async def rehydrate(self, state: dict[str, Any]) -> None:
tool_ctx = llm.ToolContext(self.tools)
valid_tools: list[llm.FunctionTool | llm.RawFunctionTool] = []
for name in state["tools"]:
if name in tool_ctx.function_tools:
valid_tools.append(tool_ctx.function_tools[name])
else:
logger.warning("tool not found when unpickling", extra={"missing_tool": name})

self._tools = valid_tools
self._chat_ctx = llm.ChatContext.from_dict(state["chat_ctx"])

if self._started:
# only allow rehydrate session that not started yet?
await self._update_activity(state["agent"])
else:
await self.start(agent=state["agent"])

def update_options(
self,
*,
Expand Down Expand Up @@ -1076,8 +1103,7 @@ async def _update_activity(
await self._activity.resume()

# move it outside the lock to allow calling _update_activity in on_enter of a new agent
if wait_on_enter:
assert self._activity._on_enter_task is not None
if wait_on_enter and self._activity._on_enter_task:
await asyncio.shield(self._activity._on_enter_task)

@utils.log_exceptions(logger=logger)
Expand Down
Loading
Loading