@guava-ai/guava-sdk 0.1.0 → 0.3.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/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  rejectInboundCallCommand,
13
13
  transferCommand,
14
14
  listenInboundCommand,
15
+ inboundTunnelCommand,
15
16
  } from "./commands.ts";
16
17
  import * as z from "zod";
17
18
  import {
@@ -21,13 +22,14 @@ import {
21
22
  decodeEvent,
22
23
  type CallerSpeechEvent,
23
24
  type AgentSpeechEvent,
25
+ inboundTunnelEvent,
24
26
  } from "./events.ts";
25
27
  import type { ActionItem, Field, Say, Todo } from "./action_item.ts";
26
28
  import pkgdata from "../package.json" with { type: "json" };
27
29
  import os from "node:os";
28
30
 
29
- const DEFAULT_BASE_URL = "wss://guava-dev.gridspace.com/guava/";
30
- const DEFAULT_LOG_LEVEL = "info";
31
+ const DEFAULT_BASE_URL = "https://guava-dev.gridspace.com/";
32
+ const DEFAULT_LOG_LEVEL = "debug";
31
33
 
32
34
  /**
33
35
  * @description convenience function for stringifying data according to a schema
@@ -39,33 +41,53 @@ function stringifyZod<Schema extends z.ZodType>(
39
41
  return JSON.stringify(schema.parse(data));
40
42
  }
41
43
 
44
+ /**
45
+ * Interface between Guava services and user-supplied code
46
+ */
42
47
  export class CallController {
43
48
  private _commandQueue: Command[] = [];
44
49
  private _on_complete_current_task?: () => void;
45
50
  // private _field_values: Record<string, any>;
46
51
  private _current_task_id?: string;
47
- private _logger: Logger;
52
+ /**
53
+ * @protected
54
+ * @description logger used to emit diagnostics
55
+ */
56
+ protected logger: Logger;
48
57
  // drain functions are expected to cleanup
49
58
  // the part of the queue that is successfully sent from its
50
59
  // input (mutating it) (i.e. _drain should use Array.splice)
51
60
  private _drain?: (_: Command[]) => Promise<void>;
61
+ private _fieldValues: Record<string, unknown> = {};
52
62
 
53
63
  constructor(logger: Logger) {
54
64
  // Set up the default logger.
55
- this._logger = logger;
65
+ this.logger = logger;
56
66
  }
57
67
 
68
+ /**
69
+ * @description Supply a function used to consume commands from the internal command queue.
70
+ *
71
+ * The function is expected to remove from the argument array commands that it has handled (iterating
72
+ * through the result of `Array.splice(0)` is sufficient)
73
+ */
58
74
  setDrain(newDrain: (_: Command[]) => Promise<void>) {
59
75
  this._drain = newDrain;
60
76
  this.flush();
61
77
  }
62
78
 
79
+ /**
80
+ * @description [inbound] receive a call, and process further.
81
+ */
63
82
  protected async acceptCall() {
64
83
  await this.sendCommand(acceptInboundCallCommand, {
65
84
  command_type: "accept-inbound",
66
85
  });
67
86
  }
68
87
 
88
+ /**
89
+ * @description read a span of text verbatim
90
+ */
69
91
  protected async readScript(script: string) {
70
92
  await this.sendCommand(readScriptCommand, {
71
93
  command_type: "read-script",
@@ -73,16 +95,22 @@ export class CallController {
73
95
  });
74
96
  }
75
97
 
98
+ /**
99
+ * @description [inbound] reject a call
100
+ */
76
101
  protected async rejectCall() {
77
102
  await this.sendCommand(rejectInboundCallCommand, {
78
103
  command_type: "reject-inbound",
79
104
  });
80
105
  }
81
106
 
82
- protected async addInfo(info: string) {
107
+ protected async addInfo(_info: string) {
83
108
  throw new Error("not implemeneted");
84
109
  }
85
110
 
111
+ /**
112
+ * @description read a span of text non-verbatim
113
+ */
86
114
  protected async sendInstruction(instruction: string) {
87
115
  await this.sendCommand(sendInstructionCommand, {
88
116
  command_type: "send-instruction",
@@ -90,49 +118,87 @@ export class CallController {
90
118
  });
91
119
  }
92
120
 
93
- protected async setPersona(
94
- organization_name?: string,
95
- agent_name?: string,
96
- agent_purpose?: string,
97
- ) {
121
+ /**
122
+ * @description provide identifiers the agent will use to identify the virtual agent
123
+ */
124
+ protected async setPersona(args: {
125
+ organizationName?: string;
126
+ agentName?: string;
127
+ agentPurpose?: string;
128
+ }) {
98
129
  await this.sendCommand(setPersona, {
99
130
  command_type: "set-persona",
100
- organization_name: organization_name,
101
- agent_name: agent_name,
102
- agent_purpose: agent_purpose,
131
+ organization_name: args.organizationName,
132
+ agent_name: args.agentName,
133
+ agent_purpose: args.agentPurpose,
103
134
  });
104
135
  }
105
136
 
106
- protected async setTask(
107
- objective: string = "",
108
- checklist?: (Field | Say | string)[],
109
- on_complete: (...c) => void = () => {},
110
- ...args
137
+ /**
138
+ * @description direct the agent to collect information
139
+ * @param goal {} an objective string and/or a checklist of information to collect
140
+ * @param on_complete {} a callback to call once the information is available from the agent
141
+ * @param args {} arguments to pass through to the `on_complete` callback
142
+ */
143
+ protected setTask(
144
+ goal: TaskObjective,
145
+ on_complete: (...c: any[]) => void = () => {},
146
+ ...args: any[]
111
147
  ) {
112
- if (!objective && !checklist) {
113
- throw new Error(
114
- "At least one of args ['objective','checklist'] must be provided.",
115
- );
116
- }
117
148
  this._current_task_id = Math.random().toString(16).substring(2, 8);
118
- checklist = checklist ?? [];
119
- const action_items = checklist.map((item) =>
120
- typeof item == "string"
121
- ? ({
122
- item_type: "todo",
123
- description: item,
124
- } satisfies Todo)
125
- : item,
126
- ) satisfies ActionItem[];
127
- this._on_complete_current_task = on_complete.apply(this, args);
128
- this.sendCommand(setTaskCommand, {
129
- command_type: "set-task",
130
- task_id: this._current_task_id,
131
- objective: objective,
132
- action_items: action_items,
149
+ this._on_complete_current_task = on_complete.bind(this, ...args);
150
+ if (!("checklist" in goal)) {
151
+ this.sendCommand(setTaskCommand, {
152
+ command_type: "set-task",
153
+ task_id: this._current_task_id,
154
+ objective: goal.objective,
155
+ action_items: [],
156
+ });
157
+ } else {
158
+ const action_items = goal.checklist.map((item) =>
159
+ typeof item == "string"
160
+ ? ({
161
+ item_type: "todo",
162
+ description: item,
163
+ } satisfies Todo)
164
+ : item,
165
+ ) satisfies ActionItem[];
166
+ this.sendCommand(setTaskCommand, {
167
+ command_type: "set-task",
168
+ task_id: this._current_task_id,
169
+ objective: goal.objective ?? "",
170
+ action_items,
171
+ });
172
+ }
173
+ }
174
+
175
+ /**
176
+ * @description direct the agent to collect information, continuing execution once the agent has collected the information
177
+ * @param goal {} an objective string and/or a checklist of information to collect
178
+ */
179
+ protected async awaitTask(goal: TaskObjective): Promise<void> {
180
+ return new Promise((resolve) => {
181
+ this.setTask(goal, (_args) => {
182
+ resolve();
183
+ });
133
184
  });
134
185
  }
135
186
 
187
+ /**
188
+ * @description retrieve a piece of information that the agent has collected
189
+ * @param key {string} key of the field checklist item
190
+ */
191
+ protected getField(key: string) {
192
+ if (key in this._fieldValues) {
193
+ return this._fieldValues[key];
194
+ } else {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * @description [inbound] hang up an accepted call
201
+ */
136
202
  protected async hangup(final_instructions: string = "") {
137
203
  let instructions: string;
138
204
  if (final_instructions) {
@@ -144,6 +210,9 @@ export class CallController {
144
210
  this.sendInstruction(instructions);
145
211
  }
146
212
 
213
+ /**
214
+ * @description transfer an accepted call
215
+ */
147
216
  protected transfer(to_number: string, transfer_message?: string) {
148
217
  const message = transfer_message ?? "I'm transferring you now";
149
218
  this.sendCommand(transferCommand, {
@@ -159,7 +228,7 @@ export class CallController {
159
228
  ) {
160
229
  const command = schema.parse(data);
161
230
  this._commandQueue.push(command);
162
- this._logger.debug(`Command queued: ${JSON.stringify(command)}`);
231
+ this.logger.debug(`Command queued: ${JSON.stringify(command)}`);
163
232
  await this.flush();
164
233
  }
165
234
 
@@ -174,7 +243,7 @@ export class CallController {
174
243
  this.onAgentSpeech(event);
175
244
  } else if (event.event_type == "agent-question") {
176
245
  try {
177
- this._logger.info(`Received question from bot: ${event.question}`);
246
+ this.logger.info(`Received question from bot: ${event.question}`);
178
247
  const answer = await this.onQuestion(event.question);
179
248
  await this.sendCommand(answerQuestionCommand, {
180
249
  command_type: "answer-question",
@@ -182,7 +251,7 @@ export class CallController {
182
251
  answer: answer,
183
252
  });
184
253
  } catch (e) {
185
- this._logger.error("Error occured while answering question.");
254
+ this.logger.error("Error occured while answering question.");
186
255
  await this.sendCommand(answerQuestionCommand, {
187
256
  command_type: "answer-question",
188
257
  question_id: event.question_id,
@@ -190,14 +259,14 @@ export class CallController {
190
259
  });
191
260
  }
192
261
  } else if (event.event_type == "intent") {
193
- this._logger.info(
262
+ this.logger.info(
194
263
  `Received intent ${event.intent_id} from bot: ${event.intent_summary}`,
195
264
  );
196
265
  const intent_response = await this.onIntent(event.intent_summary);
197
266
  if (intent_response) {
198
- const response_str = `Responding to intent ${event.intent_id}: ${event.intent_summary}`;
199
- this._logger.info(response_str);
200
- this.sendInstruction(response_str);
267
+ const response_str = `Responding to intent ${event.intent_id}: ${intent_response}`;
268
+ this.logger.info(response_str);
269
+ this.sendInstruction(intent_response);
201
270
  }
202
271
  } else if (event.event_type == "task-done") {
203
272
  // ignore obsolete task_completed events
@@ -210,7 +279,12 @@ export class CallController {
210
279
  }
211
280
  }
212
281
  } else if (event.event_type == "action-item-done") {
213
- // self._field_values...
282
+ this._fieldValues[event.key] = event.payload;
283
+ if (event.key && event.payload) {
284
+ this.logger.info(
285
+ `Field ${event.key} updated with value: ${event.payload}`,
286
+ );
287
+ }
214
288
  } else if (event.event_type == "inbound-call") {
215
289
  this.onIncomingCall(event.caller_number);
216
290
  } else if (
@@ -219,28 +293,51 @@ export class CallController {
219
293
  ) {
220
294
  // no-op, don't warn
221
295
  } else if (event.event_type == "error") {
222
- this._logger.error(`The Guava agent reported an error: ${event.content}`);
296
+ this.logger.error(`The Guava agent reported an error: ${event.content}`);
223
297
  } else {
224
- this._logger.warn(`Unhandled event: ${JSON.stringify(event)}`);
298
+ this.logger.warn(`Unhandled event: ${JSON.stringify(event)}`);
225
299
  }
226
300
  }
227
301
 
228
302
  // callbacks
229
303
 
230
304
  /**
231
- * @requires super call
305
+ * @abstract
306
+ * @description called when an inbound call is received. The overriding function must start
307
+ * with `await super.onIncomingCall(from_number)`
232
308
  */
233
309
  async onIncomingCall(from_number?: string) {
234
310
  await this.onCallStart();
235
311
  }
236
312
 
313
+ /**
314
+ * @abstract
315
+ * @description called when a call is connected by the API, whether inbound or outbound
316
+ */
237
317
  async onCallStart(): Promise<void> {}
238
318
 
319
+ /**
320
+ * @abstract
321
+ * @description called when the caller speaks to the agent.
322
+ */
239
323
  async onCallerSpeech(event: CallerSpeechEvent) {}
324
+ /**
325
+ * @abstract
326
+ * @description called when the agent speaks to the caller.
327
+ */
240
328
  async onAgentSpeech(event: AgentSpeechEvent) {}
329
+ /**
330
+ * @abstract
331
+ * @description called when the caller expresses a task they wish to execute
332
+ */
241
333
  async onIntent(intent: string): Promise<string | null> {
242
334
  return "Unfortunately I'm not able to help with that.";
243
335
  }
336
+ /**
337
+ * @abstract
338
+ * @description called when the agent needs to respond to a question that it doesn't know
339
+ * the answer to.
340
+ */
244
341
  async onQuestion(question: string): Promise<string> {
245
342
  return "I don't have an answer to that question.";
246
343
  }
@@ -250,8 +347,12 @@ export type InboundConnection =
250
347
  | { agent_number: string }
251
348
  | { webrtc_code: string };
252
349
 
253
- const ws_start = /^ws:\/\//;
254
- const wss_start = /^wss:\/\//;
350
+ export type TaskObjective =
351
+ | { objective: string }
352
+ | { objective?: string; checklist: (Field | Say | string)[] };
353
+
354
+ const http_start = /^http:\/\//;
355
+ const https_start = /^https:\/\//;
255
356
  export class Client {
256
357
  private _apiKey: string;
257
358
  private _baseUrl: string;
@@ -289,17 +390,17 @@ export class Client {
289
390
  }
290
391
  }
291
392
 
292
- private getHttpBase() {
293
- if (ws_start.test(this._baseUrl)) {
393
+ private getWebsocketBase() {
394
+ if (http_start.test(this._baseUrl)) {
294
395
  return `ws://${this._baseUrl.substring("ws://".length)}`;
295
- } else if (wss_start.test(this._baseUrl)) {
396
+ } else if (https_start.test(this._baseUrl)) {
296
397
  return `wss://${this._baseUrl.substring("wss://".length)}`;
297
398
  } else {
298
399
  throw new Error(`Invalid base URL: ${this._baseUrl}}`);
299
400
  }
300
401
  }
301
402
 
302
- private getWebsocketBase() {
403
+ private getHttpBase() {
303
404
  return this._baseUrl;
304
405
  }
305
406
 
@@ -314,16 +415,21 @@ export class Client {
314
415
  };
315
416
  }
316
417
 
418
+ /**
419
+ * @description use the Guava API to call out to a number
420
+ */
317
421
  createOutbound(
318
422
  fromNumber: string | undefined,
319
423
  toNumber: string,
320
424
  callControllerFactory?: (logger: Logger) => CallController,
321
425
  ) {
322
- const url = new URL("create-outbound", this.getWebsocketBase()).toString();
426
+ const url = new URL("v1/create-outbound", this.getWebsocketBase());
323
427
  const ws = new WebSocket(url, {
324
428
  headers: this.headers(),
325
429
  });
326
- const callController = (callControllerFactory ?? ((_) => undefined))(this._logger);
430
+ const callController = (callControllerFactory ?? ((_) => undefined))(
431
+ this._logger,
432
+ );
327
433
 
328
434
  ws.addEventListener("open", async (_ev) => {
329
435
  ws.send(
@@ -356,7 +462,7 @@ export class Client {
356
462
  });
357
463
  }
358
464
 
359
- replaceHandler(newHandler?: (_: WebSocket.MessageEvent) => void) {
465
+ private replaceHandler(newHandler?: (_: WebSocket.MessageEvent) => void) {
360
466
  if (this.messageHandler) {
361
467
  this._ws?.removeEventListener("message", this.messageHandler);
362
468
  }
@@ -386,8 +492,6 @@ export class Client {
386
492
  // move to next state
387
493
  this.replaceHandler(this.initializedOutbound.bind(this));
388
494
  }
389
-
390
- // this._controller?.flush();
391
495
  }
392
496
 
393
497
  private async initializedOutbound(ev: WebSocket.MessageEvent) {
@@ -410,26 +514,41 @@ export class Client {
410
514
  this._ws.close();
411
515
  }
412
516
  }
413
-
414
- // this._controller?.flush();
415
517
  }
416
518
 
417
- setInboundHandler(
519
+ async setInboundHandler(
418
520
  agent_number: string,
419
521
  public_url: string,
420
522
  inbound_token: string,
421
523
  ) {
422
- // TODO
524
+ const response = await fetch(
525
+ new URL(`v1/inbound-handler/${agent_number}`, this.getHttpBase()),
526
+ {
527
+ method: "PUT",
528
+ headers: this.headers(),
529
+ body: JSON.stringify({
530
+ handler_url: public_url,
531
+ handler_token: inbound_token,
532
+ }),
533
+ },
534
+ );
535
+
536
+ if (!response.ok) {
537
+ throw new Error("Failed to set inbound handler");
538
+ }
423
539
  }
424
540
 
425
- // why use a typevar here and not for outbound?
541
+ /**
542
+ * @description use the Guava API to receive calls at a given number
543
+ */
426
544
  listenInbound<U extends CallController>(
427
545
  conn: InboundConnection,
428
- controller_class: U,
546
+ controllerClassFactory: (logger: Logger) => U,
429
547
  ) {
430
- const call_controllers: Record<string, U> = {};
548
+ const callControllers: Record<string, U> = {};
431
549
 
432
- const url = new URL("listen-inbound", this.getWebsocketBase()).toString();
550
+ // return a way to *stop* listening
551
+ const url = new URL("v1/listen-inbound", this.getWebsocketBase());
433
552
  const ws = new WebSocket(url, {
434
553
  headers: this.headers(),
435
554
  });
@@ -441,23 +560,72 @@ export class Client {
441
560
  webrtc_code = conn.webrtc_code;
442
561
  }
443
562
 
444
- ws.send(
445
- stringifyZod(listenInboundCommand, {
446
- command_type: "listen-inbound",
447
- agent_number: agent_number,
448
- webrtc_code: webrtc_code,
449
- }),
450
- );
451
-
452
563
  this._logger.info(`Listening for calls to ${agent_number ?? webrtc_code}`);
453
564
 
454
565
  if (webrtc_code) {
455
- // converted to print, but should be logger?
456
566
  const debugurl = new URL(
457
567
  `debug-webrtc?webrtc_code=${webrtc_code}`,
458
568
  this.getHttpBase(),
459
569
  );
460
- this._logger.debug(`WebRTC DebugURL: ${new URL("debug-webrtc?we")}`);
570
+ this._logger.debug(`WebRTC DebugURL: ${debugurl}`);
461
571
  }
572
+
573
+ ws.addEventListener("open", (_ev) => {
574
+ ws.send(
575
+ stringifyZod(listenInboundCommand, {
576
+ command_type: "listen-inbound",
577
+ agent_number: agent_number,
578
+ webrtc_code: webrtc_code,
579
+ }),
580
+ );
581
+ });
582
+
583
+ ws.addEventListener("close", (_ev) => {
584
+ ws.removeAllListeners();
585
+ });
586
+
587
+ ws.addEventListener("message", (ev) => {
588
+ const tunnel_event = inboundTunnelEvent.parse(
589
+ JSON.parse(ev.data.toString("utf8")),
590
+ );
591
+ if (!(tunnel_event.call_id in callControllers)) {
592
+ this._logger.info(
593
+ `Received tunnel event for new call ID: ${tunnel_event.call_id}. Creating call controller.`,
594
+ );
595
+
596
+ const newController = controllerClassFactory(this._logger);
597
+ newController.setDrain(async (commands) => {
598
+ for (const command of commands.splice(0)) {
599
+ this._logger.debug(
600
+ `Sending command: ${JSON.stringify(command)} for call ID: ${tunnel_event.call_id}`,
601
+ );
602
+ ws.send(
603
+ stringifyZod(inboundTunnelCommand, {
604
+ call_id: tunnel_event.call_id,
605
+ command,
606
+ }),
607
+ );
608
+ }
609
+ });
610
+ callControllers[tunnel_event.call_id] = newController;
611
+ newController.onEvent(tunnel_event.event);
612
+ } else {
613
+ // no threading, so manually forward to onEvent!
614
+ callControllers[tunnel_event.call_id].onEvent(tunnel_event.event);
615
+ }
616
+ });
617
+
618
+ return new InboundListener(ws);
619
+ }
620
+ }
621
+
622
+ class InboundListener {
623
+ private ws: WebSocket;
624
+ constructor(ws: WebSocket) {
625
+ this.ws = ws;
626
+ }
627
+
628
+ close() {
629
+ this.ws.close();
462
630
  }
463
631
  }