@dbx-tools/teams 0.3.39

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.
@@ -0,0 +1,640 @@
1
+ /**
2
+ * The Teams conversation turn: run a Mastra agent against an inbound Bot
3
+ * Framework activity and answer with Adaptive Card attachments.
4
+ *
5
+ * This is the piece that makes `POST /api/teams/activity` behave like a Teams
6
+ * bot rather than a chat API that happens to return JSON. A turn is:
7
+ *
8
+ * 1. read the user's text off the inbound `message` activity;
9
+ * 2. ANSWER it - a normal tool-using agent turn, with no mention of cards, so
10
+ * the agent queries Genie / calls its tools exactly as it would on a
11
+ * streaming chat endpoint;
12
+ * 3. FORMAT that answer into a {@link card.CardSpec} in a second pass, via
13
+ * Mastra's `structuredOutput` (prompt-injected, see
14
+ * {@link JSON_PROMPT_INJECTION});
15
+ * 4. compile the spec with the same deterministic builder the
16
+ * `create_teams_card` tool uses, and attach it to an outbound activity.
17
+ *
18
+ * The two passes are the important part. Asking for the answer AND the card
19
+ * shape in ONE request makes the model treat formatting as the task: it emits a
20
+ * card straight away and never calls its tools, so a question that should have
21
+ * queried a data source came back as "I don't have a real system connected -
22
+ * here is a template card with placeholders". Answering first, then formatting a
23
+ * REAL answer, makes this endpoint's content identical to the streaming
24
+ * endpoint's; only the presentation differs.
25
+ *
26
+ * Formatting is also why the turn does not simply rely on the agent calling
27
+ * `create_teams_card`: on this endpoint a card IS the response format, so it
28
+ * should be a property of the turn rather than a tool the model may forget.
29
+ * Agents keep the tool for the other direction - answering in prose on a normal
30
+ * chat endpoint and choosing to attach a card. When the agent DOES call it
31
+ * during the answering pass, that spec wins and the formatting pass is skipped.
32
+ *
33
+ * The conversation id doubles as the agent's memory thread id, so a client that
34
+ * keeps posting the same `conversation.id` gets a continuous conversation - the
35
+ * same mapping a real channel relies on.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import { error, hash, json, log, object, string } from "@dbx-tools/shared-core";
41
+ import { activity as activityContract, card } from "@dbx-tools/shared-teams";
42
+ import { buildAdaptiveCard } from "./builder";
43
+
44
+ const logger = log.logger("teams:conversation");
45
+
46
+ /**
47
+ * Minimal structural shape of the Mastra `Agent` this module drives.
48
+ *
49
+ * Declared structurally rather than importing `@mastra/core`'s `Agent`: this
50
+ * package must not depend on the Mastra plugin (the plugin depends on nothing
51
+ * here either), and a turn only ever needs `generate`. Any object with a
52
+ * compatible `generate` satisfies it, which also makes the turn trivially
53
+ * testable with a stub.
54
+ */
55
+ export interface CardAgentLike {
56
+ generate(
57
+ prompt: string,
58
+ options: {
59
+ structuredOutput?: {
60
+ schema: typeof card.cardSpecSchema;
61
+ jsonPromptInjection?: boolean | "system" | "inline";
62
+ };
63
+ memory?: { thread: string; resource: string };
64
+ /**
65
+ * Mastra's per-turn `RequestContext`. Opaque here - the object comes from
66
+ * the agent plugin (see {@link AgentProviderLike.exports}) and is only
67
+ * forwarded - but REQUIRED for parity with the chat endpoints: Mastra's
68
+ * user-scoped tools read the AppKit user off it, so a turn without one
69
+ * answers "the data source is unreachable" where chat answers with data.
70
+ */
71
+ requestContext?: unknown;
72
+ abortSignal?: AbortSignal;
73
+ },
74
+ ): Promise<AgentResult>;
75
+ }
76
+
77
+ /**
78
+ * The slice of Mastra's `generate` result a turn reads.
79
+ *
80
+ * `toolResults` matters as much as `object` here: when structured output fails
81
+ * and the turn re-asks as prose, an agent holding the `create_teams_card` tool
82
+ * typically CALLS it, so the best available card spec is the tool's arguments -
83
+ * already in the right vocabulary - rather than anything in the prose.
84
+ */
85
+ export interface AgentResult {
86
+ object?: unknown;
87
+ text?: string;
88
+ toolResults?: {
89
+ payload?: { toolName?: string; args?: unknown };
90
+ toolName?: string;
91
+ args?: unknown;
92
+ }[];
93
+ }
94
+
95
+ /** Tool id whose arguments are already a {@link card.CardSpec}. */
96
+ const CARD_TOOL_NAMES = ["create_teams_card", "createCard"];
97
+
98
+ /**
99
+ * The card spec an agent passed to the card tool, if it called it.
100
+ *
101
+ * Mastra nests a tool result under `payload` (`{ payload: { toolName, args } }`)
102
+ * but has used a flat shape too, so both are read.
103
+ */
104
+ const toolCardSpec = (result: AgentResult): card.CardSpec | null => {
105
+ for (const entry of result.toolResults ?? []) {
106
+ const name = entry.payload?.toolName ?? entry.toolName;
107
+ if (!name || !CARD_TOOL_NAMES.includes(name)) continue;
108
+ const parsed = card.cardSpecSchema.safeParse(entry.payload?.args ?? entry.args);
109
+ if (parsed.success) return parsed.data;
110
+ }
111
+ return null;
112
+ };
113
+
114
+ /**
115
+ * Ask for the card shape via PROMPT injection rather than the provider's native
116
+ * response format.
117
+ *
118
+ * Databricks Model Serving rejects a request carrying both `response_format` and
119
+ * `tools` ("Cannot specify both response_format and tools in the same request"),
120
+ * and the agents this endpoint drives normally do have tools. Native structured
121
+ * output would therefore fail for exactly the agents worth talking to, so the
122
+ * schema is injected as instructions instead - which costs nothing here because
123
+ * the answer is re-validated with `cardSpecSchema` before it is compiled.
124
+ */
125
+ const JSON_PROMPT_INJECTION = "system" as const;
126
+
127
+ /** The bot's identity on outbound activities when the caller names none. */
128
+ export const BOT_ACCOUNT: activityContract.ChannelAccount = {
129
+ id: "dbx-tools-teams-bot",
130
+ name: "Databricks Agent",
131
+ };
132
+
133
+ /**
134
+ * Instructions for the FORMATTING pass only - turning an answer the agent has
135
+ * already produced into the card vocabulary.
136
+ *
137
+ * Deliberately NOT sent with the user's question. Asking for a card and an
138
+ * answer in one request makes the model treat formatting as the task: it emits a
139
+ * card immediately instead of calling its tools, so a question that should have
140
+ * queried Genie came back as "I don't have a real system connected - here is a
141
+ * template card". Formatting is a separate, second pass over a real answer.
142
+ */
143
+ export const CARD_FORMAT_INSTRUCTIONS = [
144
+ "Reformat the assistant answer below as a Microsoft Teams Adaptive Card.",
145
+ "Preserve the answer's facts EXACTLY - every number, name, date and id.",
146
+ "Do not add caveats, placeholders or invented values, and do not describe the",
147
+ "card; if the answer states a value, that value belongs in the card.",
148
+ "Put the headline finding in `title` and keep it under ~60 characters.",
149
+ "Use `text` for the explanation, with '-' bullets for lists.",
150
+ "Use `facts` for key/value detail (metrics, counts, owners, ids) instead of",
151
+ "writing it as prose.",
152
+ "Add `actions` only for URLs present in the answer; never invent a link.",
153
+ "Drop any `[chart:...]` / `[data:...]` marker: a card cannot render one, so",
154
+ "write the values it stood for into `facts` or `text` instead.",
155
+ ].join(" ");
156
+
157
+ /**
158
+ * Nudge added to the ANSWERING pass.
159
+ *
160
+ * The agent answers the question normally here - tools included - so this only
161
+ * asks for the shape that survives compression into a card well. It must not
162
+ * mention cards, or the model starts formatting instead of answering.
163
+ */
164
+ export const CARD_ANSWER_INSTRUCTIONS = [
165
+ "Answer using your tools and data sources as you normally would.",
166
+ "Be concise and lead with the finding, and state concrete values",
167
+ "(numbers, names, dates) plainly so they can be summarized.",
168
+ // Agents on a chat endpoint are told to defer tables and charts to the host UI
169
+ // as `[data:<id>]` / `[chart:<id>]` markers. A card has no embed slot to fill,
170
+ // so a deferred number arrives as a literal `[data:01f1...]` in the card. The
171
+ // turn asks for the values themselves instead, and strips any that slip
172
+ // through (see EMBED_MARKER_RE).
173
+ "This channel cannot display chart or data embeds: do not emit",
174
+ "`[chart:...]` or `[data:...]` markers - write the actual numbers you",
175
+ "retrieved into your answer instead.",
176
+ ].join(" ");
177
+
178
+ /**
179
+ * A host-embed marker (`[chart:<id>]`, `[data:<id>]`) in an agent's prose.
180
+ *
181
+ * Chat hosts swap these for a rendered chart or table. An Adaptive Card has no
182
+ * such slot, so a marker that survives into card text renders as literal
183
+ * `[data:01f1895c-...]` noise where a number should be. The answering pass asks
184
+ * the agent not to emit them; this removes any that still arrive.
185
+ *
186
+ * The generic `[type:id]` grammar is spelled here rather than imported so this
187
+ * package stays a leaf add-on with no dependency on the agent plugin's
188
+ * contracts - it only ever needs to RECOGNIZE a marker, never resolve one.
189
+ */
190
+ const EMBED_MARKER_RE = /\[[A-Za-z][A-Za-z0-9_-]*:[^\]\s]+\]/g;
191
+
192
+ /**
193
+ * Drop embed markers from `text`, tidying the whitespace they leave behind so a
194
+ * marker that sat on its own line does not become a blank one.
195
+ */
196
+ const stripEmbedMarkers = (text: string): string =>
197
+ text
198
+ .replace(EMBED_MARKER_RE, "")
199
+ .replace(/[ \t]+$/gm, "")
200
+ .replace(/\n{3,}/g, "\n\n")
201
+ .trim();
202
+
203
+ /**
204
+ * Structural shape of the sibling plugin that owns the agent registry - the
205
+ * slice of `@dbx-tools/appkit-mastra`'s `exports()` a turn needs.
206
+ *
207
+ * Matched structurally, by registered plugin NAME, rather than importing the
208
+ * Mastra plugin: this package stays a leaf add-on (it depends on no other
209
+ * dbx-tools runtime package, like node-email), the dependency direction stays
210
+ * one-way, and any plugin exposing the same `get` / `getDefault` pair can back
211
+ * the endpoint.
212
+ */
213
+ export interface AgentProviderLike {
214
+ exports(): {
215
+ get(id: string): unknown;
216
+ getDefault(): unknown;
217
+ /**
218
+ * Builds the per-turn `RequestContext` the provider's tools expect. Optional
219
+ * so an older provider (or a differently-shaped one) still resolves an
220
+ * agent; the turn simply runs without user-scoped tool context then.
221
+ */
222
+ createRequestContext?(options: { threadId?: string; resourceId?: string }): Promise<unknown>;
223
+ };
224
+ }
225
+
226
+ /** True when `value` exposes the `generate` a turn drives. */
227
+ const isCardAgent = (value: unknown): value is CardAgentLike =>
228
+ typeof (value as CardAgentLike | null)?.generate === "function";
229
+
230
+ /** True when `value` exposes the agent-registry `exports()` a turn reads. */
231
+ const isAgentProvider = (value: unknown): value is AgentProviderLike =>
232
+ typeof (value as AgentProviderLike | null)?.exports === "function";
233
+
234
+ /**
235
+ * Read one agent out of a provider's `exports()`.
236
+ *
237
+ * Every member is probed before it is called: the provider is matched
238
+ * structurally by plugin name, so a DIFFERENT plugin registered under that name
239
+ * can satisfy `isAgentProvider` (it has an `exports()`) while exposing no agent
240
+ * registry at all. Calling blindly would throw a `TypeError` out of route
241
+ * resolution; a miss is just "no agent", which the route reports as 503.
242
+ */
243
+ const readAgent = (provider: AgentProviderLike, agentId?: string): unknown => {
244
+ const registry = provider.exports() as Partial<ReturnType<AgentProviderLike["exports"]>>;
245
+ if (agentId) return typeof registry.get === "function" ? registry.get(agentId) : null;
246
+ return typeof registry.getDefault === "function" ? registry.getDefault() : null;
247
+ };
248
+
249
+ /**
250
+ * Resolve the agent that answers a turn from the AppKit plugin registry.
251
+ *
252
+ * `agentId` picks a specific agent; omitted, the provider's default agent
253
+ * answers. Returns `null` when the provider is absent or the id is unknown, so
254
+ * the route can answer 503 / 404 instead of throwing.
255
+ */
256
+ export const resolveCardAgent = (
257
+ plugins: ReadonlyMap<string, unknown> | undefined,
258
+ providerName: string,
259
+ agentId?: string,
260
+ ): CardAgentLike | null => {
261
+ const provider = plugins?.get(providerName);
262
+ if (!isAgentProvider(provider)) return null;
263
+ const found = readAgent(provider, agentId);
264
+ return isCardAgent(found) ? found : null;
265
+ };
266
+
267
+ /** Options for {@link runCardTurn}. */
268
+ export interface CardTurnOptions {
269
+ /** Cancels the agent call with the request. */
270
+ signal?: AbortSignal;
271
+ /** Overrides the bot identity stamped on the reply. */
272
+ bot?: activityContract.ChannelAccount;
273
+ /**
274
+ * Builds the Mastra `RequestContext` for the turn, from
275
+ * {@link resolveCardContextFactory}. Omitted, the turn still answers, but
276
+ * without the AppKit user its user-scoped tools cannot reach Databricks - so
277
+ * the route should always supply it.
278
+ */
279
+ createRequestContext?: CardContextFactory;
280
+ }
281
+
282
+ /** Builds the per-turn request context the agent's tools read. */
283
+ export type CardContextFactory = (options: {
284
+ threadId?: string;
285
+ resourceId?: string;
286
+ }) => Promise<unknown>;
287
+
288
+ /**
289
+ * The agent plugin's request-context factory, when it exposes one.
290
+ *
291
+ * Resolved separately from the agent because it is the piece that gives an
292
+ * out-of-band turn the same tool reach as a chat turn; a provider without it
293
+ * still answers, just without user-scoped tools.
294
+ */
295
+ export const resolveCardContextFactory = (
296
+ plugins: ReadonlyMap<string, unknown> | undefined,
297
+ providerName: string,
298
+ ): CardContextFactory | null => {
299
+ const provider = plugins?.get(providerName);
300
+ if (!isAgentProvider(provider)) return null;
301
+ const registry = provider.exports() as Partial<ReturnType<AgentProviderLike["exports"]>>;
302
+ const factory = registry.createRequestContext;
303
+ return typeof factory === "function" ? (options) => factory.call(registry, options) : null;
304
+ };
305
+
306
+ /**
307
+ * The text a `message` activity carries, or `null` when it carries none.
308
+ *
309
+ * A channel sends plenty of activities with no usable text (a `typing`
310
+ * indicator, a `conversationUpdate` when someone joins, or a `message` whose
311
+ * payload is only an attachment). Those are not errors - they simply produce no
312
+ * reply - so this returns `null` rather than throwing.
313
+ */
314
+ export const promptOf = (inbound: activityContract.Activity): string | null =>
315
+ inbound.type === "message" ? string.trimToNull(inbound.text ?? "") : null;
316
+
317
+ /**
318
+ * Build an outbound activity carrying `cards`, addressed back to the sender of
319
+ * `inbound`.
320
+ *
321
+ * Exported because a client rendering an optimistic local reply, and a test
322
+ * asserting the envelope, both need the same construction the turn uses.
323
+ */
324
+ export const toReplyActivity = (
325
+ inbound: activityContract.Activity,
326
+ cards: card.AdaptiveCard[],
327
+ options: { bot?: activityContract.ChannelAccount; text?: string } = {},
328
+ ): activityContract.Activity => ({
329
+ type: "message",
330
+ id: hash.id(),
331
+ timestamp: new Date().toISOString(),
332
+ from: options.bot ?? BOT_ACCOUNT,
333
+ ...(inbound.from ? { recipient: inbound.from } : {}),
334
+ ...(inbound.conversation ? { conversation: inbound.conversation } : {}),
335
+ ...(options.text ? { text: options.text } : {}),
336
+ attachments: cards.map((document) => activityContract.toCardAttachment(document)),
337
+ });
338
+
339
+ /**
340
+ * Recover a {@link card.CardSpec} from a full Adaptive Card DOCUMENT.
341
+ *
342
+ * A capable model asked for "a Microsoft Teams Adaptive Card" often answers with
343
+ * the finished 1.5 document instead of the small spec - correct Adaptive Card
344
+ * JSON, wrong schema, so `structuredOutput` rejects it (`title: expected
345
+ * string`) and a genuinely good answer is thrown away. Reading the document back
346
+ * into the spec vocabulary keeps it: the heading TextBlocks become
347
+ * title/subtitle, remaining TextBlocks the body, the FactSet the facts, and any
348
+ * `Action.OpenUrl` the actions.
349
+ *
350
+ * Deliberately tolerant about the container: `type` may be missing and the body
351
+ * may nest elements inside a `Container` / `ColumnSet`, so blocks are collected
352
+ * recursively and anything unrecognized is ignored.
353
+ */
354
+ export const documentCardSpec = (value: unknown): card.CardSpec | null => {
355
+ if (!object.isRecord(value) || !Array.isArray(value.body)) return null;
356
+
357
+ const texts: string[] = [];
358
+ const facts: card.CardFact[] = [];
359
+ const collect = (elements: unknown[]): void => {
360
+ for (const element of elements) {
361
+ if (!object.isRecord(element)) continue;
362
+ if (element.type === "TextBlock") {
363
+ const text = string.trimToNull(typeof element.text === "string" ? element.text : "");
364
+ if (text) texts.push(text);
365
+ } else if (element.type === "FactSet" && Array.isArray(element.facts)) {
366
+ for (const fact of element.facts) {
367
+ const parsed = card.cardFactSchema.safeParse(fact);
368
+ if (parsed.success) facts.push(parsed.data);
369
+ }
370
+ }
371
+ // A Container / ColumnSet / Column nests the blocks that matter.
372
+ for (const key of ["items", "columns"]) {
373
+ const nested = element[key];
374
+ if (Array.isArray(nested)) collect(nested);
375
+ }
376
+ }
377
+ };
378
+ collect(value.body);
379
+
380
+ const actions: card.CardAction[] = [];
381
+ for (const action of Array.isArray(value.actions) ? value.actions : []) {
382
+ if (!object.isRecord(action)) continue;
383
+ const parsed = card.cardActionSchema.safeParse({ title: action.title, url: action.url });
384
+ if (parsed.success) actions.push(parsed.data);
385
+ }
386
+
387
+ const [title, ...rest] = texts;
388
+ if (!title) return null;
389
+ // The second block is a subtitle only when the document styled it as one;
390
+ // otherwise it is body text and must not be demoted to a subheading.
391
+ const second = object.isRecord(value.body[1]) ? value.body[1] : undefined;
392
+ const hasSubtitle = rest.length > 0 && second?.isSubtle === true;
393
+ const body = (hasSubtitle ? rest.slice(1) : rest).join("\n\n");
394
+ return {
395
+ title: toTitle(title),
396
+ ...(hasSubtitle && rest[0] ? { subtitle: rest[0] } : {}),
397
+ ...(body ? { text: body } : {}),
398
+ ...(facts.length > 0 ? { facts } : {}),
399
+ ...(actions.length > 0 ? { actions } : {}),
400
+ };
401
+ };
402
+
403
+ /**
404
+ * The card the model produced, wherever it ended up.
405
+ *
406
+ * `structuredOutput` rejects anything off-schema by THROWING, and Mastra puts
407
+ * the offending payload on the error (`details.value`) - so the model's real
408
+ * answer is recoverable from a failed call. Both the spec shape and a full
409
+ * Adaptive Card document are accepted, from an object or from JSON text.
410
+ */
411
+ export const rejectedCardSpec = (err: unknown): card.CardSpec | null => {
412
+ const details = object.isRecord(err) ? err.details : undefined;
413
+ const value = object.isRecord(details) ? details.value : undefined;
414
+ const candidates: unknown[] = [];
415
+ if (typeof value === "string") {
416
+ candidates.push(json.parse(value, undefined));
417
+ } else if (value !== undefined) {
418
+ candidates.push(value);
419
+ }
420
+ for (const candidate of candidates) {
421
+ const spec = card.cardSpecSchema.safeParse(candidate);
422
+ if (spec.success) return spec.data;
423
+ const fromDocument = documentCardSpec(candidate);
424
+ if (fromDocument) return fromDocument;
425
+ }
426
+ return null;
427
+ };
428
+
429
+ /**
430
+ * Turn whatever the agent produced into a card spec.
431
+ *
432
+ * `structuredOutput` is best-effort in practice, not a guarantee. Because the
433
+ * schema is prompt-injected (see {@link JSON_PROMPT_INJECTION}) rather than
434
+ * enforced by the provider, a model can answer with prose, with JSON wrapped in
435
+ * a ```` ```json ```` fence, or - on Databricks Model Serving - with an empty
436
+ * parsed object while the text carries the real answer. Observed failure rate on
437
+ * the demo endpoint was roughly one turn in three, all of which surfaced as a
438
+ * hard `STRUCTURED_OUTPUT_SCHEMA_VALIDATION_FAILED` and no reply at all.
439
+ *
440
+ * A missing card is not worth dropping the answer over, so this recovers in
441
+ * four stages: the parsed object when it validates; else the arguments the agent
442
+ * passed to `create_teams_card` (on the prose retry an agent holding that tool
443
+ * usually calls it, which is a REAL card spec, not a guess); else a JSON object
444
+ * embedded in the text; else the prose wrapped in a minimal text-only card. The
445
+ * user always gets the agent's answer; at worst it is less structured.
446
+ */
447
+ const toCardSpec = (result: AgentResult): card.CardSpec => {
448
+ const parsed = card.cardSpecSchema.safeParse(result.object);
449
+ if (parsed.success) return parsed.data;
450
+
451
+ // A model that answered with the finished Adaptive Card document rather than
452
+ // the spec still produced a real card; read it back into the spec vocabulary.
453
+ const fromDocument = documentCardSpec(result.object);
454
+ if (fromDocument) return fromDocument;
455
+
456
+ // Prefer a card the agent explicitly composed via the tool over anything
457
+ // scraped out of its prose.
458
+ const fromTool = toolCardSpec(result);
459
+ if (fromTool) return fromTool;
460
+
461
+ const text = string.trimToNull(result.text ?? "");
462
+ if (text) {
463
+ // A fenced or inline JSON object in the prose: the model followed the
464
+ // instructions but the provider did not surface a parsed object.
465
+ const embedded = text.match(/\{[\s\S]*\}/);
466
+ if (embedded) {
467
+ const value = json.parse(embedded[0], undefined);
468
+ const retry = card.cardSpecSchema.safeParse(value);
469
+ if (retry.success) return retry.data;
470
+ const asDocument = documentCardSpec(value);
471
+ if (asDocument) return asDocument;
472
+ }
473
+ // Prose only: keep the answer, drop the structure. The first line becomes
474
+ // the title (a card with no title renders as an untitled block), the rest
475
+ // stays as the body.
476
+ const lines = text
477
+ .split("\n")
478
+ .filter((line) => line.trim().length > 0)
479
+ // A model introducing its own answer ("Here is the Adaptive Card:") would
480
+ // otherwise become the card's headline, which reads as a bug. Drop the
481
+ // preamble so the first REAL line is the title.
482
+ .filter((line) => !PREAMBLE_RE.test(line.trim()));
483
+ const [first, ...rest] = lines;
484
+ const title = string.trimToNull(first ?? "") ?? "Answer";
485
+ const body = rest.join("\n").trim();
486
+ return {
487
+ title: toTitle(title),
488
+ ...(body ? { text: body } : title.length > 120 ? { text: title } : {}),
489
+ };
490
+ }
491
+
492
+ throw new Error("teams: the agent returned neither a card nor any text to fall back on");
493
+ };
494
+
495
+ /**
496
+ * A line that only announces the card rather than saying anything - e.g.
497
+ * "Here is the Adaptive Card:", "Here's your Teams card:", "Here are the card
498
+ * details:". Matched so it never becomes the card's title.
499
+ *
500
+ * Anchored to a "here is/are/here's ... card ..." opener that ENDS in a colon,
501
+ * which is what keeps it from eating a real sentence that merely mentions a card
502
+ * ("Here is the card reader status: offline." has text after the colon).
503
+ */
504
+ const PREAMBLE_RE = /^(here (is|are)|here's)\b[^.!?]*\bcard\b[^.!?:]*:\s*$/i;
505
+
506
+ /**
507
+ * Trim a line down to something usable as a card title. Adaptive Card titles are
508
+ * a single bold line, so an overlong one is truncated rather than wrapped; the
509
+ * markdown emphasis a model often adds is stripped since the title is already
510
+ * styled bold.
511
+ */
512
+ const toTitle = (value: string): string => {
513
+ const plain = value
514
+ .replace(/^#+\s*/, "")
515
+ .replace(/\*\*/g, "")
516
+ .trim();
517
+ return plain.length > 120 ? `${plain.slice(0, 117)}...` : plain;
518
+ };
519
+
520
+ /**
521
+ * Compile an answer the agent has already produced into a card spec.
522
+ *
523
+ * A second, deliberately stateless pass: the formatting request is NOT threaded
524
+ * onto agent memory, so the conversation the user sees keeps only their question
525
+ * and the answer, not the reformatting chatter in between.
526
+ *
527
+ * Returns `null` when the pass is unusable (Mastra throws when the model returns
528
+ * no parsed object) so the caller can fall back to the answer text - a less
529
+ * structured card carrying the real answer beats losing the answer.
530
+ */
531
+ const formatAsCard = async (
532
+ agent: CardAgentLike,
533
+ prompt: string,
534
+ answer: string,
535
+ request: { abortSignal?: AbortSignal; requestContext?: unknown },
536
+ ): Promise<card.CardSpec | null> => {
537
+ const body = [
538
+ CARD_FORMAT_INSTRUCTIONS,
539
+ "",
540
+ `Question: ${prompt}`,
541
+ "",
542
+ "Assistant answer:",
543
+ answer,
544
+ ].join("\n");
545
+ const options = {
546
+ structuredOutput: {
547
+ schema: card.cardSpecSchema,
548
+ jsonPromptInjection: JSON_PROMPT_INJECTION,
549
+ },
550
+ ...request,
551
+ };
552
+ // Mastra THROWS when the model returns no parsed object, and because the
553
+ // schema is prompt-injected rather than provider-enforced that happens
554
+ // intermittently on the same input. One retry converts most of those misses
555
+ // into a proper card; the answer is already safe either way.
556
+ for (let attempt = 0; attempt < 2; attempt += 1) {
557
+ try {
558
+ return toCardSpec(await agent.generate(body, options));
559
+ } catch (err) {
560
+ // The rejected payload usually IS the card - most often the full Adaptive
561
+ // Card document rather than the spec - so it is salvaged before retrying.
562
+ const rejected = rejectedCardSpec(err);
563
+ if (rejected) {
564
+ logger.debug("recovered the card from the rejected structured payload");
565
+ return rejected;
566
+ }
567
+ logger.warn("card formatting pass failed", {
568
+ attempt: attempt + 1,
569
+ error: error.errorMessage(err),
570
+ });
571
+ }
572
+ }
573
+ logger.warn("falling back to the answer text for the card");
574
+ return null;
575
+ };
576
+
577
+ /**
578
+ * Run one conversation turn: drive `agent` with the inbound activity's text and
579
+ * return the activities to append to the transcript.
580
+ *
581
+ * Returns an EMPTY array for an activity that carries no prompt (a typing
582
+ * indicator, a join event), which is exactly what a bot does with one - the
583
+ * route answers `{ activities: [] }` and the transcript is unchanged.
584
+ *
585
+ * Two agent calls, in this order:
586
+ *
587
+ * 1. the ANSWER - no `structuredOutput`, so the agent's tools are available
588
+ * and the content matches what the streaming endpoint would say;
589
+ * 2. the FORMAT - {@link formatAsCard} compiling that answer into a card.
590
+ *
591
+ * Every failure mode still yields the answer: a card the agent composed with
592
+ * `create_teams_card` during pass 1 short-circuits pass 2, and a failed pass 2
593
+ * falls back to {@link toCardSpec} over the answer text.
594
+ */
595
+ export const runCardTurn = async (
596
+ agent: CardAgentLike,
597
+ inbound: activityContract.Activity,
598
+ options: CardTurnOptions = {},
599
+ ): Promise<activityContract.Activity[]> => {
600
+ const prompt = promptOf(inbound);
601
+ if (!prompt) return [];
602
+
603
+ const conversationId = inbound.conversation?.id;
604
+ const userId = inbound.from?.id;
605
+ const signal = options.signal ? { abortSignal: options.signal } : {};
606
+ // The AppKit user rides on this, so the agent's tools (Genie, serving, the
607
+ // model resolver) work exactly as they do on the chat endpoints. Built once
608
+ // and shared by both passes.
609
+ const requestContext = options.createRequestContext
610
+ ? await options.createRequestContext({
611
+ ...(conversationId ? { threadId: conversationId } : {}),
612
+ ...(userId ? { resourceId: userId } : {}),
613
+ })
614
+ : undefined;
615
+ const context = requestContext ? { requestContext } : {};
616
+ // Thread the conversation onto agent memory so the same `conversation.id`
617
+ // continues one conversation. Only passed when the client supplied both ids -
618
+ // Mastra requires the pair, and a memory-less agent ignores it.
619
+ const memory =
620
+ conversationId && userId ? { memory: { thread: conversationId, resource: userId } } : {};
621
+
622
+ const answered = await agent.generate(`${prompt}\n\n${CARD_ANSWER_INSTRUCTIONS}`, {
623
+ ...memory,
624
+ ...context,
625
+ ...signal,
626
+ });
627
+
628
+ // An agent that composed a card itself has already said it in the right
629
+ // vocabulary; reformatting it would only risk paraphrasing the values.
630
+ const composed = toolCardSpec(answered);
631
+ const answer = string.trimToNull(stripEmbedMarkers(answered.text ?? ""));
632
+ const spec =
633
+ composed ??
634
+ (answer ? await formatAsCard(agent, prompt, answer, { ...context, ...signal }) : null);
635
+
636
+ const document = buildAdaptiveCard(
637
+ spec ?? toCardSpec({ ...answered, ...(answer ? { text: answer } : {}) }),
638
+ );
639
+ return [toReplyActivity(inbound, [document], { ...(options.bot ? { bot: options.bot } : {}) })];
640
+ };