@memberjunction/messaging-adapters 0.0.1 → 5.17.0
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 +231 -43
- package/dist/base/BaseMessagingAdapter.d.ts +428 -0
- package/dist/base/BaseMessagingAdapter.d.ts.map +1 -0
- package/dist/base/BaseMessagingAdapter.js +934 -0
- package/dist/base/BaseMessagingAdapter.js.map +1 -0
- package/dist/base/message-formatter.d.ts +70 -0
- package/dist/base/message-formatter.d.ts.map +1 -0
- package/dist/base/message-formatter.js +201 -0
- package/dist/base/message-formatter.js.map +1 -0
- package/dist/base/types.d.ts +211 -0
- package/dist/base/types.d.ts.map +1 -0
- package/dist/base/types.js +6 -0
- package/dist/base/types.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/slack/SlackAdapter.d.ts +141 -0
- package/dist/slack/SlackAdapter.d.ts.map +1 -0
- package/dist/slack/SlackAdapter.js +291 -0
- package/dist/slack/SlackAdapter.js.map +1 -0
- package/dist/slack/SlackMessagingExtension.d.ts +148 -0
- package/dist/slack/SlackMessagingExtension.d.ts.map +1 -0
- package/dist/slack/SlackMessagingExtension.js +433 -0
- package/dist/slack/SlackMessagingExtension.js.map +1 -0
- package/dist/slack/slack-block-builder.d.ts +133 -0
- package/dist/slack/slack-block-builder.d.ts.map +1 -0
- package/dist/slack/slack-block-builder.js +748 -0
- package/dist/slack/slack-block-builder.js.map +1 -0
- package/dist/slack/slack-formatter.d.ts +37 -0
- package/dist/slack/slack-formatter.d.ts.map +1 -0
- package/dist/slack/slack-formatter.js +116 -0
- package/dist/slack/slack-formatter.js.map +1 -0
- package/dist/slack/slack-interactivity.d.ts +38 -0
- package/dist/slack/slack-interactivity.d.ts.map +1 -0
- package/dist/slack/slack-interactivity.js +414 -0
- package/dist/slack/slack-interactivity.js.map +1 -0
- package/dist/slack/slack-routes.d.ts +35 -0
- package/dist/slack/slack-routes.d.ts.map +1 -0
- package/dist/slack/slack-routes.js +98 -0
- package/dist/slack/slack-routes.js.map +1 -0
- package/dist/teams/TeamsAdapter.d.ts +155 -0
- package/dist/teams/TeamsAdapter.d.ts.map +1 -0
- package/dist/teams/TeamsAdapter.js +383 -0
- package/dist/teams/TeamsAdapter.js.map +1 -0
- package/dist/teams/TeamsMessagingExtension.d.ts +75 -0
- package/dist/teams/TeamsMessagingExtension.d.ts.map +1 -0
- package/dist/teams/TeamsMessagingExtension.js +176 -0
- package/dist/teams/TeamsMessagingExtension.js.map +1 -0
- package/dist/teams/teams-card-builder.d.ts +94 -0
- package/dist/teams/teams-card-builder.d.ts.map +1 -0
- package/dist/teams/teams-card-builder.js +648 -0
- package/dist/teams/teams-card-builder.js.map +1 -0
- package/dist/teams/teams-formatter.d.ts +39 -0
- package/dist/teams/teams-formatter.d.ts.map +1 -0
- package/dist/teams/teams-formatter.js +107 -0
- package/dist/teams/teams-formatter.js.map +1 -0
- package/package.json +40 -7
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @memberjunction/messaging-adapters
|
|
3
|
+
* @description Abstract base class for messaging platform adapters.
|
|
4
|
+
*
|
|
5
|
+
* Handles the common flow: receive message -> resolve user -> fetch thread history
|
|
6
|
+
* -> run agent -> stream updates -> send final response.
|
|
7
|
+
*
|
|
8
|
+
* Platform subclasses (Slack, Teams) implement the abstract methods for
|
|
9
|
+
* platform-specific operations like sending messages and fetching threads.
|
|
10
|
+
*/
|
|
11
|
+
import { AgentRunner } from '@memberjunction/ai-agents';
|
|
12
|
+
import { RunView, LogError, LogStatus } from '@memberjunction/core';
|
|
13
|
+
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
14
|
+
/**
|
|
15
|
+
* Abstract base class for messaging platform adapters.
|
|
16
|
+
*
|
|
17
|
+
* Handles the common orchestration flow that is shared across all messaging
|
|
18
|
+
* platforms (Slack, Teams, Discord, etc.):
|
|
19
|
+
*
|
|
20
|
+
* 1. Receive an incoming message
|
|
21
|
+
* 2. Determine if the bot should respond (DM, @mention, etc.)
|
|
22
|
+
* 3. Resolve the platform user to an MJ UserInfo
|
|
23
|
+
* 4. Show a typing/thinking indicator
|
|
24
|
+
* 5. Fetch thread history for conversation context
|
|
25
|
+
* 6. Route to the correct agent (default or @mentioned)
|
|
26
|
+
* 7. Run the agent via `AgentRunner.RunAgent()` with streaming
|
|
27
|
+
* 8. Send progressive streaming updates to the platform
|
|
28
|
+
* 9. Send the final formatted response
|
|
29
|
+
*
|
|
30
|
+
* Platform subclasses implement the abstract methods for platform-specific
|
|
31
|
+
* operations (sending messages, fetching threads, formatting responses).
|
|
32
|
+
*
|
|
33
|
+
* This class is NOT a `BaseServerExtension` itself — the platform-specific
|
|
34
|
+
* Extension classes (e.g., `SlackMessagingExtension`) own the Express route
|
|
35
|
+
* registration and delegate message handling to their adapter instance.
|
|
36
|
+
*
|
|
37
|
+
* ## Multi-Agent Routing
|
|
38
|
+
*
|
|
39
|
+
* Users can @mention different agents in different messages within the same
|
|
40
|
+
* thread. Each message routes to exactly one agent. If multiple agents are
|
|
41
|
+
* mentioned in a single message, the first one is used and a note is included
|
|
42
|
+
* in the response.
|
|
43
|
+
*
|
|
44
|
+
* ## Multi-Word Agent Names
|
|
45
|
+
*
|
|
46
|
+
* Agent names can be multi-word (e.g., "Research Agent"). At initialization,
|
|
47
|
+
* all active agents are loaded from the database. When parsing @mentions,
|
|
48
|
+
* known agent names are matched longest-first to avoid prefix collisions.
|
|
49
|
+
*
|
|
50
|
+
* ## User Identity Mapping
|
|
51
|
+
*
|
|
52
|
+
* The adapter resolves the platform user's email to an MJ `UserInfo` record.
|
|
53
|
+
* This gives proper per-user permission scoping without a separate auth flow.
|
|
54
|
+
* Falls back to the configured service account email if no MJ user matches.
|
|
55
|
+
*/
|
|
56
|
+
export class BaseMessagingAdapter {
|
|
57
|
+
/** Max age for thread→conversation mappings (24 hours). */
|
|
58
|
+
static { this.THREAD_MAP_TTL_MS = 24 * 60 * 60 * 1000; }
|
|
59
|
+
/** Max entries in the thread map before forced eviction of oldest entries. */
|
|
60
|
+
static { this.THREAD_MAP_MAX_SIZE = 10_000; }
|
|
61
|
+
constructor(settings) {
|
|
62
|
+
/** Fallback context user (service account) loaded from config email. */
|
|
63
|
+
this.fallbackContextUser = null;
|
|
64
|
+
/** Default agent loaded from config DefaultAgentName. */
|
|
65
|
+
this.defaultAgent = null;
|
|
66
|
+
/** All active agents, loaded at init for multi-word name matching. Sorted longest-name-first. */
|
|
67
|
+
this.availableAgents = [];
|
|
68
|
+
/**
|
|
69
|
+
* Maps platform thread IDs to MJ Conversation IDs.
|
|
70
|
+
* Ensures all messages in the same Slack/Teams thread share a single MJ Conversation,
|
|
71
|
+
* preserving context across follow-up messages.
|
|
72
|
+
*
|
|
73
|
+
* Entries are evicted after 24 hours to prevent unbounded growth on long-running servers.
|
|
74
|
+
*/
|
|
75
|
+
this.threadConversationMap = new Map();
|
|
76
|
+
this.settings = settings;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Initialize the adapter: resolve the fallback context user, load the default
|
|
80
|
+
* MJ agent (using the fallback user as contextUser), load all available agents,
|
|
81
|
+
* then run platform init.
|
|
82
|
+
* Must be called before handling any messages.
|
|
83
|
+
*
|
|
84
|
+
* @throws Error if the configured agent or fallback user cannot be found.
|
|
85
|
+
*/
|
|
86
|
+
async Initialize() {
|
|
87
|
+
await this.loadFallbackContextUser();
|
|
88
|
+
await this.loadDefaultAgent();
|
|
89
|
+
await this.loadAvailableAgents();
|
|
90
|
+
await this.onInitialize();
|
|
91
|
+
LogStatus(`Messaging adapter initialized: agent='${this.defaultAgent?.Name}', fallbackUser='${this.settings.ContextUserEmail}', availableAgents=${this.availableAgents.length}`);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Main entry point: handle an incoming message from the platform.
|
|
95
|
+
*
|
|
96
|
+
* Orchestrates the full flow from message receipt to response delivery.
|
|
97
|
+
* Errors are caught and reported as user-facing error messages — this
|
|
98
|
+
* method never throws.
|
|
99
|
+
*
|
|
100
|
+
* @param message - Normalized incoming message from the platform adapter.
|
|
101
|
+
*/
|
|
102
|
+
async HandleMessage(message) {
|
|
103
|
+
// 1. Should we respond to this message?
|
|
104
|
+
if (!this.shouldRespond(message)) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// 2. Resolve the MJ user for this platform sender
|
|
108
|
+
const contextUser = await this.resolveContextUser(message);
|
|
109
|
+
// 3. Show typing indicator
|
|
110
|
+
if (this.settings.ShowTypingIndicator !== false) {
|
|
111
|
+
await this.safeShowTypingIndicator(message);
|
|
112
|
+
}
|
|
113
|
+
// 4. Fetch thread history (needed for both agent resolution and conversation context)
|
|
114
|
+
const threadHistory = await this.safeGetThreadHistory(message);
|
|
115
|
+
// 5. Determine which agent to use (uses thread history for agent affinity)
|
|
116
|
+
const { agent, multiAgentNote } = await this.resolveAgent(message, contextUser, threadHistory);
|
|
117
|
+
// 6. Build conversation messages from thread history
|
|
118
|
+
const conversationMessages = this.buildConversationMessages(threadHistory, message);
|
|
119
|
+
// 7. Run the agent with streaming
|
|
120
|
+
await this.executeAgentAndRespond(message, agent, contextUser, conversationMessages, multiAgentNote);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Get the list of available agent names, sorted longest-first.
|
|
124
|
+
* Useful for subclasses implementing multi-word agent name matching.
|
|
125
|
+
*/
|
|
126
|
+
get AvailableAgentNames() {
|
|
127
|
+
return this.availableAgents.map(a => a.Name ?? '').filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
// ─── Protected helper methods ─────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Build an `AgentIdentity` from an agent entity.
|
|
132
|
+
* Only includes `IconURL` if it's a valid HTTPS URL.
|
|
133
|
+
*/
|
|
134
|
+
buildAgentIdentity(agent) {
|
|
135
|
+
const identity = { Name: agent.Name ?? 'Agent' };
|
|
136
|
+
const logoURL = agent.LogoURL;
|
|
137
|
+
if (logoURL && typeof logoURL === 'string' && logoURL.startsWith('https://')) {
|
|
138
|
+
identity.IconURL = logoURL;
|
|
139
|
+
}
|
|
140
|
+
return identity;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Match agent names mentioned in the message text against known agents.
|
|
144
|
+
*
|
|
145
|
+
* Strips the bot's platform mention, then checks for known agent names
|
|
146
|
+
* preceded by `@` (case-insensitive). Names are checked longest-first
|
|
147
|
+
* to avoid prefix collisions (e.g., "Research Agent" before "Research").
|
|
148
|
+
*
|
|
149
|
+
* @param text - The raw message text.
|
|
150
|
+
* @returns Array of matched agent names.
|
|
151
|
+
*/
|
|
152
|
+
matchAgentMentions(text) {
|
|
153
|
+
const cleanText = this.stripBotMention(text);
|
|
154
|
+
// Pass 1: Exact full-name match with @ prefix (longest-first)
|
|
155
|
+
const exactMatched = this.matchExactAgentNames(cleanText);
|
|
156
|
+
if (exactMatched.length > 0)
|
|
157
|
+
return exactMatched;
|
|
158
|
+
// Pass 2: First-word prefix match with @ prefix — "@Codesmith" matches "Codesmith Agent"
|
|
159
|
+
const prefixMatched = this.matchPrefixAgentNames(cleanText);
|
|
160
|
+
if (prefixMatched.length > 0)
|
|
161
|
+
return prefixMatched;
|
|
162
|
+
// Pass 3: Bare name match (no @ required) — handles "@Bot marketing agent help"
|
|
163
|
+
// where the @ was consumed by the bot mention. Only matches at the start of
|
|
164
|
+
// the message to avoid false positives in regular text.
|
|
165
|
+
const bareMatched = this.matchBareAgentNames(cleanText);
|
|
166
|
+
if (bareMatched.length > 0)
|
|
167
|
+
return bareMatched;
|
|
168
|
+
// Pass 4: Agent name anywhere in the message — handles "write a blog for me marketing agent"
|
|
169
|
+
// where the agent name is at the end or middle. Only matches full agent names
|
|
170
|
+
// with word boundaries, longest-first, to avoid false positives.
|
|
171
|
+
const anywhereMatched = this.matchAnywhereAgentNames(cleanText);
|
|
172
|
+
if (anywhereMatched.length > 0)
|
|
173
|
+
return anywhereMatched;
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Match @mentions against full agent names (exact match, case-insensitive).
|
|
178
|
+
*/
|
|
179
|
+
matchExactAgentNames(text) {
|
|
180
|
+
const matched = [];
|
|
181
|
+
for (const agent of this.availableAgents) {
|
|
182
|
+
const agentName = agent.Name;
|
|
183
|
+
if (!agentName)
|
|
184
|
+
continue;
|
|
185
|
+
const pattern = new RegExp(`@${this.escapeRegex(agentName)}(?:\\b|$)`, 'i');
|
|
186
|
+
if (pattern.test(text)) {
|
|
187
|
+
matched.push(agentName);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return matched;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Match @mentions by first-word prefix — e.g., "@Codesmith" matches agent "Codesmith Agent".
|
|
194
|
+
*
|
|
195
|
+
* Extracts all `@word` tokens from the text and checks if any agent name starts with
|
|
196
|
+
* that word. Only matches if exactly one agent matches to avoid ambiguity.
|
|
197
|
+
*/
|
|
198
|
+
matchPrefixAgentNames(text) {
|
|
199
|
+
// Extract @word tokens (single words after @)
|
|
200
|
+
const mentionPattern = /@(\w+)/gi;
|
|
201
|
+
const mentions = [];
|
|
202
|
+
let match;
|
|
203
|
+
while ((match = mentionPattern.exec(text)) !== null) {
|
|
204
|
+
mentions.push(match[1]);
|
|
205
|
+
}
|
|
206
|
+
const matched = [];
|
|
207
|
+
for (const mention of mentions) {
|
|
208
|
+
const candidates = this.availableAgents.filter(a => a.Name != null && a.Name.toLowerCase().startsWith(mention.toLowerCase()));
|
|
209
|
+
// Only accept unambiguous prefix matches
|
|
210
|
+
if (candidates.length === 1 && candidates[0].Name) {
|
|
211
|
+
matched.push(candidates[0].Name);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return matched;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Match agent names at the start of the message without an @ prefix.
|
|
218
|
+
*
|
|
219
|
+
* Handles the common Slack pattern where the user types `@Bot Marketing Agent help me`
|
|
220
|
+
* — the `@Bot` becomes `<@U123>` and gets stripped, leaving `Marketing Agent help me`.
|
|
221
|
+
* We check if the cleaned text starts with a known agent name (case-insensitive,
|
|
222
|
+
* longest-first to avoid prefix collisions).
|
|
223
|
+
*/
|
|
224
|
+
matchBareAgentNames(text) {
|
|
225
|
+
const trimmed = text.trim().toLowerCase();
|
|
226
|
+
for (const agent of this.availableAgents) {
|
|
227
|
+
const agentName = agent.Name;
|
|
228
|
+
if (!agentName)
|
|
229
|
+
continue;
|
|
230
|
+
const lowerName = agentName.toLowerCase();
|
|
231
|
+
// Must start with the agent name followed by a word boundary or end
|
|
232
|
+
if (trimmed.startsWith(lowerName) &&
|
|
233
|
+
(trimmed.length === lowerName.length || /\W/.test(trimmed[lowerName.length]))) {
|
|
234
|
+
return [agentName];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Match agent names appearing anywhere in the message text.
|
|
241
|
+
*
|
|
242
|
+
* This is the lowest-priority matching pass. It handles cases like
|
|
243
|
+
* "write a blog for me marketing agent" where the agent name is at the
|
|
244
|
+
* end or middle of the message. Uses word boundary matching and checks
|
|
245
|
+
* longest names first to avoid false positives.
|
|
246
|
+
*
|
|
247
|
+
* Only returns a match if exactly one agent is found, to avoid ambiguity.
|
|
248
|
+
*/
|
|
249
|
+
matchAnywhereAgentNames(text) {
|
|
250
|
+
const lowerText = text.trim().toLowerCase();
|
|
251
|
+
const matched = [];
|
|
252
|
+
for (const agent of this.availableAgents) {
|
|
253
|
+
const agentName = agent.Name;
|
|
254
|
+
if (!agentName || agentName.length < 3)
|
|
255
|
+
continue; // Skip very short names
|
|
256
|
+
const lowerName = agentName.toLowerCase();
|
|
257
|
+
// Check for the agent name with word boundaries on both sides
|
|
258
|
+
const pattern = new RegExp(`(?:^|\\W)${this.escapeRegex(lowerName)}(?:\\W|$)`, 'i');
|
|
259
|
+
if (pattern.test(lowerText)) {
|
|
260
|
+
matched.push(agentName);
|
|
261
|
+
// For anywhere matching, only accept unambiguous results
|
|
262
|
+
// If we find more than one agent, it's ambiguous — skip
|
|
263
|
+
if (matched.length > 1)
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return matched;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Determine whether the bot should respond to this message.
|
|
271
|
+
*
|
|
272
|
+
* Responds to:
|
|
273
|
+
* - Direct messages (DMs)
|
|
274
|
+
* - Explicit @mentions (`app_mention` events)
|
|
275
|
+
* - Thread replies (any reply in a thread the bot is participating in)
|
|
276
|
+
*
|
|
277
|
+
* Thread replies are included because the bot only has threads it started
|
|
278
|
+
* (via slash commands or @mention responses), so a reply in such a thread
|
|
279
|
+
* is implicitly directed at the bot.
|
|
280
|
+
*
|
|
281
|
+
* Subclasses can override for platform-specific logic.
|
|
282
|
+
*/
|
|
283
|
+
shouldRespond(message) {
|
|
284
|
+
return message.IsDirectMessage || message.IsBotMention || message.ThreadID != null;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Resolve the MJ user for the platform sender.
|
|
288
|
+
*
|
|
289
|
+
* Flow:
|
|
290
|
+
* 1. If IncomingMessage has SenderEmail, look up in UserCache
|
|
291
|
+
* 2. If not, call `lookupUserEmail()` (platform API) then look up
|
|
292
|
+
* 3. Fall back to the configured service account
|
|
293
|
+
*/
|
|
294
|
+
async resolveContextUser(message) {
|
|
295
|
+
let email = message.SenderEmail;
|
|
296
|
+
if (!email) {
|
|
297
|
+
try {
|
|
298
|
+
email = await this.lookupUserEmail(message.SenderID) ?? undefined;
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
LogError('Failed to look up user email from platform:', undefined, error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (email) {
|
|
305
|
+
const userCache = new UserCache();
|
|
306
|
+
const mjUser = userCache.Users.find((u) => u.Email?.toLowerCase() === email.toLowerCase());
|
|
307
|
+
if (mjUser) {
|
|
308
|
+
return mjUser;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Fall back to service account
|
|
312
|
+
return this.fallbackContextUser;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Resolve which agent to use for this message.
|
|
316
|
+
*
|
|
317
|
+
* Priority:
|
|
318
|
+
* 1. Explicit @mention in the current message
|
|
319
|
+
* 2. Thread affinity — if this is a reply in a thread, use the same agent
|
|
320
|
+
* that was originally @mentioned in the thread's first message
|
|
321
|
+
* 3. Fall back to the default agent from config
|
|
322
|
+
*
|
|
323
|
+
* If the mentioned agent is not found, responds with available agents.
|
|
324
|
+
*/
|
|
325
|
+
async resolveAgent(message, contextUser, threadHistory = []) {
|
|
326
|
+
const mentionedNames = message.MentionedAgentNames ?? [];
|
|
327
|
+
if (mentionedNames.length === 0) {
|
|
328
|
+
// No explicit @mention — check thread affinity
|
|
329
|
+
const threadAgent = this.resolveThreadAgent(threadHistory);
|
|
330
|
+
if (threadAgent) {
|
|
331
|
+
return { agent: threadAgent, multiAgentNote: null };
|
|
332
|
+
}
|
|
333
|
+
return { agent: this.defaultAgent, multiAgentNote: null };
|
|
334
|
+
}
|
|
335
|
+
// Look up the first mentioned agent from the cached list
|
|
336
|
+
const firstAgentName = mentionedNames[0];
|
|
337
|
+
const matchedAgent = this.availableAgents.find(a => a.Name != null && a.Name.toLowerCase() === firstAgentName.toLowerCase());
|
|
338
|
+
if (matchedAgent) {
|
|
339
|
+
let multiAgentNote = null;
|
|
340
|
+
const name = matchedAgent.Name ?? 'Agent';
|
|
341
|
+
if (mentionedNames.length > 1) {
|
|
342
|
+
multiAgentNote = `_Note: Only one agent can be addressed per message. Routing to **${name}**. Other mentioned agents (${mentionedNames.slice(1).join(', ')}) were not invoked._`;
|
|
343
|
+
}
|
|
344
|
+
return { agent: matchedAgent, multiAgentNote };
|
|
345
|
+
}
|
|
346
|
+
// Agent not found by name — fall back to default with helpful message
|
|
347
|
+
const agentNames = this.availableAgents.map(a => a.Name ?? '').filter(Boolean).join(', ');
|
|
348
|
+
LogStatus(`Agent '${firstAgentName}' not found, using default agent. Available: ${agentNames}`);
|
|
349
|
+
const defaultName = this.defaultAgent.Name ?? 'default agent';
|
|
350
|
+
const helpNote = agentNames
|
|
351
|
+
? `_I couldn't find an agent named "${firstAgentName}". Available agents: ${agentNames}. Routing to **${defaultName}**._`
|
|
352
|
+
: null;
|
|
353
|
+
return { agent: this.defaultAgent, multiAgentNote: helpNote };
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Look through the thread history to find the agent that was originally invoked.
|
|
357
|
+
*
|
|
358
|
+
* Checks the first user message in the thread for @agent mentions. This provides
|
|
359
|
+
* thread affinity — follow-up messages in a thread continue with the same agent
|
|
360
|
+
* without requiring the user to @mention it again, matching MJ Explorer behavior.
|
|
361
|
+
*
|
|
362
|
+
* @returns The agent from the thread's first message, or `null` if none found.
|
|
363
|
+
*/
|
|
364
|
+
resolveThreadAgent(threadHistory) {
|
|
365
|
+
if (threadHistory.length === 0)
|
|
366
|
+
return null;
|
|
367
|
+
const botUserId = this.getBotUserId();
|
|
368
|
+
// Walk through thread messages (oldest first) looking for user messages with agent mentions
|
|
369
|
+
for (const msg of threadHistory) {
|
|
370
|
+
// Skip bot messages
|
|
371
|
+
if (msg.SenderID === botUserId)
|
|
372
|
+
continue;
|
|
373
|
+
// Check for agent mentions in this message
|
|
374
|
+
const mentions = msg.MentionedAgentNames ?? this.matchAgentMentions(msg.Text);
|
|
375
|
+
if (mentions.length > 0) {
|
|
376
|
+
const matched = this.availableAgents.find(a => a.Name != null && a.Name.toLowerCase() === mentions[0].toLowerCase());
|
|
377
|
+
if (matched) {
|
|
378
|
+
LogStatus(`Thread affinity: routing to '${matched.Name}' (mentioned in thread message ${msg.MessageID})`);
|
|
379
|
+
return matched;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
/** Maximum number of delegation hops to prevent infinite loops. */
|
|
386
|
+
static { this.MAX_DELEGATION_HOPS = 3; }
|
|
387
|
+
/**
|
|
388
|
+
* Execute the agent and send the response, with streaming support.
|
|
389
|
+
* Handles delegation automatically: when the agent returns `payload.invokeAgent`,
|
|
390
|
+
* the target agent is auto-executed (matching MJ Explorer behavior).
|
|
391
|
+
*/
|
|
392
|
+
/**
|
|
393
|
+
* Get the thread key for conversation mapping.
|
|
394
|
+
* Uses ThreadID if in a thread, otherwise MessageID (for thread-root messages).
|
|
395
|
+
*/
|
|
396
|
+
getThreadKey(message) {
|
|
397
|
+
return `${message.ChannelID}:${message.ThreadID ?? message.MessageID}`;
|
|
398
|
+
}
|
|
399
|
+
async executeAgentAndRespond(message, agent, contextUser, conversationMessages, multiAgentNote) {
|
|
400
|
+
// Look up existing MJ Conversation for this thread
|
|
401
|
+
const threadKey = this.getThreadKey(message);
|
|
402
|
+
const existingConversationId = this.getThreadConversationId(threadKey);
|
|
403
|
+
let agentResult;
|
|
404
|
+
try {
|
|
405
|
+
agentResult = await this.runAgentWithStreaming(message, agent, contextUser, conversationMessages, existingConversationId);
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
// runAgentWithStreaming already sent an error message to the user
|
|
409
|
+
LogError('executeAgentAndRespond caught error from runAgentWithStreaming:', undefined, error);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const { result, progressMessageId, artifactId, conversationId } = agentResult;
|
|
413
|
+
// Store the conversation ID for future messages in this thread
|
|
414
|
+
if (conversationId) {
|
|
415
|
+
this.setThreadConversationId(threadKey, conversationId);
|
|
416
|
+
}
|
|
417
|
+
// Check for delegation: payload.invokeAgent indicates the agent wants to hand off
|
|
418
|
+
const delegationTarget = this.detectDelegation(result);
|
|
419
|
+
if (delegationTarget) {
|
|
420
|
+
await this.handleDelegation(message, agent, result, delegationTarget, contextUser, conversationMessages, multiAgentNote, 0, conversationId);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
// Log diagnostic info when no delegation detected (helps debug routing issues)
|
|
424
|
+
if (result.success && result.agentRun?.FinalStep === 'Success') {
|
|
425
|
+
const payloadKeys = result.payload != null && typeof result.payload === 'object'
|
|
426
|
+
? Object.keys(result.payload).join(', ')
|
|
427
|
+
: '(no payload)';
|
|
428
|
+
LogStatus(`No delegation detected. Agent='${agent.Name}', FinalStep='${result.agentRun.FinalStep}', payloadKeys=[${payloadKeys}]`);
|
|
429
|
+
}
|
|
430
|
+
// No delegation — send the result directly
|
|
431
|
+
const metadata = { ArtifactId: artifactId, ConversationId: conversationId };
|
|
432
|
+
await this.sendAgentResult(message, agent, result, multiAgentNote, metadata, progressMessageId);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Run an agent within a conversation context, with streaming progress updates.
|
|
436
|
+
*
|
|
437
|
+
* Uses `AgentRunner.RunAgentInConversation()` so that:
|
|
438
|
+
* - An MJ Conversation is created (or reused) for the interaction
|
|
439
|
+
* - Artifacts are automatically created from the agent's payload
|
|
440
|
+
* - The artifact ID is returned for deep-linking into MJ Explorer
|
|
441
|
+
*
|
|
442
|
+
* @returns The agent result plus conversation/artifact metadata.
|
|
443
|
+
*/
|
|
444
|
+
async runAgentWithStreaming(message, agent, contextUser, conversationMessages, conversationId) {
|
|
445
|
+
const runner = new AgentRunner();
|
|
446
|
+
let streamBuffer = '';
|
|
447
|
+
let lastUpdateTime = 0;
|
|
448
|
+
const updateInterval = this.settings.StreamingUpdateIntervalMs ?? 1000;
|
|
449
|
+
const agentName = agent.Name ?? 'Agent';
|
|
450
|
+
// Send initial "thinking" message with agent name
|
|
451
|
+
const thinkingText = `_${agentName} is thinking..._`;
|
|
452
|
+
let progressMessageId = await this.sendOrUpdateStreamingMessage(message, thinkingText, null, agent);
|
|
453
|
+
lastUpdateTime = Date.now();
|
|
454
|
+
const params = {
|
|
455
|
+
agent,
|
|
456
|
+
conversationMessages,
|
|
457
|
+
contextUser,
|
|
458
|
+
payload: {}, // Provide empty starting payload so loop agents can apply payloadChangeRequests
|
|
459
|
+
onProgress: (progress) => {
|
|
460
|
+
if (progress.displayMode === 'historical')
|
|
461
|
+
return;
|
|
462
|
+
const stepCount = progress.metadata?.stepCount ?? undefined;
|
|
463
|
+
const stepNumber = progress.metadata?.stepNumber ?? undefined;
|
|
464
|
+
const stepSuffix = stepNumber != null && stepCount != null
|
|
465
|
+
? ` (Step ${stepNumber} of ${stepCount})`
|
|
466
|
+
: '';
|
|
467
|
+
const progressLabel = `_${progress.message}${stepSuffix}_`;
|
|
468
|
+
if (!streamBuffer) {
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
if (now - lastUpdateTime >= updateInterval) {
|
|
471
|
+
lastUpdateTime = now;
|
|
472
|
+
this.sendOrUpdateStreamingMessage(message, progressLabel, progressMessageId, agent).then(msgId => {
|
|
473
|
+
progressMessageId = msgId;
|
|
474
|
+
}).catch((err) => {
|
|
475
|
+
LogError('Progress update failed:', undefined, err);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
onStreaming: (chunk) => {
|
|
481
|
+
streamBuffer += chunk.content;
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
if (now - lastUpdateTime >= updateInterval) {
|
|
484
|
+
lastUpdateTime = now;
|
|
485
|
+
this.sendOrUpdateStreamingMessage(message, streamBuffer, progressMessageId, agent).then(msgId => {
|
|
486
|
+
progressMessageId = msgId;
|
|
487
|
+
}).catch((err) => {
|
|
488
|
+
LogError('Streaming update failed:', undefined, err);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
// Extract the user's message text (last user message in the conversation)
|
|
494
|
+
const userMessageText = this.stripBotMention(message.Text);
|
|
495
|
+
try {
|
|
496
|
+
const conversationResult = await runner.RunAgentInConversation(params, {
|
|
497
|
+
conversationId,
|
|
498
|
+
userMessage: userMessageText,
|
|
499
|
+
createArtifacts: true,
|
|
500
|
+
conversationName: `${this.PlatformName}: ${userMessageText.substring(0, 80)}`,
|
|
501
|
+
});
|
|
502
|
+
const artifactId = conversationResult.artifactInfo?.artifactId;
|
|
503
|
+
return {
|
|
504
|
+
result: conversationResult.agentResult,
|
|
505
|
+
progressMessageId,
|
|
506
|
+
artifactId,
|
|
507
|
+
conversationId: conversationResult.conversationId,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
LogError('Error running agent for messaging adapter:', undefined, error);
|
|
512
|
+
// Send error message using the progress message if available
|
|
513
|
+
const errorMessage = "I'm sorry, I encountered an error processing your request. Please try again.";
|
|
514
|
+
const errorFormatted = await this.formatResponse(null, agent, errorMessage);
|
|
515
|
+
if (progressMessageId) {
|
|
516
|
+
await this.updateFinalMessage(message, progressMessageId, errorFormatted);
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
await this.sendFinalMessage(message, errorFormatted);
|
|
520
|
+
}
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Detect if an agent result contains a delegation request.
|
|
526
|
+
*
|
|
527
|
+
* Three detection strategies, tried in order:
|
|
528
|
+
* 1. `payload.invokeAgent` — formal delegation field (same as MJ Explorer)
|
|
529
|
+
* 2. `agentRun.FinalPayload` — serialized payload fallback (in case in-memory payload is empty)
|
|
530
|
+
* 3. Message text pattern matching — detects "I'll have the {Agent Name}..." phrasing
|
|
531
|
+
* when the agent describes delegation intent without formally setting the payload
|
|
532
|
+
*
|
|
533
|
+
* @returns The target agent name, or null if no delegation.
|
|
534
|
+
*/
|
|
535
|
+
detectDelegation(result) {
|
|
536
|
+
if (!result.success)
|
|
537
|
+
return null;
|
|
538
|
+
// Strategy 1: Check in-memory payload.invokeAgent (primary, matches MJ Explorer)
|
|
539
|
+
const fromPayload = this.extractInvokeAgentFromPayload(result.payload);
|
|
540
|
+
if (fromPayload) {
|
|
541
|
+
LogStatus(`Delegation detected via payload.invokeAgent: '${fromPayload}'`);
|
|
542
|
+
return fromPayload;
|
|
543
|
+
}
|
|
544
|
+
// Strategy 2: Check FinalPayload (serialized string on agentRun)
|
|
545
|
+
const fromFinalPayload = this.extractInvokeAgentFromFinalPayload(result.agentRun);
|
|
546
|
+
if (fromFinalPayload) {
|
|
547
|
+
LogStatus(`Delegation detected via FinalPayload: '${fromFinalPayload}'`);
|
|
548
|
+
return fromFinalPayload;
|
|
549
|
+
}
|
|
550
|
+
// Strategy 3: Detect delegation intent from message text
|
|
551
|
+
// Handles cases where the agent says "I'll have the Marketing Agent..." without
|
|
552
|
+
// setting payload.invokeAgent (common when conversation context is less structured)
|
|
553
|
+
const fromMessage = this.extractDelegationFromMessage(result.agentRun?.Message);
|
|
554
|
+
if (fromMessage) {
|
|
555
|
+
LogStatus(`Delegation detected via message text: '${fromMessage}'`);
|
|
556
|
+
return fromMessage;
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Extract `invokeAgent` from the in-memory payload object.
|
|
562
|
+
*/
|
|
563
|
+
extractInvokeAgentFromPayload(payload) {
|
|
564
|
+
if (payload == null || typeof payload !== 'object')
|
|
565
|
+
return null;
|
|
566
|
+
const obj = payload;
|
|
567
|
+
if (typeof obj.invokeAgent === 'string' && obj.invokeAgent.trim()) {
|
|
568
|
+
return obj.invokeAgent.trim();
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Extract `invokeAgent` from the serialized FinalPayload string on the agentRun.
|
|
574
|
+
* Fallback for cases where the in-memory payload is empty but FinalPayload was persisted.
|
|
575
|
+
*/
|
|
576
|
+
extractInvokeAgentFromFinalPayload(agentRun) {
|
|
577
|
+
const fpStr = agentRun?.FinalPayload;
|
|
578
|
+
if (!fpStr || typeof fpStr !== 'string')
|
|
579
|
+
return null;
|
|
580
|
+
try {
|
|
581
|
+
const parsed = JSON.parse(fpStr);
|
|
582
|
+
if (parsed != null && typeof parsed === 'object' && typeof parsed.invokeAgent === 'string') {
|
|
583
|
+
return parsed.invokeAgent.trim() || null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// Not valid JSON — ignore
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Detect delegation intent from the agent's message text.
|
|
593
|
+
*
|
|
594
|
+
* Matches patterns like:
|
|
595
|
+
* - "I'll have the Marketing Agent write..."
|
|
596
|
+
* - "I'll delegate to the Research Agent..."
|
|
597
|
+
* - "Routing to Marketing Agent"
|
|
598
|
+
* - "Let me have the Codesmith Agent handle this"
|
|
599
|
+
*
|
|
600
|
+
* Only matches against known agent names from `availableAgents` to avoid false positives.
|
|
601
|
+
*/
|
|
602
|
+
extractDelegationFromMessage(message) {
|
|
603
|
+
if (!message || typeof message !== 'string')
|
|
604
|
+
return null;
|
|
605
|
+
const lowerMessage = message.toLowerCase();
|
|
606
|
+
// Quick gate: must contain a delegation-intent phrase
|
|
607
|
+
const delegationPhrases = [
|
|
608
|
+
"i'll have the", "i'll delegate to", "i will have the", "i will delegate to",
|
|
609
|
+
"routing to", "delegating to", "let me have the", "i'll ask the",
|
|
610
|
+
"i will ask the", "handing off to", "passing to", "let me route to",
|
|
611
|
+
"i'll get the", "i will get the", "i'll invoke the", "i will invoke the"
|
|
612
|
+
];
|
|
613
|
+
const hasDelegationPhrase = delegationPhrases.some(phrase => lowerMessage.includes(phrase));
|
|
614
|
+
if (!hasDelegationPhrase)
|
|
615
|
+
return null;
|
|
616
|
+
// Check if any known agent name appears in the message
|
|
617
|
+
// availableAgents is sorted longest-first, so we'll match the most specific name
|
|
618
|
+
for (const agent of this.availableAgents) {
|
|
619
|
+
const agentName = agent.Name;
|
|
620
|
+
if (!agentName)
|
|
621
|
+
continue;
|
|
622
|
+
if (lowerMessage.includes(agentName.toLowerCase())) {
|
|
623
|
+
return agentName;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Handle agent delegation: send a delegation note, then auto-execute the target agent.
|
|
630
|
+
*
|
|
631
|
+
* Mirrors MJ Explorer's `handleSubAgentInvocation()`:
|
|
632
|
+
* 1. Show the delegating agent's message (e.g., "Delegating to Marketing Agent")
|
|
633
|
+
* 2. Find the target agent in availableAgents
|
|
634
|
+
* 3. Execute the target agent with the same conversation context
|
|
635
|
+
* 4. Send the target agent's result
|
|
636
|
+
*
|
|
637
|
+
* Supports chained delegation up to MAX_DELEGATION_HOPS.
|
|
638
|
+
*/
|
|
639
|
+
async handleDelegation(message, sourceAgent, sourceResult, targetAgentName, contextUser, conversationMessages, multiAgentNote, hopCount = 0, conversationId) {
|
|
640
|
+
// Prevent infinite delegation loops
|
|
641
|
+
if (hopCount >= BaseMessagingAdapter.MAX_DELEGATION_HOPS) {
|
|
642
|
+
LogError(`Delegation loop detected: exceeded ${BaseMessagingAdapter.MAX_DELEGATION_HOPS} hops`);
|
|
643
|
+
await this.sendAgentResult(message, sourceAgent, sourceResult, multiAgentNote);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Find the target agent
|
|
647
|
+
const targetAgent = this.availableAgents.find(a => a.Name != null && a.Name.toLowerCase() === targetAgentName.toLowerCase());
|
|
648
|
+
if (!targetAgent) {
|
|
649
|
+
LogError(`Delegation target '${targetAgentName}' not found in available agents`);
|
|
650
|
+
await this.sendAgentResult(message, sourceAgent, sourceResult, multiAgentNote);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
// Send the delegating agent's message as a brief note
|
|
654
|
+
const sourceMessage = sourceResult.agentRun?.Message;
|
|
655
|
+
const delegationNote = sourceMessage && typeof sourceMessage === 'string' && sourceMessage.trim()
|
|
656
|
+
? sourceMessage
|
|
657
|
+
: `_Delegating to ${targetAgent.Name}..._`;
|
|
658
|
+
const delegationFormatted = await this.formatResponse(sourceResult, sourceAgent, delegationNote);
|
|
659
|
+
await this.sendFinalMessage(message, delegationFormatted);
|
|
660
|
+
LogStatus(`Delegation: ${sourceAgent.Name} → ${targetAgent.Name} (hop ${hopCount + 1})`);
|
|
661
|
+
// Build conversation context including the delegation payload
|
|
662
|
+
const updatedMessages = [...conversationMessages];
|
|
663
|
+
const delegationPayload = sourceResult.payload;
|
|
664
|
+
if (delegationPayload?.inputPayload != null) {
|
|
665
|
+
// Pass the source agent's input payload as context for the target agent
|
|
666
|
+
updatedMessages.push({
|
|
667
|
+
role: 'assistant',
|
|
668
|
+
content: typeof delegationPayload.inputPayload === 'string'
|
|
669
|
+
? delegationPayload.inputPayload
|
|
670
|
+
: JSON.stringify(delegationPayload.inputPayload)
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
// Execute the target agent (reuse the same MJ Conversation for continuity)
|
|
674
|
+
try {
|
|
675
|
+
const targetAgentResult = await this.runAgentWithStreaming(message, targetAgent, contextUser, updatedMessages, conversationId);
|
|
676
|
+
// Check if the target agent also delegates
|
|
677
|
+
const nextDelegation = this.detectDelegation(targetAgentResult.result);
|
|
678
|
+
if (nextDelegation) {
|
|
679
|
+
await this.handleDelegation(message, targetAgent, targetAgentResult.result, nextDelegation, contextUser, updatedMessages, null, hopCount + 1, targetAgentResult.conversationId ?? conversationId);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// Send the target agent's result
|
|
683
|
+
const metadata = {
|
|
684
|
+
ArtifactId: targetAgentResult.artifactId,
|
|
685
|
+
ConversationId: targetAgentResult.conversationId ?? conversationId,
|
|
686
|
+
};
|
|
687
|
+
await this.sendAgentResult(message, targetAgent, targetAgentResult.result, null, metadata);
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
// runAgentWithStreaming already sent an error message
|
|
691
|
+
LogError(`Delegation target '${targetAgent.Name}' failed:`, undefined, error);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Extract response text, format it, and send the final message.
|
|
696
|
+
*/
|
|
697
|
+
async sendAgentResult(message, agent, result, multiAgentNote, metadata, progressMessageId) {
|
|
698
|
+
const responseText = this.extractResponseText(result);
|
|
699
|
+
const fullResponse = multiAgentNote
|
|
700
|
+
? multiAgentNote + '\n\n' + responseText
|
|
701
|
+
: responseText;
|
|
702
|
+
const formatted = await this.formatResponse(result, agent, fullResponse, metadata);
|
|
703
|
+
if (progressMessageId) {
|
|
704
|
+
await this.updateFinalMessage(message, progressMessageId, formatted);
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
await this.sendFinalMessage(message, formatted);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Extract the user-facing response text from an `ExecuteAgentResult`.
|
|
712
|
+
*
|
|
713
|
+
* Mirrors MJ Explorer's approach: the data model itself tells us what's user-facing.
|
|
714
|
+
* `agentRun.Message` is the field agents explicitly set for the user. Everything
|
|
715
|
+
* else (payload, FinalPayload, step outputs) is internal agent state.
|
|
716
|
+
*
|
|
717
|
+
* We do NOT mine payloads for content — that approach requires hardcoding
|
|
718
|
+
* agent-specific payload shapes and inevitably leaks internal LLM state
|
|
719
|
+
* (research plans, orchestration metadata, etc.) to the user.
|
|
720
|
+
*
|
|
721
|
+
* When `Message` is a JSON blob (some agents dump structured payloads there),
|
|
722
|
+
* we don't try to extract content from it. If an artifact exists, the
|
|
723
|
+
* "View in MJ Explorer" link handles rendering. If not, we fall through
|
|
724
|
+
* to a generic message rather than showing raw JSON.
|
|
725
|
+
*
|
|
726
|
+
* Fallback chain (only when Message is absent or is raw JSON):
|
|
727
|
+
* 1. `agentRun.Message` — the primary human-readable field (skipped if JSON)
|
|
728
|
+
* 2. `agentRun.Result` — simple string result (skipped if JSON)
|
|
729
|
+
* 3. Generic fallback message
|
|
730
|
+
*/
|
|
731
|
+
extractResponseText(result) {
|
|
732
|
+
if (!result.success) {
|
|
733
|
+
return this.extractErrorText(result);
|
|
734
|
+
}
|
|
735
|
+
// Primary: agentRun.Message — the explicit user-facing field
|
|
736
|
+
const message = result.agentRun?.Message;
|
|
737
|
+
if (message && typeof message === 'string' && message.trim()) {
|
|
738
|
+
if (!this.looksLikeJSON(message)) {
|
|
739
|
+
return message;
|
|
740
|
+
}
|
|
741
|
+
// Message is JSON — skip it, fall through to friendlier alternatives
|
|
742
|
+
}
|
|
743
|
+
// Fallback: agentRun.Result as a simple string
|
|
744
|
+
const runResult = result.agentRun?.Result;
|
|
745
|
+
if (runResult && typeof runResult === 'string' && runResult.trim()) {
|
|
746
|
+
if (!this.looksLikeJSON(runResult)) {
|
|
747
|
+
return runResult;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return "I've completed your request. View the full result in MJ Explorer.";
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Quick check: does this string look like a JSON object or array?
|
|
754
|
+
* Used to avoid showing raw JSON to users when agents dump structured
|
|
755
|
+
* payloads into the Message field instead of human-readable text.
|
|
756
|
+
*/
|
|
757
|
+
looksLikeJSON(text) {
|
|
758
|
+
const trimmed = text.trim();
|
|
759
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
|
|
760
|
+
return false;
|
|
761
|
+
try {
|
|
762
|
+
JSON.parse(trimmed);
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Extract error text from a failed agent result.
|
|
771
|
+
*/
|
|
772
|
+
extractErrorText(result) {
|
|
773
|
+
const errorMsg = result.agentRun?.ErrorMessage;
|
|
774
|
+
if (errorMsg) {
|
|
775
|
+
return `I encountered an issue: ${errorMsg}`;
|
|
776
|
+
}
|
|
777
|
+
return "I'm sorry, I encountered an error processing your request. Please try again.";
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Fetch thread history with error handling. Returns empty array on failure.
|
|
781
|
+
*/
|
|
782
|
+
async safeGetThreadHistory(message) {
|
|
783
|
+
if (!message.ThreadID) {
|
|
784
|
+
return []; // Top-level message, no prior history
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
const maxMessages = this.settings.MaxThreadMessages ?? 50;
|
|
788
|
+
const history = await this.fetchThreadHistory(message.ChannelID, message.ThreadID);
|
|
789
|
+
// Exclude the current message and limit
|
|
790
|
+
return history
|
|
791
|
+
.filter(m => m.MessageID !== message.MessageID)
|
|
792
|
+
.slice(-maxMessages);
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
LogError('Failed to fetch thread history, proceeding without context:', undefined, error);
|
|
796
|
+
return [];
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Show typing indicator with error handling. Failures are silently ignored.
|
|
801
|
+
* Now passes the resolved agent for per-agent identity on the indicator.
|
|
802
|
+
*/
|
|
803
|
+
async safeShowTypingIndicator(message, agent) {
|
|
804
|
+
try {
|
|
805
|
+
await this.showTypingIndicator(message, agent);
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
LogStatus(`${this.PlatformName}: typing indicator failed (non-critical), continuing`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Convert thread history + current message into `ChatMessage[]` for `AgentRunner`.
|
|
813
|
+
*
|
|
814
|
+
* Maps platform messages to roles:
|
|
815
|
+
* - Messages from the bot → `assistant`
|
|
816
|
+
* - Messages from anyone else → `user`
|
|
817
|
+
*
|
|
818
|
+
* Bot @mentions are stripped from user messages.
|
|
819
|
+
*/
|
|
820
|
+
buildConversationMessages(history, currentMessage) {
|
|
821
|
+
const botUserId = this.getBotUserId();
|
|
822
|
+
const messages = [];
|
|
823
|
+
for (const msg of history) {
|
|
824
|
+
const role = msg.SenderID === botUserId ? 'assistant' : 'user';
|
|
825
|
+
const content = role === 'user' ? this.stripBotMention(msg.Text) : msg.Text;
|
|
826
|
+
if (content.trim()) {
|
|
827
|
+
messages.push({ role, content });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// Add the current message
|
|
831
|
+
const currentText = this.stripBotMention(currentMessage.Text).trim();
|
|
832
|
+
if (currentText) {
|
|
833
|
+
messages.push({ role: 'user', content: currentText });
|
|
834
|
+
}
|
|
835
|
+
return messages;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Load the default MJ agent entity from database by name.
|
|
839
|
+
* Matches the pattern used by the conversation UI (ConversationAgentService).
|
|
840
|
+
* @throws Error if the configured agent name cannot be found.
|
|
841
|
+
*/
|
|
842
|
+
async loadDefaultAgent() {
|
|
843
|
+
const agentName = this.settings.DefaultAgentName;
|
|
844
|
+
const rv = new RunView();
|
|
845
|
+
const result = await rv.RunView({
|
|
846
|
+
EntityName: 'MJ: AI Agents',
|
|
847
|
+
ExtraFilter: `Name='${agentName.replace(/'/g, "''")}'`,
|
|
848
|
+
ResultType: 'entity_object'
|
|
849
|
+
}, this.fallbackContextUser);
|
|
850
|
+
if (!result.Success || result.Results.length === 0) {
|
|
851
|
+
throw new Error(`Default agent '${agentName}' not found. Verify the DefaultAgentName in your messaging adapter config.`);
|
|
852
|
+
}
|
|
853
|
+
this.defaultAgent = result.Results[0];
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Load all active agents for multi-word name matching.
|
|
857
|
+
* Sorted longest-name-first to avoid prefix collisions.
|
|
858
|
+
*/
|
|
859
|
+
async loadAvailableAgents() {
|
|
860
|
+
const rv = new RunView();
|
|
861
|
+
const result = await rv.RunView({
|
|
862
|
+
EntityName: 'MJ: AI Agents',
|
|
863
|
+
ExtraFilter: `Status='Active'`,
|
|
864
|
+
ResultType: 'entity_object'
|
|
865
|
+
}, this.fallbackContextUser);
|
|
866
|
+
if (result.Success) {
|
|
867
|
+
// Sort longest name first for greedy matching
|
|
868
|
+
this.availableAgents = result.Results.sort((a, b) => (b.Name?.length ?? 0) - (a.Name?.length ?? 0));
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
LogError('Failed to load available agents for name matching', undefined, result.ErrorMessage);
|
|
872
|
+
this.availableAgents = [];
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Resolve the fallback context user from the configured email.
|
|
877
|
+
* @throws Error if the configured email cannot be found.
|
|
878
|
+
*/
|
|
879
|
+
async loadFallbackContextUser() {
|
|
880
|
+
const userCache = new UserCache();
|
|
881
|
+
const user = userCache.Users.find((u) => u.Email?.toLowerCase() === this.settings.ContextUserEmail.toLowerCase());
|
|
882
|
+
if (!user) {
|
|
883
|
+
throw new Error(`Fallback context user not found: ${this.settings.ContextUserEmail}`);
|
|
884
|
+
}
|
|
885
|
+
this.fallbackContextUser = user;
|
|
886
|
+
}
|
|
887
|
+
// ─── Thread conversation map with TTL ──────────────────────────────
|
|
888
|
+
/**
|
|
889
|
+
* Get a conversation ID from the thread map, respecting TTL.
|
|
890
|
+
*/
|
|
891
|
+
getThreadConversationId(threadKey) {
|
|
892
|
+
const entry = this.threadConversationMap.get(threadKey);
|
|
893
|
+
if (!entry)
|
|
894
|
+
return undefined;
|
|
895
|
+
if (Date.now() - entry.timestamp > BaseMessagingAdapter.THREAD_MAP_TTL_MS) {
|
|
896
|
+
this.threadConversationMap.delete(threadKey);
|
|
897
|
+
return undefined;
|
|
898
|
+
}
|
|
899
|
+
return entry.conversationId;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Store a conversation ID in the thread map with TTL and max-size eviction.
|
|
903
|
+
*/
|
|
904
|
+
setThreadConversationId(threadKey, conversationId) {
|
|
905
|
+
this.threadConversationMap.set(threadKey, { conversationId, timestamp: Date.now() });
|
|
906
|
+
this.evictExpiredThreadEntries();
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Evict expired and overflow entries from the thread conversation map.
|
|
910
|
+
*/
|
|
911
|
+
evictExpiredThreadEntries() {
|
|
912
|
+
const now = Date.now();
|
|
913
|
+
for (const [key, entry] of this.threadConversationMap) {
|
|
914
|
+
if (now - entry.timestamp > BaseMessagingAdapter.THREAD_MAP_TTL_MS) {
|
|
915
|
+
this.threadConversationMap.delete(key);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// If still over max size, evict oldest entries
|
|
919
|
+
if (this.threadConversationMap.size > BaseMessagingAdapter.THREAD_MAP_MAX_SIZE) {
|
|
920
|
+
const sorted = [...this.threadConversationMap.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
921
|
+
const toRemove = sorted.slice(0, sorted.length - BaseMessagingAdapter.THREAD_MAP_MAX_SIZE);
|
|
922
|
+
for (const [key] of toRemove) {
|
|
923
|
+
this.threadConversationMap.delete(key);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Escape special regex characters in a string.
|
|
929
|
+
*/
|
|
930
|
+
escapeRegex(str) {
|
|
931
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
//# sourceMappingURL=BaseMessagingAdapter.js.map
|