@guava-ai/guava-sdk 0.25.0 → 0.27.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.
@@ -1,8 +1,9 @@
1
1
  # Examples
2
2
 
3
- - [example.test.ts](./example.test.ts) - Agent testing example demonstrating `agent.test()`, `agent.testRoleplay()`, `agent.patch()`, and `session.evaluate()` for unit testing handlers, scripted conversation flows, LLM-driven roleplay, and natural-language pass/fail evaluation.
4
- - [help-desk.ts](./help-desk.ts) - An inbound call agent for a furniture retailer that uses `DocumentQA` for RAG-based question answering and `IntentRecognizer` to classify caller intent and route to the appropriate department.
5
- - [polling-campaign.ts](./polling-campaign.ts) - Attaches a political polling agent to an existing Guava campaign via `attachCampaign()`, uses `reachPerson()` to confirm availability, then conducts a structured poll with text and multiple-choice fields.
6
- - [property-insurance.ts](./property-insurance.ts) - A simple inbound call agent that uses `DocumentQA` and RAG to answer questions about a property insurance policy.
7
- - [restaurant-waitlist.ts](./restaurant-waitlist.ts) - An inbound call agent that collects a caller's name, party size, and phone number to add them to a restaurant waitlist using a structured task checklist.
8
- - [scheduling-outbound.ts](./scheduling-outbound.ts) - An outbound call agent for a dental office that calls patients to schedule appointments, using `reachPerson()` for human detection and `DatetimeFilter` for searchable calendar slot availability.
3
+ - [agent-testing.test.ts](./agent-testing.test.ts) - Test suite for the help-desk agent demonstrating `agent.test()`, `agent.testRoleplay()`, `agent.patch()`, handler unit tests with `MockCall`, and LLM-evaluated assertions with `session.evaluate()`.
4
+ - [help-desk.ts](./help-desk.ts) - Inbound call agent for a furniture retailer that uses `DocumentQA` for RAG-backed question answering and `IntentRecognizer` to route callers to the correct department (sales, delivery, account management).
5
+ - [multiple-agents.ts](./multiple-agents.ts) - Demonstrates running two independent agents simultaneously on different connection types (phone and WebRTC) using a shared `Runner`.
6
+ - [polling-campaign.ts](./polling-campaign.ts) - Outbound campaign agent that conducts a non-partisan political opinion poll, uses `reachPerson` to confirm contact availability, and collects structured responses via a task checklist.
7
+ - [property-insurance.ts](./property-insurance.ts) - Inbound call agent for a property insurance company that uses `DocumentQA` to answer policy questions from a knowledge base.
8
+ - [restaurant-waitlist.ts](./restaurant-waitlist.ts) - Inbound call agent for a restaurant that collects caller name, party size, and phone number to add callers to a waitlist using a structured task.
9
+ - [scheduling-outbound.ts](./scheduling-outbound.ts) - Outbound call agent for a dental office that uses `reachPerson` to confirm contact availability, then schedules appointments using a searchable calendar-slot field backed by `DatetimeFilter`.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@guava-ai/guava-sdk",
3
3
  "description": "The official TypeScript SDK for building Guava voice agents.",
4
4
  "homepage": "https://docs.goguava.ai/typescript-sdk/",
5
- "version": "0.25.0",
5
+ "version": "0.27.0",
6
6
  "type": "commonjs",
7
7
  "main": "dist/src/index.js",
8
8
  "types": "dist/src/index.d.ts",
package/src/agent.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  AnswerQuestionCommand,
5
5
  ChoiceResultCommand,
6
6
  type Command,
7
+ ExpertErrorCommand,
7
8
  RegisteredHooksCommand,
8
9
  } from "./commands.ts";
9
10
  import {
@@ -13,6 +14,7 @@ import {
13
14
  type DTMFPressedEvent,
14
15
  type EscalateEvent,
15
16
  type GuavaEvent,
17
+ type OutboundCallFailed,
16
18
  decodeEventDict,
17
19
  } from "./events.ts";
18
20
  import {
@@ -91,6 +93,7 @@ export class Agent {
91
93
  private _onSessionEnd?: (call: Call, event: BotSessionEnded) => Promise<void>;
92
94
  private _onEscalate?: (call: Call, event: EscalateEvent) => Promise<void>;
93
95
  private _onDtmf?: (call: Call, event: DTMFPressedEvent) => Promise<void>;
96
+ private _onOutboundFailed?: (event: OutboundCallFailed) => Promise<void>;
94
97
 
95
98
  constructor(args?: {
96
99
  name?: string;
@@ -194,6 +197,10 @@ export class Agent {
194
197
  this._onDtmf = callback;
195
198
  }
196
199
 
200
+ onOutboundFailed(callback: (event: OutboundCallFailed) => Promise<void>): void {
201
+ this._onOutboundFailed = callback;
202
+ }
203
+
197
204
  get handlers() {
198
205
  return {
199
206
  onCallReceived: (callInfo: CallInfo) => this._onCallReceived(callInfo),
@@ -278,14 +285,37 @@ export class Agent {
278
285
  await runWebrtcHelper(webrtcCode, getBaseUrl());
279
286
  }
280
287
 
288
+ private async _invokeHandler(
289
+ call: Call,
290
+ name: string,
291
+ informAgent: boolean,
292
+ handler: () => Promise<void>,
293
+ ): Promise<void> {
294
+ try {
295
+ await handler();
296
+ } catch (err) {
297
+ this._logger.error(`An error occurred in the ${name} handler.`, err);
298
+ if (informAgent) {
299
+ await call.sendCommand(ExpertErrorCommand, {
300
+ command_type: "expert-error",
301
+ message: `The expert encountered an error while processing ${name}`,
302
+ });
303
+ }
304
+ }
305
+ }
306
+
281
307
  private async _dispatchEvent(call: Call, event: GuavaEvent, testSession?: TestSession) {
282
308
  if (event.event_type === "caller-speech") {
283
309
  if (this._onCallerSpeech !== undefined) {
284
- await this._onCallerSpeech(call, event);
310
+ await this._invokeHandler(call, "onCallerSpeech", false, () =>
311
+ this._onCallerSpeech!(call, event),
312
+ );
285
313
  }
286
314
  } else if (event.event_type === "agent-speech") {
287
315
  if (this._onAgentSpeech !== undefined) {
288
- await this._onAgentSpeech(call, event);
316
+ await this._invokeHandler(call, "onAgentSpeech", false, () =>
317
+ this._onAgentSpeech!(call, event),
318
+ );
289
319
  }
290
320
  } else if (event.event_type === "task-done") {
291
321
  const errors: string[] = [];
@@ -304,17 +334,28 @@ export class Agent {
304
334
  await call.retryTask(errors.join(" "));
305
335
  } else {
306
336
  this._logger.info(`Task ${event.task_id} completed.`);
307
- if (this._onTaskCompleteGeneric !== undefined) {
308
- await this._onTaskCompleteGeneric(call, event.task_id);
309
- } else if (event.task_id in this._onTaskCompleteHandlers) {
310
- await this._onTaskCompleteHandlers[event.task_id](call);
311
- } else {
312
- this._logger.warn(`No handler registered for completion of task '${event.task_id}'`);
337
+ try {
338
+ if (this._onTaskCompleteGeneric !== undefined) {
339
+ await this._onTaskCompleteGeneric(call, event.task_id);
340
+ } else if (event.task_id in this._onTaskCompleteHandlers) {
341
+ await this._onTaskCompleteHandlers[event.task_id](call);
342
+ } else {
343
+ this._logger.warn(`No handler registered for completion of task '${event.task_id}'`);
344
+ }
345
+ } catch (err) {
346
+ this._logger.error(
347
+ `An error occurred in the on_task_complete('${event.task_id}') handler.`,
348
+ err,
349
+ );
350
+ await call.sendCommand(ExpertErrorCommand, {
351
+ command_type: "expert-error",
352
+ message: `The expert encountered an error while processing on_task_complete('${event.task_id}') - the task has failed.`,
353
+ });
313
354
  }
314
355
  }
315
356
  } else if (event.event_type === "agent-question") {
357
+ this._logger.info(`Received question from bot: ${event.question}`);
316
358
  if (this._onQuestion !== undefined) {
317
- this._logger.info(`Received question from bot: ${event.question}`);
318
359
  let answer: string;
319
360
  try {
320
361
  answer = await this._onQuestion(call, event.question);
@@ -339,6 +380,9 @@ export class Agent {
339
380
  }
340
381
  } else if (event.event_type === "action-item-done") {
341
382
  call._fieldValues[event.key] = event.payload;
383
+ if (event.key && event.payload) {
384
+ this._logger.info(`Field ${event.key} updated.`);
385
+ }
342
386
  } else if (event.event_type === "choice-query") {
343
387
  this._logger.info(`Received search query for field '${event.field_key}': ${event.query}`);
344
388
  const handler = this._searchQueryHandlers[event.field_key];
@@ -346,25 +390,49 @@ export class Agent {
346
390
  this._logger.warn(
347
391
  `Search query arrived for field '${event.field_key}' with no handler attached.`,
348
392
  );
349
- } else {
350
- const [matchedChoices, otherChoices] = await handler(call, event.query);
351
- await call.sendCommand(ChoiceResultCommand, {
352
- command_type: "choice-query-result",
353
- field_key: event.field_key,
354
- query_id: event.query_id,
355
- matched_choices: matchedChoices,
356
- other_choices: otherChoices,
393
+ await call.sendCommand(ExpertErrorCommand, {
394
+ command_type: "expert-error",
395
+ message: `The expert failed to handle on_search_query('${event.field_key}'). No results will be forthcoming.`,
357
396
  });
397
+ } else {
398
+ try {
399
+ const [matchedChoices, otherChoices] = await handler(call, event.query);
400
+ await call.sendCommand(ChoiceResultCommand, {
401
+ command_type: "choice-query-result",
402
+ field_key: event.field_key,
403
+ query_id: event.query_id,
404
+ matched_choices: matchedChoices,
405
+ other_choices: otherChoices,
406
+ });
407
+ } catch (err) {
408
+ this._logger.error(
409
+ `An error occurred in the on_search_query('${event.field_key}') handler.`,
410
+ err,
411
+ );
412
+ await call.sendCommand(ExpertErrorCommand, {
413
+ command_type: "expert-error",
414
+ message: `The expert encountered an error while processing the on_search_query('${event.field_key}') handler. No results will be forthcoming.`,
415
+ });
416
+ }
358
417
  }
359
418
  } else if (event.event_type === "action-request") {
360
419
  this._logger.info(`Received action request ${event.intent_id}: ${event.intent_summary}`);
361
420
  let suggestions: SuggestedAction[] = [];
362
421
  if (this._onActionRequested !== undefined) {
363
- const result = await this._onActionRequested(call, event.intent_summary);
364
- if (Array.isArray(result)) {
365
- suggestions = result;
366
- } else if (result !== null && result !== undefined) {
367
- suggestions = [result];
422
+ try {
423
+ const result = await this._onActionRequested(call, event.intent_summary);
424
+ if (Array.isArray(result)) {
425
+ suggestions = result;
426
+ } else if (result !== null && result !== undefined) {
427
+ suggestions = [result];
428
+ }
429
+ } catch (err) {
430
+ this._logger.error("An error occurred in the onActionRequest handler.", err);
431
+ await call.sendCommand(ExpertErrorCommand, {
432
+ command_type: "expert-error",
433
+ message:
434
+ "The expert encountered an error while processing the on_action_request handler.",
435
+ });
368
436
  }
369
437
  }
370
438
  await call.sendCommand(ActionSuggestionCommand, {
@@ -384,7 +452,18 @@ export class Agent {
384
452
  onActionFunc = () => this._onActionHandlers[event.action_key](call);
385
453
  }
386
454
  if (onActionFunc !== undefined) {
387
- await onActionFunc();
455
+ try {
456
+ await onActionFunc();
457
+ } catch (err) {
458
+ this._logger.error(
459
+ `An error occurred in the on_action('${event.action_key}') handler.`,
460
+ err,
461
+ );
462
+ await call.sendCommand(ExpertErrorCommand, {
463
+ command_type: "expert-error",
464
+ message: `The expert encountered an error while processing the on_action('${event.action_key}') handler.`,
465
+ });
466
+ }
388
467
  } else {
389
468
  this._logger.warn(`No handler registered for action '${event.action_key}'`);
390
469
  }
@@ -393,14 +472,18 @@ export class Agent {
393
472
  if (testSession) {
394
473
  testSession.terminationReason = event.termination_reason;
395
474
  }
396
- await this._onSessionEnd?.(call, event);
475
+ if (this._onSessionEnd !== undefined) {
476
+ await this._invokeHandler(call, "onSessionEnd", false, () =>
477
+ this._onSessionEnd!(call, event),
478
+ );
479
+ }
397
480
  } else if (event.event_type === "dtmf") {
398
481
  if (this._onDtmf !== undefined) {
399
- await this._onDtmf(call, event);
482
+ await this._invokeHandler(call, "onDtmf", false, () => this._onDtmf!(call, event));
400
483
  }
401
484
  } else if (event.event_type === "escalate") {
402
485
  if (this._onEscalate !== undefined) {
403
- await this._onEscalate(call, event);
486
+ await this._invokeHandler(call, "onEscalate", true, () => this._onEscalate!(call, event));
404
487
  } else if (event.requested_by === "agent") {
405
488
  await call.sendInstruction(
406
489
  "No escalation target set. Apologize for not being able to help, ask them to try calling another time, and hang up the call immediately.",
@@ -410,6 +493,13 @@ export class Agent {
410
493
  "Let them know there are no representatives available to take their call. Ask them if they would prefer to continue or to call another time.",
411
494
  );
412
495
  }
496
+ } else if (event.event_type === "outbound-call-failed") {
497
+ this._logger.error(`Outbound call failed: ${event.error_reason}`);
498
+ if (this._onOutboundFailed !== undefined) {
499
+ await this._invokeHandler(call, "onOutboundFailed", false, () =>
500
+ this._onOutboundFailed!(event),
501
+ );
502
+ }
413
503
  } else if (event.event_type === "error") {
414
504
  this._logger.error(`The Guava agent reported an error: ${event.content}`);
415
505
  } else if (event.event_type === "warning") {
@@ -796,6 +886,7 @@ Choose "speak" and provide your next utterance, or choose "hangup" if the conver
796
886
  cloned._onSessionEnd = this._onSessionEnd;
797
887
  cloned._onEscalate = this._onEscalate;
798
888
  cloned._onDtmf = this._onDtmf;
889
+ cloned._onOutboundFailed = this._onOutboundFailed;
799
890
  return cloned;
800
891
  }
801
892
 
package/src/call.ts CHANGED
@@ -10,7 +10,9 @@ import {
10
10
  ReadScriptCommand,
11
11
  RetryTaskCommand,
12
12
  SetAgentDTMFCommand,
13
+ SendAgentDTMFCommand,
13
14
  } from "./commands.ts";
15
+ import { DTMF_DIGITS, type DTMFDigit } from "./events.ts";
14
16
  import type * as z from "zod";
15
17
  import type {
16
18
  ActionItem,
@@ -133,12 +135,35 @@ export class Call {
133
135
  }
134
136
 
135
137
  async setAgentDtmf(enabled: boolean) {
138
+ if (this._callInfo.call_type === "webrtc") {
139
+ throw new Error("WebRTC calls do not support sending DTMF.");
140
+ }
136
141
  await this.sendCommand(SetAgentDTMFCommand, {
137
142
  command_type: "set-agent-dtmf",
138
143
  enabled,
139
144
  });
140
145
  }
141
146
 
147
+ async sendDtmf(digits: DTMFDigit[] | string) {
148
+ if (this._callInfo.call_type === "webrtc") {
149
+ throw new Error("WebRTC calls do not support sending DTMF.");
150
+ }
151
+
152
+ const digitsList = (typeof digits === "string" ? [...digits] : digits) as DTMFDigit[];
153
+
154
+ const validSet = new Set<string>(DTMF_DIGITS);
155
+ if (!digitsList.every((d) => validSet.has(d))) {
156
+ throw new Error(
157
+ `Please input a valid set of DTMF digits. The valid DTMF digits are: ${JSON.stringify(DTMF_DIGITS)}.`,
158
+ );
159
+ }
160
+
161
+ await this.sendCommand(SendAgentDTMFCommand, {
162
+ command_type: "send-agent-dtmf",
163
+ digits: digitsList,
164
+ });
165
+ }
166
+
142
167
  async setLanguageMode(args: { primary?: Language; secondary?: Language[] }) {
143
168
  await this.sendCommand(SetLanguageModeCommand, {
144
169
  command_type: "set-language-mode",
package/src/client.ts CHANGED
@@ -1,19 +1,10 @@
1
- import WebSocket from "ws";
2
1
  import { type Logger, getDefaultLogger } from "./logging.ts";
3
- import {
4
- StartOutboundCallCommand,
5
- ListenInboundCommand,
6
- InboundTunnelCommand,
7
- } from "./commands.ts";
8
- import * as z from "zod";
9
- import { ErrorEvent, SessionStartedEvent, decodeEvent, InboundTunnelEvent } from "./events.ts";
10
2
  import { SDK_VERSION } from "./version.ts";
11
3
  import os from "node:os";
12
4
  import * as fs from "node:fs";
13
5
  import { getBaseUrl, fetchOrThrow, sleep } from "./utils.ts";
14
6
  import { SmsMessage } from "./sms.ts";
15
7
  import { telemetryClient } from "./telemetry.ts";
16
- import type { CallController } from "./call-controller.ts";
17
8
  import {
18
9
  type AuthStrategy,
19
10
  APIKeyAuth,
@@ -35,10 +26,6 @@ export interface ClientOptions {
35
26
 
36
27
  let firstClient = false;
37
28
 
38
- function stringifyZod<Schema extends z.ZodType>(schema: Schema, data: z.input<Schema>): string {
39
- return JSON.stringify(schema.parse(data));
40
- }
41
-
42
29
  export type InboundConnection = { agent_number: string } | { webrtc_code: string };
43
30
 
44
31
  const http_start = /^http:\/\//;
@@ -49,9 +36,6 @@ export class Client {
49
36
  private _auth: AuthStrategy;
50
37
  private _baseUrl: string;
51
38
  private _logger: Logger;
52
- private _ws?: WebSocket;
53
- private _controller?: CallController;
54
- private messageHandler?: (_: WebSocket.MessageEvent) => void;
55
39
 
56
40
  constructor({
57
41
  apiKey,
@@ -245,185 +229,4 @@ export class Client {
245
229
  await sleep(Math.min(pollIntervalMs, remaining));
246
230
  }
247
231
  }
248
-
249
- /**
250
- * @description use the Guava API to call out to a number
251
- */
252
- async createOutbound(
253
- fromNumber: string | undefined,
254
- toNumber: string,
255
- callController: CallController,
256
- ) {
257
- const url = new URL("v1/create-outbound", this.getWebsocketBase());
258
- const ws = new WebSocket(url, {
259
- headers: await this.headers(),
260
- });
261
-
262
- ws.addEventListener("open", async (_ev) => {
263
- ws.send(
264
- stringifyZod(StartOutboundCallCommand, {
265
- command_type: "start-outbound",
266
- to_number: toNumber,
267
- from_number: fromNumber,
268
- }),
269
- );
270
-
271
- // set the callController drain function to send all commands
272
- // through the now open websocket
273
- callController.setDrain(async (commands) => {
274
- for (const command of commands.splice(0)) {
275
- this._logger.debug(`Sending command ${JSON.stringify(command)}`);
276
- ws.send(JSON.stringify(command));
277
- }
278
- });
279
-
280
- await callController.onCallStart();
281
- });
282
-
283
- ws.addEventListener("close", (_ev) => {
284
- // we are closing the socket, so don't trigger any other listeners
285
- ws.removeAllListeners();
286
- this._ws = undefined;
287
- this._controller = undefined;
288
- });
289
-
290
- this._ws = ws;
291
- this._controller = callController;
292
- this.replaceHandler(this.uninitializedOutbound.bind(this));
293
- }
294
-
295
- private replaceHandler(newHandler?: (_: WebSocket.MessageEvent) => void) {
296
- if (this.messageHandler) {
297
- this._ws?.removeEventListener("message", this.messageHandler);
298
- }
299
- if (newHandler) {
300
- this._ws?.addEventListener("message", newHandler);
301
- }
302
- this.messageHandler = newHandler;
303
- }
304
-
305
- // eventlistener handlers for server events
306
- // (a state machine in functions)
307
- private uninitializedOutbound(ev: WebSocket.MessageEvent) {
308
- // for correctness (and type correctness)
309
- if (!this._ws) {
310
- throw new Error("[internal] Uninitialized WebSocket");
311
- }
312
-
313
- const session_started = z
314
- .union([SessionStartedEvent, ErrorEvent])
315
- .parse(JSON.parse(ev.data.toString("utf8")));
316
- if (session_started.event_type === "error") {
317
- throw new Error(`Outbound call failed: ${session_started.content}`);
318
- } else {
319
- this._logger.info(`Started session with ID: ${session_started.session_id}`);
320
- // move to next state
321
- this.replaceHandler(this.initializedOutbound.bind(this));
322
- }
323
- }
324
-
325
- private async initializedOutbound(ev: WebSocket.MessageEvent) {
326
- // for correctness (and type correctness)
327
- if (!this._ws) {
328
- throw new Error("[internal] Uninitialized WebSocket");
329
- }
330
-
331
- // handle the received event
332
- const event = decodeEvent(ev.data);
333
- if (event) {
334
- if (this._controller) {
335
- await this._controller.onEvent(event);
336
- }
337
- if (event.event_type === "outbound-call-failed" || event.event_type === "bot-session-ended") {
338
- // shutdown the websocket
339
- this._ws.close();
340
- }
341
- }
342
- }
343
-
344
- /**
345
- * @description use the Guava API to receive calls at a given number
346
- */
347
- async listenInbound<U extends CallController>(
348
- conn: InboundConnection,
349
- controllerClassFactory: (logger: Logger) => U,
350
- ): Promise<InboundListener> {
351
- const callControllers: Record<string, U> = {};
352
-
353
- // return a way to *stop* listening
354
- const url = new URL("v1/listen-inbound", this.getWebsocketBase());
355
- const ws = new WebSocket(url, {
356
- headers: await this.headers(),
357
- });
358
- let agent_number: string | undefined;
359
- let webrtc_code: string | undefined;
360
- if ("agent_number" in conn) {
361
- agent_number = conn.agent_number;
362
- } else {
363
- webrtc_code = conn.webrtc_code;
364
- }
365
-
366
- this._logger.info(`Listening for calls to ${agent_number ?? webrtc_code}`);
367
-
368
- if (webrtc_code) {
369
- const debugurl = new URL(`debug-webrtc?webrtc_code=${webrtc_code}`, this.getHttpBase());
370
- this._logger.debug(`WebRTC DebugURL: ${debugurl}`);
371
- }
372
-
373
- ws.addEventListener("open", (_ev) => {
374
- ws.send(
375
- stringifyZod(ListenInboundCommand, {
376
- command_type: "listen-inbound",
377
- agent_number: agent_number,
378
- webrtc_code: webrtc_code,
379
- }),
380
- );
381
- });
382
-
383
- ws.addEventListener("close", (_ev) => {
384
- ws.removeAllListeners();
385
- });
386
-
387
- ws.addEventListener("message", (ev) => {
388
- const tunnel_event = InboundTunnelEvent.parse(JSON.parse(ev.data.toString("utf8")));
389
- if (!(tunnel_event.call_id in callControllers)) {
390
- this._logger.info(
391
- `Received tunnel event for new call ID: ${tunnel_event.call_id}. Creating call controller.`,
392
- );
393
-
394
- const newController = controllerClassFactory(this._logger);
395
- newController.setDrain(async (commands) => {
396
- for (const command of commands.splice(0)) {
397
- this._logger.debug(
398
- `Sending command: ${JSON.stringify(command)} for call ID: ${tunnel_event.call_id}`,
399
- );
400
- ws.send(
401
- stringifyZod(InboundTunnelCommand, {
402
- call_id: tunnel_event.call_id,
403
- command,
404
- }),
405
- );
406
- }
407
- });
408
- callControllers[tunnel_event.call_id] = newController;
409
- newController.onEvent(tunnel_event.event);
410
- } else {
411
- // no threading, so manually forward to onEvent!
412
- callControllers[tunnel_event.call_id].onEvent(tunnel_event.event);
413
- }
414
- });
415
-
416
- return new InboundListener(ws);
417
- }
418
- }
419
-
420
- class InboundListener {
421
- private ws: WebSocket;
422
- constructor(ws: WebSocket) {
423
- this.ws = ws;
424
- }
425
-
426
- close() {
427
- this.ws.close();
428
- }
429
232
  }
package/src/commands.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as z from "zod";
2
2
  import { ActionItem } from "./action-item.ts";
3
+ import { DTMF_DIGITS } from "./events.ts";
3
4
 
4
5
  export const StartOutboundCallCommand = z.strictObject({
5
6
  command_type: z.literal("start-outbound"),
@@ -133,6 +134,18 @@ export const SetAgentDTMFCommand = z.strictObject({
133
134
  });
134
135
  export type SetAgentDTMFCommand = z.input<typeof SetAgentDTMFCommand>;
135
136
 
137
+ export const SendAgentDTMFCommand = z.strictObject({
138
+ command_type: z.literal("send-agent-dtmf"),
139
+ digits: z.array(z.enum(DTMF_DIGITS)),
140
+ });
141
+ export type SendAgentDTMFCommand = z.input<typeof SendAgentDTMFCommand>;
142
+
143
+ export const ExpertErrorCommand = z.strictObject({
144
+ command_type: z.literal("expert-error"),
145
+ message: z.string(),
146
+ });
147
+ export type ExpertErrorCommand = z.input<typeof ExpertErrorCommand>;
148
+
136
149
  export const AnyCommand = z.union([
137
150
  StartOutboundCallCommand,
138
151
  ListenInboundCommand,
@@ -150,6 +163,8 @@ export const AnyCommand = z.union([
150
163
  ActionSuggestionCommand,
151
164
  RetryTaskCommand,
152
165
  SetAgentDTMFCommand,
166
+ SendAgentDTMFCommand,
167
+ ExpertErrorCommand,
153
168
  ]);
154
169
  export type Command = z.input<typeof AnyCommand>;
155
170
 
package/src/events.ts CHANGED
@@ -126,27 +126,29 @@ export const ExecuteActionEvent = z.object({
126
126
  });
127
127
  export type ExecuteActionEvent = z.infer<typeof ExecuteActionEvent>;
128
128
 
129
- export type DTMFDigit =
130
- | "0"
131
- | "1"
132
- | "2"
133
- | "3"
134
- | "4"
135
- | "5"
136
- | "6"
137
- | "7"
138
- | "8"
139
- | "9"
140
- | "*"
141
- | "#"
142
- | "A"
143
- | "B"
144
- | "C"
145
- | "D";
129
+ export const DTMF_DIGITS = [
130
+ "0",
131
+ "1",
132
+ "2",
133
+ "3",
134
+ "4",
135
+ "5",
136
+ "6",
137
+ "7",
138
+ "8",
139
+ "9",
140
+ "*",
141
+ "#",
142
+ "A",
143
+ "B",
144
+ "C",
145
+ "D",
146
+ ] as const;
147
+ export type DTMFDigit = (typeof DTMF_DIGITS)[number];
146
148
 
147
149
  export const DTMFPressedEvent = z.object({
148
150
  event_type: z.literal("dtmf"),
149
- digit: z.enum(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#", "A", "B", "C", "D"]),
151
+ digit: z.enum(DTMF_DIGITS),
150
152
  recent_digits: z.string().default(""),
151
153
  });
152
154
  export type DTMFPressedEvent = z.infer<typeof DTMFPressedEvent>;
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  export { Client, type InboundConnection } from "./client.ts";
2
2
  export { Runner } from "./runner.ts";
3
3
  export type { SmsMessage } from "./sms.ts";
4
- export { CallController, type TaskObjective } from "./call-controller.ts";
5
4
  export { Say, Field } from "./action-item.ts";
6
5
  export { Logger, getConsoleLogger, getDefaultLogger } from "./logging.ts";
7
6
  export { Agent, CallInfo } from "./agent.ts";
@@ -1,4 +1,5 @@
1
1
  import WebSocket from "ws";
2
+ import type { IncomingMessage } from "node:http";
2
3
  import { randomBytes } from "node:crypto";
3
4
  import * as z from "zod";
4
5
  import type { Client } from "../client.ts";
@@ -134,6 +135,12 @@ export class GuavaSocket<SendT, RecvT> {
134
135
  const headers = await this._client.headers();
135
136
 
136
137
  try {
138
+ this._logger.debug(
139
+ "Connecting to %s (attempt %d/%d)...",
140
+ this._connectionUrl,
141
+ attempt,
142
+ MAX_RECONNECT_ATTEMPTS,
143
+ );
137
144
  const ws = await new Promise<WebSocket>((resolve, reject) => {
138
145
  const socket = new WebSocket(this._connectionUrl, { headers });
139
146
  let settled = false;
@@ -147,6 +154,21 @@ export class GuavaSocket<SendT, RecvT> {
147
154
  };
148
155
 
149
156
  socket.once("error", (err) => settle(() => reject(err)));
157
+ socket.once("unexpected-response", (_req, res: IncomingMessage) => {
158
+ let body = "";
159
+ res.on("data", (chunk: Buffer) => {
160
+ body += chunk.toString();
161
+ });
162
+ res.on("end", () => {
163
+ settle(() =>
164
+ reject(
165
+ new Error(
166
+ `Unexpected server response: ${res.statusCode}. Body: ${body || "<empty>"}`,
167
+ ),
168
+ ),
169
+ );
170
+ });
171
+ });
150
172
  socket.once("close", () =>
151
173
  settle(() => reject(new Error("Connection closed before open-ack"))),
152
174
  );