@guava-ai/guava-sdk 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/examples/example.test.d.ts +5 -0
- package/dist/examples/example.test.js +46 -0
- package/dist/examples/example.test.js.map +1 -0
- package/dist/examples/help-desk.d.ts +3 -1
- package/dist/examples/help-desk.js +25 -9
- package/dist/examples/help-desk.js.map +1 -1
- package/dist/examples/property-insurance.js +4 -1
- package/dist/examples/property-insurance.js.map +1 -1
- package/dist/examples/restaurant-waitlist.js +4 -1
- package/dist/examples/restaurant-waitlist.js.map +1 -1
- package/dist/examples/scheduling-outbound.js +6 -0
- package/dist/examples/scheduling-outbound.js.map +1 -1
- package/dist/src/action-item.d.ts +4 -4
- package/dist/src/agent.d.ts +85 -18
- package/dist/src/agent.js +404 -129
- package/dist/src/agent.js.map +1 -1
- package/dist/src/auth.d.ts +27 -0
- package/dist/src/auth.js +127 -0
- package/dist/src/auth.js.map +1 -0
- package/dist/src/call.d.ts +1 -1
- package/dist/src/call.js +2 -2
- package/dist/src/call.js.map +1 -1
- package/dist/src/client.d.ts +38 -12
- package/dist/src/client.js +88 -16
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands.d.ts +3 -3
- package/dist/src/events.d.ts +87 -0
- package/dist/src/events.js +25 -6
- package/dist/src/events.js.map +1 -1
- package/dist/src/helpers/llm.d.ts +2 -0
- package/dist/src/helpers/llm.js +17 -0
- package/dist/src/helpers/llm.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +5 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/logging.js +16 -11
- package/dist/src/logging.js.map +1 -1
- package/dist/src/sms.d.ts +19 -0
- package/dist/src/sms.js +52 -0
- package/dist/src/sms.js.map +1 -0
- package/dist/src/socket/call-info.d.ts +35 -0
- package/dist/src/socket/call-info.js +59 -0
- package/dist/src/socket/call-info.js.map +1 -0
- package/dist/src/socket/client.d.ts +51 -0
- package/dist/src/socket/client.js +455 -0
- package/dist/src/socket/client.js.map +1 -0
- package/dist/src/socket/listen-inbound.d.ts +83 -0
- package/dist/src/socket/listen-inbound.js +82 -0
- package/dist/src/socket/listen-inbound.js.map +1 -0
- package/dist/src/socket/protocol.d.ts +127 -0
- package/dist/src/socket/protocol.js +69 -0
- package/dist/src/socket/protocol.js.map +1 -0
- package/dist/src/socket/utils.d.ts +8 -0
- package/dist/src/socket/utils.js +26 -0
- package/dist/src/socket/utils.js.map +1 -0
- package/dist/src/telemetry.d.ts +3 -3
- package/dist/src/telemetry.js +9 -7
- package/dist/src/telemetry.js.map +1 -1
- package/dist/src/testing/chat.d.ts +2 -0
- package/dist/src/testing/chat.js +181 -0
- package/dist/src/testing/chat.js.map +1 -0
- package/dist/src/testing/mocks.d.ts +6 -0
- package/dist/src/testing/mocks.js +14 -0
- package/dist/src/testing/mocks.js.map +1 -0
- package/dist/src/testing/protocol.d.ts +46 -0
- package/dist/src/testing/protocol.js +61 -0
- package/dist/src/testing/protocol.js.map +1 -0
- package/dist/src/testing/session.d.ts +26 -0
- package/dist/src/testing/session.js +219 -0
- package/dist/src/testing/session.js.map +1 -0
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.js +19 -1
- package/dist/src/utils.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/examples/example.test.ts +58 -0
- package/examples/help-desk.ts +14 -3
- package/examples/property-insurance.ts +3 -1
- package/examples/restaurant-waitlist.ts +3 -1
- package/examples/scheduling-outbound.ts +7 -0
- package/package.json +10 -2
- package/src/agent.ts +386 -166
- package/src/auth.ts +109 -0
- package/src/call.ts +3 -3
- package/src/client.ts +119 -18
- package/src/events.ts +52 -10
- package/src/helpers/llm.ts +20 -0
- package/src/index.ts +4 -0
- package/src/logging.ts +21 -13
- package/src/sms.ts +17 -0
- package/src/socket/call-info.ts +30 -0
- package/src/socket/client.ts +433 -0
- package/src/socket/listen-inbound.ts +62 -0
- package/src/socket/protocol.ts +89 -0
- package/src/socket/utils.ts +25 -0
- package/src/telemetry.ts +11 -8
- package/src/testing/chat.ts +196 -0
- package/src/testing/mocks.ts +12 -0
- package/src/testing/protocol.ts +40 -0
- package/src/testing/session.ts +218 -0
- package/src/utils.ts +19 -1
- package/src/version.ts +1 -1
package/src/agent.ts
CHANGED
|
@@ -1,35 +1,33 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { Call, Client } from "./index.ts";
|
|
3
3
|
import { getDefaultLogger, type Logger } from "./logging.ts";
|
|
4
|
-
import { getBaseUrl } from "./utils.ts";
|
|
4
|
+
import { getBaseUrl, fetchOrThrow } from "./utils.ts";
|
|
5
5
|
import { runWebrtcHelper } from "./webrtc-helper.ts";
|
|
6
|
-
import * as z from "zod";
|
|
7
6
|
import {
|
|
8
|
-
ListenInboundCommand,
|
|
9
|
-
InboundTunnelCommand,
|
|
10
7
|
AnswerQuestionCommand,
|
|
11
8
|
ChoiceResultCommand,
|
|
12
9
|
RegisteredHooksCommand,
|
|
13
|
-
AcceptInboundCallCommand,
|
|
14
|
-
RejectInboundCallCommand,
|
|
15
|
-
StartOutboundCallCommand,
|
|
16
10
|
ActionSuggestionCommand,
|
|
11
|
+
type Command,
|
|
17
12
|
} from "./commands.ts";
|
|
18
13
|
import {
|
|
19
14
|
type GuavaEvent,
|
|
20
15
|
type CallerSpeechEvent,
|
|
21
16
|
type AgentSpeechEvent,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
decodeEvent,
|
|
17
|
+
type BotSessionEnded,
|
|
18
|
+
type DTMFPressedEvent,
|
|
19
|
+
decodeEventDict,
|
|
26
20
|
} from "./events.ts";
|
|
27
21
|
import { telemetryClient } from "./telemetry.ts";
|
|
22
|
+
import { GuavaSocket, GuavaSocketClosedError } from "./socket/client.ts";
|
|
23
|
+
import * as ListenInbound from "./socket/listen-inbound.ts";
|
|
24
|
+
import type { CallInfo } from "./socket/call-info.ts";
|
|
25
|
+
import { TestSession } from "./testing/session.ts";
|
|
26
|
+
import { SessionStarted } from "./testing/protocol.ts";
|
|
27
|
+
import { runChat } from "./testing/chat.ts";
|
|
28
|
+
import { _generate } from "./helpers/llm.ts";
|
|
28
29
|
|
|
29
|
-
export
|
|
30
|
-
caller_number: string | null;
|
|
31
|
-
agent_number: string | null;
|
|
32
|
-
}
|
|
30
|
+
export type { CallInfo } from "./socket/call-info.ts";
|
|
33
31
|
|
|
34
32
|
export type IncomingCallAction = { action: "accept" } | { action: "decline" };
|
|
35
33
|
|
|
@@ -38,13 +36,6 @@ export interface SuggestedAction {
|
|
|
38
36
|
description?: string;
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
/**
|
|
42
|
-
* @description convenience function for stringifying data according to a schema
|
|
43
|
-
*/
|
|
44
|
-
function stringifyZod<Schema extends z.ZodType>(schema: Schema, data: z.input<Schema>): string {
|
|
45
|
-
return JSON.stringify(schema.parse(data));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
39
|
export type InboundConnection = { agent_number: string } | { webrtc_code: string };
|
|
49
40
|
|
|
50
41
|
@telemetryClient.trackClass()
|
|
@@ -75,7 +66,8 @@ export class Agent {
|
|
|
75
66
|
) => Promise<SuggestedAction | undefined>;
|
|
76
67
|
private _onActionGeneric?: (call: Call, actionKey: string) => Promise<void>;
|
|
77
68
|
private _onActionHandlers: Record<string, (call: Call) => Promise<void>> = {};
|
|
78
|
-
private _onSessionEnd?: (call: Call) => Promise<void>;
|
|
69
|
+
private _onSessionEnd?: (call: Call, event: BotSessionEnded) => Promise<void>;
|
|
70
|
+
private _onDtmf?: (call: Call, event: DTMFPressedEvent) => Promise<void>;
|
|
79
71
|
|
|
80
72
|
constructor(args?: { name?: string; organization?: string; purpose?: string }) {
|
|
81
73
|
this._name = args?.name;
|
|
@@ -149,10 +141,60 @@ export class Agent {
|
|
|
149
141
|
}
|
|
150
142
|
}
|
|
151
143
|
|
|
152
|
-
onSessionEnd(callback: (call: Call) => Promise<void>): void {
|
|
144
|
+
onSessionEnd(callback: (call: Call, event: BotSessionEnded) => Promise<void>): void {
|
|
153
145
|
this._onSessionEnd = callback;
|
|
154
146
|
}
|
|
155
147
|
|
|
148
|
+
onDtmf(callback: (call: Call, event: DTMFPressedEvent) => Promise<void>): void {
|
|
149
|
+
this._onDtmf = callback;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get handlers() {
|
|
153
|
+
return {
|
|
154
|
+
onCallReceived: (callInfo: CallInfo) => this._onCallReceived(callInfo),
|
|
155
|
+
onCallStart: (call: Call) => {
|
|
156
|
+
if (!this._onCallStart) throw new Error("No onCallStart handler registered.");
|
|
157
|
+
return this._onCallStart(call);
|
|
158
|
+
},
|
|
159
|
+
onCallerSpeech: (call: Call, event: CallerSpeechEvent) => {
|
|
160
|
+
if (!this._onCallerSpeech) throw new Error("No onCallerSpeech handler registered.");
|
|
161
|
+
return this._onCallerSpeech(call, event);
|
|
162
|
+
},
|
|
163
|
+
onAgentSpeech: (call: Call, event: AgentSpeechEvent) => {
|
|
164
|
+
if (!this._onAgentSpeech) throw new Error("No onAgentSpeech handler registered.");
|
|
165
|
+
return this._onAgentSpeech(call, event);
|
|
166
|
+
},
|
|
167
|
+
onQuestion: (call: Call, question: string) => {
|
|
168
|
+
if (!this._onQuestion) throw new Error("No onQuestion handler registered.");
|
|
169
|
+
return this._onQuestion(call, question);
|
|
170
|
+
},
|
|
171
|
+
onTaskComplete: (taskId: string, call: Call) => {
|
|
172
|
+
if (this._onTaskCompleteGeneric) return this._onTaskCompleteGeneric(call, taskId);
|
|
173
|
+
if (taskId in this._onTaskCompleteHandlers)
|
|
174
|
+
return this._onTaskCompleteHandlers[taskId](call);
|
|
175
|
+
throw new Error(`No onTaskComplete handler registered for task '${taskId}'.`);
|
|
176
|
+
},
|
|
177
|
+
onSearchQuery: (fieldKey: string, call: Call, query: string) => {
|
|
178
|
+
if (!(fieldKey in this._searchQueryHandlers))
|
|
179
|
+
throw new Error(`No onSearchQuery handler registered for field '${fieldKey}'.`);
|
|
180
|
+
return this._searchQueryHandlers[fieldKey](call, query);
|
|
181
|
+
},
|
|
182
|
+
onActionRequest: (call: Call, intentSummary: string) => {
|
|
183
|
+
if (!this._onActionRequested) throw new Error("No onActionRequest handler registered.");
|
|
184
|
+
return this._onActionRequested(call, intentSummary);
|
|
185
|
+
},
|
|
186
|
+
onAction: (actionKey: string, call: Call) => {
|
|
187
|
+
if (this._onActionGeneric) return this._onActionGeneric(call, actionKey);
|
|
188
|
+
if (actionKey in this._onActionHandlers) return this._onActionHandlers[actionKey](call);
|
|
189
|
+
throw new Error(`No onAction handler registered for action '${actionKey}'.`);
|
|
190
|
+
},
|
|
191
|
+
onSessionEnd: (call: Call, event: BotSessionEnded) => {
|
|
192
|
+
if (!this._onSessionEnd) throw new Error("No onSessionEnd handler registered.");
|
|
193
|
+
return this._onSessionEnd(call, event);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
156
198
|
onReachPerson(callback: (call: Call, availability: string) => Promise<void>): void {
|
|
157
199
|
this.onTaskComplete("reach_person", async (call) => {
|
|
158
200
|
const availability = (await call.getField("contact_availability")) as string;
|
|
@@ -160,13 +202,11 @@ export class Agent {
|
|
|
160
202
|
});
|
|
161
203
|
}
|
|
162
204
|
|
|
163
|
-
listenPhone(phoneNumber: string):
|
|
164
|
-
return this._listenInbound({
|
|
165
|
-
agent_number: phoneNumber,
|
|
166
|
-
});
|
|
205
|
+
async listenPhone(phoneNumber: string): Promise<void> {
|
|
206
|
+
return this._listenInbound({ agent_number: phoneNumber });
|
|
167
207
|
}
|
|
168
208
|
|
|
169
|
-
async listenWebrtc(webrtcCode?: string): Promise<
|
|
209
|
+
async listenWebrtc(webrtcCode?: string): Promise<void> {
|
|
170
210
|
if (!webrtcCode) {
|
|
171
211
|
this._logger.info("No WebRTC code provided. Creating a temporary one.");
|
|
172
212
|
webrtcCode = await this._client.createWebrtcAgent(3600);
|
|
@@ -176,11 +216,13 @@ export class Agent {
|
|
|
176
216
|
|
|
177
217
|
async callLocal(): Promise<void> {
|
|
178
218
|
const webrtcCode = await this._client.createWebrtcAgent(300);
|
|
179
|
-
this._listenInbound({ webrtc_code: webrtcCode })
|
|
219
|
+
this._listenInbound({ webrtc_code: webrtcCode }).catch((err) => {
|
|
220
|
+
this._logger.error("Listen loop error: %s", err);
|
|
221
|
+
});
|
|
180
222
|
await runWebrtcHelper(webrtcCode, getBaseUrl());
|
|
181
223
|
}
|
|
182
224
|
|
|
183
|
-
private async _dispatchEvent(call: Call, event: GuavaEvent) {
|
|
225
|
+
private async _dispatchEvent(call: Call, event: GuavaEvent, testSession?: TestSession) {
|
|
184
226
|
if (event.event_type === "caller-speech") {
|
|
185
227
|
if (this._onCallerSpeech !== undefined) {
|
|
186
228
|
await this._onCallerSpeech(call, event);
|
|
@@ -189,17 +231,6 @@ export class Agent {
|
|
|
189
231
|
if (this._onAgentSpeech !== undefined) {
|
|
190
232
|
await this._onAgentSpeech(call, event);
|
|
191
233
|
}
|
|
192
|
-
} else if (event.event_type === "inbound-call") {
|
|
193
|
-
this._logger.info(`Received inbound call from ${event.caller_number ?? "unknown"}`);
|
|
194
|
-
const action = await this._onCallReceived({
|
|
195
|
-
caller_number: event.caller_number,
|
|
196
|
-
agent_number: event.agent_number,
|
|
197
|
-
});
|
|
198
|
-
if (action.action === "accept") {
|
|
199
|
-
call.sendCommand(AcceptInboundCallCommand, { command_type: "accept-inbound" });
|
|
200
|
-
} else {
|
|
201
|
-
call.sendCommand(RejectInboundCallCommand, { command_type: "reject-inbound" });
|
|
202
|
-
}
|
|
203
234
|
} else if (event.event_type === "task-done") {
|
|
204
235
|
this._logger.info(`Task ${event.task_id} completed.`);
|
|
205
236
|
if (this._onTaskCompleteGeneric !== undefined) {
|
|
@@ -219,7 +250,7 @@ export class Agent {
|
|
|
219
250
|
this._logger.error(`Error occurred while answering question: ${err}`);
|
|
220
251
|
answer = "An error occurred and the question could not be answered.";
|
|
221
252
|
}
|
|
222
|
-
call.sendCommand(AnswerQuestionCommand, {
|
|
253
|
+
await call.sendCommand(AnswerQuestionCommand, {
|
|
223
254
|
command_type: "answer-question",
|
|
224
255
|
question_id: event.question_id,
|
|
225
256
|
answer,
|
|
@@ -228,14 +259,13 @@ export class Agent {
|
|
|
228
259
|
this._logger.warn(
|
|
229
260
|
`Received question but no onQuestion handler is registered: ${event.question}`,
|
|
230
261
|
);
|
|
231
|
-
call.sendCommand(AnswerQuestionCommand, {
|
|
262
|
+
await call.sendCommand(AnswerQuestionCommand, {
|
|
232
263
|
command_type: "answer-question",
|
|
233
264
|
question_id: event.question_id,
|
|
234
265
|
answer: "I don't have an answer to that question.",
|
|
235
266
|
});
|
|
236
267
|
}
|
|
237
268
|
} else if (event.event_type === "action-item-done") {
|
|
238
|
-
this._logger.info(`Action item '${event.key}' completed.`);
|
|
239
269
|
call._fieldValues[event.key] = event.payload;
|
|
240
270
|
} else if (event.event_type === "choice-query") {
|
|
241
271
|
this._logger.info(`Received search query for field '${event.field_key}': ${event.query}`);
|
|
@@ -246,7 +276,7 @@ export class Agent {
|
|
|
246
276
|
);
|
|
247
277
|
} else {
|
|
248
278
|
const [matchedChoices, otherChoices] = await handler(call, event.query);
|
|
249
|
-
call.sendCommand(ChoiceResultCommand, {
|
|
279
|
+
await call.sendCommand(ChoiceResultCommand, {
|
|
250
280
|
command_type: "choice-query-result",
|
|
251
281
|
field_key: event.field_key,
|
|
252
282
|
query_id: event.query_id,
|
|
@@ -260,7 +290,7 @@ export class Agent {
|
|
|
260
290
|
if (this._onActionRequested !== undefined) {
|
|
261
291
|
suggestion = await this._onActionRequested(call, event.intent_summary);
|
|
262
292
|
}
|
|
263
|
-
call.sendCommand(ActionSuggestionCommand, {
|
|
293
|
+
await call.sendCommand(ActionSuggestionCommand, {
|
|
264
294
|
command_type: "action-suggestion",
|
|
265
295
|
intent_id: event.intent_id,
|
|
266
296
|
action_key: suggestion?.key ?? null,
|
|
@@ -268,6 +298,9 @@ export class Agent {
|
|
|
268
298
|
});
|
|
269
299
|
} else if (event.event_type === "execute-action") {
|
|
270
300
|
this._logger.info(`Executing action '${event.action_key}'`);
|
|
301
|
+
if (testSession) {
|
|
302
|
+
testSession.executedActions.push(event.action_key);
|
|
303
|
+
}
|
|
271
304
|
let onActionFunc: (() => Promise<void>) | undefined;
|
|
272
305
|
if (this._onActionGeneric !== undefined) {
|
|
273
306
|
onActionFunc = () => this._onActionGeneric!(call, event.action_key);
|
|
@@ -280,9 +313,14 @@ export class Agent {
|
|
|
280
313
|
this._logger.warn(`No handler registered for action '${event.action_key}'`);
|
|
281
314
|
}
|
|
282
315
|
} else if (event.event_type === "bot-session-ended") {
|
|
283
|
-
this._logger.info(
|
|
284
|
-
if (
|
|
285
|
-
|
|
316
|
+
this._logger.info(`Session ended: ${event.termination_reason}`);
|
|
317
|
+
if (testSession) {
|
|
318
|
+
testSession.terminationReason = event.termination_reason;
|
|
319
|
+
}
|
|
320
|
+
await this._onSessionEnd?.(call, event);
|
|
321
|
+
} else if (event.event_type === "dtmf") {
|
|
322
|
+
if (this._onDtmf !== undefined) {
|
|
323
|
+
await this._onDtmf(call, event);
|
|
286
324
|
}
|
|
287
325
|
} else if (event.event_type === "error") {
|
|
288
326
|
this._logger.error(`The Guava agent reported an error: ${event.content}`);
|
|
@@ -291,14 +329,14 @@ export class Agent {
|
|
|
291
329
|
}
|
|
292
330
|
}
|
|
293
331
|
|
|
294
|
-
async
|
|
332
|
+
async _initCall(variables: Record<string, any> = {}): Promise<Call> {
|
|
295
333
|
const call = new Call(variables);
|
|
296
|
-
call.setPersona({
|
|
334
|
+
await call.setPersona({
|
|
297
335
|
agentName: this._name,
|
|
298
336
|
agentPurpose: this._purpose,
|
|
299
337
|
organizationName: this._organization,
|
|
300
338
|
});
|
|
301
|
-
call.sendCommand(RegisteredHooksCommand, {
|
|
339
|
+
await call.sendCommand(RegisteredHooksCommand, {
|
|
302
340
|
command_type: "registered-hooks",
|
|
303
341
|
has_on_question: this._onQuestion !== undefined,
|
|
304
342
|
has_on_intent: false,
|
|
@@ -310,142 +348,335 @@ export class Agent {
|
|
|
310
348
|
return call;
|
|
311
349
|
}
|
|
312
350
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
351
|
+
async _attachToCall(
|
|
352
|
+
callId: string,
|
|
353
|
+
initialVariables: Record<string, any> = {},
|
|
354
|
+
testSession?: TestSession,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
const call = await this._initCall(initialVariables);
|
|
357
|
+
|
|
358
|
+
const url = new URL(`v2/connect-call/${callId}`, this._client.getWebsocketBase());
|
|
359
|
+
await using socket = await new GuavaSocket<Command, GuavaEvent | null>(
|
|
360
|
+
`call-connection-${callId}`,
|
|
361
|
+
url.toString(),
|
|
362
|
+
this._client,
|
|
363
|
+
(cmd) => cmd as unknown as Record<string, unknown>,
|
|
364
|
+
(payload) => decodeEventDict(payload),
|
|
365
|
+
18000,
|
|
366
|
+
).connect();
|
|
367
|
+
|
|
368
|
+
await call.setDrain(async (commands) => {
|
|
369
|
+
for (const command of commands.splice(0)) {
|
|
370
|
+
socket.send(command);
|
|
371
|
+
}
|
|
320
372
|
});
|
|
321
|
-
|
|
322
|
-
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
for await (const event of socket) {
|
|
376
|
+
if (event === null) continue;
|
|
377
|
+
await this._dispatchEvent(call, event, testSession);
|
|
378
|
+
if (
|
|
379
|
+
event.event_type === "bot-session-ended" ||
|
|
380
|
+
event.event_type === "outbound-call-failed"
|
|
381
|
+
) {
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} catch (e) {
|
|
386
|
+
if (!(e instanceof GuavaSocketClosedError)) throw e;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async _listenInbound(conn: InboundConnection): Promise<void> {
|
|
391
|
+
const url = new URL("v2/listen-inbound", this._client.getWebsocketBase());
|
|
323
392
|
if ("agent_number" in conn) {
|
|
324
|
-
|
|
393
|
+
url.searchParams.set("phone_number", conn.agent_number);
|
|
325
394
|
} else {
|
|
326
|
-
webrtc_code
|
|
395
|
+
url.searchParams.set("webrtc_code", conn.webrtc_code);
|
|
327
396
|
}
|
|
328
397
|
|
|
329
|
-
|
|
398
|
+
await using socket = await new GuavaSocket<
|
|
399
|
+
ListenInbound.ClientMessage,
|
|
400
|
+
ListenInbound.ServerMessage
|
|
401
|
+
>(
|
|
402
|
+
"listen-inbound",
|
|
403
|
+
url.toString(),
|
|
404
|
+
this._client,
|
|
405
|
+
(msg) => msg as unknown as Record<string, unknown>,
|
|
406
|
+
ListenInbound.decodeServerMessage,
|
|
407
|
+
).connect();
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
for await (const msg of socket) {
|
|
411
|
+
switch (msg.message_type) {
|
|
412
|
+
case "listen-started":
|
|
413
|
+
if ("agent_number" in conn) {
|
|
414
|
+
this._logger.info(
|
|
415
|
+
"Listening on %s (%d other listeners).",
|
|
416
|
+
conn.agent_number,
|
|
417
|
+
msg.other_listeners,
|
|
418
|
+
);
|
|
419
|
+
} else {
|
|
420
|
+
this._logger.info(
|
|
421
|
+
"Listening on WebRTC code %s (%d other listeners).",
|
|
422
|
+
conn.webrtc_code,
|
|
423
|
+
msg.other_listeners,
|
|
424
|
+
);
|
|
425
|
+
const debugUrl = new URL(
|
|
426
|
+
`debug-webrtc?webrtc_code=${conn.webrtc_code}`,
|
|
427
|
+
this._client.getHttpBase(),
|
|
428
|
+
);
|
|
429
|
+
this._logger.info("Call your agent at: %s", debugUrl);
|
|
430
|
+
}
|
|
431
|
+
break;
|
|
432
|
+
case "incoming-call":
|
|
433
|
+
socket.send({ message_type: "claim-call", call_id: msg.call_id });
|
|
434
|
+
break;
|
|
435
|
+
case "assign-call": {
|
|
436
|
+
const { call_id, call_info } = msg;
|
|
437
|
+
this._logger.info("Received call (session ID: %s)", call_id);
|
|
438
|
+
this._handleAssignedCall(call_id, call_info, socket).catch((err) => {
|
|
439
|
+
this._logger.error("Error handling assigned call %s: %s", call_id, err);
|
|
440
|
+
});
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} catch (e) {
|
|
446
|
+
if (!(e instanceof GuavaSocketClosedError)) throw e;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
330
449
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
450
|
+
private async _handleAssignedCall(
|
|
451
|
+
callId: string,
|
|
452
|
+
callInfo: CallInfo,
|
|
453
|
+
socket: GuavaSocket<ListenInbound.ClientMessage, ListenInbound.ServerMessage>,
|
|
454
|
+
initialVariables: Record<string, any> = {},
|
|
455
|
+
): Promise<void> {
|
|
456
|
+
const action = await this._onCallReceived(callInfo);
|
|
457
|
+
if (action.action === "decline") {
|
|
458
|
+
this._logger.info("Declining call %s.", callId);
|
|
459
|
+
socket.send({ message_type: "decline-call", call_id: callId });
|
|
460
|
+
} else {
|
|
461
|
+
this._logger.info("Accepting call %s.", callId);
|
|
462
|
+
socket.send({ message_type: "answer-call", call_id: callId });
|
|
463
|
+
await this._attachToCall(callId, initialVariables);
|
|
337
464
|
}
|
|
465
|
+
}
|
|
338
466
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
467
|
+
/**
|
|
468
|
+
* @description use the Guava API to call out to a number
|
|
469
|
+
*/
|
|
470
|
+
async callPhone(
|
|
471
|
+
fromNumber: string | undefined,
|
|
472
|
+
toNumber: string,
|
|
473
|
+
variables: Record<string, any> = {},
|
|
474
|
+
): Promise<void> {
|
|
475
|
+
const url = new URL("v2/create-outbound", this._client.getHttpBase());
|
|
476
|
+
if (fromNumber) url.searchParams.set("from_number", fromNumber);
|
|
477
|
+
url.searchParams.set("to_number", toNumber);
|
|
478
|
+
|
|
479
|
+
const response = await fetchOrThrow(url, {
|
|
480
|
+
method: "POST",
|
|
481
|
+
headers: await this._client.headers(),
|
|
347
482
|
});
|
|
483
|
+
const { call_id } = (await response.json()) as { call_id: string };
|
|
348
484
|
|
|
349
|
-
|
|
350
|
-
|
|
485
|
+
this._logger.info("Outbound call created with session ID: %s", call_id);
|
|
486
|
+
await this._attachToCall(call_id, variables);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Run the agent against a live test session.
|
|
491
|
+
*
|
|
492
|
+
* Connects to the Guava test endpoint, starts the agent's call handling, and
|
|
493
|
+
* calls `callback` with a TestSession for driving the conversation
|
|
494
|
+
* programmatically. Returns the completed TestSession after the callback and
|
|
495
|
+
* call handler both finish.
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* const session = await agent.test(async (session) => {
|
|
499
|
+
* await session.waitForTurn();
|
|
500
|
+
* session.say("Hi, I'd like to make a purchase.");
|
|
501
|
+
* await session.waitForEnd();
|
|
502
|
+
* });
|
|
503
|
+
* assert(session.executedActions.includes("sales"));
|
|
504
|
+
*/
|
|
505
|
+
async test(
|
|
506
|
+
callback: (session: TestSession) => Promise<void>,
|
|
507
|
+
variables: Record<string, any> = {},
|
|
508
|
+
): Promise<TestSession> {
|
|
509
|
+
const url = new URL("v1/test-agent", this._client.getWebsocketBase());
|
|
510
|
+
const headers = await this._client.headers();
|
|
511
|
+
const ws = new WebSocket(url.toString(), { headers });
|
|
512
|
+
|
|
513
|
+
await new Promise<void>((resolve, reject) => {
|
|
514
|
+
ws.once("open", resolve);
|
|
515
|
+
ws.once("error", reject);
|
|
351
516
|
});
|
|
352
517
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
this._logger.info(
|
|
358
|
-
`Received tunnel event for new call ID: ${tunnel_event.call_id}. Creating call object.`,
|
|
359
|
-
);
|
|
518
|
+
const rawFirst = await new Promise<string>((resolve, reject) => {
|
|
519
|
+
ws.once("message", (data) => resolve(data.toString()));
|
|
520
|
+
ws.once("error", reject);
|
|
521
|
+
});
|
|
360
522
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
for (const command of commands.splice(0)) {
|
|
364
|
-
this._logger.debug(
|
|
365
|
-
`Sending command: ${JSON.stringify(command)} for call ID: ${tunnel_event.call_id}`,
|
|
366
|
-
);
|
|
367
|
-
ws.send(
|
|
368
|
-
stringifyZod(InboundTunnelCommand, {
|
|
369
|
-
call_id: tunnel_event.call_id,
|
|
370
|
-
command,
|
|
371
|
-
}),
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
calls[tunnel_event.call_id] = call;
|
|
376
|
-
}
|
|
523
|
+
const sessionStarted = SessionStarted.parse(JSON.parse(rawFirst));
|
|
524
|
+
const testSession = new TestSession(ws, this._client);
|
|
377
525
|
|
|
378
|
-
|
|
526
|
+
const attachPromise = this._attachToCall(
|
|
527
|
+
sessionStarted.session_id,
|
|
528
|
+
variables,
|
|
529
|
+
testSession,
|
|
530
|
+
).catch((err: unknown) => {
|
|
531
|
+
this._logger.error("Error in _attachToCall during test: %s", err);
|
|
379
532
|
});
|
|
380
533
|
|
|
381
|
-
|
|
534
|
+
try {
|
|
535
|
+
await callback(testSession);
|
|
536
|
+
} finally {
|
|
537
|
+
ws.close();
|
|
538
|
+
await attachPromise;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return testSession;
|
|
382
542
|
}
|
|
383
543
|
|
|
384
544
|
/**
|
|
385
|
-
*
|
|
545
|
+
* Run an automated test conversation where an LLM plays the caller.
|
|
546
|
+
*
|
|
547
|
+
* Connects to the Guava test endpoint, starts the agent, then drives a
|
|
548
|
+
* back-and-forth conversation by repeatedly asking the Guava LLM to decide
|
|
549
|
+
* whether to speak or hang up based on the transcript so far.
|
|
550
|
+
*
|
|
551
|
+
* @param roleplayPrompt - Instructions for the simulated caller, e.g.
|
|
552
|
+
* `"You are a frustrated customer trying to cancel your subscription."`
|
|
553
|
+
* @param variables - Optional initial call variables.
|
|
554
|
+
* @returns The completed TestSession. Call `session.evaluate()` to assert
|
|
555
|
+
* pass/fail criteria, or `session.getTranscript()` to inspect the conversation.
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* const session = await agent.testRoleplay(
|
|
559
|
+
* "You are a caller trying to buy a new table.",
|
|
560
|
+
* );
|
|
561
|
+
* assert(session.executedActions.includes("sales"));
|
|
386
562
|
*/
|
|
387
|
-
async
|
|
388
|
-
|
|
389
|
-
toNumber: string,
|
|
563
|
+
async testRoleplay(
|
|
564
|
+
roleplayPrompt: string,
|
|
390
565
|
variables: Record<string, any> = {},
|
|
391
|
-
) {
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
566
|
+
): Promise<TestSession> {
|
|
567
|
+
const roleplaySchema = {
|
|
568
|
+
type: "object",
|
|
569
|
+
properties: {
|
|
570
|
+
action: { type: "string", enum: ["speak", "hangup"] },
|
|
571
|
+
utterance: { type: "string" },
|
|
572
|
+
},
|
|
573
|
+
required: ["action"],
|
|
574
|
+
additionalProperties: false,
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
return this.test(async (session) => {
|
|
578
|
+
let snapshotLen = 0;
|
|
579
|
+
try {
|
|
580
|
+
while (true) {
|
|
581
|
+
await session.waitForTurn();
|
|
582
|
+
|
|
583
|
+
for (const event of session._events.slice(snapshotLen)) {
|
|
584
|
+
if (event.message_type === "bot-tts") {
|
|
585
|
+
this._logger.info("(Roleplay Session) [agent]: %s", event.transcript);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
snapshotLen = session._events.length;
|
|
396
589
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}),
|
|
407
|
-
);
|
|
408
|
-
|
|
409
|
-
// set the callController drain function to send all commands
|
|
410
|
-
// through the now open websocket
|
|
411
|
-
call.setDrain(async (commands) => {
|
|
412
|
-
for (const command of commands.splice(0)) {
|
|
413
|
-
this._logger.debug(`Sending command ${JSON.stringify(command)}`);
|
|
414
|
-
ws.send(JSON.stringify(command));
|
|
415
|
-
}
|
|
416
|
-
});
|
|
417
|
-
});
|
|
590
|
+
const transcript = session.getTranscript();
|
|
591
|
+
const prompt = `${roleplayPrompt}
|
|
592
|
+
|
|
593
|
+
You are roleplaying as a caller on a phone call. Decide what to do next based on the conversation so far.
|
|
594
|
+
|
|
595
|
+
Conversation:
|
|
596
|
+
${transcript || "(The agent has not spoken yet)"}
|
|
597
|
+
|
|
598
|
+
Choose "speak" and provide your next utterance, or choose "hangup" if the conversation has naturally concluded.`;
|
|
418
599
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const session_started = z
|
|
422
|
-
.union([SessionStartedEvent, ErrorEvent])
|
|
423
|
-
.parse(JSON.parse(ev.data.toString("utf8")));
|
|
600
|
+
const raw = await _generate(this._client, prompt, roleplaySchema);
|
|
601
|
+
const action = JSON.parse(raw) as { action: string; utterance?: string };
|
|
424
602
|
|
|
425
|
-
|
|
426
|
-
|
|
603
|
+
if (action.action === "hangup") {
|
|
604
|
+
this._logger.info("(Roleplay Session) [caller hangs up]");
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (action.action === "speak" && action.utterance) {
|
|
609
|
+
this._logger.info("(Roleplay Session) [caller]: %s", action.utterance);
|
|
610
|
+
session.say(action.utterance);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if ((err as Error).message === "Test session WebSocket closed") {
|
|
615
|
+
this._logger.info("Roleplay session ended by server.");
|
|
427
616
|
} else {
|
|
428
|
-
|
|
429
|
-
socketInitialized = true;
|
|
617
|
+
throw err;
|
|
430
618
|
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (event) {
|
|
435
|
-
this.
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
for (const event of session._events.slice(snapshotLen)) {
|
|
622
|
+
if (event.message_type === "bot-tts") {
|
|
623
|
+
this._logger.info("(Roleplay Session) [agent]: %s", event.transcript);
|
|
436
624
|
}
|
|
437
625
|
}
|
|
438
|
-
});
|
|
626
|
+
}, variables);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Start an interactive terminal chat session with the agent.
|
|
631
|
+
*
|
|
632
|
+
* Opens a TUI with a scrolling conversation panel and an input line.
|
|
633
|
+
* Agent responses appear in real time. Press Ctrl+C or let the agent
|
|
634
|
+
* end the session to exit.
|
|
635
|
+
*
|
|
636
|
+
* @param variables - Optional initial call variables.
|
|
637
|
+
*
|
|
638
|
+
* @example
|
|
639
|
+
* await agent.chat();
|
|
640
|
+
* // or: await agent.chat({ patient_name: "Benjamin Buttons" });
|
|
641
|
+
*/
|
|
642
|
+
async chat(variables: Record<string, any> = {}): Promise<void> {
|
|
643
|
+
await this.test(async (session) => {
|
|
644
|
+
await runChat(session);
|
|
645
|
+
}, variables);
|
|
646
|
+
}
|
|
439
647
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
648
|
+
/**
|
|
649
|
+
* Return a shallow copy of this agent with independently overridable
|
|
650
|
+
* callbacks.
|
|
651
|
+
*
|
|
652
|
+
* Use in tests to register alternative handlers on the clone without
|
|
653
|
+
* affecting the original agent.
|
|
654
|
+
*/
|
|
655
|
+
patch(): Agent {
|
|
656
|
+
const cloned = new Agent({
|
|
657
|
+
name: this._name,
|
|
658
|
+
organization: this._organization,
|
|
659
|
+
purpose: this._purpose,
|
|
443
660
|
});
|
|
661
|
+
cloned._onCallReceived = this._onCallReceived;
|
|
662
|
+
cloned._onCallStart = this._onCallStart;
|
|
663
|
+
cloned._onCallerSpeech = this._onCallerSpeech;
|
|
664
|
+
cloned._onAgentSpeech = this._onAgentSpeech;
|
|
665
|
+
cloned._onQuestion = this._onQuestion;
|
|
666
|
+
cloned._onTaskCompleteGeneric = this._onTaskCompleteGeneric;
|
|
667
|
+
cloned._onTaskCompleteHandlers = { ...this._onTaskCompleteHandlers };
|
|
668
|
+
cloned._searchQueryHandlers = { ...this._searchQueryHandlers };
|
|
669
|
+
cloned._onActionRequested = this._onActionRequested;
|
|
670
|
+
cloned._onActionGeneric = this._onActionGeneric;
|
|
671
|
+
cloned._onActionHandlers = { ...this._onActionHandlers };
|
|
672
|
+
cloned._onSessionEnd = this._onSessionEnd;
|
|
673
|
+
cloned._onDtmf = this._onDtmf;
|
|
674
|
+
return cloned;
|
|
444
675
|
}
|
|
445
676
|
|
|
446
677
|
/* ===== Aliases to be removed at some point. ===== */
|
|
447
678
|
/** @deprecated Use {@link listenPhone} instead. */
|
|
448
|
-
inboundPhone(phoneNumber: string):
|
|
679
|
+
async inboundPhone(phoneNumber: string): Promise<void> {
|
|
449
680
|
return this.listenPhone(phoneNumber);
|
|
450
681
|
}
|
|
451
682
|
|
|
@@ -458,14 +689,3 @@ export class Agent {
|
|
|
458
689
|
return this.callPhone(fromNumber, toNumber, variables);
|
|
459
690
|
}
|
|
460
691
|
}
|
|
461
|
-
|
|
462
|
-
class InboundListener {
|
|
463
|
-
private ws: WebSocket;
|
|
464
|
-
constructor(ws: WebSocket) {
|
|
465
|
-
this.ws = ws;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
close() {
|
|
469
|
-
this.ws.close();
|
|
470
|
-
}
|
|
471
|
-
}
|