@dbx-tools/appkit-mastra 0.6.14 → 0.6.27

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/genie.ts CHANGED
@@ -48,14 +48,14 @@ import { plugin } from "@dbx-tools/appkit";
48
48
  import { chat, space as genieSpace } from "@dbx-tools/genie";
49
49
  import { error, log, string } from "@dbx-tools/shared-core";
50
50
  import { genieModel, type GenieMessage } from "@dbx-tools/shared-genie";
51
- import { wire, type MastraWriter, type StartedEvent } from "@dbx-tools/shared-mastra";
51
+ import type { MastraWriter, StartedEvent } from "@dbx-tools/shared-mastra";
52
52
  import type { RequestContext } from "@mastra/core/request-context";
53
53
  import { MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
54
54
  import { createTool } from "@mastra/core/tools";
55
55
  import { z } from "zod";
56
56
 
57
57
  import type { MastraTools } from "./agents.ts";
58
- import { chartPlannerRequestSchema, prepareChart } from "./chart.ts";
58
+ import { chartPlannerRequestSchema, chartToolOutputSchema, prepareChart } from "./chart.ts";
59
59
  import { MASTRA_USER_KEY, resolveUserKey } from "./config.ts";
60
60
  import type { MastraPluginConfig, User } from "./config.ts";
61
61
  import { fetchStatementData } from "./statement.ts";
@@ -687,9 +687,9 @@ function buildGetStatementTool() {
687
687
  * {@link prepareChart} that resolves the dataset by fetching the
688
688
  * Genie statement's rows on demand. The tool mints a `chartId`
689
689
  * synchronously, caches an empty placeholder, and kicks off the
690
- * planner in the background so the agent loop never blocks. The
691
- * host UI resolves `[chart:<chartId>]` markers by reading the
692
- * cached {@link Chart} entry (1h TTL).
690
+ * planner in the background so the agent loop never blocks. The result carries
691
+ * the complete marker to copy; the host UI resolves it by reading the cached
692
+ * {@link Chart} entry (1h TTL).
693
693
  *
694
694
  * Space-agnostic: a Genie `statement_id` is workspace-scoped, so
695
695
  * one shared `prepare_chart` tool covers every wired Genie space.
@@ -709,20 +709,21 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
709
709
  description: string.toDescription([
710
710
  `
711
711
  Queue a chart for the rows of a Genie statement. Mints a
712
- short \`chartId\` synchronously and kicks off a BACKGROUND
712
+ \`chartId\` plus its complete \`marker\` synchronously and
713
+ kicks off a BACKGROUND
713
714
  task that fetches the statement's rows, runs the
714
715
  chart-planner to pick a chart type and Echarts spec, and
715
716
  caches the result under the \`chartId\` for one hour. The
716
717
  host UI fetches the cached chart on its own once it lands.
717
718
  `,
718
719
  `
719
- To display the chart in your reply, embed
720
- \`[chart:<chartId>]\` on its own line at the position you
721
- want it to appear, using the EXACT \`chartId\` string this
722
- call returned. Never construct a chart id yourself (it is
723
- not the \`statement_id\` or any variation of it) - only a
724
- value returned by this tool resolves to a real chart. The
725
- tool returns immediately - do NOT wait or call it again to
720
+ To display the chart in your reply, copy the returned
721
+ \`marker\` VERBATIM onto its own line at the position you
722
+ want it to appear. Never construct, alter, or invent a
723
+ marker from \`chartId\` (and never use the \`statement_id\`
724
+ or any variation of it) - only the complete value returned
725
+ by this tool resolves to a real chart. The tool returns
726
+ immediately - do NOT wait or call it again to
726
727
  "check progress"; the chart resolves asynchronously on the
727
728
  host UI's side.
728
729
  `,
@@ -734,7 +735,7 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
734
735
  `,
735
736
  ]),
736
737
  inputSchema: prepareChartRequestSchema,
737
- outputSchema: wire.ChartSchema.pick({ chartId: true }),
738
+ outputSchema: chartToolOutputSchema,
738
739
  execute: async (request, ctxRaw) => {
739
740
  const ctx = ctxRaw as ToolExecuteCtx;
740
741
  const { client, requestContext } = requireClient(ctx, toolId);
@@ -835,20 +836,20 @@ export const GENIE_INSTRUCTIONS = string.toDescription([
835
836
  `,
836
837
  `
837
838
  \`[chart:<chartId>]\` - render the rows as a chart. To
838
- get a \`<chartId>\`, call \`prepare_chart\` with the
839
+ get one, call \`prepare_chart\` with the
839
840
  statement's id (and an optional \`title\` / one-line
840
841
  \`description\` of the insight to surface). The tool
841
- returns the \`chartId\` synchronously and prepares the
842
- chart spec in the background; embed the returned id as
843
- \`[chart:<chartId>]\` on its own line wherever the
844
- chart should appear. Use a chart when the data has a
842
+ returns the complete \`marker\` synchronously and
843
+ prepares the chart spec in the background; copy that
844
+ marker VERBATIM onto its own line wherever the chart
845
+ should appear. Use a chart when the data has a
845
846
  story a visual conveys better than a table (trends,
846
847
  rankings, distributions, parts-of-a-whole).
847
848
 
848
- NEVER invent or hand-build a \`<chartId>\`. A valid
849
- \`<chartId>\` is the opaque token a \`prepare_chart\`
850
- call returned to you in THIS turn - nothing else. It
851
- is NOT a \`statement_id\`, and it is NOT a
849
+ NEVER invent or hand-build a marker. A valid marker is
850
+ the complete opaque string a \`prepare_chart\` call
851
+ returned to you in THIS turn - nothing else. Its id is
852
+ NOT a \`statement_id\`, and it is NOT a
852
853
  \`statement_id\` prefix with a label appended (e.g.
853
854
  \`01f1...-region-fill\`). If you have not called
854
855
  \`prepare_chart\` and received an id back, do not write
package/src/mlflow.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * Feedback attaches to a trace, and the plugin's spans reach MLflow
7
7
  * through the same OTel pipeline as every other AppKit span (see
8
- * `observability.ts`). MLflow derives its trace id from the OpenTelemetry
8
+ * `observability.ts`). On Databricks Apps that pipeline is the UC
9
+ * sidecar injected by `telemetry_export_destinations`, not a direct
10
+ * workspace OTLP URL. MLflow derives its trace id from the OpenTelemetry
9
11
  * trace id (`tr-<hex(otelTraceId)>`), so the server stamps the active
10
12
  * trace id on each turn's response and the client sends it back here.
11
13
  *
@@ -30,6 +30,15 @@
30
30
  * registered at all (unless `observability: true` forces it on), so
31
31
  * Mastra does not emit `[OtelBridge] No OTEL span found` warnings.
32
32
  *
33
+ * On Databricks Apps the supported sink is Unity Catalog via
34
+ * `telemetry_export_destinations` (the platform injects the local OTLP
35
+ * sidecar). Managed MLflow has no OTLP ingest endpoint - do not point
36
+ * `OTEL_EXPORTER_OTLP_*` at the workspace host. Set
37
+ * `OTEL_PROPAGATORS=none` so Apps ingress `traceparent` does not hide
38
+ * every request-scoped root span from the UC `*_trace_unified` view.
39
+ * Chat request/response on that root span is stamped by
40
+ * `traceIo.attachChatTurnTraceIo` (see `trace-io.ts`).
41
+ *
33
42
  * @module
34
43
  */
35
44
 
@@ -39,6 +48,7 @@ import { Observability } from "@mastra/observability";
39
48
  import { OtelBridge } from "@mastra/otel-bridge";
40
49
 
41
50
  import { TRACE_REQUEST_CONTEXT_KEYS } from "./config.ts";
51
+ import { mlflowEnabled } from "./mlflow.ts";
42
52
 
43
53
  const logger = log.logger("mastra/observability");
44
54
 
@@ -127,11 +137,17 @@ export async function buildObservability(
127
137
  : otelBase
128
138
  ? `${otelBase.replace(/\/+$/, "")}/v1/traces`
129
139
  : undefined;
140
+ // `mlflowEnabled` is the same gate that flips chat feedback on, so the
141
+ // boot line is the one-glance check that both halves of the Apps UC
142
+ // pipeline (OTLP sidecar + experiment id) are present.
143
+ const feedback = mlflowEnabled();
130
144
  logger.info("Mastra observability wired through OTel bridge", {
131
145
  serviceName,
132
146
  requestContextKeys,
133
147
  otelBase: otelBase ?? "<unset>",
134
148
  resolvedTracesUrl: resolvedTracesUrl ?? "<unset>",
149
+ feedback,
150
+ observability: feedback ? "mlflow" : "otel",
135
151
  });
136
152
 
137
153
  return new Observability({
package/src/plugin.ts CHANGED
@@ -106,6 +106,7 @@ import {
106
106
  import { resolveServingConfig } from "./serving.ts";
107
107
  import { fetchStatementData, STATEMENT_ROW_CAP } from "./statement.ts";
108
108
  import { threadsRoute } from "./threads.ts";
109
+ import { attachChatTurnTraceIo } from "./trace-io.ts";
109
110
  import { invalidFields } from "./validation.ts";
110
111
 
111
112
  const GENIE_MANIFEST = plugin.data(genie).plugin.manifest;
@@ -594,10 +595,24 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
594
595
  const id = string.firstNonEmpty(req.params.id);
595
596
  const resolve = embedResolvers[type];
596
597
  if (!resolve) {
598
+ this.logger.warn("embed:unsupported", {
599
+ status: 404,
600
+ type,
601
+ id,
602
+ method: req.method,
603
+ path: req.path,
604
+ });
597
605
  res.status(404).json({ error: `unsupported embed type: ${type}` });
598
606
  return;
599
607
  }
600
608
  if (!id) {
609
+ this.logger.warn("embed:invalid", {
610
+ status: 400,
611
+ type,
612
+ method: req.method,
613
+ path: req.path,
614
+ error: "id is required",
615
+ });
601
616
  res.status(400).json({ error: "id is required" });
602
617
  return;
603
618
  }
@@ -615,6 +630,18 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
615
630
  return;
616
631
  }
617
632
  if (result.data === undefined) {
633
+ // A cache miss is security-deliberately ambiguous: unknown, expired,
634
+ // or owned by another identity all map to the same 404. Log the
635
+ // opaque id and those possible causes so a fabricated marker is
636
+ // diagnosable from the server without disclosing which cause applied.
637
+ this.logger.warn(`embed:${type}:not-found`, {
638
+ status: 404,
639
+ type,
640
+ id,
641
+ method: req.method,
642
+ path: req.path,
643
+ reason: "unknown, expired, or owned by another identity",
644
+ });
618
645
  res.status(404).json({ error: `${type} not found` });
619
646
  return;
620
647
  }
@@ -1004,7 +1031,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
1004
1031
  // every default-workspace agent via `extraSkillPaths`.
1005
1032
  const provisioned = await provisionRemoteSkills(this.config.remoteSkills);
1006
1033
  if (provisioned.skillNames.length > 0) {
1007
- this.logger.info("remote skills provisioned", {
1034
+ this.logger.info("remote skills configured", {
1008
1035
  skills: provisioned.skillNames,
1009
1036
  databricksBasePath: provisioned.databricksBasePath,
1010
1037
  localSkillPaths: provisioned.localSkillPaths.length,
@@ -1061,6 +1088,12 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
1061
1088
  });
1062
1089
  this.mastraApp = express();
1063
1090
  attachRoutePatchMiddleware(this.mastraApp);
1091
+ // Stamp chat request/response onto the HTTP root span so MLflow's
1092
+ // UC `*_trace_unified` view can surface them (Mastra's own
1093
+ // `mastra.agent_run.*` attributes sit on a child span the view
1094
+ // never reads). Must run before `MastraServer.init` so the layer
1095
+ // sits ahead of the agent routes.
1096
+ attachChatTurnTraceIo(this.mastraApp);
1064
1097
  this.mastraServer = new MastraServer(this.config, {
1065
1098
  app: this.mastraApp,
1066
1099
  mastra: this.mastra,
@@ -61,15 +61,9 @@ import { error, hash, json, log, net, object, string } from "@dbx-tools/shared-c
61
61
  import type { OneOrMany } from "@dbx-tools/shared-core";
62
62
  import type { FileSystem } from "@dbx-tools/shared-fs";
63
63
 
64
- const logger = log.logger("mastra/remote-skills");
65
-
66
- /** Shared Assistant skills tree in the Databricks workspace (default target). */
67
- const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
64
+ import { ASSISTANT_SHARED_SKILLS_PATH, userAssistantSkillsPath } from "./skill-paths.ts";
68
65
 
69
- /** Assistant skills directory for a specific Databricks user. */
70
- function userAssistantSkillsPath(userEmail: string): string {
71
- return `/Users/${userEmail.trim()}/.assistant/skills`;
72
- }
66
+ const logger = log.logger("mastra/remote-skills");
73
67
 
74
68
  /**
75
69
  * Agent id the `skills` CLI installs a bare `SKILL.md` tree under.
@@ -452,14 +446,19 @@ export async function provisionRemoteSkills(
452
446
  if (cached && isFresh(cached, resolveRefreshTtl(sourceOptions, options))) {
453
447
  skillNames.push(...cached.skills);
454
448
  if (!destination) localSkillPaths.push(cacheFS.root);
455
- logger.debug("source:cached", {
449
+ logger.info("remote skill ready", {
456
450
  source: sourceOptions.source,
451
+ destination: databricksBasePath ?? cacheFS.root,
457
452
  downloadedAt: cached.downloadedAt,
458
453
  skills: cached.skills,
454
+ cached: true,
459
455
  });
460
456
  continue;
461
457
  }
462
458
 
459
+ logger.info("installing remote skill", {
460
+ source: sourceOptions.source,
461
+ });
463
462
  staging ??= await initializedScratch("mastra-remote-skills");
464
463
  const stagedDir = await stageSource(sourceOptions, staging.root, options);
465
464
  const staged = await collectSkillDirs(stagedDir);
@@ -482,9 +481,9 @@ export async function provisionRemoteSkills(
482
481
  localSkillPaths.push(await persistLocally(key, staged, record));
483
482
  }
484
483
  skillNames.push(...record.skills);
485
- logger.debug("source:provisioned", {
484
+ logger.info("remote skill installed", {
486
485
  source: sourceOptions.source,
487
- destination: databricksBasePath ?? "local-temp",
486
+ destination: databricksBasePath ?? localSkillPaths.at(-1),
488
487
  skills: record.skills,
489
488
  });
490
489
  } catch (err) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Where Databricks Assistant `SKILL.md` trees live.
3
+ *
4
+ * Two modules need these paths and must agree on them: `workspaces.ts` MOUNTS
5
+ * them per request (see `DEFAULT_SKILL_FOLDERS`), and `remote-skills.ts`
6
+ * WRITES provisioned skills into them at startup. They were previously spelled
7
+ * out in both, so a change to one silently provisioned skills into a tree the
8
+ * other never scanned.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /** Shared Assistant skills tree, readable by everyone in the workspace. */
14
+ export const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
15
+
16
+ /** Assistant skills tree owned by one user (the "save this as a skill" target). */
17
+ export function userAssistantSkillsPath(userEmail: string): string {
18
+ return `/Users/${userEmail.trim()}/.assistant/skills`;
19
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Copy each Mastra chat turn's request/response onto the active OTel
3
+ * root span so Databricks-managed MLflow can display them.
4
+ *
5
+ * Why this exists: MLflow's UC `<target>_trace_unified` view picks the
6
+ * one span whose `parent_span_id` is empty and reads request/response
7
+ * from `mlflow.spanInputs` / `mlflow.spanOutputs` (or the `gen_ai.*`
8
+ * equivalents) on THAT span only. Mastra records the turn on its
9
+ * `invoke_agent` child under `mastra.agent_run.input` / `.output`, keys
10
+ * the view never reads, so chat traces arrive with both columns null
11
+ * even when the spans themselves look healthy.
12
+ *
13
+ * The active span inside the Mastra sub-app is still AppKit's HTTP
14
+ * server span (OTel context is async-local). With `OTEL_PROPAGATORS=none`
15
+ * that span is the trace root - see the package README's observability
16
+ * section for why Apps ingress `traceparent` otherwise hides every turn.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { json, log, object } from "@dbx-tools/shared-core";
22
+ import { trace } from "@opentelemetry/api";
23
+ import type express from "express";
24
+
25
+ const logger = log.logger("mastra/trace-io");
26
+
27
+ /** Cap on each payload copied onto a span, so one turn cannot bloat the export. */
28
+ export const TRACE_IO_LIMIT = 8_000;
29
+
30
+ /**
31
+ * Mount-relative Mastra agent invoke paths that carry a chat turn body
32
+ * (`messages`) and stream an assistant answer. Resume / approve verbs
33
+ * are intentionally excluded: their bodies are tool decisions, not the
34
+ * user prompt, and they rarely produce a fresh answer worth surfacing.
35
+ */
36
+ const AGENT_TURN_ROUTE = /^\/agents\/[^/]+\/(stream|generate)(\/|$)/i;
37
+
38
+ /** Attribute keys the MLflow UC `*_trace_unified` view reads from the root span. */
39
+ export const MLFLOW_SPAN_INPUTS_ATTR = "mlflow.spanInputs";
40
+ export const MLFLOW_SPAN_OUTPUTS_ATTR = "mlflow.spanOutputs";
41
+
42
+ /**
43
+ * Concatenate the assistant's answer out of an AI SDK SSE transcript.
44
+ *
45
+ * The agent streams its reply as `text-delta` frames
46
+ * (`data: {"type":"text-delta","payload":{"text":"..."}}`), so the full
47
+ * answer only ever exists as deltas on the wire and has to be
48
+ * reassembled here.
49
+ */
50
+ export function assistantTextFromSse(body: string): string {
51
+ const parts: string[] = [];
52
+ for (const line of body.split("\n")) {
53
+ if (!line.startsWith("data:")) continue;
54
+ // SSE carries comment/keepalive lines that are not JSON; they never hold
55
+ // assistant text, so skipping them is the intended path rather than an error.
56
+ const frame = json.parse(line.slice(5));
57
+ if (!object.isRecord(frame) || frame.type !== "text-delta") continue;
58
+ const text = object.isRecord(frame.payload) ? frame.payload.text : undefined;
59
+ if (typeof text === "string") parts.push(text);
60
+ }
61
+ return parts.join("");
62
+ }
63
+
64
+ /**
65
+ * Express middleware that stamps chat turn I/O onto the active OTel span.
66
+ *
67
+ * Tee `res.write` / `res.end` instead of listening for `finish`: the HTTP
68
+ * instrumentation ends the root span on finish, and an attribute set after
69
+ * a span ends never reaches the exporter.
70
+ *
71
+ * `path` is mount-relative (what the Mastra sub-app sees), e.g.
72
+ * `/agents/support/stream`.
73
+ */
74
+ export function chatTurnTraceIoMiddleware(
75
+ req: express.Request,
76
+ res: express.Response,
77
+ next: express.NextFunction,
78
+ ): void {
79
+ if (req.method !== "POST" || !AGENT_TURN_ROUTE.test(req.path)) {
80
+ next();
81
+ return;
82
+ }
83
+ const span = trace.getActiveSpan();
84
+ if (!span) {
85
+ next();
86
+ return;
87
+ }
88
+
89
+ const messages = (req.body as { messages?: unknown } | undefined)?.messages;
90
+ if (messages !== undefined) {
91
+ span.setAttribute(MLFLOW_SPAN_INPUTS_ATTR, JSON.stringify(messages).slice(0, TRACE_IO_LIMIT));
92
+ }
93
+
94
+ const chunks: string[] = [];
95
+ const passThroughWrite = res.write.bind(res) as (...args: unknown[]) => boolean;
96
+ const passThroughEnd = res.end.bind(res) as (...args: unknown[]) => unknown;
97
+ const collect = (chunk: unknown): void => {
98
+ if (typeof chunk === "string") chunks.push(chunk);
99
+ else if (Buffer.isBuffer(chunk)) chunks.push(chunk.toString("utf8"));
100
+ };
101
+
102
+ res.write = ((chunk: unknown, ...rest: unknown[]) => {
103
+ collect(chunk);
104
+ return passThroughWrite(chunk, ...rest);
105
+ }) as typeof res.write;
106
+
107
+ res.end = ((chunk?: unknown, ...rest: unknown[]) => {
108
+ collect(chunk);
109
+ const answer = assistantTextFromSse(chunks.join(""));
110
+ if (answer) {
111
+ span.setAttribute(MLFLOW_SPAN_OUTPUTS_ATTR, answer.slice(0, TRACE_IO_LIMIT));
112
+ }
113
+ return passThroughEnd(chunk, ...rest);
114
+ }) as typeof res.end;
115
+
116
+ next();
117
+ }
118
+
119
+ /**
120
+ * Install {@link chatTurnTraceIoMiddleware} on a Mastra Express sub-app.
121
+ *
122
+ * Call before `MastraServer.init()` so the layer sits ahead of the agent
123
+ * routes. Safe to call unconditionally: with no active span (local, no
124
+ * OTLP) the middleware is a no-op.
125
+ */
126
+ export function attachChatTurnTraceIo(app: express.Express): void {
127
+ app.use(chatTurnTraceIoMiddleware);
128
+ logger.info("chat turn I/O middleware attached");
129
+ }