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