@lumavox/client 0.5.0 → 0.6.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/README.md CHANGED
@@ -105,6 +105,17 @@ A tool marked "ask first" sends `tool.confirm` and does not run:
105
105
  `confirmTool(callId, yes)` answers with the person's tap — a turn of
106
106
  theirs, queued as `send` is, which runs the tool first on a yes.
107
107
 
108
+ The agent's app tools run in your app: `connect`'s `tools` gives a
109
+ handler for each, by name. The conversation names them when it
110
+ authenticates, runs one when the model calls it, and sends back what it
111
+ answers — or what it threw:
112
+
113
+ ```ts
114
+ const conversation = client.connect(sessionId, {}, {
115
+ tools: { open_page: async ({ page }) => ({ opened: page }) },
116
+ });
117
+ ```
118
+
108
119
  ### Speaking
109
120
 
110
121
  ```ts
@@ -35,7 +35,7 @@
35
35
  * shows — a `text.delta`, say — and none for audio, which is passed
36
36
  * through, never copied.
37
37
  */
38
- import type { ErrorFrame, InterruptedFrame, KnowledgeFrame, ReadyFrame, ServerFrame, ServerFrames, TranscriptFrame, TurnDoneFrame } from "./events.js";
38
+ import type { ErrorFrame, InterruptedFrame, KnowledgeFrame, ReadyFrame, ServerFrame, ServerFrames, ToolCallFrame, TranscriptFrame, TurnDoneFrame } from "./events.js";
39
39
  import type { RelationInfo } from "./gen/index.js";
40
40
  import { type AudioFrame, type Warning, type WebSocketConstructor } from "./socket.js";
41
41
  import type { Tokens } from "./token.js";
@@ -169,6 +169,12 @@ export interface ConversationHandlers {
169
169
  /** Every frame, the ones above included: what a newer edge sends that this SDK does not know. */
170
170
  onEvent?: (frame: ServerFrame) => void;
171
171
  }
172
+ /**
173
+ * One of your app's tools: given the model's arguments, it answers any
174
+ * JSON (or nothing, `null`), maybe as a promise. A throw or a rejection is
175
+ * the call's `error`, which the model is told.
176
+ */
177
+ export type ToolHandler = (args: Record<string, unknown>, call: ToolCallFrame) => unknown;
172
178
  export interface ConnectOptions {
173
179
  /**
174
180
  * Every turn's `speech`. By default `false` when there is no `onAudio`
@@ -178,6 +184,12 @@ export interface ConnectOptions {
178
184
  speech?: boolean;
179
185
  /** Ask every turn for thinking summaries (`onThought`). */
180
186
  thoughts?: boolean;
187
+ /**
188
+ * The app's own tools, by name: the agent is offered those of its tools
189
+ * of kind `app` named here, and each call runs its handler here. At most
190
+ * `APP_TOOLS_MAX` (50).
191
+ */
192
+ tools?: Record<string, ToolHandler>;
181
193
  }
182
194
  /** Per turn: overrides `ConnectOptions`. */
183
195
  export interface TurnOptions {
@@ -308,6 +320,15 @@ export declare class Conversation {
308
320
  private stopTimers;
309
321
  private frame;
310
322
  private ready;
323
+ /**
324
+ * One of the app's tools, called by the model: its handler run, and its
325
+ * answer — or what it threw — sent back as `tool.result` on the socket
326
+ * the call came on. A reconnect in between ended the call at the edge,
327
+ * so nothing is sent on the new socket. An action-only call is answered
328
+ * by nobody: its handler runs, and one that throws is rethrown on its
329
+ * own, as any handler's is.
330
+ */
331
+ private runTool;
311
332
  /** The end of a turn: answers whether it was a second for the same turn, which says nothing new. */
312
333
  private terminal;
313
334
  private error;
@@ -1,5 +1,5 @@
1
1
  import { wireBytes } from "./pcm.js";
2
- import { KEEPALIVE_SECONDS, MAX_AUDIO_BYTES, MAX_MESSAGE_CHARS, RECORDING_RATES, } from "./protocol.gen.js";
2
+ import { KEEPALIVE_SECONDS, MAX_AUDIO_BYTES, MAX_MESSAGE_CHARS, RECORDING_RATES, TOOL_ERROR_MAX_CHARS, } from "./protocol.gen.js";
3
3
  import { dial, isFinalClose, reconnectAfter, socketUrl, } from "./socket.js";
4
4
  /** No `ready` this long after the dial — a proxy that took the upgrade and says nothing — is a dropped connection. */
5
5
  export const READY_DEADLINE_MS = 15000;
@@ -315,6 +315,7 @@ export class Conversation {
315
315
  url: socketUrl(this.setup.socketUrl, this.sessionId),
316
316
  key,
317
317
  sessionId: this.sessionId,
318
+ tools: Object.keys(this.setup.options.tools ?? {}),
318
319
  WebSocket: this.setup.WebSocket,
319
320
  onFrame: (frame) => this.frame(frame),
320
321
  onAudio: (frame) => {
@@ -508,6 +509,9 @@ export class Conversation {
508
509
  case "tool.end":
509
510
  this.update({ tool: null });
510
511
  break;
512
+ case "tool.call":
513
+ this.runTool(frame);
514
+ break;
511
515
  case "knowledge":
512
516
  this.update({
513
517
  knowledge: [...(this.snap.turn?.knowledge ?? []), frame],
@@ -563,6 +567,48 @@ export class Conversation {
563
567
  }
564
568
  this.sync();
565
569
  }
570
+ /**
571
+ * One of the app's tools, called by the model: its handler run, and its
572
+ * answer — or what it threw — sent back as `tool.result` on the socket
573
+ * the call came on. A reconnect in between ended the call at the edge,
574
+ * so nothing is sent on the new socket. An action-only call is answered
575
+ * by nobody: its handler runs, and one that throws is rethrown on its
576
+ * own, as any handler's is.
577
+ */
578
+ runTool(frame) {
579
+ const handler = this.setup.options.tools?.[frame.name];
580
+ const socket = this.socket;
581
+ const answer = (fields) => {
582
+ if (frame.action_only || !socket || this.socket !== socket)
583
+ return;
584
+ const sent = { type: "tool.result", call_id: frame.call_id };
585
+ try {
586
+ socket.send({ ...sent, ...fields });
587
+ }
588
+ catch {
589
+ // swallowed: a result JSON cannot carry (a cycle, a BigInt), told
590
+ // to the model as the failure it is.
591
+ socket.send({ ...sent, error: "The tool's answer is not JSON." });
592
+ }
593
+ };
594
+ new Promise((resolve) => {
595
+ if (!handler)
596
+ throw new Error(`This app has no tool ${frame.name}.`);
597
+ resolve(handler(frame.arguments, frame));
598
+ }).then((result) => answer({ result: result === undefined ? null : result }), (error) => {
599
+ const said = error instanceof Error ? error.message : String(error);
600
+ answer({
601
+ error: [...(said || "The tool failed.")]
602
+ .slice(0, TOOL_ERROR_MAX_CHARS)
603
+ .join(""),
604
+ });
605
+ if (frame.action_only) {
606
+ setTimeout(() => {
607
+ throw error;
608
+ });
609
+ }
610
+ });
611
+ }
566
612
  /** The end of a turn: answers whether it was a second for the same turn, which says nothing new. */
567
613
  terminal(frame) {
568
614
  if (frame.turn === this.finished)
package/dist/events.d.ts CHANGED
@@ -102,6 +102,19 @@ export interface ToolEndFrame extends Frame<"tool.end">, InTurn {
102
102
  ok: boolean;
103
103
  ms: number;
104
104
  }
105
+ /**
106
+ * The model called one of your app's tools (`ConnectOptions.tools`): the
107
+ * conversation runs its handler and answers `tool.result` within
108
+ * `timeout_ms`. An `action_only` call is not waited for — the reply goes
109
+ * on at once.
110
+ */
111
+ export interface ToolCallFrame extends Frame<"tool.call">, InTurn {
112
+ call_id: string;
113
+ name: string;
114
+ arguments: Record<string, unknown>;
115
+ action_only: boolean;
116
+ timeout_ms: number;
117
+ }
105
118
  /**
106
119
  * A tool marked "ask first" waits for the person's yes: the agent asks in
107
120
  * its own words, and `confirmTool(call_id, yes)` answers with a tap.
@@ -244,6 +257,7 @@ export type ServerFrames = Exhaustive<{
244
257
  "thought.delta": ThoughtDeltaFrame;
245
258
  "tool.start": ToolStartFrame;
246
259
  "tool.end": ToolEndFrame;
260
+ "tool.call": ToolCallFrame;
247
261
  "tool.confirm": ToolConfirmFrame;
248
262
  knowledge: KnowledgeFrame;
249
263
  "audio.start": AudioStartFrame;
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  export type { AgentAudio } from "./audio.js";
11
11
  export { type Client, type ClientOptions, createClient, DEFAULT_BASE_URL, type Facts, type RelationListParams, type Relations, type SessionGetParams, type SessionListParams, type Sessions, } from "./client.js";
12
- export { type CloseInfo, type ConnectOptions, Conversation, type ConversationHandlers, type ConversationState, type Disconnect, type Dropped, READY_DEADLINE_MS, type Snapshot, type StartAudioOptions, type TranscriptOutcome, type TurnOptions, type TurnOutcome, type TurnSnapshot, } from "./conversation.js";
12
+ export { type CloseInfo, type ConnectOptions, Conversation, type ConversationHandlers, type ConversationState, type Disconnect, type Dropped, READY_DEADLINE_MS, type Snapshot, type StartAudioOptions, type ToolHandler, type TranscriptOutcome, type TurnOptions, type TurnOutcome, type TurnSnapshot, } from "./conversation.js";
13
13
  export { AuthenticationError, ConflictError, ConnectionError, InsufficientCreditsError, NotFoundError, PermissionError, PlatformError, type PlatformErrorInit, RateLimitError, ServerError, TimeoutError, ValidationError, } from "./errors.js";
14
14
  export type * from "./events.js";
15
15
  export type * from "./gen/index.js";
@@ -1,10 +1,10 @@
1
1
  /** The protocol, sent as the WebSocket subprotocol and echoed on `ready`. */
2
2
  export declare const PROTOCOL = "ino.v2";
3
3
  /** Every frame a client may send; the edge closes on any other (4009). */
4
- export declare const CLIENT_VERBS: readonly ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping", "tool.confirmed"];
4
+ export declare const CLIENT_VERBS: readonly ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping", "tool.confirmed", "tool.result"];
5
5
  export type ClientVerb = (typeof CLIENT_VERBS)[number];
6
6
  /** Every event the edge sends. */
7
- export declare const SERVER_EVENTS: readonly ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
7
+ export declare const SERVER_EVENTS: readonly ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
8
8
  export type ServerEventName = (typeof SERVER_EVENTS)[number];
9
9
  /** The events that end a turn: exactly one of them ends every turn. */
10
10
  export declare const TERMINAL_EVENTS: readonly ["turn.done", "interrupted"];
@@ -45,3 +45,5 @@ export declare const AUDIO_CODEC = "pcm_s16le";
45
45
  export declare const AUDIO_CHANNELS = 1;
46
46
  export declare const DEFAULT_SAMPLE_RATE = 24000;
47
47
  export declare const MAX_SOCKETS_PER_END_USER = 3;
48
+ export declare const APP_TOOLS_MAX = 50;
49
+ export declare const TOOL_ERROR_MAX_CHARS = 2000;
@@ -5,9 +5,9 @@
5
5
  /** The protocol, sent as the WebSocket subprotocol and echoed on `ready`. */
6
6
  export const PROTOCOL = "ino.v2";
7
7
  /** Every frame a client may send; the edge closes on any other (4009). */
8
- export const CLIENT_VERBS = ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping", "tool.confirmed"];
8
+ export const CLIENT_VERBS = ["auth", "user_message", "user_audio.start", "user_audio.end", "cancel", "mute", "ping", "tool.confirmed", "tool.result"];
9
9
  /** Every event the edge sends. */
10
- export const SERVER_EVENTS = ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
10
+ export const SERVER_EVENTS = ["ready", "turn.start", "heard", "heard.partial", "transcript", "mood", "text.delta", "text.chunk", "thought.delta", "tool.start", "tool.end", "tool.call", "tool.confirm", "knowledge", "audio.start", "audio.end", "muted", "relation", "usage", "turn.done", "interrupted", "error", "pong"];
11
11
  /** The events that end a turn: exactly one of them ends every turn. */
12
12
  export const TERMINAL_EVENTS = ["turn.done", "interrupted"];
13
13
  /** A `ping` of exactly this text is answered by the edge's runtime with `KEEPALIVE_PONG`, which has no `seq`. */
@@ -47,3 +47,5 @@ export const AUDIO_CODEC = "pcm_s16le";
47
47
  export const AUDIO_CHANNELS = 1;
48
48
  export const DEFAULT_SAMPLE_RATE = 24000;
49
49
  export const MAX_SOCKETS_PER_END_USER = 3;
50
+ export const APP_TOOLS_MAX = 50;
51
+ export const TOOL_ERROR_MAX_CHARS = 2000;
package/dist/socket.d.ts CHANGED
@@ -81,6 +81,8 @@ export interface DialOptions {
81
81
  url: string;
82
82
  key: string;
83
83
  sessionId: string;
84
+ /** The app tools this connection runs, by name: `auth.tools`. */
85
+ tools?: string[];
84
86
  WebSocket: WebSocketConstructor;
85
87
  onFrame(frame: ServerFrame): void;
86
88
  onAudio(frame: AudioFrame): void;
package/dist/socket.js CHANGED
@@ -21,11 +21,13 @@ export function dial(options) {
21
21
  return true;
22
22
  };
23
23
  socket.onopen = () => {
24
- // Two fields, and the edge refuses any third by name.
24
+ // The edge refuses a field it does not know by name, so `tools` goes
25
+ // only when there are some: no app tools, no field.
25
26
  send(JSON.stringify({
26
27
  type: "auth",
27
28
  key: options.key,
28
29
  session_id: options.sessionId,
30
+ ...(options.tools?.length ? { tools: options.tools } : {}),
29
31
  }));
30
32
  };
31
33
  socket.onmessage = ({ data }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumavox/client",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Talk to Lumavox agents from a browser, a React Native app or Node, with an end user's ephemeral key: conversations on the agent's socket, sessions, memory and relations. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",