426 lines
17 KiB
Python
426 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from livekit import api, rtc
|
|
|
|
from ... import llm, stt, tts, utils, vad
|
|
from ...job import DEFAULT_PARTICIPANT_KINDS, get_job_context
|
|
from ...llm.chat_context import Instructions
|
|
from ...llm.tool_context import ToolError, ToolFlag, function_tool
|
|
from ...log import logger
|
|
from ...types import NOT_GIVEN, NotGivenOr
|
|
from ...utils import is_given
|
|
from ...voice import room_io
|
|
from ...voice.agent import Agent, AgentTask
|
|
from ...voice.agent_session import AgentSession
|
|
from ...voice.background_audio import (
|
|
AudioConfig,
|
|
AudioSource,
|
|
BackgroundAudioPlayer,
|
|
BuiltinAudioClip,
|
|
PlayHandle,
|
|
)
|
|
from .utils import WorkflowInstructions
|
|
|
|
if TYPE_CHECKING:
|
|
from ...voice.turn import TurnDetectionMode
|
|
|
|
|
|
@dataclass
|
|
class WarmTransferResult:
|
|
human_agent_identity: str
|
|
|
|
|
|
class WarmTransferTask(AgentTask[WarmTransferResult]):
|
|
def __init__(
|
|
self,
|
|
sip_call_to: NotGivenOr[str] = NOT_GIVEN,
|
|
*,
|
|
sip_trunk_id: NotGivenOr[str | None] = NOT_GIVEN,
|
|
sip_connection: NotGivenOr[api.SIPOutboundConfig] = NOT_GIVEN,
|
|
sip_number: NotGivenOr[str] = NOT_GIVEN,
|
|
sip_headers: NotGivenOr[dict[str, str]] = NOT_GIVEN,
|
|
dtmf: NotGivenOr[str | None] = NOT_GIVEN,
|
|
ringing_timeout: NotGivenOr[float | None] = NOT_GIVEN,
|
|
hold_audio: NotGivenOr[AudioSource | AudioConfig | list[AudioConfig] | None] = NOT_GIVEN,
|
|
instructions: NotGivenOr[WorkflowInstructions | Instructions | str] = NOT_GIVEN,
|
|
chat_ctx: NotGivenOr[llm.ChatContext] = NOT_GIVEN,
|
|
turn_detection: NotGivenOr[TurnDetectionMode | None] = NOT_GIVEN,
|
|
tools: NotGivenOr[list[llm.Tool | llm.Toolset]] = NOT_GIVEN,
|
|
stt: NotGivenOr[stt.STT | None] = NOT_GIVEN,
|
|
vad: NotGivenOr[vad.VAD | None] = NOT_GIVEN,
|
|
llm: NotGivenOr[llm.LLM | llm.RealtimeModel | None] = NOT_GIVEN,
|
|
tts: NotGivenOr[tts.TTS | None] = NOT_GIVEN,
|
|
allow_interruptions: NotGivenOr[bool] = NOT_GIVEN,
|
|
# deprecated
|
|
extra_instructions: str = "",
|
|
target_phone_number: NotGivenOr[str] = NOT_GIVEN,
|
|
) -> None:
|
|
"""Initialize a WarmTransferTask to dial a human agent via SIP.
|
|
|
|
Args:
|
|
sip_call_to: The phone number or SIP URI to dial for the human agent
|
|
(e.g. ``"+15105550123"`` or ``"sip:user@example.com"``).
|
|
sip_trunk_id: ID of a pre-configured LiveKit SIP outbound trunk used to
|
|
originate the call. Falls back to the ``LIVEKIT_SIP_OUTBOUND_TRUNK``
|
|
environment variable when not provided.
|
|
sip_connection: Low-level SIP connection config (``api.SIPOutboundConfig``)
|
|
for originating calls from a **custom SIP domain** instead of through a
|
|
saved trunk. Use this when you need to specify a custom hostname,
|
|
transport, or authentication credentials directly, bypassing the
|
|
trunk-based configuration.
|
|
dtmf: DTMF tones to send once the human agent's call is answered, e.g. to dial
|
|
an extension or navigate an IVR menu (``"1234#"``). Insert ``w`` characters
|
|
to pause ~0.5s each before/between digits (``"wwww1234#"`` waits ~2s, useful
|
|
when the destination plays a greeting before accepting input).
|
|
ringing_timeout: How long to wait, in seconds, for the human agent to answer
|
|
before giving up on the call. When the timeout elapses the task completes
|
|
with a ``ToolError`` and the caller conversation resumes.
|
|
hold_audio: Audio played to the caller while they are on hold during the
|
|
transfer.
|
|
extra_instructions: Extra instructions to append to the base instructions
|
|
that are used to summarize the conversation history.
|
|
"""
|
|
|
|
if not is_given(instructions):
|
|
instructions = WorkflowInstructions(persona=PERSONA, extra=extra_instructions)
|
|
elif extra_instructions:
|
|
logger.warning("`extra_instructions` will be ignored when `instructions` is provided")
|
|
|
|
if isinstance(instructions, WorkflowInstructions):
|
|
conversation_history = self._format_conversation_history(chat_ctx)
|
|
instructions = instructions.resolve(
|
|
template=INSTRUCTIONS_TEMPLATE,
|
|
default_persona=PERSONA,
|
|
_conversation_history=conversation_history,
|
|
)
|
|
|
|
assert isinstance(instructions, (str, Instructions)) # for type checking
|
|
super().__init__(
|
|
instructions=instructions,
|
|
chat_ctx=NOT_GIVEN, # don't pass the chat_ctx
|
|
turn_detection=turn_detection,
|
|
tools=tools or [],
|
|
stt=stt,
|
|
vad=vad,
|
|
llm=llm,
|
|
tts=tts,
|
|
allow_interruptions=allow_interruptions,
|
|
)
|
|
|
|
self._caller_room: rtc.Room | None = None
|
|
self._human_agent_sess: AgentSession | None = None
|
|
self._human_agent_failed_fut: asyncio.Future[None] = asyncio.Future()
|
|
self._human_agent_identity = "human-agent-sip"
|
|
|
|
if target_phone_number:
|
|
logger.warning("`target_phone_number` is deprecated, use `sip_call_to` instead")
|
|
if not sip_call_to:
|
|
sip_call_to = target_phone_number
|
|
|
|
if not sip_call_to:
|
|
raise ValueError("`sip_call_to` must be set")
|
|
|
|
self._sip_call_to = sip_call_to
|
|
self._sip_connection = sip_connection if is_given(sip_connection) else None
|
|
if is_given(sip_trunk_id):
|
|
self._sip_trunk_id = sip_trunk_id
|
|
elif self._sip_connection is not None:
|
|
# explicit sip_connection: don't override with the env var trunk
|
|
self._sip_trunk_id = None
|
|
else:
|
|
self._sip_trunk_id = os.getenv("LIVEKIT_SIP_OUTBOUND_TRUNK", None)
|
|
if self._sip_trunk_id is None and self._sip_connection is None:
|
|
raise ValueError(
|
|
"`LIVEKIT_SIP_OUTBOUND_TRUNK` environment variable, `sip_trunk_id`,"
|
|
" or `sip_connection` must be set"
|
|
)
|
|
|
|
self._sip_number = (
|
|
sip_number if is_given(sip_number) else os.getenv("LIVEKIT_SIP_NUMBER", "")
|
|
)
|
|
self._sip_headers = sip_headers if is_given(sip_headers) else {}
|
|
self._dtmf = dtmf if is_given(dtmf) else None
|
|
self._ringing_timeout = ringing_timeout if is_given(ringing_timeout) else None
|
|
|
|
# background audio and io
|
|
self._background_audio = BackgroundAudioPlayer()
|
|
self._hold_audio_handle: PlayHandle | None = None
|
|
self._hold_audio = (
|
|
hold_audio
|
|
if is_given(hold_audio)
|
|
else AudioConfig(BuiltinAudioClip.HOLD_MUSIC, volume=0.8)
|
|
)
|
|
|
|
self._original_io_state: dict[str, bool] = {}
|
|
|
|
@staticmethod
|
|
def _format_conversation_history(chat_ctx: NotGivenOr[llm.ChatContext]) -> str:
|
|
if not is_given(chat_ctx) or not chat_ctx:
|
|
return ""
|
|
prev_convo = ""
|
|
for msg in chat_ctx.messages():
|
|
if msg.role not in ("user", "assistant"):
|
|
continue
|
|
if not msg.text_content:
|
|
continue
|
|
role = "Caller" if msg.role == "user" else "Assistant"
|
|
prev_convo += f"{role}: {msg.text_content}\n"
|
|
return prev_convo
|
|
|
|
async def on_enter(self) -> None:
|
|
job_ctx = get_job_context()
|
|
self._caller_room = job_ctx.room
|
|
|
|
# start the background audio
|
|
if self._hold_audio is not None:
|
|
await self._background_audio.start(room=self._caller_room)
|
|
self._hold_audio_handle = self._background_audio.play(self._hold_audio, loop=True)
|
|
|
|
self._set_io_enabled(False)
|
|
|
|
try:
|
|
dial_human_agent_task = asyncio.create_task(self._dial_human_agent())
|
|
done, _ = await asyncio.wait(
|
|
(dial_human_agent_task, self._human_agent_failed_fut),
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
if dial_human_agent_task not in done:
|
|
raise RuntimeError()
|
|
|
|
self._human_agent_sess = dial_human_agent_task.result()
|
|
# let the human speak first
|
|
|
|
except Exception:
|
|
logger.exception("could not dial human agent")
|
|
self._set_result(ToolError("could not dial human agent"))
|
|
return
|
|
|
|
finally:
|
|
await utils.aio.cancel_and_wait(dial_human_agent_task)
|
|
|
|
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
|
|
async def connect_to_caller(self) -> None:
|
|
"""Called when the human agent wants to connect to the caller."""
|
|
logger.debug("connecting to caller")
|
|
assert self._caller_room is not None
|
|
|
|
await self._merge_calls()
|
|
self._set_result(WarmTransferResult(human_agent_identity=self._human_agent_identity))
|
|
|
|
# when the caller or human agent leaves the room, we'll delete the room
|
|
self._caller_room.on("participant_disconnected", self._on_caller_participant_disconnected)
|
|
|
|
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
|
|
async def decline_transfer(self, reason: str) -> None:
|
|
"""Handles the case when the human agent explicitly declines to connect to the caller.
|
|
|
|
Args:
|
|
reason: A short explanation of why the human agent declined to connect to the caller
|
|
"""
|
|
self._set_result(ToolError(f"human agent declined to connect: {reason}"))
|
|
|
|
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
|
|
async def voicemail_detected(self) -> None:
|
|
"""Called when the call reaches voicemail. Use this tool AFTER you hear the voicemail greeting"""
|
|
self._set_result(ToolError("voicemail detected"))
|
|
|
|
def _on_human_agent_room_close(self, reason: rtc.DisconnectReason.ValueType) -> None:
|
|
logger.debug(
|
|
"human agent's room closed",
|
|
extra={"reason": rtc.DisconnectReason.Name(reason)},
|
|
)
|
|
with contextlib.suppress(asyncio.InvalidStateError):
|
|
self._human_agent_failed_fut.set_result(None)
|
|
|
|
self._set_result(ToolError(f"room closed: {rtc.DisconnectReason.Name(reason)}"))
|
|
|
|
def _on_caller_participant_disconnected(self, participant: rtc.RemoteParticipant) -> None:
|
|
if participant.kind not in DEFAULT_PARTICIPANT_KINDS:
|
|
return
|
|
|
|
logger.info(f"participant disconnected from caller room: {participant.identity}, closing")
|
|
|
|
assert self._caller_room is not None
|
|
self._caller_room.off("participant_disconnected", self._on_caller_participant_disconnected)
|
|
job_ctx = get_job_context()
|
|
job_ctx.delete_room(room_name=self._caller_room.name)
|
|
|
|
def _set_result(self, result: WarmTransferResult | Exception) -> None:
|
|
if self.done():
|
|
return
|
|
|
|
if self._human_agent_sess:
|
|
self._human_agent_sess.shutdown()
|
|
self._human_agent_sess = None
|
|
|
|
if self._hold_audio_handle:
|
|
self._hold_audio_handle.stop()
|
|
self._hold_audio_handle = None
|
|
|
|
self._set_io_enabled(True)
|
|
self.complete(result)
|
|
|
|
async def _dial_human_agent(self) -> AgentSession:
|
|
assert self._caller_room is not None
|
|
|
|
job_ctx = get_job_context()
|
|
ws_url = job_ctx._info.url
|
|
|
|
# create a new room for the human agent
|
|
human_agent_room_name = self._caller_room.name + "-human-agent"
|
|
room = rtc.Room()
|
|
token = (
|
|
api.AccessToken()
|
|
.with_identity(self._caller_room.local_participant.identity)
|
|
.with_grants(
|
|
api.VideoGrants(
|
|
room_join=True,
|
|
room=human_agent_room_name,
|
|
can_update_own_metadata=True,
|
|
can_publish=True,
|
|
can_subscribe=True,
|
|
)
|
|
)
|
|
.with_kind("agent")
|
|
).to_jwt()
|
|
|
|
logger.debug(
|
|
"connecting to human agent room",
|
|
extra={"ws_url": ws_url, "human_agent_room_name": human_agent_room_name},
|
|
)
|
|
await room.connect(ws_url, token)
|
|
|
|
# if human agent hung up for whatever reason, we'd resume the caller conversation
|
|
room.on("disconnected", self._on_human_agent_room_close)
|
|
|
|
human_agent_sess: AgentSession = AgentSession(
|
|
vad=self.session.vad or NOT_GIVEN,
|
|
llm=self.session.llm or NOT_GIVEN,
|
|
stt=self.session.stt or NOT_GIVEN,
|
|
tts=self.session.tts or NOT_GIVEN,
|
|
turn_detection=self.session.turn_detection or NOT_GIVEN,
|
|
)
|
|
# create a copy of this AgentTask
|
|
human_agent_agent = Agent(
|
|
instructions=self.instructions,
|
|
turn_detection=self.turn_detection,
|
|
stt=self.stt,
|
|
vad=self.vad,
|
|
llm=self.llm,
|
|
tts=self.tts,
|
|
tools=self.tools,
|
|
chat_ctx=self.chat_ctx,
|
|
allow_interruptions=self.allow_interruptions,
|
|
)
|
|
await human_agent_sess.start(
|
|
agent=human_agent_agent,
|
|
room=room,
|
|
room_options=room_io.RoomOptions(
|
|
close_on_disconnect=True,
|
|
delete_room_on_close=True,
|
|
participant_identity=self._human_agent_identity,
|
|
),
|
|
record=False, # TODO: support recording on multiple sessions?
|
|
)
|
|
|
|
# dial the human agent
|
|
sip_request = api.CreateSIPParticipantRequest(
|
|
sip_trunk_id=self._sip_trunk_id,
|
|
sip_call_to=self._sip_call_to,
|
|
room_name=human_agent_room_name,
|
|
participant_identity=self._human_agent_identity,
|
|
wait_until_answered=True,
|
|
sip_number=self._sip_number or None,
|
|
headers=self._sip_headers,
|
|
dtmf=self._dtmf or "",
|
|
)
|
|
if self._ringing_timeout is not None:
|
|
sip_request.ringing_timeout.FromNanoseconds(int(self._ringing_timeout * 1e9))
|
|
if self._sip_connection is not None:
|
|
sip_request.trunk.CopyFrom(self._sip_connection)
|
|
await job_ctx.api.sip.create_sip_participant(sip_request)
|
|
|
|
return human_agent_sess
|
|
|
|
async def _merge_calls(self) -> None:
|
|
assert self._caller_room is not None
|
|
assert self._human_agent_sess is not None
|
|
|
|
job_ctx = get_job_context()
|
|
human_agent_room = self._human_agent_sess.room_io.room
|
|
# we no longer care about the human agent session. it's supposed to be over
|
|
human_agent_room.off("disconnected", self._on_human_agent_room_close)
|
|
|
|
logger.debug(f"moving {self._human_agent_identity} to caller room {self._caller_room.name}")
|
|
await job_ctx.api.room.move_participant(
|
|
api.MoveParticipantRequest(
|
|
room=human_agent_room.name,
|
|
identity=self._human_agent_identity,
|
|
destination_room=self._caller_room.name,
|
|
)
|
|
)
|
|
|
|
def _set_io_enabled(self, enabled: bool) -> None:
|
|
input = self.session.input
|
|
output = self.session.output
|
|
|
|
if not self._original_io_state:
|
|
self._original_io_state = {
|
|
"audio_input": input.audio_enabled,
|
|
"video_input": input.video_enabled,
|
|
"audio_output": output.audio_enabled,
|
|
"transcription_output": output.transcription_enabled,
|
|
"video_output": output.video_enabled,
|
|
}
|
|
|
|
if input.audio:
|
|
input.set_audio_enabled(enabled and self._original_io_state["audio_input"])
|
|
if input.video:
|
|
input.set_video_enabled(enabled and self._original_io_state["video_input"])
|
|
if output.audio:
|
|
output.set_audio_enabled(enabled and self._original_io_state["audio_output"])
|
|
if output.transcription:
|
|
output.set_transcription_enabled(
|
|
enabled and self._original_io_state["transcription_output"]
|
|
)
|
|
if output.video:
|
|
output.set_video_enabled(enabled and self._original_io_state["video_output"])
|
|
|
|
|
|
# instructions
|
|
PERSONA = """\
|
|
# Identity
|
|
|
|
You are an agent that is reaching out to a human agent for help. There has been a previous conversation
|
|
between you and a caller, the conversation history is included below.
|
|
|
|
# Goal
|
|
|
|
Your main goal is to give the human agent sufficient context about why the caller had called in,
|
|
so that the human agent could gain sufficient knowledge to help the caller directly."""
|
|
|
|
INSTRUCTIONS_TEMPLATE = """\
|
|
{persona}
|
|
|
|
# Context
|
|
|
|
In the conversation, user refers to the human agent, caller refers to the person who's transcript is included.
|
|
Remember, you are not speaking to the caller right now, you are speaking to the human agent.
|
|
|
|
## Conversation history with caller
|
|
{_conversation_history}
|
|
## End of conversation history with caller
|
|
|
|
Once the human agent has confirmed, you should call the tool `connect_to_caller` to connect them to the caller.
|
|
|
|
You are talking to the human agent now, start by giving them a summary of the conversation so far, and answer any questions they might have.
|
|
|
|
{extra}
|
|
"""
|