Skip to content

Commit 01fecd1

Browse files
fix(interview-coach): bind grading snapshots to the tool call, not shared state
The user_turn_seq / response_turn_seq guard from 98b31fc only held until the *next* response started: that overwrote response_turn_seq, the equality became true again, and a delayed call from the earlier turn was rebound to the newer transcript. Comparing two mutable globals could never express "this call's turn" — it only described the pipeline's current position. Replaced with an immutable per-invocation snapshot. pipecat's on_function_calls_started fires when the LLM announces its calls, before the runner executes them and while the originating turn is still current, and each FunctionCallFromLLM carries a unique tool_call_id. The handler freezes the answer, question, rubric id and rubric text under that id; the tool consumes its own entry via params.tool_call_id. Nothing later can rebind it. The store is capped at MAX_CALL_SNAPSHOTS so a call whose handler never runs cannot leak. The rubric now comes from the same snapshot as the answer, so a graded turn cannot be scored against a rubric fetched for a later one. Note the event dispatches handlers as tasks rather than awaiting them, so the snapshot is not strictly guaranteed to be recorded before the handler runs. The fallback is safe by construction: with no snapshot the tool uses the supplied arguments, which are themselves bound to the invocation — never shared state that may have moved on. Verified with the reported sequence — turn 1 call, barge-in to turn 2, next response starts, then the delayed call runs: the old guard resolved to turn 2's answer and question, the snapshot stays on turn 1 with its matching rubric. Normal, missing-snapshot and prompt-injection cases all unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 98b31fc commit 01fecd1

1 file changed

Lines changed: 67 additions & 27 deletions

File tree

apps/moss-interview-coach/backend/server.py

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,25 @@ class GradeResult(BaseModel):
182182
tips: list[str] = Field(default_factory=list)
183183

184184

185+
# Cap on retained per-call snapshots; far above the handful ever in flight.
186+
MAX_CALL_SNAPSHOTS = 32
187+
188+
189+
class CallSnapshot(BaseModel):
190+
"""What was true for the turn a tool call was issued for.
191+
192+
Frozen deliberately: the point is to survive later turns mutating the
193+
shared state these values were read from.
194+
"""
195+
196+
model_config = {"frozen": True}
197+
198+
answer: str | None = None
199+
question: str | None = None
200+
rubric_id: str | None = None
201+
rubric_text: str | None = None
202+
203+
185204
class InterviewAssistState:
186205
"""Shared question text for the Assist panel and grading tool."""
187206

@@ -192,16 +211,28 @@ def __init__(self) -> None:
192211
# Incremented each time the coach starts speaking. Lets a waiter tell
193212
# "has not started yet" apart from "already finished".
194213
self.bot_speech_turns: int = 0
195-
# Incremented per captured user turn, and snapshotted when an LLM
196-
# response begins. A tool call outlives its turn (the grading tool sets
197-
# cancel_on_interruption=False), so comparing the two tells us whether
198-
# the captured transcript still belongs to the call being handled.
199-
self.user_turn_seq: int = 0
200-
self.response_turn_seq: int = -1
214+
# Immutable per-tool-call snapshots of the turn a call was issued for,
215+
# keyed by tool_call_id. A grading call outlives its turn (the tool sets
216+
# cancel_on_interruption=False), so reading shared state when it finally
217+
# runs can bind it to a later turn. Recorded when the LLM announces the
218+
# call — i.e. while the originating turn is still current.
219+
self.call_snapshots: dict[str, CallSnapshot] = {}
201220
self._grade_generation: int = 0
202221
self._grade_tasks: set[asyncio.Task[None]] = set()
203222
self._grade_lock = asyncio.Lock()
204223

224+
def record_call_snapshot(self, tool_call_id: str, snapshot: CallSnapshot) -> None:
225+
"""Bind the current turn to a tool call before it can be rebound."""
226+
# Bounded: a call whose handler never runs would otherwise leak an entry.
227+
if len(self.call_snapshots) >= MAX_CALL_SNAPSHOTS:
228+
oldest = next(iter(self.call_snapshots))
229+
self.call_snapshots.pop(oldest, None)
230+
self.call_snapshots[tool_call_id] = snapshot
231+
232+
def take_call_snapshot(self, tool_call_id: str) -> CallSnapshot | None:
233+
"""Consume the snapshot for a call. Each call may only claim its own."""
234+
return self.call_snapshots.pop(tool_call_id, None)
235+
205236
def cancel_grades(self) -> list[asyncio.Task[None]]:
206237
"""Cancel in-flight grade tasks so subprocess workers are torn down."""
207238
tasks = list(self._grade_tasks)
@@ -267,7 +298,6 @@ async def _inject_rubric(self, frame: LLMContextFrame) -> None:
267298
return
268299

269300
self.last_user_answer = user_text
270-
self._assist.user_turn_seq += 1
271301
started = time.perf_counter()
272302
try:
273303
results = await self._client.query(
@@ -328,8 +358,6 @@ async def process_frame(self, frame: Frame, direction: FrameDirection) -> None:
328358

329359
if isinstance(frame, LLMFullResponseStartFrame):
330360
self._state.bot_buf = []
331-
# This response answers whatever turn was captured most recently.
332-
self._state.response_turn_seq = self._state.user_turn_seq
333361

334362
if isinstance(frame, LLMTextFrame) and frame.text:
335363
self._state.bot_buf.append(frame.text)
@@ -450,23 +478,21 @@ async def grade_candidate_answer(
450478
# already captured the real turn, so that is the source of truth; the tool
451479
# arguments are only a fallback for when nothing was captured.
452480
#
453-
# Only when the capture still belongs to *this* call, though. The tool sets
454-
# cancel_on_interruption=False, so a call issued for one turn can execute
455-
# after the candidate has spoken again and overwritten last_user_answer —
456-
# grading the wrong turn. user_turn_seq advances on every captured turn and
457-
# response_turn_seq is snapshotted when the response carrying this call
458-
# began, so an inequality means a newer turn has landed and the supplied
459-
# argument (produced from the right turn) is the better record.
460-
capture_matches_call = assist is not None and assist.user_turn_seq == assist.response_turn_seq
481+
# It has to be *this* call's transcript, though. The tool sets
482+
# cancel_on_interruption=False, so a call issued for one turn can run after
483+
# the candidate has spoken again — and shared state read at that point
484+
# describes the newer turn. The snapshot was frozen when the LLM announced
485+
# this call, keyed by its tool_call_id, so it cannot be rebound afterwards.
486+
snapshot = assist.take_call_snapshot(params.tool_call_id) if assist else None
461487
supplied_answer = (answer or "").strip()
462-
captured_answer = (
463-
(moss.last_user_answer or "").strip() if moss and capture_matches_call else ""
464-
)
488+
captured_answer = (snapshot.answer or "").strip() if snapshot else ""
465489
answer_text = captured_answer or supplied_answer
466490
if captured_answer and supplied_answer and supplied_answer != captured_answer:
467491
logger.info("Grading the captured transcript rather than the model-supplied answer.")
468-
elif not capture_matches_call and supplied_answer:
469-
logger.info("Transcript moved on since this tool call; grading its supplied answer.")
492+
elif snapshot is None and supplied_answer:
493+
# No snapshot: fall back to the arguments, which are themselves bound to
494+
# this invocation, rather than to shared state that may have moved on.
495+
logger.info("No turn snapshot for this call; grading its supplied answer.")
470496
if not answer_text:
471497
await params.result_callback(
472498
{
@@ -479,14 +505,13 @@ async def grade_candidate_answer(
479505

480506
# Same ordering for the question: assist.last_question is what the coach was
481507
# recorded as actually asking, so it outranks the model's restatement.
482-
captured_question = (
483-
(assist.last_question or "").strip() if assist and capture_matches_call else ""
484-
)
508+
captured_question = (snapshot.question or "").strip() if snapshot else ""
485509
question_text = (
486510
captured_question or (question or "").strip() or f"General {track_label} answer"
487511
)
488-
rubric_id = moss.last_rubric_id if moss else None
489-
rubric_text = moss.last_rubric_text if moss else None
512+
# From the same snapshot, so the rubric matches the graded turn.
513+
rubric_id = snapshot.rubric_id if snapshot else (moss.last_rubric_id if moss else None)
514+
rubric_text = snapshot.rubric_text if snapshot else (moss.last_rubric_text if moss else None)
490515
turn_id = assist.begin_grading() if assist else 0
491516

492517
await _queue_rtvi(
@@ -858,6 +883,21 @@ async def _run_interview_bot(
858883
assist_state=assist_state,
859884
)
860885

886+
# Bind each tool call to the turn it was issued for, while that turn is
887+
# still current. Fires when the LLM announces its calls, before the
888+
# runner executes them — so a call that later runs after the candidate
889+
# has spoken again still grades the right transcript.
890+
@llm.event_handler("on_function_calls_started")
891+
async def on_function_calls_started(service: Any, function_calls: Any) -> None:
892+
snapshot = CallSnapshot(
893+
answer=moss_injector.last_user_answer,
894+
question=assist_state.last_question,
895+
rubric_id=moss_injector.last_rubric_id,
896+
rubric_text=moss_injector.last_rubric_text,
897+
)
898+
for call in function_calls:
899+
assist_state.record_call_snapshot(call.tool_call_id, snapshot)
900+
861901
context = LLMContext(
862902
messages=[{"role": "system", "content": system_prompt}],
863903
tools=[grade_candidate_answer],

0 commit comments

Comments
 (0)