398 lines
15 KiB
Python
398 lines
15 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import datetime
|
||
import logging
|
||
import os
|
||
import sys
|
||
from collections.abc import Callable
|
||
from dataclasses import dataclass
|
||
from typing import Literal
|
||
from zoneinfo import ZoneInfo
|
||
|
||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
import simulation
|
||
from calendar_api import (
|
||
AvailableSlot,
|
||
CalComCalendar,
|
||
Calendar,
|
||
FakeCalendar,
|
||
SlotUnavailableError,
|
||
)
|
||
from dotenv import load_dotenv
|
||
from ui_view import UIView
|
||
|
||
from livekit.agents import (
|
||
Agent,
|
||
AgentServer,
|
||
AgentSession,
|
||
JobContext,
|
||
RunContext,
|
||
SimulationContext,
|
||
ToolError,
|
||
beta,
|
||
cli,
|
||
function_tool,
|
||
get_job_context,
|
||
inference,
|
||
mock_tools,
|
||
)
|
||
from livekit.agents.evals import (
|
||
JudgeGroup,
|
||
accuracy_judge,
|
||
coherence_judge,
|
||
conciseness_judge,
|
||
handoff_judge,
|
||
relevancy_judge,
|
||
safety_judge,
|
||
task_completion_judge,
|
||
tool_use_judge,
|
||
)
|
||
from livekit.agents.voice import UserStateChangedEvent
|
||
|
||
load_dotenv()
|
||
|
||
|
||
@dataclass
|
||
class Userdata:
|
||
cal: Calendar
|
||
slot_unavailable_count: int = 0
|
||
# Optional UI for the LiveKit Playground. ``None`` when the agent
|
||
# is running anywhere else — the tool handlers no-op on it and
|
||
# the rest of the code stays oblivious to the playground.
|
||
ui: UIView | None = None
|
||
|
||
|
||
logger = logging.getLogger("front-desk")
|
||
|
||
|
||
class FrontDeskAgent(Agent):
|
||
def __init__(
|
||
self, *, timezone: str, now: Callable[[], datetime.datetime] | None = None
|
||
) -> None:
|
||
self.tz = ZoneInfo(timezone)
|
||
# the calendar's clock, so the agent's sense of "today" matches the
|
||
# availability it sees. Defaults to wall-clock; the simulation entrypoint
|
||
# passes the calendar's pinned clock. Exposed to the model via the
|
||
# get_current_time tool rather than baked into the (cached) instructions.
|
||
self._now = now or (lambda: datetime.datetime.now(self.tz))
|
||
|
||
super().__init__(
|
||
instructions=(
|
||
# Outcome — what a great interaction looks like.
|
||
"You are Front-Desk, a helpful and efficient voice assistant. "
|
||
"A great interaction ends with the user booked into an appointment slot that works "
|
||
"for them, reached through a warm, flowing conversation with as little "
|
||
"back-and-forth as possible. "
|
||
# The current date/time is not baked in (it would break the prompt cache);
|
||
# pull it from get_current_time whenever you need to reason about dates.
|
||
"You do not inherently know the current date or time — call get_current_time "
|
||
"whenever you need to reason about dates, such as interpreting a request like "
|
||
"'next Tuesday' or checking whether a date the caller mentions has already passed. "
|
||
# Voice & personality — keep it short and human.
|
||
"Your output is synthesized directly to speech, so produce a natural verbatim "
|
||
"transcript, not polished text. Start responses with real reactions (oh, hmm, ah) "
|
||
'and fillers (um, uh, like) rather than "Absolutely" or "Certainly", with '
|
||
"mid-sentence fillers (like, you know, I mean) where they’d naturally fall. Mirror "
|
||
"the user's formality: if they're casual, use informal phrasing (gotcha, alright, "
|
||
"gonna, kinda, lemme, yeah); if they're more formal, keep your speech cleaner. Vary "
|
||
"your openers across turns — if you opened the last turn with 'gotcha', pick "
|
||
"'alright' or 'okay' this turn; don't repeat the same opener back-to-back. "
|
||
# How to work — be proactive, acknowledge before acting, stop when you can move forward.
|
||
"Be proactive: when the user greets you, use it to move things forward (e.g. "
|
||
"'Would you like to book a time?') rather than just greeting back. Before a tool "
|
||
"call that takes a moment, give a brief spoken acknowledgment so there’s no dead "
|
||
"air. After each result, check whether you can now move the user toward a booking: "
|
||
"if so, do it; if you're missing something, ask for just that. "
|
||
# Speaking about times — constraints that keep it natural over voice.
|
||
"When talking about availability, call list_available_slots and offer a few clear "
|
||
"options at a time, then pause for a response and guide the user to confirm. Say "
|
||
"times like 'Monday at 2' — avoid timezones, timestamps, and the words 'AM'/'PM'; "
|
||
"use natural phrases like 'in the morning' or 'in the evening', and don’t mention "
|
||
"the year unless it differs from the current one. When listing several times in the "
|
||
"same window, group them ('in the evening at 4, 5, or 6') instead of repeating the "
|
||
"time-of-day qualifier on each slot. If a chosen time is no longer available, let "
|
||
"them know gently and offer the next options."
|
||
)
|
||
)
|
||
|
||
self._slots_map: dict[str, AvailableSlot] = {}
|
||
|
||
async def on_enter(self) -> None:
|
||
hour = self._now().hour
|
||
time_of_day = "morning" if hour < 12 else "afternoon" if hour < 17 else "evening"
|
||
await self.session.generate_reply(
|
||
instructions=(
|
||
f"Say hello and welcome to the caller — it's currently {time_of_day} their time. "
|
||
"You're the front desk of an office and you're here to help them schedule a visit. "
|
||
"Invite them to book an appointment to visit, and ask what time works. "
|
||
"Keep it warm and brief."
|
||
)
|
||
)
|
||
|
||
@function_tool
|
||
async def get_current_time(self) -> str:
|
||
"""Get the current date and time.
|
||
|
||
Call this whenever you need to reason about dates — to interpret relative
|
||
requests like "next Tuesday", or to check whether a date the caller
|
||
mentions has already passed.
|
||
"""
|
||
# Kept out of the (cached) system instructions and served on demand, so the
|
||
# prompt-cache prefix stays stable and the time is always current.
|
||
return f"The current date and time is {self._now():%A, %B %d, %Y at %H:%M %Z}."
|
||
|
||
@function_tool
|
||
async def schedule_appointment(
|
||
self,
|
||
ctx: RunContext[Userdata],
|
||
slot_id: str,
|
||
) -> str | None:
|
||
"""
|
||
Schedule an appointment at the given slot.
|
||
|
||
Args:
|
||
slot_id: The identifier for the selected time slot (as shown in the list of available slots).
|
||
"""
|
||
if not (slot := self._slots_map.get(slot_id)):
|
||
raise ToolError(f"error: slot {slot_id} was not found")
|
||
|
||
email_result = await beta.workflows.GetEmailTask(chat_ctx=self.chat_ctx)
|
||
|
||
if ctx.speech_handle.interrupted:
|
||
return None
|
||
|
||
ctx.disallow_interruptions()
|
||
|
||
try:
|
||
await ctx.userdata.cal.schedule_appointment(
|
||
start_time=slot.start_time, attendee_email=email_result.email_address
|
||
)
|
||
except SlotUnavailableError:
|
||
ctx.userdata.slot_unavailable_count += 1
|
||
try:
|
||
get_job_context().tagger.add(
|
||
"slot:unavailable",
|
||
metadata={"count": ctx.userdata.slot_unavailable_count},
|
||
)
|
||
except RuntimeError:
|
||
pass
|
||
# exceptions other than ToolError are treated as "An internal error occurred" for the LLM.
|
||
# Tell the LLM this slot isn't available anymore
|
||
raise ToolError("This slot isn't available anymore") from None
|
||
|
||
# the booking is recorded by the calendar (the system of record); no
|
||
# parallel bookkeeping here that the simulation mock would have to mirror
|
||
local = slot.start_time.astimezone(self.tz)
|
||
try:
|
||
get_job_context().tagger.add(
|
||
"appointment:booked",
|
||
metadata={"time": local.isoformat()},
|
||
)
|
||
except RuntimeError:
|
||
pass
|
||
|
||
if ctx.userdata.ui is not None:
|
||
ctx.userdata.ui.appointment_booked(slot, self.tz)
|
||
|
||
return f"The appointment was successfully scheduled for {local.strftime('%A, %B %d, %Y at %H:%M %Z')}."
|
||
|
||
@function_tool
|
||
async def list_available_slots(
|
||
self, ctx: RunContext[Userdata], range: Literal["+2week", "+1month", "+3month", "default"]
|
||
) -> str:
|
||
"""
|
||
Return a plain-text list of available slots, one per line.
|
||
|
||
<slot_id> – <Weekday>, <Month> <Day>, <Year> at <HH:MM> <TZ> (<relative time>)
|
||
|
||
You must infer the appropriate ``range`` implicitly from the
|
||
conversational context and **must not** prompt the user to pick a value
|
||
explicitly.
|
||
|
||
Args:
|
||
range: Determines how far ahead to search for free time slots.
|
||
"""
|
||
current_time = self._now()
|
||
lines: list[str] = []
|
||
|
||
if range == "+2week" or range == "default":
|
||
range_days = 14
|
||
elif range == "+1month":
|
||
range_days = 30
|
||
elif range == "+3month":
|
||
range_days = 90
|
||
|
||
slots = await ctx.userdata.cal.list_available_slots(
|
||
start_time=current_time, end_time=current_time + datetime.timedelta(days=range_days)
|
||
)
|
||
|
||
for slot in slots:
|
||
local = slot.start_time.astimezone(self.tz)
|
||
delta = local - current_time
|
||
days = delta.days
|
||
seconds = delta.seconds
|
||
|
||
if local.date() == current_time.date():
|
||
if seconds < 3600:
|
||
rel = "in less than an hour"
|
||
else:
|
||
rel = "later today"
|
||
elif local.date() == (current_time.date() + datetime.timedelta(days=1)):
|
||
rel = "tomorrow"
|
||
elif days < 7:
|
||
rel = f"in {days} days"
|
||
elif days < 14:
|
||
rel = "in 1 week"
|
||
else:
|
||
rel = f"in {days // 7} weeks"
|
||
|
||
lines.append(
|
||
f"{slot.unique_hash} – {local.strftime('%A, %B %d, %Y')} at "
|
||
f"{local:%H:%M} {local.tzname()} ({rel})"
|
||
)
|
||
self._slots_map[slot.unique_hash] = slot
|
||
|
||
if ctx.userdata.ui is not None:
|
||
ctx.userdata.ui.slots_listed(slots, current_time, self.tz, range_days)
|
||
|
||
return "\n".join(lines) or "No slots available at the moment."
|
||
|
||
|
||
server = AgentServer()
|
||
|
||
|
||
async def on_simulation_end(ctx: SimulationContext) -> None:
|
||
# grade the run on final calendar state; a mismatch vetoes the run
|
||
userdata = ctx.userdata()
|
||
if "expected_booking" not in userdata:
|
||
return # scenario graded on conversation only
|
||
|
||
cal: FakeCalendar = ctx.job_context.primary_session.userdata.cal
|
||
booked = cal.scheduled_appointments
|
||
|
||
def speak(dt: datetime.datetime) -> str:
|
||
return dt.astimezone(cal.tz).isoformat()
|
||
|
||
if (expected_raw := userdata["expected_booking"]) is None:
|
||
if booked:
|
||
times = ", ".join(speak(b.slot.start_time) for b in booked)
|
||
ctx.fail(reason=f"no booking was expected, but the agent booked: {times}")
|
||
return
|
||
|
||
expected = simulation.parse_slot(expected_raw, cal.tz)
|
||
if len(booked) != 1 or booked[0].slot.start_time != expected:
|
||
times = ", ".join(speak(b.slot.start_time) for b in booked) or "nothing"
|
||
ctx.fail(reason=f"expected a single booking at {speak(expected)}, got {times}")
|
||
|
||
|
||
async def on_session_end(ctx: JobContext) -> None:
|
||
# `on_session_end` runs even if the job crashed before the AgentSession
|
||
# started (e.g. a bad timezone, a calendar fault) — make_session_report
|
||
# raises in that case, and there's nothing to evaluate anyway.
|
||
try:
|
||
report = ctx.make_session_report()
|
||
except RuntimeError:
|
||
return
|
||
|
||
# Skip evaluation for very short conversations
|
||
chat = report.chat_history.copy(exclude_function_call=True, exclude_instructions=True)
|
||
if len(chat.items) < 3:
|
||
return
|
||
|
||
judges = JudgeGroup(
|
||
llm="openai/gpt-4o-mini",
|
||
judges=[
|
||
task_completion_judge(),
|
||
accuracy_judge(),
|
||
tool_use_judge(),
|
||
handoff_judge(),
|
||
safety_judge(),
|
||
relevancy_judge(),
|
||
coherence_judge(),
|
||
conciseness_judge(),
|
||
],
|
||
)
|
||
|
||
await judges.evaluate(report.chat_history)
|
||
|
||
userdata = ctx.primary_session.userdata
|
||
if userdata.cal.scheduled_appointments:
|
||
ctx.tagger.success()
|
||
else:
|
||
ctx.tagger.fail(reason="Appointment was not booked")
|
||
|
||
logger.info("session tags: %s", ctx.tagger.tags)
|
||
|
||
|
||
@server.rtc_session(on_session_end=on_session_end, on_simulation_end=on_simulation_end)
|
||
async def frontdesk_agent(ctx: JobContext):
|
||
await ctx.connect()
|
||
|
||
timezone = "UTC"
|
||
tool_mocks: dict[str, Callable] = {}
|
||
|
||
if sim := ctx.simulation_context():
|
||
# the scenario's userdata seeds the calendar (pinned to the scenario's
|
||
# clock so its absolute dates line up); the tools run mocked
|
||
cal = simulation.fake_calendar(sim, timezone=timezone)
|
||
tool_mocks = simulation.tool_mocks(cal, ZoneInfo(timezone))
|
||
elif cal_api_key := os.getenv("CAL_API_KEY", None):
|
||
logger.info("CAL_API_KEY detected, using cal.com calendar")
|
||
cal = CalComCalendar(api_key=cal_api_key, timezone=timezone)
|
||
else:
|
||
logger.warning(
|
||
"CAL_API_KEY is not set. Falling back to FakeCalendar; set CAL_API_KEY to enable Cal.com integration."
|
||
)
|
||
cal = FakeCalendar(timezone=timezone)
|
||
|
||
await cal.initialize()
|
||
|
||
userdata = Userdata(cal=cal, ui=UIView(ctx))
|
||
|
||
session = AgentSession[Userdata](
|
||
userdata=userdata,
|
||
stt=inference.STT("deepgram/nova-3"),
|
||
llm=inference.LLM("google/gemma-4-31b-it"),
|
||
tts=inference.TTS(
|
||
"inworld/inworld-tts-2",
|
||
voice="Nadia",
|
||
extra_kwargs={"delivery_mode": "CREATIVE", "speaking_rate": 1.1},
|
||
),
|
||
# leave max_tool_steps at the default (3) so a turn can chain
|
||
# get_current_time -> list_available_slots
|
||
# Flip user_state to "away" after 10s of mutual silence so we can
|
||
# check whether they're still there (default is 15s).
|
||
user_away_timeout=10.0,
|
||
)
|
||
|
||
idle_task: asyncio.Task[None] | None = None
|
||
|
||
async def _nudge_while_idle() -> None:
|
||
# Nudge every 10s until the user speaks again — speaking flips
|
||
# user_state out of "away", which cancels this task below.
|
||
while True:
|
||
logger.info("user idle — checking if they're still there")
|
||
await session.generate_reply(
|
||
instructions="The user has been idle, see if they're still there"
|
||
)
|
||
await asyncio.sleep(10)
|
||
|
||
@session.on("user_state_changed")
|
||
def _on_user_state_changed(ev: UserStateChangedEvent) -> None:
|
||
nonlocal idle_task
|
||
if ev.new_state == "away":
|
||
if idle_task is None or idle_task.done():
|
||
idle_task = asyncio.create_task(_nudge_while_idle())
|
||
elif idle_task is not None:
|
||
idle_task.cancel()
|
||
idle_task = None
|
||
|
||
mock_tools(FrontDeskAgent, tool_mocks, session=session)
|
||
await session.start(agent=FrontDeskAgent(timezone=timezone, now=cal.now), room=ctx.room)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
cli.run_app(server)
|