Skip to content

Commit d162ddb

Browse files
committed
fix(voice): align AMD categories with python
1 parent bab9d91 commit d162ddb

3 files changed

Lines changed: 138 additions & 13 deletions

File tree

agents/src/voice/amd.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,33 @@ describe('AMD', () => {
8989
expect(session.interrupt).toHaveBeenCalledWith({ force: true });
9090
});
9191

92+
it('should classify unavailable mailbox as machine', async () => {
93+
const session = new MockSession();
94+
const llm = new StaticLLM(
95+
JSON.stringify({
96+
category: AMDCategory.MACHINE_UNAVAILABLE,
97+
reason: 'The mailbox is unavailable and cannot accept messages.',
98+
}),
99+
);
100+
llm.on('error', () => {});
101+
const amd = new AMD(asAgentSession(session), { llm });
102+
103+
const promise = amd.execute();
104+
session.emit(AgentSessionEventTypes.UserInputTranscribed, {
105+
type: 'user_input_transcribed',
106+
transcript: 'The mailbox you are trying to reach is unavailable',
107+
isFinal: true,
108+
speakerId: null,
109+
createdAt: Date.now(),
110+
language: null,
111+
});
112+
113+
await expect(promise).resolves.toMatchObject({
114+
category: AMDCategory.MACHINE_UNAVAILABLE,
115+
isMachine: true,
116+
});
117+
});
118+
92119
it('should resume authorization when detection fails', async () => {
93120
const session = new MockSession();
94121
const llm = new StaticLLM(new Error('boom'));

agents/src/voice/amd.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import { setParticipantSpanAttributes } from './utils.js';
1010

1111
export enum AMDCategory {
1212
HUMAN = 'human',
13-
MACHINE = 'machine',
1413
MACHINE_IVR = 'machine-ivr',
1514
MACHINE_VM = 'machine-vm',
16-
UNKNOWN = 'unknown',
15+
MACHINE_UNAVAILABLE = 'machine-unavailable',
16+
UNCERTAIN = 'uncertain',
1717
}
1818

1919
export interface AMDResult {
@@ -36,12 +36,12 @@ const DEFAULT_MAX_TRANSCRIPT_TURNS = 2;
3636

3737
const AMD_PROMPT = `You classify the start of a phone call.
3838
Return strict JSON with keys "category" and "reason".
39-
Valid categories: "human", "machine", "machine-ivr", "machine-vm", "unknown".
39+
Valid categories: "human", "machine-ivr", "machine-vm", "machine-unavailable", "uncertain".
4040
- "human": a live person answered.
41-
- "machine": generic answering machine signal without enough detail to distinguish IVR or voicemail.
4241
- "machine-ivr": an IVR, phone tree, or menu system answered.
4342
- "machine-vm": a voicemail greeting or mailbox prompt answered.
44-
- "unknown": not enough evidence yet.
43+
- "machine-unavailable": the call reached an unavailable mailbox, failed mailbox, or generic machine state where no message should be left.
44+
- "uncertain": not enough evidence yet.
4545
Do not include markdown fences or extra text.`;
4646

4747
export class AMD {
@@ -157,7 +157,7 @@ export class AMD {
157157
transcript.length > 0
158158
? await this.detect(transcript)
159159
: {
160-
category: AMDCategory.UNKNOWN,
160+
category: AMDCategory.UNCERTAIN,
161161
transcript: '',
162162
reason,
163163
rawResponse: '',
@@ -180,7 +180,7 @@ export class AMD {
180180

181181
const result = await this.detect(transcriptParts.join('\n'));
182182
if (
183-
result.category !== AMDCategory.UNKNOWN ||
183+
result.category !== AMDCategory.UNCERTAIN ||
184184
transcriptParts.length >= this.maxTranscriptTurns
185185
) {
186186
await finish(result, resolveRun);
@@ -240,9 +240,9 @@ export class AMD {
240240
transcript,
241241
rawResponse,
242242
isMachine:
243-
parsed.category === AMDCategory.MACHINE ||
244243
parsed.category === AMDCategory.MACHINE_IVR ||
245-
parsed.category === AMDCategory.MACHINE_VM,
244+
parsed.category === AMDCategory.MACHINE_VM ||
245+
parsed.category === AMDCategory.MACHINE_UNAVAILABLE,
246246
};
247247
}
248248

@@ -263,7 +263,7 @@ export class AMD {
263263
};
264264
} catch {
265265
return {
266-
category: AMDCategory.UNKNOWN,
266+
category: AMDCategory.UNCERTAIN,
267267
reason: normalized || 'Failed to parse AMD model response.',
268268
};
269269
}
@@ -273,14 +273,16 @@ export class AMD {
273273
switch (category) {
274274
case AMDCategory.HUMAN:
275275
return AMDCategory.HUMAN;
276-
case AMDCategory.MACHINE:
277-
return AMDCategory.MACHINE;
278276
case AMDCategory.MACHINE_IVR:
279277
return AMDCategory.MACHINE_IVR;
280278
case AMDCategory.MACHINE_VM:
281279
return AMDCategory.MACHINE_VM;
280+
case AMDCategory.MACHINE_UNAVAILABLE:
281+
return AMDCategory.MACHINE_UNAVAILABLE;
282+
case AMDCategory.UNCERTAIN:
283+
return AMDCategory.UNCERTAIN;
282284
default:
283-
return AMDCategory.UNKNOWN;
285+
return AMDCategory.UNCERTAIN;
284286
}
285287
}
286288
}

examples/src/telephony_amd.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import {
5+
type JobContext,
6+
type JobProcess,
7+
ServerOptions,
8+
cli,
9+
defineAgent,
10+
inference,
11+
log,
12+
voice,
13+
} from '@livekit/agents';
14+
import * as livekit from '@livekit/agents-plugin-livekit';
15+
import * as silero from '@livekit/agents-plugin-silero';
16+
import { fileURLToPath } from 'node:url';
17+
18+
class MyAgent extends voice.Agent {
19+
constructor() {
20+
super({
21+
instructions:
22+
'You are reaching out to a customer with a phone call. ' +
23+
'You are calling to see if they are home. ' +
24+
'You might encounter an answering machine with a DTMF menu or IVR system. ' +
25+
'If you do, you will try to leave a message asking them to call back.',
26+
});
27+
}
28+
}
29+
30+
export default defineAgent({
31+
prewarm: async (proc: JobProcess) => {
32+
proc.userData.vad = await silero.VAD.load();
33+
},
34+
entry: async (ctx: JobContext) => {
35+
const logger = log().child({ room: ctx.room.name });
36+
37+
const session = new voice.AgentSession({
38+
stt: new inference.STT({
39+
model: 'deepgram/nova-3',
40+
language: 'multi',
41+
}),
42+
llm: new inference.LLM({ model: 'openai/gpt-4.1-mini' }),
43+
tts: new inference.TTS({
44+
model: 'cartesia/sonic-3',
45+
voice: '9626c31c-bec5-4cca-baa8-f8ba9e84c8bc',
46+
}),
47+
turnHandling: {
48+
turnDetection: new livekit.turnDetector.MultilingualModel(),
49+
},
50+
vad: ctx.proc.userData.vad! as silero.VAD,
51+
preemptiveGeneration: true,
52+
});
53+
54+
await session.start({
55+
agent: new MyAgent(),
56+
room: ctx.room,
57+
});
58+
59+
const detector = new voice.AMD(session, {
60+
llm: new inference.LLM({ model: 'openai/gpt-5-mini' }),
61+
});
62+
63+
const result = await detector.execute();
64+
65+
if (result.category === voice.AMDCategory.HUMAN) {
66+
logger.info({ amd: result }, 'human answered the call, proceeding with normal conversation');
67+
return;
68+
}
69+
70+
if (result.category === voice.AMDCategory.MACHINE_IVR) {
71+
logger.info({ amd: result }, 'ivr menu detected, starting navigation');
72+
return;
73+
}
74+
75+
if (result.category === voice.AMDCategory.MACHINE_VM) {
76+
logger.info({ amd: result }, 'voicemail detected, leaving a message');
77+
const speechHandle = session.generateReply({
78+
instructions:
79+
"You've reached voicemail. Leave a brief message asking the customer to call back.",
80+
});
81+
await speechHandle.waitForPlayout();
82+
session.shutdown({ reason: 'amd:machine-vm' });
83+
return;
84+
}
85+
86+
if (result.category === voice.AMDCategory.MACHINE_UNAVAILABLE) {
87+
logger.info({ amd: result }, 'mailbox unavailable, ending call');
88+
session.shutdown({ reason: 'amd:machine-unavailable' });
89+
return;
90+
}
91+
92+
logger.info({ amd: result }, 'answering machine detection was uncertain');
93+
},
94+
});
95+
96+
cli.runApp(new ServerOptions({ agent: fileURLToPath(import.meta.url) }));

0 commit comments

Comments
 (0)