@dbx-tools/appkit-mastra 0.1.12 → 0.1.14

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 (57) hide show
  1. package/README.md +303 -637
  2. package/index.ts +46 -38
  3. package/package.json +58 -43
  4. package/src/agents.ts +224 -66
  5. package/src/chart.ts +531 -429
  6. package/src/config.ts +270 -19
  7. package/src/filesystems.ts +1090 -0
  8. package/src/genie.ts +1000 -660
  9. package/src/history.ts +166 -79
  10. package/src/mcp.ts +105 -0
  11. package/src/memory.ts +119 -81
  12. package/src/mlflow.ts +149 -0
  13. package/src/model.ts +75 -408
  14. package/src/observability.ts +144 -0
  15. package/src/pagination.ts +34 -0
  16. package/src/plugin.ts +566 -59
  17. package/src/processors.ts +168 -0
  18. package/src/rest.ts +67 -0
  19. package/src/server.ts +232 -45
  20. package/src/serving-sanitize.ts +167 -0
  21. package/src/serving.ts +27 -243
  22. package/src/statement.ts +89 -0
  23. package/src/storage-schema.ts +41 -0
  24. package/src/summarize.ts +176 -0
  25. package/src/threads.ts +338 -0
  26. package/src/workspaces.ts +346 -0
  27. package/src/writer.ts +44 -0
  28. package/tsconfig.json +41 -0
  29. package/dist/index.d.ts +0 -20
  30. package/dist/index.js +0 -20
  31. package/dist/src/agents.d.ts +0 -306
  32. package/dist/src/agents.js +0 -403
  33. package/dist/src/chart.d.ts +0 -170
  34. package/dist/src/chart.js +0 -491
  35. package/dist/src/config.d.ts +0 -183
  36. package/dist/src/config.js +0 -12
  37. package/dist/src/genie.d.ts +0 -131
  38. package/dist/src/genie.js +0 -630
  39. package/dist/src/history.d.ts +0 -67
  40. package/dist/src/history.js +0 -172
  41. package/dist/src/memory.d.ts +0 -79
  42. package/dist/src/memory.js +0 -210
  43. package/dist/src/model.d.ts +0 -159
  44. package/dist/src/model.js +0 -427
  45. package/dist/src/plugin.d.ts +0 -130
  46. package/dist/src/plugin.js +0 -261
  47. package/dist/src/processors/strip-stale-charts.d.ts +0 -29
  48. package/dist/src/processors/strip-stale-charts.js +0 -96
  49. package/dist/src/server.d.ts +0 -46
  50. package/dist/src/server.js +0 -123
  51. package/dist/src/serving.d.ts +0 -156
  52. package/dist/src/serving.js +0 -231
  53. package/dist/src/tools/email.d.ts +0 -74
  54. package/dist/src/tools/email.js +0 -122
  55. package/dist/tsconfig.build.tsbuildinfo +0 -1
  56. package/src/processors/strip-stale-charts.ts +0 -105
  57. package/src/tools/email.ts +0 -147
package/src/memory.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * with two independent knobs:
6
6
  *
7
7
  * - **Storage** (threads / messages via `PostgresStore`): defaults to
8
- * **per-agent** namespacing via `schemaName: "mastra_<agentId>"` so
8
+ * **per-agent** namespacing via {@link agentStorageSchemaName} so
9
9
  * conversation history stays isolated between agents in the same
10
10
  * database. `PostgresStore` auto-creates the schema with
11
11
  * `CREATE SCHEMA IF NOT EXISTS` on init.
@@ -14,31 +14,61 @@
14
14
  * index is almost always what users want; opt into per-agent recall
15
15
  * by passing a {@link MastraMemoryConfigOverride} on the agent.
16
16
  *
17
+ * Additionally, {@link MemoryBuilder.instanceStorage} returns a
18
+ * **Mastra-instance-level** `PostgresStore` (schema `mastra_instance`)
19
+ * used for workflow snapshots - the persistence layer
20
+ * `agent.resumeStream()` reads from when waking a suspended
21
+ * `requireApproval` tool call. Per-agent stores are not enough for
22
+ * this: workflow runs are scoped to the Mastra instance, not an
23
+ * individual agent's `Memory`.
24
+ *
17
25
  * Plugin-level `config.storage` / `config.memory` act as the baseline
18
26
  * (auto-defaulted to `true` in `plugin.ts` when the `lakebase` plugin
19
27
  * is registered); per-agent settings cascade on top of that.
20
28
  */
21
29
 
22
- import { lakebase } from "@databricks/appkit";
23
- import { logUtils, pluginUtils } from "@dbx-tools/appkit-shared";
30
+ import { getUsernameWithApiLookup } from "@databricks/appkit";
24
31
  import { fastembed } from "@mastra/fastembed";
25
32
  import { Memory } from "@mastra/memory";
26
33
  import { PgVector, PostgresStore } from "@mastra/pg";
27
34
  import { randomUUID } from "node:crypto";
28
- import type { Pool } from "pg";
35
+ import { Pool, type PoolConfig } from "pg";
29
36
 
30
- import type {
31
- MastraAgentDefinition,
32
- MastraMemoryConfigOverride,
33
- } from "./agents.js";
34
- import type { MastraPluginConfig } from "./config.js";
37
+ import type { MastraAgentDefinition, MastraMemoryConfigOverride } from "./agents";
38
+ import type { MastraPluginConfig } from "./config";
39
+ import { agentStorageSchemaName } from "./storage-schema";
40
+ import { summaryModel, TITLE_INSTRUCTIONS } from "./summarize";
41
+ import { log } from "@dbx-tools/shared-core";
35
42
 
36
- const log = logUtils.logger("mastra/memory");
43
+ const logger = log.logger("mastra/memory");
37
44
 
38
- /** Pool handle returned by the AppKit `lakebase` plugin `exports().pool`. */
39
- export type LakebasePool = ReturnType<
40
- InstanceType<ReturnType<typeof lakebase>["plugin"]>["exports"]
41
- >["pool"];
45
+ /**
46
+ * Build a dedicated **service-principal** Lakebase pool for Mastra
47
+ * memory from the lakebase plugin's resolved SP pg config.
48
+ *
49
+ * The plugin's `exports().pool` is a `RoutingPool` that switches to
50
+ * the per-user (OBO) pool whenever a query runs inside an `asUser`
51
+ * scope - exactly the context the mastra plugin establishes around
52
+ * every chat turn. Memory (threads / messages + semantic recall) must
53
+ * instead always act as the app service principal: it owns the
54
+ * auto-created `mastra_*` schemas (a per-user role usually can't
55
+ * `CREATE SCHEMA`) and is shared across users, so it cannot inherit a
56
+ * request's OBO identity.
57
+ *
58
+ * `pgConfig` must be the plugin's `exports().getPgConfig()` evaluated
59
+ * **outside** any `asUser` scope (i.e. during setup), so it carries
60
+ * the SP connection target, OAuth token-refresh `password` callback,
61
+ * and any `lakebase({ pool })` tuning overrides - all of which this
62
+ * pool inherits. See the call site in `plugin.ts`.
63
+ */
64
+ export async function createServicePrincipalPool(pgConfig: PoolConfig): Promise<Pool> {
65
+ // `getPgConfig()` resolves the SP username synchronously from
66
+ // `PGUSER` / `DATABRICKS_CLIENT_ID`; fall back to the async API
67
+ // lookup (e.g. local dev authenticating via PAT) so the pool always
68
+ // has an identity to connect with.
69
+ const user = pgConfig.user ?? (await getUsernameWithApiLookup());
70
+ return new Pool({ ...pgConfig, user });
71
+ }
42
72
 
43
73
  /** Effective per-knob setting after the plugin/agent cascade. */
44
74
  type StorageSetting = MastraAgentDefinition["storage"];
@@ -46,59 +76,69 @@ type MemorySetting = MastraAgentDefinition["memory"];
46
76
 
47
77
  /**
48
78
  * True when any plugin-level or per-agent setting could need the
49
- * Lakebase pool. Used by `plugin.ts` to gate pool acquisition; the
50
- * builder also acquires lazily so missed cases still fail with a
51
- * clear lakebase-not-registered error.
79
+ * Lakebase pool. Used by `plugin.ts` to gate creation of the
80
+ * service-principal pool and the {@link MemoryBuilder} that consumes
81
+ * it; when false neither is built.
52
82
  */
53
83
  export function needsLakebase(config: MastraPluginConfig): boolean {
54
84
  if (settingNeedsSharedPool(config.storage)) return true;
55
85
  if (settingNeedsSharedPool(config.memory)) return true;
56
86
  const defs = collectAgentDefinitions(config);
57
- return defs.some(
58
- (d) =>
59
- settingNeedsSharedPool(d.storage) || settingNeedsSharedPool(d.memory),
60
- );
61
- }
62
-
63
- /**
64
- * Look up the `lakebase` plugin and return its managed `pg.Pool`.
65
- * Throws when the sibling plugin is not registered; enabling
66
- * `storage` / `memory` without lakebase is a wiring bug, not a runtime
67
- * condition we can recover from.
68
- */
69
- export function resolveLakebasePool(
70
- context: pluginUtils.PluginContextLike | undefined,
71
- caller: MastraPluginConfig,
72
- ): LakebasePool {
73
- return pluginUtils.require(context, lakebase, caller).exports().pool;
87
+ return defs.some((d) => settingNeedsSharedPool(d.storage) || settingNeedsSharedPool(d.memory));
74
88
  }
75
89
 
76
90
  /**
77
- * Construct a per-agent {@link Memory} factory. Caches the shared
78
- * `PgVector` singleton (built on first need) and the lazily-resolved
79
- * Lakebase pool so each agent build is O(1) after the first.
91
+ * Construct a per-agent {@link Memory} factory bound to the supplied
92
+ * service-principal pool (see {@link createServicePrincipalPool}).
93
+ * Caches the shared `PgVector` singleton (built on first need) so each
94
+ * agent build is O(1) after the first.
80
95
  */
81
96
  export function createMemoryBuilder(
82
97
  config: MastraPluginConfig,
83
- context: pluginUtils.PluginContextLike | undefined,
98
+ servicePrincipalPool: Pool,
84
99
  ): MemoryBuilder {
85
- return new MemoryBuilder(config, context);
100
+ return new MemoryBuilder(config, servicePrincipalPool);
86
101
  }
87
102
 
88
103
  /**
89
- * Builds one `Memory` per agent. Per-instance state keeps the shared
90
- * `PgVector` and the resolved Lakebase pool alive across calls so
91
- * registering N agents stays cheap.
104
+ * Builds one `Memory` per agent against a shared service-principal
105
+ * Lakebase pool. Per-instance state keeps the shared `PgVector` alive
106
+ * across calls so registering N agents stays cheap.
92
107
  */
93
108
  export class MemoryBuilder {
94
109
  private sharedVector: PgVector | undefined;
95
- private pool: LakebasePool | undefined;
96
110
 
97
111
  constructor(
98
112
  private readonly config: MastraPluginConfig,
99
- private readonly context: pluginUtils.PluginContextLike | undefined,
113
+ private readonly servicePrincipalPool: Pool,
100
114
  ) {}
101
115
 
116
+ /**
117
+ * Build the Mastra-instance-level storage used for workflow
118
+ * snapshots. Returns `undefined` when plugin-level `storage` is
119
+ * disabled, in which case `agent.resumeStream()` (and therefore
120
+ * the `requireApproval` flow) will not be available.
121
+ *
122
+ * The store lives in a dedicated `mastra_instance` schema so it
123
+ * never collides with per-agent {@link agentStorageSchemaName} namespaces.
124
+ * Workflow snapshots are not per-agent state; they belong to the
125
+ * `Mastra` instance that owns the workflow execution.
126
+ */
127
+ instanceStorage(): PostgresStore | undefined {
128
+ const setting = this.config.storage;
129
+ if (!setting) return undefined;
130
+ if (typeof setting === "object") {
131
+ return new PostgresStore(
132
+ withId(setting, "mastra-store__instance") as ConstructorParameters<typeof PostgresStore>[0],
133
+ );
134
+ }
135
+ return new PostgresStore({
136
+ id: "mastra-store__instance",
137
+ schemaName: "mastra_instance",
138
+ pool: this.servicePrincipalPool,
139
+ });
140
+ }
141
+
102
142
  /**
103
143
  * Build a `Memory` for `agentId` after the plugin/agent cascade.
104
144
  * Returns `undefined` when the agent has neither storage nor a
@@ -112,20 +152,16 @@ export class MemoryBuilder {
112
152
  const storage = this.buildStorage(agentId, storageSetting);
113
153
  const vector = this.buildVector(memorySetting);
114
154
  if (!storage && !vector) {
115
- log.debug("agent:stateless", { agentId });
155
+ logger.debug("agent:stateless", { agentId });
116
156
  return undefined;
117
157
  }
118
158
 
119
- log.debug("agent:configured", {
159
+ logger.debug("agent:configured", {
120
160
  agentId,
121
161
  storage: storage !== undefined,
122
162
  vector: vector !== undefined,
123
163
  vectorMode:
124
- vector === undefined
125
- ? "off"
126
- : typeof memorySetting === "object"
127
- ? "dedicated"
128
- : "shared",
164
+ vector === undefined ? "off" : typeof memorySetting === "object" ? "dedicated" : "shared",
129
165
  });
130
166
 
131
167
  return new Memory({
@@ -133,32 +169,39 @@ export class MemoryBuilder {
133
169
  ...(vector ? { vector, embedder: fastembed } : {}),
134
170
  options: {
135
171
  lastMessages: 10,
136
- ...(vector
137
- ? { semanticRecall: { topK: 3, messageRange: 2 } }
172
+ ...(vector ? { semanticRecall: { topK: 3, messageRange: 2 } } : {}),
173
+ // Auto-name each thread from its opening turn so the
174
+ // conversation list the UI renders shows meaningful titles
175
+ // instead of raw ids. Titling runs on the small / fast chat
176
+ // tier (see `summarize.ts`) rather than the agent's primary
177
+ // model, so naming a thread never spends the heavyweight model.
178
+ // Only meaningful when storage is on; harmless otherwise.
179
+ ...(storage
180
+ ? {
181
+ generateTitle: {
182
+ model: summaryModel(this.config),
183
+ instructions: TITLE_INSTRUCTIONS,
184
+ },
185
+ }
138
186
  : {}),
139
187
  },
140
188
  });
141
189
  }
142
190
 
143
- private buildStorage(
144
- agentId: string,
145
- setting: StorageSetting,
146
- ): PostgresStore | undefined {
191
+ private buildStorage(agentId: string, setting: StorageSetting): PostgresStore | undefined {
147
192
  if (!setting) return undefined;
148
193
  if (typeof setting === "boolean") {
149
194
  return new PostgresStore({
150
195
  id: `mastra-store__${agentId}`,
151
- schemaName: `mastra_${agentId}`,
152
- pool: this.requirePool() as Pool,
196
+ schemaName: agentStorageSchemaName(agentId),
197
+ pool: this.servicePrincipalPool,
153
198
  });
154
199
  }
155
200
  // Cast: `withId` guarantees `id` is set, but the distributive
156
201
  // Omit + `id?: string` shape doesn't structurally narrow to the
157
202
  // discriminated union members. Runtime shape is identical.
158
203
  return new PostgresStore(
159
- withId(setting, `mastra-store__${agentId}`) as ConstructorParameters<
160
- typeof PostgresStore
161
- >[0],
204
+ withId(setting, `mastra-store__${agentId}`) as ConstructorParameters<typeof PostgresStore>[0],
162
205
  );
163
206
  }
164
207
 
@@ -179,17 +222,10 @@ export class MemoryBuilder {
179
222
 
180
223
  private getSharedVector(): PgVector {
181
224
  if (!this.sharedVector) {
182
- this.sharedVector = buildSharedPgVector(this.requirePool());
225
+ this.sharedVector = buildSharedPgVector(this.servicePrincipalPool);
183
226
  }
184
227
  return this.sharedVector;
185
228
  }
186
-
187
- private requirePool(): LakebasePool {
188
- if (!this.pool) {
189
- this.pool = resolveLakebasePool(this.context, this.config);
190
- }
191
- return this.pool;
192
- }
193
229
  }
194
230
 
195
231
  /**
@@ -212,9 +248,17 @@ export class MemoryBuilder {
212
248
  * the lakebase pool from then on. The placeholder pool is `.end()`'d
213
249
  * so its socket book-keeping is released.
214
250
  */
215
- function buildSharedPgVector(pool: LakebasePool): PgVector {
251
+ function buildSharedPgVector(pool: Pool): PgVector {
216
252
  const vector = new PgVector({
217
253
  id: `pg${randomUUID()}`,
254
+ // Keep the recall index out of `public`: on a Lakebase database the app
255
+ // service principal has no CREATE on `public` (PG15+ locks it down), so a
256
+ // default-schema PgVector fails on CREATE INDEX with "permission denied for
257
+ // schema public". PgVector runs CREATE SCHEMA IF NOT EXISTS for a named
258
+ // schema, so the SP creates and owns `mastra_instance` (the same
259
+ // instance-level schema the workflow-snapshot store uses) and the index
260
+ // lands there. Table names don't collide with the storage tables.
261
+ schemaName: "mastra_instance",
218
262
  host: "-1",
219
263
  port: -1,
220
264
  database: "_",
@@ -222,7 +266,7 @@ function buildSharedPgVector(pool: LakebasePool): PgVector {
222
266
  password: "_",
223
267
  });
224
268
  const placeholder = vector.pool;
225
- vector.pool = pool as Pool;
269
+ vector.pool = pool;
226
270
  void placeholder.end().catch(() => undefined);
227
271
  return vector;
228
272
  }
@@ -230,23 +274,17 @@ function buildSharedPgVector(pool: LakebasePool): PgVector {
230
274
  /** Per-agent dedicated `PgVector` (rare; opt-in via object override). */
231
275
  function buildPgVector(setting: MastraMemoryConfigOverride): PgVector {
232
276
  return new PgVector(
233
- withId(setting, `pg-vector__${randomUUID()}`) as ConstructorParameters<
234
- typeof PgVector
235
- >[0],
277
+ withId(setting, `pg-vector__${randomUUID()}`) as ConstructorParameters<typeof PgVector>[0],
236
278
  );
237
279
  }
238
280
 
239
281
  /** True when this setting requires the shared Lakebase pool. */
240
- function settingNeedsSharedPool(
241
- setting: StorageSetting | MemorySetting | undefined,
242
- ): boolean {
282
+ function settingNeedsSharedPool(setting: StorageSetting | MemorySetting | undefined): boolean {
243
283
  return setting === true;
244
284
  }
245
285
 
246
286
  /** Walk the three shapes of `config.agents` into a flat list. */
247
- function collectAgentDefinitions(
248
- config: MastraPluginConfig,
249
- ): MastraAgentDefinition[] {
287
+ function collectAgentDefinitions(config: MastraPluginConfig): MastraAgentDefinition[] {
250
288
  const agents = config.agents;
251
289
  if (!agents) return [];
252
290
  if (Array.isArray(agents)) return agents;
package/src/mlflow.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * MLflow user-feedback logging: detect whether MLflow tracing is wired
3
+ * for this deployment, and log a thumbs / comment as a trace
4
+ * *assessment* via the Databricks MLflow REST API.
5
+ *
6
+ * Feedback attaches to a trace, and the plugin's spans reach MLflow
7
+ * through the same OTel pipeline as every other AppKit span (see
8
+ * `observability.ts`). MLflow derives its trace id from the OpenTelemetry
9
+ * trace id (`tr-<hex(otelTraceId)>`), so the server stamps the active
10
+ * trace id on each turn's response and the client sends it back here.
11
+ *
12
+ * There is no MLflow JS SDK, so this posts to the assessments REST
13
+ * endpoint directly using the OBO-scoped workspace client (the feedback
14
+ * is thus attributed to the signed-in user). Trace export is
15
+ * asynchronous, so the just-finished trace may not exist in MLflow yet
16
+ * when the user reacts; the log call retries briefly on "not found"
17
+ * before giving up softly.
18
+ */
19
+
20
+ import { feedback } from "@dbx-tools/shared-mastra";
21
+ import { databricksFetch, readResponseJson, readResponseText } from "./rest";
22
+ import { async, error, log } from "@dbx-tools/shared-core";
23
+ import { appkit } from "@dbx-tools/appkit";
24
+
25
+ const logger = log.logger("mastra/mlflow");
26
+
27
+ /** Workspace client carried on an AppKit execution context. */
28
+ type WorkspaceClient = appkit.WorkspaceClientLike;
29
+
30
+ /** Assessments REST path for a trace. `3.0` is the current MLflow API version. */
31
+ const assessmentsPath = (traceId: string): string =>
32
+ `/api/3.0/mlflow/traces/${encodeURIComponent(traceId)}/assessments`;
33
+
34
+ /** Number of times to retry a "trace not found" response before giving up. */
35
+ const NOT_FOUND_RETRIES = 3;
36
+ /** Base backoff between "trace not found" retries, in ms (grows linearly). */
37
+ const NOT_FOUND_BACKOFF_MS = 1200;
38
+
39
+ /**
40
+ * Whether MLflow feedback logging is available for this deployment.
41
+ *
42
+ * Enabled when an OTLP exporter endpoint is configured (traces are
43
+ * actually shipped somewhere) AND an MLflow experiment is named - the
44
+ * two signals that the OTLP backend is MLflow and traces will
45
+ * materialize there. Both are standard env vars, so no plugin config is
46
+ * required; a deployment opts in simply by wiring MLflow tracing.
47
+ */
48
+ export function mlflowEnabled(): boolean {
49
+ const hasExporter = Boolean(
50
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() ||
51
+ process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim(),
52
+ );
53
+ const hasExperiment = Boolean(
54
+ process.env.MLFLOW_EXPERIMENT_ID?.trim() || process.env.MLFLOW_EXPERIMENT_NAME?.trim(),
55
+ );
56
+ return hasExporter && hasExperiment;
57
+ }
58
+
59
+ /**
60
+ * Resolve whether user feedback is enabled from the plugin's optional
61
+ * `config.feedback` override: the explicit boolean wins, otherwise fall
62
+ * back to auto-detecting MLflow tracing ({@link mlflowEnabled}). Shared
63
+ * by the plugin's client-config gate and the server's trace-id header so
64
+ * the two never disagree.
65
+ */
66
+ export function resolveFeedbackEnabled(explicit: boolean | undefined): boolean {
67
+ return explicit ?? mlflowEnabled();
68
+ }
69
+
70
+ /** Parameters for {@link logFeedback}. */
71
+ export interface LogFeedbackParams {
72
+ /** MLflow trace id the assessment attaches to (`tr-<hex>`). */
73
+ traceId: string;
74
+ /** Assessment name; defaults per whether a value or a comment is sent. */
75
+ name?: string;
76
+ /** Thumbs / rating / label value. Omit for a comment-only submission. */
77
+ value?: boolean | number | string;
78
+ /** Freeform comment: the rationale alongside a value, or the value itself when none. */
79
+ comment?: string;
80
+ /** Identity the feedback is attributed to (user email / id). */
81
+ sourceId?: string;
82
+ }
83
+
84
+ /**
85
+ * Log a HUMAN feedback assessment to a trace. Returns the created
86
+ * assessment id on success, or `undefined` when the trace can't be
87
+ * found (even after retrying for export lag) or the request otherwise
88
+ * fails - callers surface that as a soft "not recorded" rather than an
89
+ * error, keeping the chat usable.
90
+ */
91
+ export async function logFeedback(
92
+ client: WorkspaceClient,
93
+ params: LogFeedbackParams,
94
+ ): Promise<string | undefined> {
95
+ // A comment with no thumbs value is logged as text feedback; a value
96
+ // (with an optional comment as the rationale) is the thumbs path.
97
+ const hasValue = params.value !== undefined;
98
+ const name =
99
+ params.name?.trim() ||
100
+ (hasValue ? feedback.DEFAULT_FEEDBACK_NAME : feedback.DEFAULT_COMMENT_NAME);
101
+ const value = hasValue ? params.value : params.comment;
102
+ const assessment: Record<string, unknown> = {
103
+ trace_id: params.traceId,
104
+ assessment_name: name,
105
+ source: {
106
+ source_type: "HUMAN",
107
+ source_id: params.sourceId?.trim() || "user",
108
+ },
109
+ feedback: { value },
110
+ ...(hasValue && params.comment?.trim() ? { rationale: params.comment } : {}),
111
+ };
112
+ const body = { assessment };
113
+
114
+ for (let attempt = 0; attempt <= NOT_FOUND_RETRIES; attempt++) {
115
+ let res: Response;
116
+ try {
117
+ res = await databricksFetch(client, assessmentsPath(params.traceId), {
118
+ method: "POST",
119
+ body,
120
+ });
121
+ } catch (err) {
122
+ logger.warn("feedback request failed", {
123
+ traceId: params.traceId,
124
+ error: error.errorMessage(err),
125
+ });
126
+ return undefined;
127
+ }
128
+ if (res.ok) {
129
+ const parsed = await readResponseJson(res);
130
+ const assessmentId =
131
+ (parsed as { assessment?: { assessment_id?: unknown } })?.assessment?.assessment_id ??
132
+ (parsed as { assessment_id?: unknown })?.assessment_id;
133
+ return typeof assessmentId === "string" ? assessmentId : "";
134
+ }
135
+ // Trace export is async; a fresh trace may not exist yet. Retry a
136
+ // few times with a short backoff before giving up softly.
137
+ if (res.status === 404 && attempt < NOT_FOUND_RETRIES) {
138
+ await async.sleep(NOT_FOUND_BACKOFF_MS * (attempt + 1));
139
+ continue;
140
+ }
141
+ logger.warn("feedback not recorded", {
142
+ traceId: params.traceId,
143
+ status: res.status,
144
+ body: await readResponseText(res),
145
+ });
146
+ return undefined;
147
+ }
148
+ return undefined;
149
+ }