@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.
Files changed (102) hide show
  1. package/dist/examples/example.test.d.ts +5 -0
  2. package/dist/examples/example.test.js +46 -0
  3. package/dist/examples/example.test.js.map +1 -0
  4. package/dist/examples/help-desk.d.ts +3 -1
  5. package/dist/examples/help-desk.js +25 -9
  6. package/dist/examples/help-desk.js.map +1 -1
  7. package/dist/examples/property-insurance.js +4 -1
  8. package/dist/examples/property-insurance.js.map +1 -1
  9. package/dist/examples/restaurant-waitlist.js +4 -1
  10. package/dist/examples/restaurant-waitlist.js.map +1 -1
  11. package/dist/examples/scheduling-outbound.js +6 -0
  12. package/dist/examples/scheduling-outbound.js.map +1 -1
  13. package/dist/src/action-item.d.ts +4 -4
  14. package/dist/src/agent.d.ts +85 -18
  15. package/dist/src/agent.js +404 -129
  16. package/dist/src/agent.js.map +1 -1
  17. package/dist/src/auth.d.ts +27 -0
  18. package/dist/src/auth.js +127 -0
  19. package/dist/src/auth.js.map +1 -0
  20. package/dist/src/call.d.ts +1 -1
  21. package/dist/src/call.js +2 -2
  22. package/dist/src/call.js.map +1 -1
  23. package/dist/src/client.d.ts +38 -12
  24. package/dist/src/client.js +88 -16
  25. package/dist/src/client.js.map +1 -1
  26. package/dist/src/commands.d.ts +3 -3
  27. package/dist/src/events.d.ts +87 -0
  28. package/dist/src/events.js +25 -6
  29. package/dist/src/events.js.map +1 -1
  30. package/dist/src/helpers/llm.d.ts +2 -0
  31. package/dist/src/helpers/llm.js +17 -0
  32. package/dist/src/helpers/llm.js.map +1 -0
  33. package/dist/src/index.d.ts +4 -0
  34. package/dist/src/index.js +5 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/logging.js +16 -11
  37. package/dist/src/logging.js.map +1 -1
  38. package/dist/src/sms.d.ts +19 -0
  39. package/dist/src/sms.js +52 -0
  40. package/dist/src/sms.js.map +1 -0
  41. package/dist/src/socket/call-info.d.ts +35 -0
  42. package/dist/src/socket/call-info.js +59 -0
  43. package/dist/src/socket/call-info.js.map +1 -0
  44. package/dist/src/socket/client.d.ts +51 -0
  45. package/dist/src/socket/client.js +455 -0
  46. package/dist/src/socket/client.js.map +1 -0
  47. package/dist/src/socket/listen-inbound.d.ts +83 -0
  48. package/dist/src/socket/listen-inbound.js +82 -0
  49. package/dist/src/socket/listen-inbound.js.map +1 -0
  50. package/dist/src/socket/protocol.d.ts +127 -0
  51. package/dist/src/socket/protocol.js +69 -0
  52. package/dist/src/socket/protocol.js.map +1 -0
  53. package/dist/src/socket/utils.d.ts +8 -0
  54. package/dist/src/socket/utils.js +26 -0
  55. package/dist/src/socket/utils.js.map +1 -0
  56. package/dist/src/telemetry.d.ts +3 -3
  57. package/dist/src/telemetry.js +9 -7
  58. package/dist/src/telemetry.js.map +1 -1
  59. package/dist/src/testing/chat.d.ts +2 -0
  60. package/dist/src/testing/chat.js +181 -0
  61. package/dist/src/testing/chat.js.map +1 -0
  62. package/dist/src/testing/mocks.d.ts +6 -0
  63. package/dist/src/testing/mocks.js +14 -0
  64. package/dist/src/testing/mocks.js.map +1 -0
  65. package/dist/src/testing/protocol.d.ts +46 -0
  66. package/dist/src/testing/protocol.js +61 -0
  67. package/dist/src/testing/protocol.js.map +1 -0
  68. package/dist/src/testing/session.d.ts +26 -0
  69. package/dist/src/testing/session.js +219 -0
  70. package/dist/src/testing/session.js.map +1 -0
  71. package/dist/src/utils.d.ts +2 -0
  72. package/dist/src/utils.js +19 -1
  73. package/dist/src/utils.js.map +1 -1
  74. package/dist/src/version.d.ts +1 -1
  75. package/dist/src/version.js +1 -1
  76. package/examples/example.test.ts +58 -0
  77. package/examples/help-desk.ts +14 -3
  78. package/examples/property-insurance.ts +3 -1
  79. package/examples/restaurant-waitlist.ts +3 -1
  80. package/examples/scheduling-outbound.ts +7 -0
  81. package/package.json +10 -2
  82. package/src/agent.ts +386 -166
  83. package/src/auth.ts +109 -0
  84. package/src/call.ts +3 -3
  85. package/src/client.ts +119 -18
  86. package/src/events.ts +52 -10
  87. package/src/helpers/llm.ts +20 -0
  88. package/src/index.ts +4 -0
  89. package/src/logging.ts +21 -13
  90. package/src/sms.ts +17 -0
  91. package/src/socket/call-info.ts +30 -0
  92. package/src/socket/client.ts +433 -0
  93. package/src/socket/listen-inbound.ts +62 -0
  94. package/src/socket/protocol.ts +89 -0
  95. package/src/socket/utils.ts +25 -0
  96. package/src/telemetry.ts +11 -8
  97. package/src/testing/chat.ts +196 -0
  98. package/src/testing/mocks.ts +12 -0
  99. package/src/testing/protocol.ts +40 -0
  100. package/src/testing/session.ts +218 -0
  101. package/src/utils.ts +19 -1
  102. 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
- InboundTunnelEvent,
23
- ErrorEvent,
24
- SessionStartedEvent,
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 interface CallInfo {
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): InboundListener {
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<InboundListener> {
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("Session ended.");
284
- if (this._onSessionEnd !== undefined) {
285
- await this._onSessionEnd(call);
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 _startCall(variables: Record<string, any> = {}): Promise<Call> {
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
- _listenInbound(conn: InboundConnection): InboundListener {
314
- const calls: Record<string, Call> = {};
315
-
316
- // return a way to *stop* listening
317
- const url = new URL("v1/listen-inbound", this._client.getWebsocketBase());
318
- const ws = new WebSocket(url, {
319
- headers: this._client.headers(),
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
- let agent_number: string | undefined;
322
- let webrtc_code: string | undefined;
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
- agent_number = conn.agent_number;
393
+ url.searchParams.set("phone_number", conn.agent_number);
325
394
  } else {
326
- webrtc_code = conn.webrtc_code;
395
+ url.searchParams.set("webrtc_code", conn.webrtc_code);
327
396
  }
328
397
 
329
- this._logger.info(`Listening for calls to ${agent_number ?? webrtc_code}`);
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
- if (webrtc_code) {
332
- const debugurl = new URL(
333
- `debug-webrtc?webrtc_code=${webrtc_code}`,
334
- this._client.getHttpBase(),
335
- );
336
- this._logger.info(`Call your agent at: ${debugurl}`);
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
- ws.addEventListener("open", (_ev) => {
340
- ws.send(
341
- stringifyZod(ListenInboundCommand, {
342
- command_type: "listen-inbound",
343
- agent_number: agent_number,
344
- webrtc_code: webrtc_code,
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
- ws.addEventListener("close", (_ev) => {
350
- ws.removeAllListeners();
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
- ws.addEventListener("message", async (ev) => {
354
- this._logger.debug("Received message: %s", ev.data.toString("utf8"));
355
- const tunnel_event = InboundTunnelEvent.parse(JSON.parse(ev.data.toString("utf8")));
356
- if (!(tunnel_event.call_id in calls)) {
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
- const call = await this._startCall();
362
- await call.setDrain(async (commands) => {
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
- this._dispatchEvent(calls[tunnel_event.call_id], tunnel_event.event);
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
- return new InboundListener(ws);
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
- * @description use the Guava API to call out to a number
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 callPhone(
388
- fromNumber: string | undefined,
389
- toNumber: string,
563
+ async testRoleplay(
564
+ roleplayPrompt: string,
390
565
  variables: Record<string, any> = {},
391
- ) {
392
- const url = new URL("v1/create-outbound", this._client.getWebsocketBase());
393
- const ws = new WebSocket(url, {
394
- headers: this._client.headers(),
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
- const call = await this._startCall(variables);
398
- let socketInitialized = false;
399
-
400
- ws.addEventListener("open", async (_ev) => {
401
- ws.send(
402
- stringifyZod(StartOutboundCallCommand, {
403
- command_type: "start-outbound",
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
- });
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
- ws.addEventListener("message", (ev) => {
420
- if (socketInitialized) {
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
- if (session_started.event_type === "error") {
426
- throw new Error(`Outbound call failed: ${session_started.content}`);
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
- this._logger.info(`Started session with ID: ${session_started.session_id}`);
429
- socketInitialized = true;
617
+ throw err;
430
618
  }
431
- } else {
432
- // handle the received event
433
- const event = decodeEvent(ev.data);
434
- if (event) {
435
- this._dispatchEvent(call, event);
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
- ws.addEventListener("close", (_ev) => {
441
- // we are closing the socket, so don't trigger any other listeners
442
- ws.removeAllListeners();
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): InboundListener {
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
- }