Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 62 additions & 49 deletions assistant/src/calls/call-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion assistant/src/calls/voice-session-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down