Skip to content
Open
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
38 changes: 38 additions & 0 deletions agents/src/voice/amd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
43 changes: 39 additions & 4 deletions agents/src/voice/amd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}

Expand Down Expand Up @@ -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<AMDCategory> = 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<AMDCategory> = new Set([
...MACHINE_CATEGORIES,
AMDCategory.MACHINE_SCREENING,
]);

const VALID_CATEGORIES: ReadonlySet<string> = 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)
Expand All @@ -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.`;

Expand Down Expand Up @@ -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);
Expand All @@ -264,7 +299,7 @@ export class AMD {
reason,
transcript: this.joinTranscript(),
rawResponse: '',
isMachine: isMachineCategory(category),
isMachine: isMachineResult(category),
});
}
this.machineSilenceReached = true;
Expand Down Expand Up @@ -420,7 +455,7 @@ export class AMD {
...parsed,
transcript,
rawResponse,
isMachine: isMachineCategory(parsed.category),
isMachine: isMachineResult(parsed.category),
};
}

Expand Down