@dbx-tools/genie 0.1.18

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/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `@dbx-tools/genie` public surface.
3
+ *
4
+ * - {@link genieChat}: low-level async generator. Yields every
5
+ * poll-observed `GenieMessage` for a single turn against a
6
+ * Genie space. Multi-turn conversations are driven by the
7
+ * caller (thread the `conversation_id` off each yielded
8
+ * message into the next call's `options.conversationId`).
9
+ * - {@link genieEventChat}: high-level async generator. Drives
10
+ * `genieChat` and yields a strongly-typed
11
+ * {@link GenieChatEvent} stream of flat `{type, ...fields}`
12
+ * records (`message`, `status`, `attachment`, `thinking`,
13
+ * `text`, `query`, `statement`, `rows`,
14
+ * `suggested_questions`, `result`). The final yield for any
15
+ * terminal turn is always `{ type: "result", ... }`.
16
+ *
17
+ * Wire-format types, the {@link GenieChatEvent} discriminated
18
+ * union, per-event detectors (`detectStatus`, `detectThinking`,
19
+ * `detectAttachmentAdded`, `detectText`, `detectQuery`,
20
+ * `detectStatement`, `detectRows`, `detectSuggestedQuestions`),
21
+ * the `eventsFromMessage` sync generator, and terminal-status
22
+ * helpers all live in `@dbx-tools/genie-shared` and are
23
+ * re-exported here so a single `from "@dbx-tools/genie"` import
24
+ * works for server-side consumers. Browser-side consumers should
25
+ * import from `@dbx-tools/genie-shared` directly to avoid pulling
26
+ * in the Node-only `chat.ts` runtime.
27
+ *
28
+ * Browser safety: `chat.ts` pulls in `WorkspaceClient` and is
29
+ * Node-only; the re-exports from `@dbx-tools/genie-shared` are
30
+ * pure (types + sync functions) and safe for any runtime.
31
+ */
32
+
33
+ export * from "./src/chat.js";
34
+ export * from "@dbx-tools/genie-shared";
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "main": "./dist/index.js",
3
+ "types": "./dist/index.d.ts",
4
+ "exports": {
5
+ ".": {
6
+ "source": "./index.ts",
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
+ }
10
+ },
11
+ "name": "@dbx-tools/genie",
12
+ "version": "0.1.18",
13
+ "dependencies": {
14
+ "@databricks/sdk-experimental": "^0.17",
15
+ "@dbx-tools/genie-shared": "0.1.18",
16
+ "@dbx-tools/shared": "0.1.18"
17
+ },
18
+ "module": "index.ts",
19
+ "type": "module",
20
+ "files": [
21
+ "dist",
22
+ "index*.ts",
23
+ "src"
24
+ ],
25
+ "license": "Apache-2.0",
26
+ "homepage": "https://github.com/reggie-db/dbx-tools-appkit#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/reggie-db/dbx-tools-appkit/issues"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/reggie-db/dbx-tools-appkit.git",
33
+ "directory": "packages/genie"
34
+ }
35
+ }
package/src/chat.ts ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * `@dbx-tools/genie` chat driver.
3
+ *
4
+ * Two async generators ship from this file. Both take a single
5
+ * `content` string for one turn against a Genie space; for
6
+ * multi-turn conversations, the caller drives the loop and
7
+ * threads the `conversation_id` returned on each
8
+ * `GenieMessage` into the next call's `options.conversationId`.
9
+ *
10
+ * - {@link genieChat}: low-level. Yields every poll-observed
11
+ * `GenieMessage` verbatim. Cancellation, conversation seeding,
12
+ * distinct-filtering, and SDK quirks (Waiter stripping) live
13
+ * here. Use directly when you want the raw stream.
14
+ * - {@link genieEventChat}: high-level. Wraps `genieChat` and
15
+ * emits semantic, deduplicated events as a typed
16
+ * `{ type, payload }` stream (see {@link GenieChatEvent}). The
17
+ * final yield for a successful turn is always
18
+ * `{ type: "result", payload }` carrying the terminal
19
+ * `GenieMessage`. Errors propagate by the generator throwing -
20
+ * there is no `"error"` variant.
21
+ *
22
+ * Pick the layer that matches your consumer. Iterating UI / agent
23
+ * code that wants every message verbatim should use `genieChat`.
24
+ * Subscribers that want to react to "Genie is thinking about X"
25
+ * or "Genie produced text Y" should use `genieEventChat`.
26
+ */
27
+
28
+ import { WorkspaceClient } from "@databricks/sdk-experimental";
29
+ import {
30
+ eventsFromMessage,
31
+ isTerminalStatus,
32
+ type GenieChatEvent,
33
+ type GenieMessage,
34
+ } from "@dbx-tools/genie-shared";
35
+ import { apiUtils, commonUtils } from "@dbx-tools/shared";
36
+
37
+ /* -------------------------- shared options -------------------------- */
38
+
39
+ /** Options accepted by both {@link genieChat} and {@link genieEventChat}. */
40
+ export interface GenieChatOptions {
41
+ /**
42
+ * Seed conversation id. When set, this turn appends to the
43
+ * existing conversation (via `createMessage`) instead of opening
44
+ * a new one. Use it to thread a multi-turn conversation: read
45
+ * `conversation_id` off the prior turn's terminal `GenieMessage`
46
+ * (or the `result` event's `payload.conversation_id`) and pass
47
+ * it into the next call.
48
+ */
49
+ conversationId?: string;
50
+ /**
51
+ * Explicit `WorkspaceClient`. Defaults to AppKit's per-request
52
+ * execution-context client when AppKit is installed and we're
53
+ * inside a request; falls back to a fresh `new WorkspaceClient({})`
54
+ * (env-var auth) otherwise.
55
+ */
56
+ workspaceClient?: WorkspaceClient;
57
+ /** Poll cadence in milliseconds between successive `getMessage` calls (default 500). */
58
+ pollIntervalMs?: number;
59
+ /**
60
+ * External cancellation. Accepts a WHATWG `AbortSignal` or a
61
+ * fully-built SDK `Context` (see `apiUtils.ContextLike`).
62
+ * Aborting it cancels every in-flight SDK call and the next
63
+ * inter-poll sleep.
64
+ */
65
+ context?: apiUtils.ContextLike;
66
+ }
67
+
68
+ /* ----------------------- low-level: genieChat ----------------------- */
69
+
70
+ /**
71
+ * One turn against a Genie space, yielded as a stream of
72
+ * `GenieMessage` snapshots.
73
+ *
74
+ * Turn lifecycle:
75
+ *
76
+ * - No `options.conversationId`: open a new conversation via
77
+ * `client.genie.startConversation`. The opened conversation id
78
+ * surfaces on every yielded `GenieMessage` (`.conversation_id`)
79
+ * so the caller can thread it into a follow-up call.
80
+ * - With `options.conversationId`: append to that conversation
81
+ * via `client.genie.createMessage`.
82
+ * - In both cases, after the create/start the driver polls
83
+ * `client.genie.getMessage` every `options.pollIntervalMs`
84
+ * (default 500ms) until the message reaches a terminal
85
+ * status, then yields the terminal snapshot and returns.
86
+ *
87
+ * Cancellation: a single internal `AbortController` covers the
88
+ * whole turn. `options.context` is tied into that controller so an
89
+ * external abort tears down every in-flight SDK call AND the
90
+ * inter-poll sleep. Breaking out of the `for await` does the same
91
+ * via the `try / finally`.
92
+ *
93
+ * @example
94
+ * // Single turn.
95
+ * for await (const m of genieChat(spaceId, "Top 5 stores?")) {
96
+ * render(m);
97
+ * }
98
+ *
99
+ * @example
100
+ * // Multi-turn: caller threads the conversation id.
101
+ * let conversationId: string | undefined;
102
+ * for (const question of questions) {
103
+ * for await (const m of genieChat(spaceId, question, { conversationId })) {
104
+ * conversationId = m.conversation_id ?? conversationId;
105
+ * render(m);
106
+ * }
107
+ * }
108
+ */
109
+ export async function* genieChat(
110
+ space_id: string,
111
+ content: string,
112
+ options?: GenieChatOptions,
113
+ ): AsyncGenerator<GenieMessage, void, void> {
114
+ const controller = new AbortController();
115
+ try {
116
+ const client = await getWorkspaceClient(options);
117
+ // Build the SDK Context ONCE. Building it inside the poll
118
+ // producer would re-attach an abort listener to
119
+ // `options.context` on every poll iteration (via
120
+ // `apiUtils.toContext` -> `tieAbortSignal`), eventually
121
+ // tripping Node's `MaxListenersExceededWarning`.
122
+ const context = apiUtils.toContext(controller, options?.context);
123
+ let conversationId = options?.conversationId;
124
+ let messageId: string | undefined;
125
+
126
+ const pollProducer = async (
127
+ ctx: commonUtils.PollContext<GenieMessage>,
128
+ ): Promise<GenieMessage> => {
129
+ if (!conversationId) {
130
+ // First poll: open the conversation. Refuse to retry: if
131
+ // `startConversation` returned a response without a
132
+ // `conversation_id`, retrying would just open conversation
133
+ // after conversation.
134
+ if (ctx.attempt > 0) {
135
+ throw new Error(
136
+ "Genie did not return a conversation id; refusing to retry",
137
+ );
138
+ }
139
+ const startResponse = await client.genie.startConversation(
140
+ { space_id, content },
141
+ context,
142
+ );
143
+ conversationId = startResponse.conversation_id;
144
+ messageId = startResponse.message_id;
145
+ return startResponse.message!;
146
+ }
147
+ if (!messageId) {
148
+ // First poll of a follow-up turn: append to the seeded
149
+ // conversation. `client.genie.createMessage` returns a
150
+ // `Waiter<GenieMessage>` (`{ ...message, wait: async () =>
151
+ // ... }`). Strip `wait` here so downstream serializers
152
+ // (e.g. yaml.stringify in poll-chat) don't choke on the
153
+ // AsyncFunction value.
154
+ const { wait: _wait, ...createResponse } =
155
+ await client.genie.createMessage(
156
+ { space_id, conversation_id: conversationId, content },
157
+ context,
158
+ );
159
+ messageId = createResponse.message_id;
160
+ return createResponse;
161
+ }
162
+ // Subsequent polls: re-fetch the current message until its
163
+ // status becomes terminal.
164
+ return await client.genie.getMessage(
165
+ {
166
+ space_id,
167
+ conversation_id: conversationId,
168
+ message_id: messageId,
169
+ },
170
+ context,
171
+ );
172
+ };
173
+
174
+ yield* commonUtils.poll(pollProducer, {
175
+ intervalMs: options?.pollIntervalMs ?? 500,
176
+ // Skip yielding identical consecutive snapshots; Genie
177
+ // often returns the exact same payload twice during quiet
178
+ // periods. `poll` does a deep equal on the previous yield.
179
+ filter: "distinct",
180
+ // Stop after the terminal message is yielded. `poll` checks
181
+ // the predicate AFTER yielding, so the terminal message
182
+ // still reaches the consumer.
183
+ predicate: (m) => !isTerminalStatus(m.status),
184
+ // Wake the inter-poll sleep on abort so a `for await` break
185
+ // (or external abort) tears down promptly instead of waiting
186
+ // out the interval.
187
+ signal: controller.signal,
188
+ });
189
+ } finally {
190
+ // Cancels any still-pending SDK call and the inter-poll sleep
191
+ // whether we're unwinding from a normal return, a consumer
192
+ // break, or a thrown error. Idempotent.
193
+ controller.abort();
194
+ }
195
+ }
196
+
197
+ /* ---------------------- high-level: genieEventChat ------------------ */
198
+
199
+ /**
200
+ * One turn against a Genie space, yielded as a typed
201
+ * {@link GenieChatEvent} stream. Drives {@link genieChat}
202
+ * underneath and decorates each snapshot with the derived events
203
+ * the field-level diff produced. Stream order:
204
+ *
205
+ * 1. `{ type: "message", message }` - the raw `GenieMessage`,
206
+ * once per poll yield.
207
+ * 2. `{ type: "question", content, message_id, ... }` fires
208
+ * exactly once, on the FIRST `message` yield. We read
209
+ * `content` and `message_id` straight off the snapshot so
210
+ * every downstream event for this turn shares the same
211
+ * `message_id` (the question included) - subscribers can
212
+ * group everything for one Genie call under that one key.
213
+ * 3. Any of `status` / `attachment` / `thinking` / `text` /
214
+ * `query` / `statement` / `rows` / `suggested_questions` the
215
+ * diff against the prior snapshot produced.
216
+ * 4. On the terminal snapshot, `{ type: "result", ... }` as
217
+ * the final yield.
218
+ *
219
+ * Errors propagate by the generator throwing - there's no
220
+ * `"error"` variant. Wrap the `for await` in `try/catch` if you
221
+ * need to handle failures.
222
+ *
223
+ * @example
224
+ * for await (const event of genieEventChat(spaceId, "Top stores?")) {
225
+ * switch (event.type) {
226
+ * case "thinking":
227
+ * console.log("[thinking]", event.thought_type, event.text);
228
+ * break;
229
+ * case "text":
230
+ * console.log("[text]", event.text);
231
+ * break;
232
+ * case "result":
233
+ * console.log("[done]", event.status);
234
+ * break;
235
+ * }
236
+ * }
237
+ */
238
+ export async function* genieEventChat(
239
+ space_id: string,
240
+ content: string,
241
+ options?: GenieChatOptions,
242
+ ): AsyncGenerator<GenieChatEvent, void, void> {
243
+ // Diff source for the current turn. Always `undefined` on the
244
+ // first snapshot so the initial status / attachments emit
245
+ // fresh; updated to the most recent snapshot after each yield.
246
+ let previous: GenieMessage | undefined;
247
+ // The `question` event is deferred to the first `message` yield
248
+ // so it can carry the assigned `message_id` (subscribers use it
249
+ // as the grouping key for every event in this turn). The first
250
+ // snapshot is the earliest point that id exists.
251
+ let questionEmitted = false;
252
+ for await (const message of genieChat(space_id, content, options)) {
253
+ yield { type: "message", message };
254
+ if (!questionEmitted) {
255
+ yield {
256
+ type: "question",
257
+ space_id: message.space_id,
258
+ ...(message.conversation_id
259
+ ? { conversation_id: message.conversation_id }
260
+ : {}),
261
+ ...(message.message_id ? { message_id: message.message_id } : {}),
262
+ content: message.content,
263
+ };
264
+ questionEmitted = true;
265
+ }
266
+ yield* eventsFromMessage(message, previous, message.space_id);
267
+ if (isTerminalStatus(message.status)) {
268
+ yield {
269
+ type: "result",
270
+ space_id: message.space_id,
271
+ conversation_id: message.conversation_id,
272
+ message_id: message.message_id,
273
+ status: message.status,
274
+ message,
275
+ };
276
+ }
277
+ previous = message;
278
+ }
279
+ }
280
+
281
+ /* ---------------------- workspace client helper --------------------- */
282
+
283
+ /**
284
+ * Resolve a `WorkspaceClient` in this preference order:
285
+ *
286
+ * 1. Caller-supplied `options.workspaceClient`.
287
+ * 2. AppKit's per-request execution-context client, when AppKit
288
+ * is installed AND we're inside a request scope.
289
+ * 3. Fresh `new WorkspaceClient({})` (env-var auth via
290
+ * `DATABRICKS_CONFIG_PROFILE` / `DATABRICKS_HOST` /
291
+ * `DATABRICKS_TOKEN`).
292
+ *
293
+ * AppKit is loaded lazily so this package stays usable in
294
+ * non-AppKit environments (e.g. the `poll-chat` smoke test).
295
+ */
296
+ async function getWorkspaceClient(
297
+ options?: GenieChatOptions,
298
+ ): Promise<WorkspaceClient> {
299
+ if (options?.workspaceClient) return options.workspaceClient;
300
+ const appkit = await getAppKit();
301
+ if (appkit) {
302
+ try {
303
+ return appkit.getExecutionContext().client;
304
+ } catch {
305
+ // Not inside an AppKit request context; fall through to env.
306
+ }
307
+ }
308
+ return new WorkspaceClient({});
309
+ }
310
+
311
+ async function getAppKit() {
312
+ try {
313
+ return await import("@databricks/appkit");
314
+ } catch {
315
+ return undefined;
316
+ }
317
+ }