@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.
- package/README.md +259 -0
- package/index.ts +22 -0
- package/package.json +56 -0
- package/src/auth.ts +294 -0
- package/src/builder.ts +102 -0
- package/src/config.ts +268 -0
- package/src/connector.ts +103 -0
- package/src/conversation.ts +640 -0
- package/src/defaults.ts +89 -0
- package/src/messaging.ts +155 -0
- package/src/plugin.ts +499 -0
- package/src/runtime.ts +190 -0
- package/src/tool.ts +84 -0
|
@@ -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
|
+
};
|