From 4c4de03cc6f3e2e6d83d80c84a0aa5eb65f9b428 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 24 Feb 2026 23:03:49 -0500 Subject: [PATCH] fix: skip ASK_GUARDIAN dispatch when caller is the guardian When the guardian is on the phone, the voice LLM no longer emits [ASK_GUARDIAN:] markers. The call-control prompt now tells the model to ask them directly, and the call controller has a safety-net guard that intercepts any stray markers and re-injects them as direct questions instead of dispatching cross-channel notifications. Co-Authored-By: Claude --- assistant/src/calls/call-controller.ts | 111 +++++++++++--------- assistant/src/calls/voice-session-bridge.ts | 5 +- 2 files changed, 66 insertions(+), 50 deletions(-) diff --git a/assistant/src/calls/call-controller.ts b/assistant/src/calls/call-controller.ts index 9edfb74617f..07db8dbf194 100644 --- a/assistant/src/calls/call-controller.ts +++ b/assistant/src/calls/call-controller.ts @@ -504,58 +504,67 @@ export class CallController { const askMatch = responseText.match(ASK_GUARDIAN_CAPTURE_REGEX); if (askMatch) { const questionText = askMatch[1]; - const pendingQuestion = createPendingQuestion(this.callSessionId, questionText); - this.state = 'waiting_on_user'; - updateCallSession(this.callSessionId, { status: 'waiting_on_user' }); - recordCallEvent(this.callSessionId, 'user_question_asked', { question: questionText }); - // Notify the conversation that a question was asked - const session = getCallSession(this.callSessionId); - if (session) { - fireCallQuestionNotifier(session.conversationId, this.callSessionId, questionText); - - // Dispatch guardian action request to all configured channels - void dispatchGuardianQuestion({ - callSessionId: this.callSessionId, - conversationId: session.conversationId, - assistantId: this.assistantId, - pendingQuestion, - broadcast: this.broadcast, - }); - } + if (this.isCallerGuardian()) { + // Caller IS the guardian — don't dispatch cross-channel. + // Queue an instruction so the next turn asks them directly. + log.info({ callSessionId: this.callSessionId }, 'Caller is guardian — skipping ASK_GUARDIAN dispatch, asking directly'); + this.pendingInstructions.push(`You just tried to use [ASK_GUARDIAN] but the person on the phone IS your guardian. Ask them directly: "${questionText}"`); + // Fall through to normal turn completion (idle + flushPendingInstructions) + } else { + const pendingQuestion = createPendingQuestion(this.callSessionId, questionText); + this.state = 'waiting_on_user'; + updateCallSession(this.callSessionId, { status: 'waiting_on_user' }); + recordCallEvent(this.callSessionId, 'user_question_asked', { question: questionText }); + + // Notify the conversation that a question was asked + const session = getCallSession(this.callSessionId); + if (session) { + fireCallQuestionNotifier(session.conversationId, this.callSessionId, questionText); + + // Dispatch guardian action request to all configured channels + void dispatchGuardianQuestion({ + callSessionId: this.callSessionId, + conversationId: session.conversationId, + assistantId: this.assistantId, + pendingQuestion, + broadcast: this.broadcast, + }); + } - // Set a consultation timeout - this.consultationTimer = setTimeout(() => { - if (this.state === 'waiting_on_user') { - log.info({ callSessionId: this.callSessionId }, 'User consultation timed out'); - this.relay.sendTextToken( - 'I\'m sorry, I wasn\'t able to get that information in time. Let me move on.', - true, - ); - this.state = 'idle'; - updateCallSession(this.callSessionId, { status: 'in_progress' }); - expirePendingQuestions(this.callSessionId); - - const hasInstructions = this.pendingInstructions.length > 0; - const hasUtterances = this.pendingCallerUtterances.length > 0; - - if (hasInstructions && hasUtterances) { - // Merge both queues into a single turn to avoid the race where - // flushPendingInstructions fires a turn that gets aborted by - // drainPendingCallerUtterances, losing the instructions. - const instructionPrefix = this.pendingInstructions - .map((instr) => `[USER_INSTRUCTION: ${instr}]`) - .join('\n'); - this.pendingInstructions = []; - this.drainPendingCallerUtterances(instructionPrefix); - } else if (hasInstructions) { - this.flushPendingInstructions(); - } else if (hasUtterances) { - this.drainPendingCallerUtterances(); + // Set a consultation timeout + this.consultationTimer = setTimeout(() => { + if (this.state === 'waiting_on_user') { + log.info({ callSessionId: this.callSessionId }, 'User consultation timed out'); + this.relay.sendTextToken( + 'I\'m sorry, I wasn\'t able to get that information in time. Let me move on.', + true, + ); + this.state = 'idle'; + updateCallSession(this.callSessionId, { status: 'in_progress' }); + expirePendingQuestions(this.callSessionId); + + const hasInstructions = this.pendingInstructions.length > 0; + const hasUtterances = this.pendingCallerUtterances.length > 0; + + if (hasInstructions && hasUtterances) { + // Merge both queues into a single turn to avoid the race where + // flushPendingInstructions fires a turn that gets aborted by + // drainPendingCallerUtterances, losing the instructions. + const instructionPrefix = this.pendingInstructions + .map((instr) => `[USER_INSTRUCTION: ${instr}]`) + .join('\n'); + this.pendingInstructions = []; + this.drainPendingCallerUtterances(instructionPrefix); + } else if (hasInstructions) { + this.flushPendingInstructions(); + } else if (hasUtterances) { + this.drainPendingCallerUtterances(); + } } - } - }, getUserConsultationTimeoutMs()); - return; + }, getUserConsultationTimeoutMs()); + return; + } } // Check for END_CALL marker @@ -636,6 +645,10 @@ export class CallController { return runVersion === this.llmRunVersion; } + private isCallerGuardian(): boolean { + return this.guardianContext?.actorRole === 'guardian'; + } + /** * Check whether the underlying call session has already ended. * Used to guard against post-completion work (e.g. draining queued diff --git a/assistant/src/calls/voice-session-bridge.ts b/assistant/src/calls/voice-session-bridge.ts index 692855e8325..ca82352f0b3 100644 --- a/assistant/src/calls/voice-session-bridge.ts +++ b/assistant/src/calls/voice-session-bridge.ts @@ -109,7 +109,10 @@ function buildVoiceCallControlPrompt(opts: { 'CALL PROTOCOL RULES:', disclosureRule, '1. Be concise — keep responses to 1-3 sentences. Phone conversations should be brief and natural.', - '2. You can consult your guardian at any time by including [ASK_GUARDIAN: your question here] in your response. When you do, add a natural hold message like "Let me check on that for you."', + ...(opts.isCallerGuardian + ? ['2. You are speaking directly with your guardian (your user). Do NOT use [ASK_GUARDIAN:]. If you need permission, information, or confirmation, ask them directly in the conversation. They can answer you right now.'] + : ['2. You can consult your guardian at any time by including [ASK_GUARDIAN: your question here] in your response. When you do, add a natural hold message like "Let me check on that for you."'] + ), ); if (opts.isInbound) {