@guava-ai/guava-sdk 0.29.0 → 0.31.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 (38) hide show
  1. package/dist/examples/multiple-agents.js +2 -1
  2. package/dist/examples/multiple-agents.js.map +1 -1
  3. package/dist/examples/property-insurance.d.ts +2 -0
  4. package/dist/examples/property-insurance.js +8 -7
  5. package/dist/examples/property-insurance.js.map +1 -1
  6. package/dist/examples/restaurant-waitlist.d.ts +2 -0
  7. package/dist/examples/restaurant-waitlist.js +10 -9
  8. package/dist/examples/restaurant-waitlist.js.map +1 -1
  9. package/dist/src/agent.d.ts +2 -2
  10. package/dist/src/agent.js +68 -43
  11. package/dist/src/agent.js.map +1 -1
  12. package/dist/src/call.d.ts +20 -0
  13. package/dist/src/call.js +27 -0
  14. package/dist/src/call.js.map +1 -1
  15. package/dist/src/runner.d.ts +10 -1
  16. package/dist/src/runner.js +16 -5
  17. package/dist/src/runner.js.map +1 -1
  18. package/dist/src/socket/client.js +19 -2
  19. package/dist/src/socket/client.js.map +1 -1
  20. package/dist/src/testing/session.d.ts +2 -1
  21. package/dist/src/testing/session.js +3 -1
  22. package/dist/src/testing/session.js.map +1 -1
  23. package/dist/src/utils.d.ts +4 -0
  24. package/dist/src/utils.js +35 -1
  25. package/dist/src/utils.js.map +1 -1
  26. package/dist/src/version.d.ts +1 -1
  27. package/dist/src/version.js +1 -1
  28. package/examples/multiple-agents.ts +2 -1
  29. package/examples/property-insurance.ts +1 -1
  30. package/examples/restaurant-waitlist.ts +1 -1
  31. package/package.json +11 -2
  32. package/src/agent.ts +43 -18
  33. package/src/call.ts +28 -0
  34. package/src/runner.ts +28 -5
  35. package/src/socket/client.ts +31 -13
  36. package/src/testing/session.ts +3 -1
  37. package/src/utils.ts +38 -0
  38. package/src/version.ts +1 -1
package/src/call.ts CHANGED
@@ -237,6 +237,15 @@ export class Call {
237
237
  });
238
238
  }
239
239
 
240
+ /**
241
+ * Transfer the call to another destination.
242
+ *
243
+ * This method does not interfere with turn-taking — use this when
244
+ * you want the agent to naturally transfer the call.
245
+ *
246
+ * @param destination - The phone number or SIP address to transfer the call to.
247
+ * @param instructions - Optional guidance for how the agent should handle the transfer.
248
+ */
240
249
  async transfer(destination: string, instructions?: string) {
241
250
  await this.sendCommand(TransferCommand, {
242
251
  command_type: "transfer-call",
@@ -247,6 +256,25 @@ export class Call {
247
256
  });
248
257
  }
249
258
 
259
+ /**
260
+ * Transfer the call immediately.
261
+ *
262
+ * Unlike transfer(), this does not respect turn-taking. The agent immediately
263
+ * initiates the transfer, saying the script (if provided).
264
+ *
265
+ * @param destination - The phone number or SIP address to transfer the call to.
266
+ * @param finalScript - Optional message for the agent to deliver immediately
267
+ * before transferring. Defaults to no message.
268
+ */
269
+ async immediateTransfer(destination: string, finalScript?: string) {
270
+ await this.sendCommand(TransferCommand, {
271
+ command_type: "transfer-call",
272
+ to_number: destination,
273
+ transfer_message: finalScript ?? "",
274
+ soft_transfer: false,
275
+ });
276
+ }
277
+
250
278
  async addInfo(label: string, info: unknown) {
251
279
  await this.sendInstruction(
252
280
  `Here is some information about the following topic ${label}:\n${JSON.stringify(info, null, 2)}`,
package/src/runner.ts CHANGED
@@ -1,13 +1,27 @@
1
1
  import type { Agent } from "./agent.ts";
2
2
  import { MultiHealthContext, getHealthServer } from "./health.ts";
3
+ import { nullDisposable, onSignal } from "./utils.ts";
4
+ import { getDefaultLogger, type Logger } from "./logging.ts";
5
+
6
+ export interface RunOptions {
7
+ /**
8
+ * Install SIGINT/SIGTERM handlers that drain in-flight calls on the first
9
+ * signal; a second signal terminates the process. Defaults to `true`.
10
+ */
11
+ handleSignals?: boolean;
12
+ }
3
13
 
4
14
  export class Runner {
5
15
  private _healthCtx = new MultiHealthContext();
6
16
  private _tasks: Array<() => Promise<void>> = [];
17
+ private _drainController: AbortController = new AbortController();
18
+ private _logger: Logger = getDefaultLogger();
7
19
 
8
20
  listenPhone(agent: Agent, agentNumber: string): this {
9
21
  const ctx = this._healthCtx.createCtx();
10
- this._tasks.push(() => agent._listenInbound(ctx, { agent_number: agentNumber }));
22
+ this._tasks.push(() =>
23
+ agent._listenInbound(ctx, { agent_number: agentNumber }, {}, this._drainController.signal),
24
+ );
11
25
  return this;
12
26
  }
13
27
 
@@ -15,25 +29,34 @@ export class Runner {
15
29
  const ctx = this._healthCtx.createCtx();
16
30
  this._tasks.push(async () => {
17
31
  const code = webrtcCode ?? (await agent._client.createWebrtcAgent(3600));
18
- return agent._listenInbound(ctx, { webrtc_code: code });
32
+ return agent._listenInbound(ctx, { webrtc_code: code }, {}, this._drainController.signal);
19
33
  });
20
34
  return this;
21
35
  }
22
36
 
23
37
  listenSip(agent: Agent, sipCode: string): this {
24
38
  const ctx = this._healthCtx.createCtx();
25
- this._tasks.push(() => agent._listenInbound(ctx, { sip_code: sipCode }));
39
+ this._tasks.push(() =>
40
+ agent._listenInbound(ctx, { sip_code: sipCode }, {}, this._drainController.signal),
41
+ );
26
42
  return this;
27
43
  }
28
44
 
29
45
  attachCampaign(agent: Agent, campaignCode: string): this {
30
46
  const ctx = this._healthCtx.createCtx();
31
- this._tasks.push(() => agent._serveCampaign(ctx, campaignCode));
47
+ this._tasks.push(() => agent._serveCampaign(ctx, campaignCode, this._drainController.signal));
32
48
  return this;
33
49
  }
34
50
 
35
- async run(): Promise<void> {
51
+ async run({ handleSignals = true }: RunOptions = {}): Promise<void> {
36
52
  await using _server = await getHealthServer(this._healthCtx);
53
+ using _ = handleSignals
54
+ ? onSignal((signal) => {
55
+ this._logger.info(`Received ${signal} - draining calls. Send signal again to terminate.`);
56
+ this._drainController.abort();
57
+ })
58
+ : nullDisposable;
37
59
  await Promise.all(this._tasks.map((task) => task()));
60
+ this._logger.info("All tasks done.");
38
61
  }
39
62
  }
@@ -49,6 +49,16 @@ export class GuavaSocketConnectionFailed extends Error {
49
49
  }
50
50
  }
51
51
 
52
+ /** Raised when the server rejects the websocket handshake with an HTTP response. */
53
+ class UnexpectedResponseError extends Error {
54
+ constructor(
55
+ readonly statusCode: number | undefined,
56
+ readonly body: string,
57
+ ) {
58
+ super(`Unexpected server response: ${statusCode ?? "unknown"}. Body: ${body || "<empty>"}`);
59
+ }
60
+ }
61
+
52
62
  const HandshakeResponse = z.union([GuavaOpenAck, GuavaClose]);
53
63
 
54
64
  function _sendMessage(ws: WebSocket, message: GuavaClientMessage): void {
@@ -160,13 +170,7 @@ export class GuavaSocket<SendT, RecvT> {
160
170
  body += chunk.toString();
161
171
  });
162
172
  res.on("end", () => {
163
- settle(() =>
164
- reject(
165
- new Error(
166
- `Unexpected server response: ${res.statusCode}. Body: ${body || "<empty>"}`,
167
- ),
168
- ),
169
- );
173
+ settle(() => reject(new UnexpectedResponseError(res.statusCode, body)));
170
174
  });
171
175
  });
172
176
  socket.once("close", () =>
@@ -225,6 +229,7 @@ export class GuavaSocket<SendT, RecvT> {
225
229
  if (e instanceof GuavaSocketClosedError) throw e;
226
230
 
227
231
  if (attempt >= MAX_RECONNECT_ATTEMPTS) {
232
+ this._logger.error("Couldn't connect to server after %d attempts.", attempt);
228
233
  this._setCloseReason(
229
234
  "reconnection-failed",
230
235
  `Couldn't connect after ${attempt} attempts.`,
@@ -232,12 +237,25 @@ export class GuavaSocket<SendT, RecvT> {
232
237
  throw new GuavaSocketConnectionFailed();
233
238
  }
234
239
 
235
- this._logger.error(
236
- "Failed to connect (attempt %d/%d). Retrying in a few seconds...\n",
237
- attempt,
238
- MAX_RECONNECT_ATTEMPTS,
239
- e,
240
- );
240
+ if (e instanceof UnexpectedResponseError) {
241
+ const interpretation =
242
+ e.statusCode === 401 || e.statusCode === 403 ? "Authentication Failure" : "HTTP error";
243
+ this._logger.error(
244
+ "Failed to connect to websocket endpoint %s due to %s. HTTP %s, Body: %s. Retrying in a few seconds...",
245
+ this._connectionUrl,
246
+ interpretation,
247
+ e.statusCode ?? "unknown",
248
+ e.body || "No response body.",
249
+ );
250
+ } else {
251
+ this._logger.error(
252
+ "Failed to connect to %s (attempt %d/%d). Retrying in a few seconds...\n",
253
+ this._connectionUrl,
254
+ attempt,
255
+ MAX_RECONNECT_ATTEMPTS,
256
+ e,
257
+ );
258
+ }
241
259
  await sleep(reconnectDelay(attempt));
242
260
  }
243
261
  }
@@ -29,15 +29,17 @@ export class TestSession {
29
29
 
30
30
  executedActions: string[] = [];
31
31
  terminationReason: string | null = null;
32
+ readonly id: string;
32
33
 
33
34
  /** @internal */
34
35
  get _events(): ReadonlyArray<InjectASR | BotTTS> {
35
36
  return this._sessionEvents;
36
37
  }
37
38
 
38
- constructor(ws: WebSocket, client: Client) {
39
+ constructor(ws: WebSocket, client: Client, id: string) {
39
40
  this._ws = ws;
40
41
  this._client = client;
42
+ this.id = id;
41
43
 
42
44
  ws.on("message", (data) => {
43
45
  let event: TestingEvent;
package/src/utils.ts CHANGED
@@ -63,3 +63,41 @@ export async function fetchOrThrow(
63
63
 
64
64
  return res;
65
65
  }
66
+
67
+ /** A Disposable whose disposal does nothing. Useful as a conditional no-op. */
68
+ export const nullDisposable: Disposable = { [Symbol.dispose]() {} };
69
+
70
+ export function onAbort(signal: AbortSignal, listener: () => void): Disposable {
71
+ signal.throwIfAborted();
72
+ signal.addEventListener("abort", listener, { once: true });
73
+
74
+ return {
75
+ [Symbol.dispose]() {
76
+ signal.removeEventListener("abort", listener);
77
+ },
78
+ };
79
+ }
80
+
81
+ export function onSignal(handler: (signal: "SIGINT" | "SIGTERM") => void): Disposable {
82
+ // Remove both handlers on the first signal so any second signal (of either
83
+ // type) falls through to Node's default behavior and terminates the process.
84
+ const remove = () => {
85
+ process.off("SIGINT", sigint);
86
+ process.off("SIGTERM", sigterm);
87
+ };
88
+ const sigint = () => {
89
+ remove();
90
+ handler("SIGINT");
91
+ };
92
+ const sigterm = () => {
93
+ remove();
94
+ handler("SIGTERM");
95
+ };
96
+
97
+ process.on("SIGINT", sigint);
98
+ process.on("SIGTERM", sigterm);
99
+
100
+ return {
101
+ [Symbol.dispose]: remove,
102
+ };
103
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = "0.29.0";
1
+ export const SDK_VERSION = "0.31.0";