Files
MockInterviewer/readme.md
2026-06-17 23:45:08 -04:00

10 KiB

AI Mock Interview Agent Walkthrough

Purpose

This project turns the LiveKit Agents Python starter into a realtime voice AI mock interviewer. The agent plays the interviewer, named Jordan, and guides the candidate through two foundational interview stages:

  1. Self-introduction
  2. Past experience

The implementation is centered in src/agent.py. It uses LiveKit's multi-agent workflow model so each interview stage has its own focused agent, prompt, entry behavior, and transition rules.

Architecture at a Glance

The runtime is one LiveKit AgentSession[InterviewData] with shared speech, voice, turn-taking, and LLM configuration. Stage-specific behavior lives in two Agent subclasses:

  • SelfIntroAgent: opens the interview and asks for the candidate's self-introduction.
  • PastExperienceAgent: asks targeted questions about the candidate's prior work or projects.

The shared session state is intentionally small:

@dataclass
class InterviewData:
    """Session-level state for the mock interview."""

    intro_was_given: bool = field(default=False)

The production session uses LiveKit Inference for models, prewarmed Silero VAD for speech detection, multilingual turn detection, adaptive interruption handling, preemptive generation, and AI-coustics noise cancellation:

session = AgentSession[InterviewData](
    llm=inference.LLM(model="openai/gpt-4.1-mini"),
    stt=inference.STT(model="deepgram/nova-3", language="en"),
    tts=inference.TTS(
        model="cartesia/sonic-3",
        voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc",
    ),
    vad=ctx.proc.userdata["vad"],
    turn_handling=TurnHandlingOptions(
        turn_detection=MultilingualModel(),
        endpointing={"mode": "dynamic", "min_delay": 0.5, "max_delay": 3.0},
        interruption={
            "mode": "adaptive",
            "min_words": 2,
            "resume_false_interruption": True,
        },
        preemptive_generation={"enabled": True, "preemptive_tts": True},
    ),
    userdata=InterviewData(),
)

Goal One: Two Interview Stages with Clear Logic

The project avoids one large, general-purpose interviewer prompt. Instead, each stage has a dedicated agent with only the instructions needed for that phase.

SelfIntroAgent is responsible for exactly one job: greet the candidate and collect a self-introduction. Its instructions explicitly tell the model to call intro_complete as soon as the candidate gives any substantive introduction:

class SelfIntroAgent(Agent):
    def __init__(self, *, silence_budget: float = 20.0, stage_budget: float = 60.0) -> None:
        super().__init__(
            instructions=textwrap.dedent(
                """\
                You are Jordan, a senior hiring manager...
                You are conducting the opening stage of a mock job interview, and your
                sole goal at this stage is to hear the candidate's self-introduction.

                ...Once they have shared a description of themselves
                and their experience — even briefly — call `intro_complete` immediately
                to advance the interview. Do not ask follow-up questions...
                """
            )
            + "\n"
            + _VOICE_RULES,
        )

When the self-introduction stage begins, the agent generates a short greeting and invitation, then starts both fallback timers:

async def on_enter(self) -> None:
    await self.session.generate_reply(
        instructions="Greet the candidate warmly and invite them to introduce themselves."
    )
    self._silence_task = asyncio.create_task(self._silence_watchdog())
    self._stage_task = asyncio.create_task(self._stage_watchdog())

PastExperienceAgent owns the second stage. It assumes the introduction has already happened and focuses on one meaningful experience at a time:

class PastExperienceAgent(Agent):
    def __init__(self, chat_ctx: ChatContext | None = None) -> None:
        super().__init__(
            instructions=textwrap.dedent(
                """\
                You are Jordan, the same senior hiring manager from the opening stage...
                You are now conducting the past-experience stage of a mock job interview.

                Pick the most relevant experience the candidate mentioned and ask them
                to walk you through it: what the challenge was, their specific
                contribution, the outcome, and what they learned.
                """
            )
            + "\n"
            + _VOICE_RULES,
            chat_ctx=chat_ctx,
        )

This split keeps each prompt short, reduces unnecessary context, and makes the conversation easier to test.

Goal Two: Smooth Stage Transition Without Repetition

The normal transition is handled by the intro_complete function tool. It is called by SelfIntroAgent after the candidate gives a self-introduction.

@function_tool
async def intro_complete(self, context: RunContext[InterviewData]):
    """Call this tool as soon as the candidate has described themselves and
    their work experience."""
    context.userdata.intro_was_given = True
    return (
        PastExperienceAgent(chat_ctx=self.chat_ctx.copy(exclude_instructions=True)),
        "Thanks for that introduction. Now I'd like to explore your past experience.",
    )

There are three important details here:

  • context.userdata.intro_was_given = True records that the normal introduction happened.
  • PastExperienceAgent(chat_ctx=self.chat_ctx.copy(exclude_instructions=True)) preserves conversation history without carrying old system instructions into the next agent.
  • The tool returns only the next agent and one transition line, avoiding competing replies during the handoff.

When PastExperienceAgent enters, it checks the shared session state. If the self-introduction happened normally, it immediately asks about the most relevant experience without reintroducing Jordan or restating the transition:

async def on_enter(self) -> None:
    if self.session.userdata.intro_was_given:
        instructions = (
            "Ask the candidate to walk you through their most relevant past experience "
            "or project. Do not re-introduce yourself or re-explain the transition — "
            "go straight to the question."
        )
    else:
        instructions = (
            "Start directly: ask the candidate to walk you through their most recent "
            "role or a project they've worked on. Do not reference any prior "
            "introduction — go straight to the question."
        )
    await self.session.generate_reply(instructions=instructions)

That fallback branch matters because forced transitions can happen even if the candidate never completed a normal introduction.

Goal Three: Time-Based Fallback for Continuous Progress

The normal path depends on the LLM calling intro_complete. To guarantee the interview still moves forward, SelfIntroAgent starts two independent watchdogs after its opening prompt:

  • Silence watchdog: moves on after continuous candidate silence.
  • Stage watchdog: moves on after the total stage time budget is reached.

The default budgets are:

def __init__(self, *, silence_budget: float = 20.0, stage_budget: float = 60.0) -> None:

The silence watchdog resets while LiveKit reports that the user is speaking:

while True:
    if self.session.user_state == "speaking":
        last_speech_time = time.monotonic()
    silence_elapsed = time.monotonic() - last_speech_time
    if silence_elapsed >= self._silence_budget:
        break
    await asyncio.sleep(min(self._silence_budget - silence_elapsed, poll))

When a fallback fires, it uses a deterministic spoken transition and then updates the active agent:

if not isinstance(self.session.current_agent, SelfIntroAgent):
    return
if self._transitioning:
    return
self._transitioning = True

await self.session.say(
    "Let's go ahead and move on to your background and experience."
)
self.session.update_agent(
    PastExperienceAgent(chat_ctx=self.chat_ctx.copy(exclude_instructions=True))
)

The fallback also protects against double transitions. Each watchdog checks whether SelfIntroAgent is still active, waits for any in-flight speech to finish, checks again, and then uses _transitioning as a final guard.

On normal handoff or fallback handoff, on_exit cancels both watchdog tasks:

async def on_exit(self) -> None:
    for task in (self._silence_task, self._stage_task):
        if task is not None:
            task.cancel()
    self._silence_task = None
    self._stage_task = None

This prevents the old stage from firing a second transition after control has already moved to PastExperienceAgent.

Options Considered but Not Used

Option Why We Did Not Use It
One monolithic interviewer agent It would mix stage instructions, increase prompt scope, and make transitions less reliable.
LLM-only transition logic If the model fails to call the handoff tool, the interview can stall. The watchdog fallback guarantees progress.
generate_reply for forced transitions Fallback movement needs deterministic wording. session.say(...) avoids wandering or repeated explanations.
Dropping chat history on handoff The past-experience stage needs the candidate's introduction. Without copied chat_ctx, the candidate may have to repeat themselves.
Direct provider plugins for every model LiveKit Inference keeps model routing under LiveKit Cloud credentials and avoids separate provider-key setup for the demo.
Extra interview stages now The current roadmap limits scope to the foundational two-stage demo. More stages can be added later with the same handoff pattern.

Verification

The test suite in tests/test_agent.py covers the core behaviors:

  • test_self_intro_greeting: verifies the opening agent greets and asks for an introduction.
  • test_intro_complete_handoff: verifies a complete introduction calls intro_complete and transfers to PastExperienceAgent.
  • test_no_premature_handoff: verifies a bare greeting does not trigger the handoff.
  • test_watchdog_cancelled_on_normal_handoff: verifies fallback timers are cancelled after normal transition.
  • test_fallback_handoff: verifies the silence fallback moves to PastExperienceAgent.
  • test_silence_reset_on_speech: verifies the silence timer resets while the candidate is speaking.