From bd19a1aa61a93c660d96d1c351cebe8a03328d35 Mon Sep 17 00:00:00 2001 From: karan-dhir Date: Fri, 1 May 2026 18:06:28 -0400 Subject: [PATCH] feat(amd): add MACHINE_SCREENING category for carrier screening services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 6th `AMDCategory` for carrier-injected call-screening prompts (Google Pixel Call Screen, iOS 18 Call Screening, and similar). Today these prompts are most often classified as `MACHINE_VM` because they sound like a TTS message after pickup, but they're not voicemail — the callee is reachable, the caller is being asked to record a brief identification. Surface contract: - `result.category === MACHINE_SCREENING` on detection. - `result.isMachine === true` so consumers' "did a machine answer?" checks behave intuitively. - `interruptOnMachine: true` does NOT auto-interrupt on screening. Callers handling screening typically need to play a short identification greeting in response, which an automatic interrupt would cancel. Implementation note: auto-interrupt now gates on `isMachineCategory(result.category)` (the narrower set excluding SCREENING), while `result.isMachine` uses the wider `isMachineResult` set including SCREENING. This is the intended asymmetry. Documented in the enum JSDoc + with examples in the LLM classification prompt (`AMD_PROMPT`). Test: new case asserts SCREENING → `isMachine: true` AND `interrupt` NOT called when `interruptOnMachine: true`. Existing 4 test cases unchanged. Vitest, lint, and tsc all clean on the changed files. Motivated by an outbound voice agent (Woflow) where Pixel Call Screen prompts were being classified as voicemail and the agent dumped a full voicemail script into Pixel's screened transcript shown to the recipient. With MACHINE_SCREENING as a distinct verdict, callers can respond appropriately without false-flagging as voicemail. --- agents/src/voice/amd.test.ts | 38 +++++++++++++++++++++++++++++++ agents/src/voice/amd.ts | 43 ++++++++++++++++++++++++++++++++---- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/agents/src/voice/amd.test.ts b/agents/src/voice/amd.test.ts index 98b767df3..a36ae2701 100644 --- a/agents/src/voice/amd.test.ts +++ b/agents/src/voice/amd.test.ts @@ -116,6 +116,44 @@ describe('AMD', () => { }); }); + it('should classify call screening as machine without auto-interrupt', async () => { + // Screening surfaces as `isMachine: true` so consumers' "did a machine + // answer?" checks see it, but `interruptOnMachine: true` does NOT + // trigger auto-interrupt — callers handling screening typically need + // to play a short identification greeting in response, which an + // automatic interrupt would cancel. + const session = new MockSession(); + const llm = new StaticLLM( + JSON.stringify({ + category: AMDCategory.MACHINE_SCREENING, + reason: 'Carrier-injected screening prompt asking for caller identification.', + }), + ); + llm.on('error', () => {}); + const amd = new AMD(asAgentSession(session), { + llm, + detectionTimeoutMs: 50, + interruptOnMachine: true, + }); + + const promise = amd.execute(); + session.emit(AgentSessionEventTypes.UserInputTranscribed, { + type: 'user_input_transcribed', + transcript: + "If you record your name and reason for calling, I'll see if this person is available", + isFinal: true, + speakerId: null, + createdAt: Date.now(), + language: null, + }); + + await expect(promise).resolves.toMatchObject({ + category: AMDCategory.MACHINE_SCREENING, + isMachine: true, + }); + expect(session.interrupt).not.toHaveBeenCalled(); + }); + it('should resume authorization when detection fails', async () => { const session = new MockSession(); const llm = new StaticLLM(new Error('boom')); diff --git a/agents/src/voice/amd.ts b/agents/src/voice/amd.ts index 9bab6efe6..c3b0fbaed 100644 --- a/agents/src/voice/amd.ts +++ b/agents/src/voice/amd.ts @@ -18,6 +18,22 @@ export enum AMDCategory { MACHINE_IVR = 'machine-ivr', MACHINE_VM = 'machine-vm', MACHINE_UNAVAILABLE = 'machine-unavailable', + /** + * A carrier-injected call-screening prompt — Google Pixel Call Screen, + * iOS 18 Call Screening, or a similar service that intercepts the call + * and asks the caller to identify themselves before reaching the human + * owner. Distinct from MACHINE_VM because the callee is reachable; the + * caller is being asked to record a brief identification. + * + * NOTE: intentionally NOT a member of `MACHINE_CATEGORIES`, so + * `interruptOnMachine: true` does NOT auto-interrupt on screening. + * Callers handling screening typically need to play a short + * identification greeting in response, which an automatic interrupt + * would cancel. `result.isMachine` still reads `true` for screening + * verdicts, so consumers' "did a machine answer?" checks behave + * intuitively. + */ + MACHINE_SCREENING = 'machine-screening', UNCERTAIN = 'uncertain', } @@ -46,18 +62,32 @@ const DEFAULT_NO_SPEECH_TIMEOUT_MS = 10_000; const DEFAULT_DETECTION_TIMEOUT_MS = 20_000; const DEFAULT_MAX_TRANSCRIPT_TURNS = 2; +// Categories that drive `interruptOnMachine` auto-interrupt logic. +// `MACHINE_SCREENING` is intentionally absent — see the enum doc. const MACHINE_CATEGORIES: ReadonlySet = new Set([ AMDCategory.MACHINE_IVR, AMDCategory.MACHINE_VM, AMDCategory.MACHINE_UNAVAILABLE, ]); +// Categories that count as "a machine answered" for `result.isMachine`. +// Includes `MACHINE_SCREENING` so consumers see screening as a machine +// event without it triggering auto-interrupt. +const MACHINE_RESULT_CATEGORIES: ReadonlySet = new Set([ + ...MACHINE_CATEGORIES, + AMDCategory.MACHINE_SCREENING, +]); + const VALID_CATEGORIES: ReadonlySet = new Set(Object.values(AMDCategory)); function isMachineCategory(category: AMDCategory): boolean { return MACHINE_CATEGORIES.has(category); } +function isMachineResult(category: AMDCategory): boolean { + return MACHINE_RESULT_CATEGORIES.has(category); +} + function parseCategory(raw: string | undefined): AMDCategory { return typeof raw === 'string' && VALID_CATEGORIES.has(raw) ? (raw as AMDCategory) @@ -66,11 +96,12 @@ function parseCategory(raw: string | undefined): AMDCategory { const AMD_PROMPT = `You classify the start of a phone call. Return strict JSON with keys "category" and "reason". -Valid categories: "human", "machine-ivr", "machine-vm", "machine-unavailable", "uncertain". +Valid categories: "human", "machine-ivr", "machine-vm", "machine-unavailable", "machine-screening", "uncertain". - "human": a live person answered. - "machine-ivr": an IVR, phone tree, or menu system answered. - "machine-vm": a voicemail greeting or mailbox prompt answered. - "machine-unavailable": the call reached an unavailable mailbox, failed mailbox, or generic machine state where no message should be left. +- "machine-screening": a carrier-injected call-screening prompt (Google Pixel Call Screen, iOS 18 Call Screening, or similar) asking the caller to identify themselves before reaching the human owner. Examples: "If you record your name and reason for calling, I'll see if this person is available.", "Please state your name and the reason for calling.", "Hi, the person you're calling is using a screening service from Google.", "After the tone, please say your name." - "uncertain": not enough evidence yet. Do not include markdown fences or extra text.`; @@ -244,7 +275,11 @@ export class AMD { this.settled = true; this.cleanup(); this.setSpanAttributes(result); - if (result.isMachine && this.interruptOnMachine) { + // Auto-interrupt gates on `isMachineCategory`, NOT `result.isMachine`, + // so MACHINE_SCREENING does not trigger auto-interrupt — callers + // handling screening typically need to play a short identification + // greeting in response, which an automatic interrupt would cancel. + if (isMachineCategory(result.category) && this.interruptOnMachine) { this.session.interrupt({ force: true }).await.catch(() => {}); } this.resolveRun?.(result); @@ -264,7 +299,7 @@ export class AMD { reason, transcript: this.joinTranscript(), rawResponse: '', - isMachine: isMachineCategory(category), + isMachine: isMachineResult(category), }); } this.machineSilenceReached = true; @@ -420,7 +455,7 @@ export class AMD { ...parsed, transcript, rawResponse, - isMachine: isMachineCategory(parsed.category), + isMachine: isMachineResult(parsed.category), }; }