@herdctl/core 4.2.0 → 5.0.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.
Files changed (46) hide show
  1. package/dist/fleet-manager/chat-manager-interface.d.ts +116 -0
  2. package/dist/fleet-manager/chat-manager-interface.d.ts.map +1 -0
  3. package/dist/fleet-manager/chat-manager-interface.js +11 -0
  4. package/dist/fleet-manager/chat-manager-interface.js.map +1 -0
  5. package/dist/fleet-manager/context.d.ts +24 -5
  6. package/dist/fleet-manager/context.d.ts.map +1 -1
  7. package/dist/fleet-manager/event-types.d.ts +135 -0
  8. package/dist/fleet-manager/event-types.d.ts.map +1 -1
  9. package/dist/fleet-manager/fleet-manager.d.ts +18 -6
  10. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  11. package/dist/fleet-manager/fleet-manager.js +79 -20
  12. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  13. package/dist/fleet-manager/index.d.ts +2 -3
  14. package/dist/fleet-manager/index.d.ts.map +1 -1
  15. package/dist/fleet-manager/index.js +4 -1
  16. package/dist/fleet-manager/index.js.map +1 -1
  17. package/dist/fleet-manager/status-queries.d.ts +5 -6
  18. package/dist/fleet-manager/status-queries.d.ts.map +1 -1
  19. package/dist/fleet-manager/status-queries.js +52 -59
  20. package/dist/fleet-manager/status-queries.js.map +1 -1
  21. package/dist/fleet-manager/types.d.ts +11 -31
  22. package/dist/fleet-manager/types.d.ts.map +1 -1
  23. package/dist/runner/index.d.ts +1 -1
  24. package/dist/runner/index.d.ts.map +1 -1
  25. package/dist/runner/index.js.map +1 -1
  26. package/dist/scheduler/__tests__/scheduler.test.js +9 -7
  27. package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
  28. package/dist/scheduler/scheduler.js +5 -4
  29. package/dist/scheduler/scheduler.js.map +1 -1
  30. package/package.json +1 -1
  31. package/dist/fleet-manager/__tests__/discord-manager.test.d.ts +0 -8
  32. package/dist/fleet-manager/__tests__/discord-manager.test.d.ts.map +0 -1
  33. package/dist/fleet-manager/__tests__/discord-manager.test.js +0 -3508
  34. package/dist/fleet-manager/__tests__/discord-manager.test.js.map +0 -1
  35. package/dist/fleet-manager/__tests__/slack-manager.test.d.ts +0 -11
  36. package/dist/fleet-manager/__tests__/slack-manager.test.d.ts.map +0 -1
  37. package/dist/fleet-manager/__tests__/slack-manager.test.js +0 -983
  38. package/dist/fleet-manager/__tests__/slack-manager.test.js.map +0 -1
  39. package/dist/fleet-manager/discord-manager.d.ts +0 -353
  40. package/dist/fleet-manager/discord-manager.d.ts.map +0 -1
  41. package/dist/fleet-manager/discord-manager.js +0 -1087
  42. package/dist/fleet-manager/discord-manager.js.map +0 -1
  43. package/dist/fleet-manager/slack-manager.d.ts +0 -201
  44. package/dist/fleet-manager/slack-manager.d.ts.map +0 -1
  45. package/dist/fleet-manager/slack-manager.js +0 -576
  46. package/dist/fleet-manager/slack-manager.js.map +0 -1
@@ -1,1087 +0,0 @@
1
- /**
2
- * Discord Manager Module
3
- *
4
- * Manages Discord connectors for agents that have `chat.discord` configured.
5
- * This module is responsible for:
6
- * - Creating one DiscordConnector instance per Discord-enabled agent
7
- * - Managing connector lifecycle (start/stop)
8
- * - Providing access to connectors for status queries
9
- *
10
- * Note: This module dynamically imports @herdctl/discord at runtime to avoid
11
- * a hard dependency. The @herdctl/core package can be used without Discord support.
12
- *
13
- * @module discord-manager
14
- */
15
- /**
16
- * Lazy import the Discord package to avoid hard dependency
17
- * This allows @herdctl/core to be used without @herdctl/discord installed
18
- */
19
- async function importDiscordPackage() {
20
- try {
21
- // Dynamic import - will be resolved at runtime
22
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
- const pkg = (await import("@herdctl/discord"));
24
- return pkg;
25
- }
26
- catch {
27
- return null;
28
- }
29
- }
30
- /**
31
- * StreamingResponder handles incremental message delivery to Discord
32
- *
33
- * Instead of collecting all output and sending at the end, this class:
34
- * - Buffers incoming content
35
- * - Sends messages as complete chunks arrive (detected by double newlines or size)
36
- * - Respects rate limits by enforcing minimum intervals between sends
37
- * - Handles message splitting for content exceeding Discord's 2000 char limit
38
- */
39
- class StreamingResponder {
40
- buffer = "";
41
- lastSendTime = 0;
42
- messagesSent = 0;
43
- reply;
44
- splitResponse;
45
- logger;
46
- agentName;
47
- minMessageInterval;
48
- maxBufferSize;
49
- constructor(options) {
50
- this.reply = options.reply;
51
- this.splitResponse = options.splitResponse;
52
- this.logger = options.logger;
53
- this.agentName = options.agentName;
54
- this.minMessageInterval = options.minMessageInterval ?? 1000; // 1 second default
55
- this.maxBufferSize = options.maxBufferSize ?? 1500; // Leave room for Discord's 2000 limit
56
- }
57
- /**
58
- * Add a complete message and send it immediately (with rate limiting)
59
- *
60
- * Use this for complete assistant message turns from the SDK.
61
- * Each assistant message is a complete response that should be sent.
62
- */
63
- async addMessageAndSend(content) {
64
- if (!content || content.trim().length === 0) {
65
- return;
66
- }
67
- // Add to any existing buffer (in case there's leftover content)
68
- this.buffer += content;
69
- // Send everything in the buffer
70
- await this.sendAll();
71
- }
72
- /**
73
- * Send all buffered content immediately (with rate limiting)
74
- */
75
- async sendAll() {
76
- if (this.buffer.trim().length === 0) {
77
- return;
78
- }
79
- const content = this.buffer.trim();
80
- this.buffer = "";
81
- // Respect rate limiting - wait if needed
82
- const now = Date.now();
83
- const timeSinceLastSend = now - this.lastSendTime;
84
- if (timeSinceLastSend < this.minMessageInterval && this.lastSendTime > 0) {
85
- const waitTime = this.minMessageInterval - timeSinceLastSend;
86
- await this.sleep(waitTime);
87
- }
88
- // Split if needed for Discord's limit
89
- const chunks = this.splitResponse(content);
90
- for (const chunk of chunks) {
91
- try {
92
- await this.reply(chunk);
93
- this.messagesSent++;
94
- this.lastSendTime = Date.now();
95
- this.logger.debug(`Streamed message to Discord`, {
96
- agentName: this.agentName,
97
- chunkLength: chunk.length,
98
- totalSent: this.messagesSent,
99
- });
100
- // Small delay between multiple chunks from same content
101
- if (chunks.length > 1) {
102
- await this.sleep(500);
103
- }
104
- }
105
- catch (error) {
106
- const errorMessage = error instanceof Error ? error.message : String(error);
107
- this.logger.error(`Failed to send Discord message`, {
108
- agentName: this.agentName,
109
- error: errorMessage,
110
- });
111
- throw error;
112
- }
113
- }
114
- }
115
- /**
116
- * Flush any remaining content in the buffer
117
- */
118
- async flush() {
119
- await this.sendAll();
120
- }
121
- /**
122
- * Check if any messages have been sent
123
- */
124
- hasSentMessages() {
125
- return this.messagesSent > 0;
126
- }
127
- /**
128
- * Sleep for a given number of milliseconds
129
- */
130
- sleep(ms) {
131
- return new Promise((resolve) => setTimeout(resolve, ms));
132
- }
133
- }
134
- // =============================================================================
135
- // Discord Manager
136
- // =============================================================================
137
- /**
138
- * DiscordManager handles Discord connections for agents
139
- *
140
- * This class encapsulates the creation and lifecycle management of
141
- * DiscordConnector instances for agents that have Discord chat configured.
142
- */
143
- export class DiscordManager {
144
- ctx;
145
- connectors = new Map();
146
- initialized = false;
147
- constructor(ctx) {
148
- this.ctx = ctx;
149
- }
150
- /**
151
- * Initialize Discord connectors for all configured agents
152
- *
153
- * This method:
154
- * 1. Checks if @herdctl/discord package is available
155
- * 2. Iterates through agents to find those with Discord configured
156
- * 3. Creates a DiscordConnector for each Discord-enabled agent
157
- *
158
- * Should be called during FleetManager initialization.
159
- */
160
- async initialize() {
161
- if (this.initialized) {
162
- return;
163
- }
164
- const logger = this.ctx.getLogger();
165
- const config = this.ctx.getConfig();
166
- if (!config) {
167
- logger.debug("No config available, skipping Discord initialization");
168
- return;
169
- }
170
- // Try to import the discord package
171
- const discordPkg = await importDiscordPackage();
172
- if (!discordPkg) {
173
- logger.debug("@herdctl/discord not installed, skipping Discord connectors");
174
- return;
175
- }
176
- const { DiscordConnector, SessionManager } = discordPkg;
177
- const stateDir = this.ctx.getStateDir();
178
- // Find agents with Discord configured
179
- const discordAgents = config.agents.filter((agent) => agent.chat?.discord !== undefined);
180
- if (discordAgents.length === 0) {
181
- logger.debug("No agents with Discord configured");
182
- this.initialized = true;
183
- return;
184
- }
185
- logger.debug(`Initializing Discord connectors for ${discordAgents.length} agent(s)`);
186
- for (const agent of discordAgents) {
187
- try {
188
- const discordConfig = agent.chat.discord;
189
- if (!discordConfig)
190
- continue;
191
- // Get bot token from environment variable
192
- const botToken = process.env[discordConfig.bot_token_env];
193
- if (!botToken) {
194
- logger.warn(`Discord bot token not found in environment variable '${discordConfig.bot_token_env}' for agent '${agent.name}'`);
195
- continue;
196
- }
197
- // Create logger adapter for this agent
198
- const createAgentLogger = (prefix) => ({
199
- debug: (msg, data) => logger.debug(`${prefix} ${msg}${data ? ` ${JSON.stringify(data)}` : ""}`),
200
- info: (msg, data) => logger.info(`${prefix} ${msg}${data ? ` ${JSON.stringify(data)}` : ""}`),
201
- warn: (msg, data) => logger.warn(`${prefix} ${msg}${data ? ` ${JSON.stringify(data)}` : ""}`),
202
- error: (msg, data) => logger.error(`${prefix} ${msg}${data ? ` ${JSON.stringify(data)}` : ""}`),
203
- });
204
- // Create session manager for this agent
205
- const sessionManager = new SessionManager({
206
- agentName: agent.name,
207
- stateDir,
208
- sessionExpiryHours: discordConfig.session_expiry_hours,
209
- logger: createAgentLogger(`[discord:${agent.name}:session]`),
210
- });
211
- // Create the connector
212
- // Note: FleetManager is passed via ctx.getEmitter() which returns the FleetManager instance
213
- const connector = new DiscordConnector({
214
- agentConfig: agent,
215
- discordConfig,
216
- botToken,
217
- // The context's getEmitter() returns the FleetManager instance (which extends EventEmitter)
218
- fleetManager: this.ctx.getEmitter(),
219
- sessionManager,
220
- stateDir,
221
- logger: createAgentLogger(`[discord:${agent.name}]`),
222
- });
223
- this.connectors.set(agent.name, connector);
224
- logger.debug(`Created Discord connector for agent '${agent.name}'`);
225
- }
226
- catch (error) {
227
- const errorMessage = error instanceof Error ? error.message : String(error);
228
- logger.error(`Failed to create Discord connector for agent '${agent.name}': ${errorMessage}`);
229
- // Continue with other agents - don't fail the whole initialization
230
- }
231
- }
232
- this.initialized = true;
233
- logger.debug(`Discord manager initialized with ${this.connectors.size} connector(s)`);
234
- }
235
- /**
236
- * Connect all Discord connectors
237
- *
238
- * Connects each connector to the Discord gateway and subscribes to events.
239
- * Errors are logged but don't stop other connectors from connecting.
240
- */
241
- async start() {
242
- const logger = this.ctx.getLogger();
243
- if (this.connectors.size === 0) {
244
- logger.debug("No Discord connectors to start");
245
- return;
246
- }
247
- logger.debug(`Starting ${this.connectors.size} Discord connector(s)...`);
248
- const connectPromises = [];
249
- for (const [agentName, connector] of this.connectors) {
250
- // Subscribe to connector events before connecting
251
- connector.on("message", (event) => {
252
- this.handleMessage(agentName, event).catch((error) => {
253
- this.handleError(agentName, error);
254
- });
255
- });
256
- connector.on("error", (event) => {
257
- this.handleError(agentName, event.error);
258
- });
259
- connectPromises.push(connector.connect().catch((error) => {
260
- const errorMessage = error instanceof Error ? error.message : String(error);
261
- logger.error(`Failed to connect Discord for agent '${agentName}': ${errorMessage}`);
262
- // Don't re-throw - we want to continue connecting other agents
263
- }));
264
- }
265
- await Promise.all(connectPromises);
266
- const connectedCount = Array.from(this.connectors.values()).filter((c) => c.isConnected()).length;
267
- logger.info(`Discord connectors started: ${connectedCount}/${this.connectors.size} connected`);
268
- }
269
- /**
270
- * Disconnect all Discord connectors gracefully
271
- *
272
- * Sessions are automatically persisted to disk on every update,
273
- * so they survive bot restarts. This method logs session state
274
- * before disconnecting for monitoring purposes.
275
- *
276
- * Errors are logged but don't prevent other connectors from disconnecting.
277
- */
278
- async stop() {
279
- const logger = this.ctx.getLogger();
280
- if (this.connectors.size === 0) {
281
- logger.debug("No Discord connectors to stop");
282
- return;
283
- }
284
- logger.debug(`Stopping ${this.connectors.size} Discord connector(s)...`);
285
- // Log session state before shutdown (sessions are already persisted to disk)
286
- for (const [agentName, connector] of this.connectors) {
287
- try {
288
- const activeSessionCount = await connector.sessionManager.getActiveSessionCount();
289
- if (activeSessionCount > 0) {
290
- logger.debug(`Preserving ${activeSessionCount} active session(s) for agent '${agentName}'`);
291
- }
292
- }
293
- catch (error) {
294
- const errorMessage = error instanceof Error ? error.message : String(error);
295
- logger.warn(`Failed to get session count for agent '${agentName}': ${errorMessage}`);
296
- // Continue with shutdown - this is just informational logging
297
- }
298
- }
299
- const disconnectPromises = [];
300
- for (const [agentName, connector] of this.connectors) {
301
- disconnectPromises.push(connector.disconnect().catch((error) => {
302
- const errorMessage = error instanceof Error ? error.message : String(error);
303
- logger.error(`Error disconnecting Discord for agent '${agentName}': ${errorMessage}`);
304
- // Don't re-throw - graceful shutdown should continue
305
- }));
306
- }
307
- await Promise.all(disconnectPromises);
308
- logger.debug("All Discord connectors stopped");
309
- }
310
- /**
311
- * Get a connector for a specific agent
312
- *
313
- * @param agentName - Name of the agent
314
- * @returns The DiscordConnector instance, or undefined if not found
315
- */
316
- getConnector(agentName) {
317
- return this.connectors.get(agentName);
318
- }
319
- /**
320
- * Get all connector names
321
- *
322
- * @returns Array of agent names that have Discord connectors
323
- */
324
- getConnectorNames() {
325
- return Array.from(this.connectors.keys());
326
- }
327
- /**
328
- * Get the number of active connectors
329
- *
330
- * @returns Number of connectors that are currently connected
331
- */
332
- getConnectedCount() {
333
- return Array.from(this.connectors.values()).filter((c) => c.isConnected()).length;
334
- }
335
- /**
336
- * Check if a specific agent has a Discord connector
337
- *
338
- * @param agentName - Name of the agent
339
- * @returns true if the agent has a Discord connector
340
- */
341
- hasConnector(agentName) {
342
- return this.connectors.has(agentName);
343
- }
344
- // ===========================================================================
345
- // Message Handling Pipeline
346
- // ===========================================================================
347
- /**
348
- * Handle an incoming Discord message
349
- *
350
- * This method:
351
- * 1. Gets or creates a session for the channel
352
- * 2. Builds job context from the message
353
- * 3. Executes the job via trigger
354
- * 4. Sends the response back to Discord
355
- *
356
- * @param agentName - Name of the agent handling the message
357
- * @param event - The Discord message event
358
- */
359
- async handleMessage(agentName, event) {
360
- const logger = this.ctx.getLogger();
361
- const emitter = this.ctx.getEmitter();
362
- logger.info(`Discord message for agent '${agentName}': ${event.prompt.substring(0, 50)}...`);
363
- // Get the agent configuration
364
- const config = this.ctx.getConfig();
365
- const agent = config?.agents.find((a) => a.name === agentName);
366
- if (!agent) {
367
- logger.error(`Agent '${agentName}' not found in configuration`);
368
- try {
369
- await event.reply("Sorry, I'm not properly configured. Please contact an administrator.");
370
- }
371
- catch (replyError) {
372
- logger.error(`Failed to send error reply: ${replyError.message}`);
373
- }
374
- return;
375
- }
376
- // Get output configuration (with defaults)
377
- const outputConfig = agent.chat?.discord?.output ?? {
378
- tool_results: true,
379
- tool_result_max_length: 900,
380
- system_status: true,
381
- result_summary: false,
382
- errors: true,
383
- };
384
- // Get existing session for this channel (for conversation continuity)
385
- const connector = this.connectors.get(agentName);
386
- let existingSessionId;
387
- if (connector) {
388
- try {
389
- const existingSession = await connector.sessionManager.getSession(event.metadata.channelId);
390
- if (existingSession) {
391
- existingSessionId = existingSession.sessionId;
392
- logger.debug(`Resuming session for channel ${event.metadata.channelId}: ${existingSessionId}`);
393
- }
394
- else {
395
- logger.debug(`No existing session for channel ${event.metadata.channelId}, starting new conversation`);
396
- }
397
- }
398
- catch (error) {
399
- const errorMessage = error instanceof Error ? error.message : String(error);
400
- logger.warn(`Failed to get session: ${errorMessage}`);
401
- // Continue processing - session failure shouldn't block message handling
402
- }
403
- }
404
- // Create streaming responder for incremental message delivery
405
- // StreamingResponder only sends text, so narrow the reply type
406
- const streamer = new StreamingResponder({
407
- reply: (content) => event.reply(content),
408
- splitResponse: (text) => this.splitResponse(text),
409
- logger,
410
- agentName,
411
- });
412
- // Start typing indicator while processing
413
- const stopTyping = event.startTyping();
414
- // Track if we've stopped typing to avoid multiple calls
415
- let typingStopped = false;
416
- try {
417
- // Import FleetManager dynamically to avoid circular dependency
418
- // The context's getEmitter() returns the FleetManager instance
419
- const fleetManager = emitter;
420
- // Track pending tool_use blocks so we can pair them with results
421
- const pendingToolUses = new Map();
422
- let embedsSent = 0;
423
- // Execute job via FleetManager.trigger()
424
- // Pass resume option for conversation continuity
425
- // The onMessage callback streams output incrementally to Discord
426
- const result = await fleetManager.trigger(agentName, undefined, {
427
- prompt: event.prompt,
428
- resume: existingSessionId,
429
- onMessage: async (message) => {
430
- // Extract text content from assistant messages and stream to Discord
431
- if (message.type === "assistant") {
432
- const content = this.extractMessageContent(message);
433
- if (content) {
434
- // Each assistant message is a complete turn - send immediately
435
- await streamer.addMessageAndSend(content);
436
- }
437
- // Track tool_use blocks for pairing with results later
438
- const toolUseBlocks = this.extractToolUseBlocks(message);
439
- for (const block of toolUseBlocks) {
440
- if (block.id) {
441
- pendingToolUses.set(block.id, {
442
- name: block.name,
443
- input: block.input,
444
- startTime: Date.now(),
445
- });
446
- }
447
- }
448
- }
449
- // Build and send embeds for tool results
450
- if (message.type === "user" && outputConfig.tool_results) {
451
- const toolResults = this.extractToolResults(message);
452
- for (const toolResult of toolResults) {
453
- // Look up the matching tool_use for name, input, and timing
454
- const toolUse = toolResult.toolUseId
455
- ? pendingToolUses.get(toolResult.toolUseId)
456
- : undefined;
457
- if (toolResult.toolUseId) {
458
- pendingToolUses.delete(toolResult.toolUseId);
459
- }
460
- const embed = this.buildToolEmbed(toolUse ?? null, toolResult, outputConfig.tool_result_max_length);
461
- // Flush any buffered text before sending embed to preserve ordering
462
- await streamer.flush();
463
- await event.reply({ embeds: [embed] });
464
- embedsSent++;
465
- }
466
- }
467
- // Show system status messages (e.g., "compacting context...")
468
- if (message.type === "system" && outputConfig.system_status) {
469
- if (message.subtype === "status" && message.status) {
470
- const statusText = message.status === "compacting"
471
- ? "Compacting context..."
472
- : `Status: ${message.status}`;
473
- await streamer.flush();
474
- await event.reply({
475
- embeds: [{
476
- title: "\u2699\uFE0F System",
477
- description: statusText,
478
- color: DiscordManager.EMBED_COLOR_SYSTEM,
479
- }],
480
- });
481
- embedsSent++;
482
- }
483
- }
484
- // Show result summary embed (cost, tokens, turns)
485
- if (message.type === "result" && outputConfig.result_summary) {
486
- const fields = [];
487
- if (message.duration_ms !== undefined) {
488
- fields.push({
489
- name: "Duration",
490
- value: DiscordManager.formatDuration(message.duration_ms),
491
- inline: true,
492
- });
493
- }
494
- if (message.num_turns !== undefined) {
495
- fields.push({
496
- name: "Turns",
497
- value: String(message.num_turns),
498
- inline: true,
499
- });
500
- }
501
- if (message.total_cost_usd !== undefined) {
502
- fields.push({
503
- name: "Cost",
504
- value: `$${message.total_cost_usd.toFixed(4)}`,
505
- inline: true,
506
- });
507
- }
508
- if (message.usage) {
509
- const inputTokens = message.usage.input_tokens ?? 0;
510
- const outputTokens = message.usage.output_tokens ?? 0;
511
- fields.push({
512
- name: "Tokens",
513
- value: `${inputTokens.toLocaleString()} in / ${outputTokens.toLocaleString()} out`,
514
- inline: true,
515
- });
516
- }
517
- const isError = message.is_error === true;
518
- await streamer.flush();
519
- await event.reply({
520
- embeds: [{
521
- title: isError ? "\u274C Task Failed" : "\u2705 Task Complete",
522
- color: isError ? DiscordManager.EMBED_COLOR_ERROR : DiscordManager.EMBED_COLOR_SUCCESS,
523
- fields,
524
- }],
525
- });
526
- embedsSent++;
527
- }
528
- // Show SDK error messages
529
- if (message.type === "error" && outputConfig.errors) {
530
- const errorText = typeof message.content === "string"
531
- ? message.content
532
- : "An unknown error occurred";
533
- await streamer.flush();
534
- await event.reply({
535
- embeds: [{
536
- title: "\u274C Error",
537
- description: errorText.length > 4000 ? errorText.substring(0, 4000) + "..." : errorText,
538
- color: DiscordManager.EMBED_COLOR_ERROR,
539
- }],
540
- });
541
- embedsSent++;
542
- }
543
- },
544
- });
545
- // Stop typing indicator immediately after SDK execution completes
546
- // This prevents the interval from firing during flush/session storage
547
- if (!typingStopped) {
548
- stopTyping();
549
- typingStopped = true;
550
- }
551
- // Flush any remaining buffered content
552
- await streamer.flush();
553
- logger.debug(`Discord job completed: ${result.jobId} for agent '${agentName}'${result.sessionId ? ` (session: ${result.sessionId})` : ""}`);
554
- // If no messages were sent (text or embeds), send an appropriate fallback
555
- if (!streamer.hasSentMessages() && embedsSent === 0) {
556
- if (result.success) {
557
- await event.reply("I've completed the task, but I don't have a specific response to share.");
558
- }
559
- else {
560
- // Job failed without streaming any messages - send error details
561
- const errorMessage = result.errorDetails?.message ?? result.error?.message ?? "An unknown error occurred";
562
- await event.reply(`❌ **Error:** ${errorMessage}\n\nThe task could not be completed. Please check the logs for more details.`);
563
- }
564
- // Stop typing after sending fallback message (if not already stopped)
565
- if (!typingStopped) {
566
- stopTyping();
567
- typingStopped = true;
568
- }
569
- }
570
- // Store the SDK session ID for future conversation continuity
571
- // Only store if the job succeeded - failed jobs may return invalid session IDs
572
- if (connector && result.sessionId && result.success) {
573
- try {
574
- await connector.sessionManager.setSession(event.metadata.channelId, result.sessionId);
575
- logger.debug(`Stored session ${result.sessionId} for channel ${event.metadata.channelId}`);
576
- }
577
- catch (sessionError) {
578
- const errorMessage = sessionError instanceof Error ? sessionError.message : String(sessionError);
579
- logger.warn(`Failed to store session: ${errorMessage}`);
580
- // Don't fail the message handling for session storage failure
581
- }
582
- }
583
- else if (connector && result.sessionId && !result.success) {
584
- logger.debug(`Not storing session ${result.sessionId} for channel ${event.metadata.channelId} - job failed`);
585
- }
586
- // Emit event for tracking
587
- emitter.emit("discord:message:handled", {
588
- agentName,
589
- channelId: event.metadata.channelId,
590
- messageId: event.metadata.messageId,
591
- jobId: result.jobId,
592
- timestamp: new Date().toISOString(),
593
- });
594
- }
595
- catch (error) {
596
- const err = error instanceof Error ? error : new Error(String(error));
597
- logger.error(`Discord message handling failed for agent '${agentName}': ${err.message}`);
598
- // Send user-friendly error message using the formatted error method
599
- try {
600
- await event.reply(this.formatErrorMessage(err));
601
- }
602
- catch (replyError) {
603
- logger.error(`Failed to send error reply: ${replyError.message}`);
604
- }
605
- // Emit error event for tracking
606
- emitter.emit("discord:message:error", {
607
- agentName,
608
- channelId: event.metadata.channelId,
609
- messageId: event.metadata.messageId,
610
- error: err.message,
611
- timestamp: new Date().toISOString(),
612
- });
613
- }
614
- finally {
615
- // Safety net: stop typing indicator if not already stopped
616
- // (Should already be stopped after sending messages, but this ensures cleanup on errors)
617
- if (!typingStopped) {
618
- stopTyping();
619
- }
620
- }
621
- }
622
- /**
623
- * Extract text content from an SDK message
624
- *
625
- * Handles various message formats from the Claude Agent SDK
626
- */
627
- extractMessageContent(message) {
628
- // Check for direct content
629
- if (typeof message.content === "string" && message.content) {
630
- return message.content;
631
- }
632
- // Check for nested message content (SDK structure)
633
- const apiMessage = message.message;
634
- const content = apiMessage?.content;
635
- if (!content)
636
- return undefined;
637
- // If it's a string, return directly
638
- if (typeof content === "string") {
639
- return content;
640
- }
641
- // If it's an array of content blocks, extract text
642
- if (Array.isArray(content)) {
643
- const textParts = [];
644
- for (const block of content) {
645
- if (block && typeof block === "object" && "type" in block) {
646
- if (block.type === "text" && "text" in block && typeof block.text === "string") {
647
- textParts.push(block.text);
648
- }
649
- }
650
- }
651
- return textParts.length > 0 ? textParts.join("") : undefined;
652
- }
653
- return undefined;
654
- }
655
- // =============================================================================
656
- // Tool Embed Support
657
- // =============================================================================
658
- /** Maximum characters for tool output in Discord embed fields */
659
- static TOOL_OUTPUT_MAX_CHARS = 900;
660
- /** Embed colors */
661
- static EMBED_COLOR_DEFAULT = 0x5865f2; // Discord blurple
662
- static EMBED_COLOR_ERROR = 0xef4444; // Red
663
- static EMBED_COLOR_SYSTEM = 0x95a5a6; // Gray
664
- static EMBED_COLOR_SUCCESS = 0x57f287; // Green
665
- /** Tool title emojis */
666
- static TOOL_EMOJIS = {
667
- Bash: "\u{1F4BB}", // laptop
668
- bash: "\u{1F4BB}",
669
- Read: "\u{1F4C4}", // page
670
- Write: "\u{270F}\u{FE0F}", // pencil
671
- Edit: "\u{270F}\u{FE0F}",
672
- Glob: "\u{1F50D}", // magnifying glass
673
- Grep: "\u{1F50D}",
674
- WebFetch: "\u{1F310}", // globe
675
- WebSearch: "\u{1F310}",
676
- };
677
- /**
678
- * Extract tool_use blocks from an assistant message's content blocks
679
- *
680
- * Returns id, name, and input for each tool_use block so we can
681
- * track pending calls and pair them with results.
682
- */
683
- extractToolUseBlocks(message) {
684
- const apiMessage = message.message;
685
- const content = apiMessage?.content;
686
- if (!Array.isArray(content))
687
- return [];
688
- const blocks = [];
689
- for (const block of content) {
690
- if (block &&
691
- typeof block === "object" &&
692
- "type" in block &&
693
- block.type === "tool_use" &&
694
- "name" in block &&
695
- typeof block.name === "string") {
696
- blocks.push({
697
- id: "id" in block && typeof block.id === "string" ? block.id : undefined,
698
- name: block.name,
699
- input: "input" in block ? block.input : undefined,
700
- });
701
- }
702
- }
703
- return blocks;
704
- }
705
- /**
706
- * Get a human-readable summary of tool input
707
- */
708
- getToolInputSummary(name, input) {
709
- const inputObj = input;
710
- if (name === "Bash" || name === "bash") {
711
- const command = inputObj?.command;
712
- if (typeof command === "string" && command.length > 0) {
713
- return command.length > 200 ? command.substring(0, 200) + "..." : command;
714
- }
715
- }
716
- if (name === "Read" || name === "Write" || name === "Edit") {
717
- const path = inputObj?.file_path ?? inputObj?.path;
718
- if (typeof path === "string")
719
- return path;
720
- }
721
- if (name === "Glob" || name === "Grep") {
722
- const pattern = inputObj?.pattern;
723
- if (typeof pattern === "string")
724
- return pattern;
725
- }
726
- if (name === "WebFetch" || name === "WebSearch") {
727
- const url = inputObj?.url;
728
- const query = inputObj?.query;
729
- if (typeof url === "string")
730
- return url;
731
- if (typeof query === "string")
732
- return query;
733
- }
734
- return undefined;
735
- }
736
- /**
737
- * Extract tool results from a user message
738
- *
739
- * Returns output, error status, and the tool_use_id for matching
740
- * to the pending tool_use that produced this result.
741
- */
742
- extractToolResults(message) {
743
- const results = [];
744
- // Check for top-level tool_use_result (direct SDK format)
745
- if (message.tool_use_result !== undefined) {
746
- const extracted = this.extractToolResultContent(message.tool_use_result);
747
- if (extracted) {
748
- results.push(extracted);
749
- }
750
- return results;
751
- }
752
- // Check for content blocks in nested message
753
- const apiMessage = message.message;
754
- const content = apiMessage?.content;
755
- if (!Array.isArray(content))
756
- return results;
757
- for (const block of content) {
758
- if (!block || typeof block !== "object" || !("type" in block))
759
- continue;
760
- if (block.type === "tool_result") {
761
- const toolResultBlock = block;
762
- const isError = toolResultBlock.is_error === true;
763
- const toolUseId = typeof toolResultBlock.tool_use_id === "string"
764
- ? toolResultBlock.tool_use_id
765
- : undefined;
766
- // Content can be a string or an array of content blocks
767
- const blockContent = toolResultBlock.content;
768
- if (typeof blockContent === "string" && blockContent.length > 0) {
769
- results.push({ output: blockContent, isError, toolUseId });
770
- }
771
- else if (Array.isArray(blockContent)) {
772
- const textParts = [];
773
- for (const part of blockContent) {
774
- if (part &&
775
- typeof part === "object" &&
776
- "type" in part &&
777
- part.type === "text" &&
778
- "text" in part &&
779
- typeof part.text === "string") {
780
- textParts.push(part.text);
781
- }
782
- }
783
- if (textParts.length > 0) {
784
- results.push({ output: textParts.join("\n"), isError, toolUseId });
785
- }
786
- }
787
- }
788
- }
789
- return results;
790
- }
791
- /**
792
- * Extract content from a top-level tool_use_result value
793
- */
794
- extractToolResultContent(result) {
795
- if (typeof result === "string" && result.length > 0) {
796
- return { output: result, isError: false };
797
- }
798
- if (result && typeof result === "object") {
799
- const obj = result;
800
- // Check for content field
801
- if (typeof obj.content === "string" && obj.content.length > 0) {
802
- return {
803
- output: obj.content,
804
- isError: obj.is_error === true,
805
- toolUseId: typeof obj.tool_use_id === "string" ? obj.tool_use_id : undefined,
806
- };
807
- }
808
- // Check for content blocks array
809
- if (Array.isArray(obj.content)) {
810
- const textParts = [];
811
- for (const block of obj.content) {
812
- if (block &&
813
- typeof block === "object" &&
814
- "type" in block &&
815
- block.type === "text" &&
816
- "text" in block &&
817
- typeof block.text === "string") {
818
- textParts.push(block.text);
819
- }
820
- }
821
- if (textParts.length > 0) {
822
- return {
823
- output: textParts.join("\n"),
824
- isError: obj.is_error === true,
825
- toolUseId: typeof obj.tool_use_id === "string" ? obj.tool_use_id : undefined,
826
- };
827
- }
828
- }
829
- }
830
- return undefined;
831
- }
832
- /**
833
- * Format duration in milliseconds to a human-readable string
834
- */
835
- static formatDuration(ms) {
836
- if (ms < 1000)
837
- return `${ms}ms`;
838
- const seconds = Math.floor(ms / 1000);
839
- if (seconds < 60)
840
- return `${seconds}s`;
841
- const minutes = Math.floor(seconds / 60);
842
- const remainingSeconds = seconds % 60;
843
- return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
844
- }
845
- /**
846
- * Build a Discord embed for a tool call result
847
- *
848
- * Combines the tool_use info (name, input) with the tool_result
849
- * (output, error status) into a compact Discord embed.
850
- *
851
- * @param toolUse - The tool_use block info (name, input, startTime)
852
- * @param toolResult - The tool result (output, isError)
853
- * @param maxOutputChars - Maximum characters for output (defaults to TOOL_OUTPUT_MAX_CHARS)
854
- */
855
- buildToolEmbed(toolUse, toolResult, maxOutputChars) {
856
- const toolName = toolUse?.name ?? "Tool";
857
- const emoji = DiscordManager.TOOL_EMOJIS[toolName] ?? "\u{1F527}"; // wrench fallback
858
- const isError = toolResult.isError;
859
- // Build description from input summary
860
- const inputSummary = toolUse ? this.getToolInputSummary(toolUse.name, toolUse.input) : undefined;
861
- let description;
862
- if (inputSummary) {
863
- if (toolName === "Bash" || toolName === "bash") {
864
- description = `\`> ${inputSummary}\``;
865
- }
866
- else {
867
- description = `\`${inputSummary}\``;
868
- }
869
- }
870
- // Build inline fields
871
- const fields = [];
872
- if (toolUse) {
873
- const durationMs = Date.now() - toolUse.startTime;
874
- fields.push({
875
- name: "Duration",
876
- value: DiscordManager.formatDuration(durationMs),
877
- inline: true,
878
- });
879
- }
880
- const outputLength = toolResult.output.length;
881
- fields.push({
882
- name: "Output",
883
- value: outputLength >= 1000
884
- ? `${(outputLength / 1000).toFixed(1)}k chars`
885
- : `${outputLength} chars`,
886
- inline: true,
887
- });
888
- // Add truncated output as a field if non-empty
889
- const trimmedOutput = toolResult.output.trim();
890
- if (trimmedOutput.length > 0) {
891
- const maxChars = maxOutputChars ?? DiscordManager.TOOL_OUTPUT_MAX_CHARS;
892
- let outputText = trimmedOutput;
893
- if (outputText.length > maxChars) {
894
- outputText = outputText.substring(0, maxChars) + `\n... (${outputLength.toLocaleString()} chars total)`;
895
- }
896
- fields.push({
897
- name: isError ? "Error" : "Result",
898
- value: `\`\`\`\n${outputText}\n\`\`\``,
899
- inline: false,
900
- });
901
- }
902
- return {
903
- title: `${emoji} ${toolName}`,
904
- description,
905
- color: isError ? DiscordManager.EMBED_COLOR_ERROR : DiscordManager.EMBED_COLOR_DEFAULT,
906
- fields,
907
- };
908
- }
909
- /**
910
- * Handle errors from Discord connectors
911
- *
912
- * Logs errors without crashing the connector
913
- *
914
- * @param agentName - Name of the agent that encountered the error
915
- * @param error - The error that occurred
916
- */
917
- handleError(agentName, error) {
918
- const logger = this.ctx.getLogger();
919
- const emitter = this.ctx.getEmitter();
920
- const errorMessage = error instanceof Error ? error.message : String(error);
921
- logger.error(`Discord connector error for agent '${agentName}': ${errorMessage}`);
922
- // Emit error event for monitoring
923
- emitter.emit("discord:error", {
924
- agentName,
925
- error: errorMessage,
926
- timestamp: new Date().toISOString(),
927
- });
928
- }
929
- // ===========================================================================
930
- // Response Formatting and Splitting
931
- // ===========================================================================
932
- /** Discord's maximum message length */
933
- static MAX_MESSAGE_LENGTH = 2000;
934
- /**
935
- * Format an error message for Discord display
936
- *
937
- * Creates a user-friendly error message with guidance on how to proceed.
938
- *
939
- * @param error - The error that occurred
940
- * @returns Formatted error message string
941
- */
942
- formatErrorMessage(error) {
943
- return `❌ **Error**: ${error.message}\n\nPlease try again or use \`/reset\` to start a new session.`;
944
- }
945
- /**
946
- * Split a response into chunks that fit Discord's 2000 character limit
947
- *
948
- * This method intelligently splits text:
949
- * - Preserves code blocks when possible (closing and reopening across chunks)
950
- * - Splits at natural boundaries (newlines, then spaces)
951
- * - Never splits mid-word
952
- *
953
- * @param text - The text to split
954
- * @returns Array of text chunks, each under 2000 characters
955
- */
956
- splitResponse(text) {
957
- const MAX_LENGTH = DiscordManager.MAX_MESSAGE_LENGTH;
958
- // If text fits in one message, return as-is
959
- if (text.length <= MAX_LENGTH) {
960
- return [text];
961
- }
962
- const chunks = [];
963
- let remaining = text;
964
- while (remaining.length > 0) {
965
- if (remaining.length <= MAX_LENGTH) {
966
- chunks.push(remaining);
967
- break;
968
- }
969
- // Find the best split point
970
- const { chunk, rest } = this.findSplitPoint(remaining, MAX_LENGTH);
971
- chunks.push(chunk);
972
- remaining = rest;
973
- }
974
- return chunks;
975
- }
976
- /**
977
- * Find the best point to split text, preserving code blocks
978
- *
979
- * @param text - Text to split
980
- * @param maxLength - Maximum chunk length
981
- * @returns Object with the chunk and remaining text
982
- */
983
- findSplitPoint(text, maxLength) {
984
- // Check if we're inside a code block at the split point
985
- const codeBlockState = this.analyzeCodeBlocks(text.substring(0, maxLength));
986
- // If inside a code block, we need to close it and reopen in the next chunk
987
- if (codeBlockState.insideBlock) {
988
- // Find a good split point before maxLength
989
- const splitIndex = this.findNaturalBreak(text, maxLength);
990
- const chunkText = text.substring(0, splitIndex);
991
- // Re-analyze the actual chunk
992
- const actualState = this.analyzeCodeBlocks(chunkText);
993
- if (actualState.insideBlock) {
994
- // Close the code block in this chunk
995
- const closedChunk = chunkText + "\n```";
996
- // Reopen with the same language in the next chunk
997
- const continuation = "```" + (actualState.language || "") + "\n" + text.substring(splitIndex);
998
- return { chunk: closedChunk, rest: continuation };
999
- }
1000
- return {
1001
- chunk: chunkText,
1002
- rest: text.substring(splitIndex),
1003
- };
1004
- }
1005
- // Not inside a code block - find natural break point
1006
- const splitIndex = this.findNaturalBreak(text, maxLength);
1007
- return {
1008
- chunk: text.substring(0, splitIndex),
1009
- rest: text.substring(splitIndex),
1010
- };
1011
- }
1012
- /**
1013
- * Analyze text to determine if it ends inside a code block
1014
- *
1015
- * @param text - Text to analyze
1016
- * @returns Object indicating if inside a block and the language if so
1017
- */
1018
- analyzeCodeBlocks(text) {
1019
- // Find all code block markers (```)
1020
- const codeBlockRegex = /```(\w*)?/g;
1021
- let match;
1022
- let insideBlock = false;
1023
- let language = null;
1024
- while ((match = codeBlockRegex.exec(text)) !== null) {
1025
- if (insideBlock) {
1026
- // This closes a block
1027
- insideBlock = false;
1028
- language = null;
1029
- }
1030
- else {
1031
- // This opens a block
1032
- insideBlock = true;
1033
- language = match[1] || null;
1034
- }
1035
- }
1036
- return { insideBlock, language };
1037
- }
1038
- /**
1039
- * Find a natural break point in text (newline or space)
1040
- *
1041
- * Prefers breaking at:
1042
- * 1. Double newlines (paragraph breaks)
1043
- * 2. Single newlines
1044
- * 3. Spaces
1045
- *
1046
- * @param text - Text to search
1047
- * @param maxLength - Maximum position to search
1048
- * @returns Index of the best split point
1049
- */
1050
- findNaturalBreak(text, maxLength) {
1051
- // Don't search beyond the text length
1052
- const searchEnd = Math.min(maxLength, text.length);
1053
- // First, try to find a double newline (paragraph break)
1054
- const doubleNewline = text.lastIndexOf("\n\n", searchEnd);
1055
- if (doubleNewline > 0 && doubleNewline > searchEnd - 500) {
1056
- // Found a paragraph break within the last 500 chars
1057
- return doubleNewline + 2; // Include the newlines
1058
- }
1059
- // Try to find a single newline
1060
- const singleNewline = text.lastIndexOf("\n", searchEnd);
1061
- if (singleNewline > 0 && singleNewline > searchEnd - 200) {
1062
- // Found a newline within the last 200 chars
1063
- return singleNewline + 1; // Include the newline
1064
- }
1065
- // Try to find a space (avoid splitting mid-word)
1066
- const space = text.lastIndexOf(" ", searchEnd);
1067
- if (space > 0 && space > searchEnd - 100) {
1068
- // Found a space within the last 100 chars
1069
- return space + 1; // Include the space
1070
- }
1071
- // Last resort: hard cut at maxLength
1072
- return searchEnd;
1073
- }
1074
- /**
1075
- * Send a response to Discord, splitting if necessary
1076
- *
1077
- * @param reply - The reply function from the message event
1078
- * @param content - The content to send
1079
- */
1080
- async sendResponse(reply, content) {
1081
- const chunks = this.splitResponse(content);
1082
- for (const chunk of chunks) {
1083
- await reply(chunk);
1084
- }
1085
- }
1086
- }
1087
- //# sourceMappingURL=discord-manager.js.map